Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: groups of agent terminals: drag a terminal tab onto a terminal to split it, tmux style, with resizable panes, a split button, and a name you can edit

This commit is contained in:
iris 2026-11-05 08:24:58 +01:00
commit 839dbd642a
3 changed files with 1270 additions and 33 deletions

View file

@ -0,0 +1,35 @@
/* <AgentGroupPage> fills `.shell-body-full`, like a single terminal page. */
.ui-agent-group-page {
flex: 1;
min-height: 0;
display: flex;
}
.ui-agent-group-page .ui-agent-term-preview-full {
flex: 1;
min-height: 0;
}
.ui-agent-group-grip {
cursor: grab;
}
.ui-agent-group-menu {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4em;
}
.ui-agent-group-menu li {
display: flex;
align-items: center;
gap: 0.5em;
}
.ui-agent-group-menu-name {
flex: 1;
}
.ui-agent-group-menu-empty {
margin: 0;
max-width: 22em;
}
.ui-agent-group-missing {
padding: 2em;
}

View file

@ -0,0 +1,187 @@
// <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
// 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.
//
// Registers its header tab like an agent terminal does, so a reload or a
// bookmark reopens it. The tab shows the group's name, or its agents.
import { useEffect } from "preact/hooks";
import { useLocation, type RouteComponentProps } from "wouter-preact";
import { Badge } from "@hive/shared/badge.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import {
AgentTermPreview,
termPaths,
} from "../../agent/terminal/AgentTermPreview.js";
import {
agentsIn,
groupHref,
groupTitle,
pruneMissing,
removeAgent,
resizeAt,
splitPane,
terminalAgentOf,
type Side,
} from "../../agent/terminal/split-layout.js";
import { endAgentDrag, startAgentDrag } from "../../agent/terminal/termDrag.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { useTermGroups } from "../../agent/terminal/useTermGroups.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js";
import { PaneDropTarget } from "../../ui/pane-tree/PaneDropTarget.js";
import { PaneTree } from "../../ui/pane-tree/PaneTree.js";
import "./AgentGroupPage.css";
// The split menu of a pane: the agents whose terminal tab is open and that are
// not in the group yet, each to be put beside this pane on the right or below.
function SplitMenu({
candidates,
onSplit,
}: {
candidates: string[];
onSplit: (agent: string, side: Side) => void;
}) {
return (
<BadgePopover
value="split"
title="put another agent's terminal beside this one"
popoverLabel="split this pane"
>
{candidates.length === 0 ? (
<p class="ui-agent-group-menu-empty">
Open another agent's terminal tab first, then split it in here, or
drag its tab onto a pane.
</p>
) : (
<ul class="ui-agent-group-menu">
{candidates.map((agent) => (
<li key={agent}>
<span class="ui-agent-group-menu-name">{agent}</span>
<Button onClick={() => onSplit(agent, "right")}>right</Button>
<Button onClick={() => onSplit(agent, "bottom")}>below</Button>
</li>
))}
</ul>
)}
</BadgePopover>
);
}
export function AgentGroupPage({
params,
}: RouteComponentProps<{ id: string }>) {
const id = decodeURIComponent(params.id);
const href = groupHref(id);
const groups = useTermGroups();
const group = groups.find(id);
const { tabs, open, close } = useDynamicTabs();
const [, navigate] = useLocation();
const inGroup = group === undefined ? [] : agentsIn(group.root);
// A saved group can name an agent that has been destroyed since: it goes
// out of the layout. Nothing is pruned until the agent list has loaded.
const { data: roster } = usePolled(60_000, (signal) =>
fetchJson<{ name: string }[]>("/api/agents/status", signal),
);
const known =
roster === null || roster === undefined ? null : roster.map((r) => r.name);
const missing =
known === null ? [] : inGroup.filter((a) => !known.includes(a));
useEffect(() => {
if (known === null || missing.length === 0) return;
groups.update(id, (r) => pruneMissing(r, new Set(known)) ?? r);
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs when the missing set changes.
}, [missing.join(",")]);
const title = group === undefined ? "" : groupTitle(group);
// Registers the header tab once per group, like a terminal's page does.
useEffect(() => {
if (group !== undefined) open({ href, label: title });
// eslint-disable-next-line react-hooks/exhaustive-deps -- `open` is a
// fresh function every render; the tab is registered once per group.
}, [id, group === undefined]);
// A group of one holds nothing a terminal of its own does not: it goes, and
// the page lands on that agent.
useEffect(() => {
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);
// eslint-disable-next-line react-hooks/exhaustive-deps -- as above.
}, [inGroup.length, group === undefined]);
if (group === undefined) {
return (
<div class="ui-agent-group-missing">
<p>This group no longer exists: it is saved in this browser only.</p>
<Button onClick={() => navigate("/agents")}>back to the agents</Button>
</div>
);
}
const candidates = tabs
.map((t) => terminalAgentOf(t.href))
.filter((a): a is string => a !== null && !inGroup.includes(a));
function renderPane(agent: string) {
return (
<PaneDropTarget
agent={agent}
onDrop={(side, dropped) =>
groups.update(id, (r) => splitPane(r, agent, dropped, side))
}
>
<AgentTermPreview
agentName={agent}
fullHeight
leading={
<>
<span
class="ui-agent-group-grip"
draggable
title={`drag ${agent} onto another pane to move it`}
onDragStart={(e) => startAgentDrag(e, agent)}
onDragEnd={endAgentDrag}
>
<Badge icon={<AgentIcon name={agent} small />} value={agent} />
</span>
<SplitMenu
candidates={candidates}
onSplit={(other, side) =>
groups.update(id, (r) => splitPane(r, agent, other, side))
}
/>
<Badge
variant="quiet"
value="×"
title={`take ${agent} out of this group`}
onClick={() =>
groups.update(id, (r) => removeAgent(r, agent) ?? r)
}
/>
</>
}
/>
</PaneDropTarget>
);
}
return (
<div class="ui-agent-group-page">
<PaneTree
root={group.root}
renderPane={renderPane}
onResize={(path, index, delta) =>
groups.update(id, (r) => resizeAt(r, path, index, delta))
}
/>
</div>
);
}

View file

@ -20,7 +20,15 @@ import {
termPaths,
} from "../../agent/terminal/AgentTermPreview.js";
import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.js";
import {
groupHref,
groupTitle,
pane,
splitPane,
} from "../../agent/terminal/split-layout.js";
import { useTermGroups } from "../../agent/terminal/useTermGroups.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import { PaneDropTarget } from "../../ui/pane-tree/PaneDropTarget.js";
import "./AgentTerminalPage.css";
// Also mounted at `/agents/:name/subagents/:subagent/terminal`, for one of
@ -55,28 +63,50 @@ export function AgentTerminalPage({
// own.
const [detailOpen, setDetailOpen] = useState(false);
const badgeRef = useRef<HTMLSpanElement>(null);
const groups = useTermGroups();
const terminal = (
<AgentTermPreview
agentName={name}
subagent={subagent}
fullHeight
leading={
<>
<span ref={badgeRef}>
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
expanded={detailOpen}
onClick={() => setDetailOpen((o) => !o)}
/>
</span>
<SubagentsMenu key={name} agentName={name} current={subagent} />
</>
}
/>
);
return (
<div class="ui-agent-terminal-page">
<AgentTermPreview
agentName={name}
subagent={subagent}
fullHeight
leading={
<>
<span ref={badgeRef}>
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
expanded={detailOpen}
onClick={() => setDetailOpen((o) => !o)}
/>
</span>
<SubagentsMenu key={name} agentName={name} current={subagent} />
</>
}
/>
{/* Another agent's terminal tab dropped on a side of this terminal starts
a group of the two, split that way. A subagent's terminal takes no
drop. */}
{subagent === undefined ? (
<PaneDropTarget
agent={name}
onDrop={(side, dropped) => {
const group = groups.create(
splitPane(pane(name), name, dropped, side),
);
open({ href: groupHref(group.id), label: groupTitle(group) });
}}
>
{terminal}
</PaneDropTarget>
) : (
terminal
)}
<AgentDetailPopover
agent={name}
open={detailOpen}