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:
parent
acc480c4f3
commit
839dbd642a
3 changed files with 1270 additions and 33 deletions
|
|
@ -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;
|
||||
}
|
||||
187
frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx
Normal file
187
frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue