Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: choosing a subagent in the subagents menu splits its terminal in beside the agent's

The menu on an agent's terminal page used to open the subagent on a tab of
its own. It now starts a group of this terminal and the subagent's, split to
the right; in a group's pane it splits that pane. A pane's layout key is the
agent, or `agent/subagent` for a subagent's pane, so the layout operations
stay as they are and a destroyed agent takes its subagent panes with it. A
subagent already in the group shows a marker and does nothing when chosen,
and maximize opens a subagent pane's terminal on its own.
This commit is contained in:
iris 2026-11-05 12:45:35 +01:00
commit d733b1e2a9
7 changed files with 168 additions and 33 deletions

View file

@ -113,7 +113,10 @@ state.
On a terminal's own page (`/agents/<name>/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

View file

@ -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);

View file

@ -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 = (
<>
<span class="ui-subagents-menu-name">{sub.name}</span>
{runLabel(sub) && (
<span class="ui-subagents-menu-age ui-subagents-menu-run">
{runLabel(sub)}
</span>
)}
{sub.last_row_unix !== null && (
<span class="ui-subagents-menu-age">
<RelativeTime epochMs={sub.last_row_unix * 1000} />
</span>
)}
</>
);
if (onSplit !== undefined) {
return (
<li>
<button
type="button"
class="ui-subagents-menu-link"
title={`put ${sub.name}'s terminal beside this one`}
aria-current={current ? "true" : undefined}
onClick={() => {
if (!current) onSplit(sub.name);
onPick();
}}
>
{body}
</button>
</li>
);
}
return (
<li>
<Link
@ -45,17 +84,7 @@ function Entry({
onClick={onPick}
aria-current={current ? "page" : undefined}
>
<span class="ui-subagents-menu-name">{sub.name}</span>
{runLabel(sub) && (
<span class="ui-subagents-menu-age ui-subagents-menu-run">
{runLabel(sub)}
</span>
)}
{sub.last_row_unix !== null && (
<span class="ui-subagents-menu-age">
<RelativeTime epochMs={sub.last_row_unix * 1000} />
</span>
)}
{body}
</Link>
</li>
);
@ -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}
/>
))}
</ul>
@ -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}
/>
))}
</ul>

View file

@ -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<string>,
): 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;
}

View file

@ -1,7 +1,8 @@
// <AgentGroupPage> — 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 (
<PaneDropTarget
agent={agent}
agent={key}
onDrop={(side, dropped) =>
groups.update(id, (r) => splitPane(r, agent, dropped, side))
groups.update(id, (r) => splitPane(r, key, dropped, side))
}
>
<AgentTermPreview
agentName={agent}
subagent={subagent}
fullHeight
leading={
<>
<span
class="ui-agent-group-grip"
draggable
title={`drag ${agent} onto another pane to move it`}
onDragStart={(e) => startAgentDrag(e, agent)}
title={`drag ${key} onto another pane to move it`}
onDragStart={(e) => startAgentDrag(e, key)}
onDragEnd={endAgentDrag}
>
<Badge icon={<AgentIcon name={agent} small />} value={agent} />
<Badge icon={<AgentIcon name={agent} small />} value={key} />
</span>
<SplitMenu
candidates={candidates}
onSplit={(other, side) =>
groups.update(id, (r) => splitPane(r, agent, other, side))
groups.update(id, (r) => splitPane(r, key, other, side))
}
/>
<SubagentsMenu
key={agent}
agentName={agent}
shown={inGroup.flatMap((k) => {
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"),
)
}
/>
<Badge
icon={<ExpandIcon />}
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,
})
}
/>
<Badge
variant="quiet"
value="×"
title={`take ${agent} out of this group`}
title={`take ${key} out of this group`}
onClick={() =>
groups.update(id, (r) => removeAgent(r, agent) ?? r)
groups.update(id, (r) => removeAgent(r, key) ?? r)
}
/>
</>

View file

@ -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)}
/>
</span>
<SubagentsMenu key={name} agentName={name} current={subagent} />
<SubagentsMenu
key={name}
agentName={name}
current={subagent}
onSplit={(picked) => {
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) });
}}
/>
</>
}
/>

View file

@ -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);