diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 61d30cc4..7caefe48 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -113,7 +113,10 @@ state. On a terminal's own page (`/agents//terminal`, or a subagent's) the header has an **N running subagents** badge. It opens a list of the agent's -subagents, each a link to its terminal; on a subagent's page the current one +subagents. Choosing one puts its terminal to the right of this one in a +[group](#groups-of-terminals); in a group, it splits that pane. A subagent +already in the group shows a marker and does nothing when chosen, and **maximize** +opens a pane's terminal on its own. On a subagent's own page the current one gets a marker and the list starts with a link back to the agent's own terminal. A subagent counts as running when it published a row in the last 15 minutes. The swarm doesn't report more than when the newest row came, so a subagent that @@ -167,7 +170,9 @@ the same drag onto a pane splits that pane, as many times as you like, and dragging a pane's header badge onto another pane moves it there. The **split** button in a pane's header does the same for an agent whose terminal tab is open: **right** or **below** puts it beside the pane. **maximize** opens that -agent's own terminal in its tab, or goes to the tab when one is open already. The **×** takes the pane's +agent's own terminal in its tab, or goes to the tab when one is open already. A pane +can hold a subagent's terminal (its header reads `agent/subagent`), put there by +the **subagents** badge in the pane's header. The **×** takes the pane's agent out of the group, and a group left with one agent goes, landing on that agent's own terminal. Drag a divider to resize, or focus it and use the arrow keys. Panes have no minimum size; a divider stops at 2% so a pane can't diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css index afa19f76..9a0b0f0f 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css @@ -20,6 +20,14 @@ color: inherit; text-decoration: none; } +button.ui-subagents-menu-link { + width: 100%; + border: 0; + background: none; + font: inherit; + text-align: left; + cursor: pointer; +} .ui-subagents-menu-link:hover, .ui-subagents-menu-link:focus-visible { background: var(--bg); diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx index 07e1cb4f..86da2631 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx @@ -7,6 +7,10 @@ // (`subagent-list.ts`). Quiet subagents are listed below, dimmed, so a finished // one can still be opened. // +// With `onSplit` the entries are buttons that put the subagent's terminal +// beside the one the menu is in, instead of links to its own page; the ones in +// `shown` are marked and do nothing. +// // Nothing shows for an agent with no subagents, and nothing before the first // answer; a failed fetch shows its reason in the open list. import { useRef, useState } from "preact/hooks"; @@ -31,12 +35,47 @@ function Entry({ sub, current, onPick, + onSplit, }: { agent: string; sub: Subagent; current: boolean; onPick: () => void; + onSplit?: (subagent: string) => void; }) { + const body = ( + <> + {sub.name} + {runLabel(sub) && ( + + {runLabel(sub)} + + )} + {sub.last_row_unix !== null && ( + + + + )} + + ); + if (onSplit !== undefined) { + return ( +
  • + +
  • + ); + } return (
  • - {sub.name} - {runLabel(sub) && ( - - {runLabel(sub)} - - )} - {sub.last_row_unix !== null && ( - - - - )} + {body}
  • ); @@ -64,10 +93,16 @@ function Entry({ export function SubagentsMenu({ agentName, current, + onSplit, + shown = [], }: { agentName: string; /** The subagent whose page this is, when it is a subagent's. */ current?: string; + /** Puts a subagent's terminal beside this one; the entries become buttons. */ + onSplit?: (subagent: string) => void; + /** Subagents whose terminal is beside this one already. */ + shown?: string[]; }) { const { subagents, error } = useSubagentList(agentName); const [open, setOpen] = useState(false); @@ -122,8 +157,9 @@ export function SubagentsMenu({ key={s.name} agent={agentName} sub={s} - current={s.name === current} + current={s.name === current || shown.includes(s.name)} onPick={() => setOpen(false)} + onSplit={onSplit} /> ))} @@ -140,8 +176,9 @@ export function SubagentsMenu({ key={s.name} agent={agentName} sub={s} - current={s.name === current} + current={s.name === current || shown.includes(s.name)} onPick={() => setOpen(false)} + onSplit={onSplit} /> ))} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts b/frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts index 299c21b1..fe2bfec8 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts @@ -41,6 +41,22 @@ export const MIN_SHARE = 0.02; export const pane = (agent: string): Pane => ({ kind: "pane", agent }); +/** + * What a pane goes by in a layout: an agent's name, or `agent/subagent` for + * one of that agent's subagents. Neither name holds a `/`. + */ +export function paneKey(agent: string, subagent?: string): string { + return subagent === undefined ? agent : `${agent}/${subagent}`; +} + +/** The agent and, for a subagent's pane, the subagent a pane key names. */ +export function splitKey(key: string): { agent: string; subagent?: string } { + const at = key.indexOf("/"); + return at < 0 + ? { agent: key } + : { agent: key.slice(0, at), subagent: key.slice(at + 1) }; +} + /** The agents of a layout, in reading order (left to right, top to bottom). */ export function agentsIn(node: Node): string[] { return node.kind === "pane" ? [node.agent] : node.children.flatMap(agentsIn); @@ -198,8 +214,10 @@ export function pruneMissing( existing: ReadonlySet, ): Node | null { let out: Node | null = root; - for (const agent of agentsIn(root)) { - if (out !== null && !existing.has(agent)) out = removeAgent(out, agent); + for (const key of agentsIn(root)) { + if (out !== null && !existing.has(splitKey(key).agent)) { + out = removeAgent(out, key); + } } return out; } diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx index c12dc8bb..84a5580d 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx @@ -1,7 +1,8 @@ // — a group of agent terminals side by side, tmux style, at // `/agents/group/:id`: each pane is one agent's full terminal, split by // dragging another agent's terminal tab (or a pane's header) onto the edge of -// a pane, or by the split button in a pane's header. Dividers resize, and the +// a pane, or by the split button in a pane's header. A pane can also be one of +// an agent's subagents, put beside it by the subagents menu. Dividers resize, and the // layout is saved in the browser (`useTermGroups`). A pane's × takes its agent // out of the group; a group of one gives way to that agent's own terminal. // @@ -12,6 +13,7 @@ import { useLocation, type RouteComponentProps } from "wouter-preact"; import { Badge } from "@hive/shared/badge.js"; import { ExpandIcon } from "@hive/shared/icons.js"; import { AgentIcon } from "../../agent/AgentIcon.js"; +import { SubagentsMenu } from "../../agent/terminal/SubagentsMenu.js"; import { AgentTermPreview, termPaths, @@ -20,9 +22,11 @@ import { agentsIn, groupHref, groupTitle, + paneKey, pruneMissing, removeAgent, resizeAt, + splitKey, splitPane, terminalAgentOf, type Side, @@ -113,8 +117,12 @@ export function AgentGroupPage({ if (group === undefined || inGroup.length > 1) return; groups.remove(id); const last = inGroup[0]; - close(href, last === undefined ? "/agents" : termPaths(last).page); - if (last !== undefined) navigate(termPaths(last).page); + const lastPage = + last === undefined + ? "/agents" + : termPaths(splitKey(last).agent, splitKey(last).subagent).page; + close(href, lastPage); + if (last !== undefined) navigate(lastPage); // eslint-disable-next-line react-hooks/exhaustive-deps -- as above. }, [inGroup.length, group === undefined]); @@ -131,48 +139,69 @@ export function AgentGroupPage({ .map((t) => terminalAgentOf(t.href)) .filter((a): a is string => a !== null && !inGroup.includes(a)); - function renderPane(agent: string) { + // `key` is the pane's key: its agent, or `agent/subagent` for a subagent's. + function renderPane(key: string) { + const { agent, subagent } = splitKey(key); return ( - groups.update(id, (r) => splitPane(r, agent, dropped, side)) + groups.update(id, (r) => splitPane(r, key, dropped, side)) } > startAgentDrag(e, agent)} + title={`drag ${key} onto another pane to move it`} + onDragStart={(e) => startAgentDrag(e, key)} onDragEnd={endAgentDrag} > - } value={agent} /> + } value={key} /> - groups.update(id, (r) => splitPane(r, agent, other, side)) + groups.update(id, (r) => splitPane(r, key, other, side)) + } + /> + { + const s = splitKey(k); + return s.agent === agent && s.subagent !== undefined + ? [s.subagent] + : []; + })} + onSplit={(picked) => + groups.update(id, (r) => + splitPane(r, key, paneKey(agent, picked), "right"), + ) } /> } value="maximize" - title={`open ${agent}'s terminal on its own`} + title={`open ${key}'s terminal on its own`} onClick={() => - open({ href: termPaths(agent).page, label: agent }) + open({ + href: termPaths(agent, subagent).page, + label: termPaths(agent, subagent).label, + }) } /> - groups.update(id, (r) => removeAgent(r, agent) ?? r) + groups.update(id, (r) => removeAgent(r, key) ?? r) } /> diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx index 55a79dba..0b1e22c9 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx @@ -24,6 +24,7 @@ import { groupHref, groupTitle, pane, + paneKey, splitPane, } from "../../agent/terminal/split-layout.js"; import { useTermGroups } from "../../agent/terminal/useTermGroups.js"; @@ -32,7 +33,8 @@ import { PaneDropTarget } from "../../ui/pane-tree/PaneDropTarget.js"; import "./AgentTerminalPage.css"; // Also mounted at `/agents/:name/subagents/:subagent/terminal`, for one of -// the agent's subagents. +// the agent's subagents. Choosing a subagent in the header's subagents menu +// starts a group of this terminal and that one, split to the right. export function AgentTerminalPage({ params, }: RouteComponentProps<{ name: string; subagent?: string }>) { @@ -81,7 +83,18 @@ export function AgentTerminalPage({ onClick={() => setDetailOpen((o) => !o)} /> - + { + const here = paneKey(name, subagent); + const group = groups.create( + splitPane(pane(here), here, paneKey(name, picked), "right"), + ); + open({ href: groupHref(group.id), label: groupTitle(group) }); + }} + /> } /> diff --git a/frontend/packages/swarm-ui/test/split-layout.test.mjs b/frontend/packages/swarm-ui/test/split-layout.test.mjs index 4bc721a7..00710c23 100644 --- a/frontend/packages/swarm-ui/test/split-layout.test.mjs +++ b/frontend/packages/swarm-ui/test/split-layout.test.mjs @@ -7,7 +7,9 @@ import { groupTitle, MIN_SHARE, pane, + paneKey, parseGroups, + splitKey, pruneMissing, removeAgent, resizeAt, @@ -219,6 +221,29 @@ test("a group's header tab and an agent's terminal tab are told apart by their p assert.equal(terminalAgentOf("/agents/group/ab12"), null); }); +test("a subagent's pane is keyed by agent/subagent, split beside its agent, and goes with its agent", () => { + assert.equal(paneKey("a"), "a"); + assert.equal(paneKey("a", "s"), "a/s"); + assert.deepEqual(splitKey("a"), { agent: "a" }); + assert.deepEqual(splitKey("a/s"), { agent: "a", subagent: "s" }); + const two = splitPane(pane("a"), "a", paneKey("a", "s"), "right"); + assert.deepEqual(agentsIn(two), ["a", "a/s"]); + // The same subagent twice changes nothing but its place. + assert.deepEqual(agentsIn(splitPane(two, "a", paneKey("a", "s"), "left")), [ + "a/s", + "a", + ]); + // The agent a subagent belongs to being gone takes both panes. + const three = splitPane(two, "a/s", "b", "bottom"); + assert.deepEqual(agentsIn(pruneMissing(three, new Set(["b"]))), ["b"]); + assert.deepEqual(agentsIn(pruneMissing(three, new Set(["a", "b"]))), [ + "a", + "a/s", + "b", + ]); + assert.equal(groupTitle({ id: "x", name: "", root: two }), "a + a/s"); +}); + test("a pane already below the floor can grow but never shrinks further, and no share goes negative", () => { const tiny = row([pane("a"), pane("b"), pane("c")], [0.005, 0.005, 0.99]); const grown = resizeAt(tiny, [], 1, 0.5);