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:
parent
5665bbf827
commit
d733b1e2a9
7 changed files with 168 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue