From 839dbd642a0a042f0d1f1cc023d1dfd7f81e7452 Mon Sep 17 00:00:00 2001 From: iris Date: Thu, 5 Nov 2026 08:24:58 +0100 Subject: [PATCH] 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 --- docs/swarm/ui.md | 22 ++ frontend/packages/swarm-ui/src/App.tsx | 2 + .../src/agent/terminal/split-layout.ts | 314 ++++++++++++++++++ .../swarm-ui/src/agent/terminal/termDrag.ts | 23 ++ .../src/agent/terminal/useTermGroups.ts | 47 +++ .../src/pages/agents/AgentGroupPage.css | 35 ++ .../src/pages/agents/AgentGroupPage.tsx | 187 +++++++++++ .../src/pages/agents/AgentTerminalPage.tsx | 68 ++-- .../packages/swarm-ui/src/shell/Shell.css | 5 + .../packages/swarm-ui/src/shell/Shell.tsx | 93 +++++- .../packages/swarm-ui/src/shell/routes.ts | 9 +- .../src/ui/pane-tree/PaneDropTarget.tsx | 69 ++++ .../swarm-ui/src/ui/pane-tree/PaneTree.css | 59 ++++ .../swarm-ui/src/ui/pane-tree/PaneTree.tsx | 125 +++++++ .../packages/swarm-ui/test/routes.test.mjs | 5 + .../swarm-ui/test/split-layout.test.mjs | 240 +++++++++++++ 16 files changed, 1270 insertions(+), 33 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts create mode 100644 frontend/packages/swarm-ui/src/agent/terminal/termDrag.ts create mode 100644 frontend/packages/swarm-ui/src/agent/terminal/useTermGroups.ts create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/pane-tree/PaneDropTarget.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css create mode 100644 frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx create mode 100644 frontend/packages/swarm-ui/test/split-layout.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index dfa50d7a..1038b511 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -157,6 +157,28 @@ popovers on table headers work the same way. You can stop or pause the agent, or link an account, without leaving the terminal. A subagent's tab opens its agent's details. +#### Groups of terminals + +Terminals can sit side by side in a group, split the way tmux splits panes. Drag +another agent's terminal tab from the header onto the left, right, top or +bottom half of a terminal: the half it would take lights up, and the drop makes +a group of the two, with its own header tab at `/agents/group/`. In a group, +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. 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 +vanish. + +A group's tab shows its agents, `foo + bar`. Double-click it to give it a +name; clear the name to go back to the agents. Closing the tab closes the +group. An agent in a group still has its own terminal tab, and you can open it +full screen a second time. Groups and their layouts live in this browser +only, like the open terminal tabs, so they survive a reload but aren't shared +with another browser. + ### Review The **review** card on the **jobs** tab is the one place for the diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index f2cc42fb..c38abe46 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -5,6 +5,7 @@ import { MOVED_ROUTES, movedHref } from "./shell/routes.js"; import { Shell } from "./shell/Shell.js"; import { useGuardedLocation } from "./shell/useGuardedLocation.js"; import { AgentsPage } from "./pages/agents/AgentsPage.js"; +import { AgentGroupPage } from "./pages/agents/AgentGroupPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { MessagesPage } from "./pages/messages/MessagesPage.js"; import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js"; @@ -43,6 +44,7 @@ export function App() { ))} + ({ kind: "pane", agent }); + +/** 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); +} + +export function contains(node: Node, agent: string): boolean { + return agentsIn(node).includes(agent); +} + +function even(n: number): number[] { + return Array.from({ length: n }, () => 1 / n); +} + +function dirOf(side: Side): "row" | "column" { + return side === "left" || side === "right" ? "row" : "column"; +} + +const before = (side: Side) => side === "left" || side === "top"; + +/** + * Takes `agent` out of the layout. A split left with one child gives way to + * that child; the shares of the others are scaled back up to fill the box. + * `null` when the layout held nothing else. + */ +export function removeAgent(node: Node, agent: string): Node | null { + if (node.kind === "pane") return node.agent === agent ? null : node; + const kept: Node[] = []; + const shares: number[] = []; + node.children.forEach((child, i) => { + const next = removeAgent(child, agent); + if (next === null) return; + kept.push(next); + shares.push(node.sizes[i] ?? 0); + }); + if (kept.length === 0) return null; + if (kept.length === 1) return kept[0] ?? null; + const total = shares.reduce((a, b) => a + b, 0); + const sizes = total > 0 ? shares.map((s) => s / total) : even(kept.length); + // A child that became a split of the same direction joins this one. + return flatten({ ...node, children: kept, sizes }); +} + +// A split directly inside a split of the same direction is one split: its +// children are lifted up, each keeping its share of the share it had. +function flatten(split: Split): Split { + const children: Node[] = []; + const sizes: number[] = []; + split.children.forEach((child, i) => { + const share = split.sizes[i] ?? 0; + if (child.kind === "split" && child.dir === split.dir) { + child.children.forEach((c, j) => { + children.push(c); + sizes.push(share * (child.sizes[j] ?? 0)); + }); + } else { + children.push(child); + sizes.push(share); + } + }); + return { ...split, children, sizes }; +} + +/** + * Puts `agent` beside the pane of `target`, on the given side, taking half of + * that pane's box. An agent already in the layout is moved, not copied. + * Dropping an agent on its own pane changes nothing. A `target` that is not in + * the layout leaves it unchanged. + */ +export function splitPane( + root: Node, + target: string, + agent: string, + side: Side, +): Node { + if (target === agent || !contains(root, target)) return root; + const without = contains(root, agent) ? removeAgent(root, agent) : root; + if (without === null) return root; + return place(without, target, agent, side); +} + +function place(node: Node, target: string, agent: string, side: Side): Node { + const dir = dirOf(side); + if (node.kind === "pane") { + if (node.agent !== target) return node; + const added = pane(agent); + return { + kind: "split", + dir, + children: before(side) ? [added, node] : [node, added], + sizes: [0.5, 0.5], + }; + } + const at = node.children.findIndex( + (c) => c.kind === "pane" && c.agent === target, + ); + if (at >= 0 && node.dir === dir) { + // Beside a pane of a split that already runs this way: one more child, + // taking half of the pane's share. + const half = (node.sizes[at] ?? 0) / 2; + const children = [...node.children]; + const sizes = [...node.sizes]; + children.splice(before(side) ? at : at + 1, 0, pane(agent)); + sizes.splice(before(side) ? at : at + 1, 0, half); + sizes[before(side) ? at + 1 : at] = half; + return { ...node, children, sizes }; + } + return { + ...node, + children: node.children.map((c) => place(c, target, agent, side)), + }; +} + +/** + * Moves the divider after child `index` of the split at `path` (the child + * indexes from the root) by `delta`, a share of the split's box: the child + * before it grows by that much and the one after shrinks, neither going below + * {@link MIN_SHARE}. + */ +export function resizeAt( + root: Node, + path: number[], + index: number, + delta: number, +): Node { + if (root.kind === "pane") return root; + const [head, ...rest] = path; + if (head !== undefined) { + const child = root.children[head]; + if (child === undefined) return root; + const children = [...root.children]; + children[head] = resizeAt(child, rest, index, delta); + return { ...root, children }; + } + const a = root.sizes[index]; + const b = root.sizes[index + 1]; + if (a === undefined || b === undefined) return root; + // A pane already below the floor (repeated splits halve a share) can grow but + // not shrink further, so a drag cannot jump or push a share negative. + const shift = Math.max( + -Math.max(a - MIN_SHARE, 0), + Math.min(Math.max(b - MIN_SHARE, 0), delta), + ); + const sizes = [...root.sizes]; + sizes[index] = a + shift; + sizes[index + 1] = b - shift; + return { ...root, sizes }; +} + +/** + * The layout without the agents `existing` lacks: a saved layout can name an + * agent that has since been destroyed. `null` when none is left. + */ +export function pruneMissing( + root: Node, + 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); + } + return out; +} + +/** What a group's header tab shows: its own name, or its agents. */ +export function groupTitle(group: Group): string { + const name = group.name.trim(); + return name === "" ? agentsIn(group.root).join(" + ") : name; +} + +// --- saved groups --------------------------------------------------------- + +function parseNode(value: unknown, seen: Set): Node | null { + if (typeof value !== "object" || value === null) return null; + const v = value as Record; + if (v["kind"] === "pane") { + const agent = v["agent"]; + // An agent shows once in a group. + if (typeof agent !== "string" || agent === "" || seen.has(agent)) { + return null; + } + seen.add(agent); + return pane(agent); + } + if (v["kind"] !== "split") return null; + const dir = v["dir"]; + const rawChildren = v["children"]; + const rawSizes = v["sizes"]; + if ((dir !== "row" && dir !== "column") || !Array.isArray(rawChildren)) { + return null; + } + const children: Node[] = []; + const shares: number[] = []; + rawChildren.forEach((raw, i) => { + const child = parseNode(raw, seen); + if (child === null) return; + const share = Array.isArray(rawSizes) ? rawSizes[i] : undefined; + children.push(child); + shares.push(typeof share === "number" && share > 0 ? share : 0); + }); + if (children.length === 0) return null; + if (children.length === 1) return children[0] ?? null; + const total = shares.reduce((a, b) => a + b, 0); + const sizes = + total > 0 ? shares.map((s) => s / total) : even(children.length); + return flatten({ kind: "split", dir, children, sizes }); +} + +/** + * The groups of a saved value (the parsed JSON of what the page stored), what + * is well formed of it: anything else is dropped, so a stale or hand-edited + * entry cannot break the page. + */ +export function parseGroups(value: unknown): Group[] { + if (!Array.isArray(value)) return []; + const groups: Group[] = []; + const ids = new Set(); + for (const raw of value) { + if (typeof raw !== "object" || raw === null) continue; + const g = raw as Record; + const id = g["id"]; + if (typeof id !== "string" || id === "" || ids.has(id)) continue; + const root = parseNode(g["root"], new Set()); + if (root === null) continue; + ids.add(id); + groups.push({ + id, + name: typeof g["name"] === "string" ? g["name"] : "", + root, + }); + } + return groups; +} + +// --- dropping --------------------------------------------------------------- + +/** + * The side a drop at (`fx`, `fy`) falls on, each a fraction (0 to 1) of the + * pane's width and height from its top left corner: the pane is cut into four + * triangles by its diagonals, so the whole pane is a drop zone and the nearest + * edge wins. + */ +export function sideAt(fx: number, fy: number): Side { + const dx = fx - 0.5; + const dy = fy - 0.5; + if (Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? "left" : "right"; + return dy < 0 ? "top" : "bottom"; +} + +// --- where a group lives in the header ---------------------------------------- + +const GROUP_PREFIX = "/agents/group/"; + +/** The path of a group's page, and so its header tab's `href`. */ +export function groupHref(id: string): string { + return `${GROUP_PREFIX}${encodeURIComponent(id)}`; +} + +/** The group a header tab's `href` opens, `null` for any other tab. */ +export function groupIdOf(href: string): string | null { + if (!href.startsWith(GROUP_PREFIX)) return null; + const id = href.slice(GROUP_PREFIX.length); + return id === "" || id.includes("/") ? null : decodeURIComponent(id); +} + +/** The agent whose own full-screen terminal a header tab's `href` opens. */ +export function terminalAgentOf(href: string): string | null { + const m = /^\/agents\/([^/]+)\/terminal$/.exec(href); + return m?.[1] === undefined || m[1] === "group" + ? null + : decodeURIComponent(m[1]); +} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/termDrag.ts b/frontend/packages/swarm-ui/src/agent/terminal/termDrag.ts new file mode 100644 index 00000000..ace4d362 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/termDrag.ts @@ -0,0 +1,23 @@ +// What a drag of an agent's terminal carries, from a header tab or a pane's +// header to a pane it is dropped on. A browser lets a drop target read the +// payload only on drop, so the agent also sits in a module variable for the +// hover, which decides whether a pane lights up for it. +export const AGENT_DRAG_TYPE = "application/x-hive-agent"; + +let dragged: string | null = null; + +export function startAgentDrag(e: DragEvent, agent: string) { + dragged = agent; + e.dataTransfer?.setData(AGENT_DRAG_TYPE, agent); + e.dataTransfer?.setData("text/plain", agent); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; +} + +export function endAgentDrag() { + dragged = null; +} + +/** The agent being dragged, `null` when the drag is something else. */ +export function draggedAgent(): string | null { + return dragged; +} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useTermGroups.ts b/frontend/packages/swarm-ui/src/agent/terminal/useTermGroups.ts new file mode 100644 index 00000000..496eaae5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/useTermGroups.ts @@ -0,0 +1,47 @@ +// The groups of agent terminals the operator has made, kept in the browser +// (`localStorage`) like the header's terminal tabs, so a reload keeps them. +// Every call reads the stored groups afresh before it changes one, so two +// changes in the same tick do not undo each other. The header's tab row, a +// group's page and an agent's terminal page each call this hook and stay in +// step through the shared setting's same-tab notification. +import { + readLocalSetting, + useLocalSetting, +} from "@hive/shared/settings-storage.js"; +import { parseGroups, type Group, type Node } from "./split-layout.js"; + +const KEY = "swarm-ui:term-groups"; + +const stored = (): Group[] => parseGroups(readLocalSetting(KEY, [])); + +export function useTermGroups() { + const [raw, setRaw] = useLocalSetting(KEY, []); + const groups = parseGroups(raw); + + return { + groups, + find: (id: string): Group | undefined => groups.find((g) => g.id === id), + /** Saves a new group holding `root`, and returns it. */ + create(root: Node): Group { + const group: Group = { + id: crypto.randomUUID().slice(0, 8), + name: "", + root, + }; + setRaw([...stored(), group]); + return group; + }, + /** Changes the layout of group `id` by `change`, from what is stored now. */ + update(id: string, change: (root: Node) => Node) { + setRaw( + stored().map((g) => (g.id === id ? { ...g, root: change(g.root) } : g)), + ); + }, + rename(id: string, name: string) { + setRaw(stored().map((g) => (g.id === id ? { ...g, name } : g))); + }, + remove(id: string) { + setRaw(stored().filter((g) => g.id !== id)); + }, + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.css b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.css new file mode 100644 index 00000000..63553426 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.css @@ -0,0 +1,35 @@ +/* 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; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx new file mode 100644 index 00000000..c0a3f871 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx @@ -0,0 +1,187 @@ +// — 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 ( + + {candidates.length === 0 ? ( +

+ Open another agent's terminal tab first, then split it in here, or + drag its tab onto a pane. +

+ ) : ( +
    + {candidates.map((agent) => ( +
  • + {agent} + + +
  • + ))} +
+ )} +
+ ); +} + +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 ( +
+

This group no longer exists: it is saved in this browser only.

+ +
+ ); + } + + const candidates = tabs + .map((t) => terminalAgentOf(t.href)) + .filter((a): a is string => a !== null && !inGroup.includes(a)); + + function renderPane(agent: string) { + return ( + + groups.update(id, (r) => splitPane(r, agent, dropped, side)) + } + > + + startAgentDrag(e, agent)} + onDragEnd={endAgentDrag} + > + } value={agent} /> + + + groups.update(id, (r) => splitPane(r, agent, other, side)) + } + /> + + groups.update(id, (r) => removeAgent(r, agent) ?? r) + } + /> + + } + /> + + ); + } + + return ( +
+ + groups.update(id, (r) => resizeAt(r, path, index, delta)) + } + /> +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx index 5e9d3cfe..55a79dba 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx @@ -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(null); + const groups = useTermGroups(); + + const terminal = ( + + + } + value={name} + title={`${detailOpen ? "close" : "open"} ${name}'s details`} + expanded={detailOpen} + onClick={() => setDetailOpen((o) => !o)} + /> + + + + } + /> + ); return (
- - - } - value={name} - title={`${detailOpen ? "close" : "open"} ${name}'s details`} - expanded={detailOpen} - onClick={() => setDetailOpen((o) => !o)} - /> - - - - } - /> + {/* 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 ? ( + { + const group = groups.create( + splitPane(pane(name), name, dropped, side), + ); + open({ href: groupHref(group.id), label: groupTitle(group) }); + }} + > + {terminal} + + ) : ( + terminal + )} void; + /** Set on an agent's terminal tab: it can be dragged onto a terminal to split it. */ + agent?: string; + /** Set on a group's tab: a double click renames the group. */ + onRename?: (name: string) => void; }) { + const [editing, setEditing] = useState(false); return ( - - - {label} - + startAgentDrag(e, agent) + } + onDragEnd={agent === undefined ? undefined : endAgentDrag} + > + {editing && onRename !== undefined ? ( + el?.select()} + onKeyDown={(e) => { + if (e.key === "Escape") setEditing(false); + if (e.key === "Enter") { + onRename((e.currentTarget as HTMLInputElement).value); + setEditing(false); + } + }} + onBlur={(e) => { + onRename((e.currentTarget as HTMLInputElement).value); + setEditing(false); + }} + /> + ) : ( + setEditing(true) + } + > + {label} + + )}
)} diff --git a/frontend/packages/swarm-ui/src/shell/routes.ts b/frontend/packages/swarm-ui/src/shell/routes.ts index 36534860..2d5a2ea7 100644 --- a/frontend/packages/swarm-ui/src/shell/routes.ts +++ b/frontend/packages/swarm-ui/src/shell/routes.ts @@ -10,10 +10,17 @@ const FULL_BLEED_ROUTE_PATTERN = /^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/; +// A group of terminals (`/agents/group/:id`) is full-bleed as well. +const GROUP_ROUTE_PATTERN = /^\/agents\/group\/[^/]+$/; + // The start page (`/`) is full-bleed too: its rain backdrop fills the whole // body below the header. export function isFullBleedRoute(location: string): boolean { - return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location); + return ( + location === "/" || + FULL_BLEED_ROUTE_PATTERN.test(location) || + GROUP_ROUTE_PATTERN.test(location) + ); } // Routes that only a developer needs. "hide developer details" takes their diff --git a/frontend/packages/swarm-ui/src/ui/pane-tree/PaneDropTarget.tsx b/frontend/packages/swarm-ui/src/ui/pane-tree/PaneDropTarget.tsx new file mode 100644 index 00000000..21fb1bed --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/pane-tree/PaneDropTarget.tsx @@ -0,0 +1,69 @@ +// — a box that takes an agent's terminal dropped on it and +// says which side it fell on. While one is dragged over, the half of the box +// the drop would take is lit, so the operator sees where the new pane goes. +import { useState } from "preact/hooks"; +import type { ComponentChildren } from "preact"; +import { sideAt, type Side } from "../../agent/terminal/split-layout.js"; +import { + AGENT_DRAG_TYPE, + draggedAgent, +} from "../../agent/terminal/termDrag.js"; +import "./PaneTree.css"; + +export function PaneDropTarget({ + agent, + onDrop, + children, +}: { + /** The agent whose pane this is: it takes no drop of itself. */ + agent: string; + onDrop: (side: Side, dropped: string) => void; + children: ComponentChildren; +}) { + const [side, setSide] = useState(null); + + const accepts = (e: DragEvent) => + e.dataTransfer?.types.includes(AGENT_DRAG_TYPE) === true && + draggedAgent() !== agent; + const sideOf = (e: DragEvent): Side => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + return sideAt( + (e.clientX - r.left) / r.width, + (e.clientY - r.top) / r.height, + ); + }; + + return ( +
{ + if (!accepts(e)) return; + e.preventDefault(); + setSide(sideOf(e)); + }} + onDragLeave={(e) => { + // Moving onto a child fires a leave on the box; only a real exit counts. + const to = e.relatedTarget as Node | null; + if (to === null || !(e.currentTarget as HTMLElement).contains(to)) { + setSide(null); + } + }} + onDrop={(e) => { + if (!accepts(e)) return; + e.preventDefault(); + const dropped = draggedAgent(); + const where = sideOf(e); + setSide(null); + if (dropped !== null) onDrop(where, dropped); + }} + > + {children} + {side === null ? null : ( + + ); +} diff --git a/frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css b/frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css new file mode 100644 index 00000000..f47c42fe --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css @@ -0,0 +1,59 @@ +.ui-pane-tree, +.ui-pane-tree-split, +.ui-pane-tree-cell, +.ui-pane-tree-pane, +.ui-pane-drop { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} +.ui-pane-tree-row { + flex-direction: row; +} +.ui-pane-tree-column { + flex-direction: column; +} +.ui-pane-tree-pane, +.ui-pane-drop { + position: relative; + flex-direction: column; +} +.ui-pane-divider { + flex: none; + background: var(--border); + touch-action: none; +} +.ui-pane-divider-row { + width: 5px; + cursor: col-resize; +} +.ui-pane-divider-column { + height: 5px; + cursor: row-resize; +} +.ui-pane-divider:hover, +.ui-pane-divider:focus-visible { + background: var(--purple); + outline: none; +} +/* The half of a pane a drop would take, lit while an agent is dragged over. */ +.ui-pane-drop-zone { + position: absolute; + pointer-events: none; + background: color-mix(in srgb, var(--purple) 25%, transparent); + border: 2px solid var(--purple); + z-index: 2; +} +.ui-pane-drop-left { + inset: 0 50% 0 0; +} +.ui-pane-drop-right { + inset: 0 0 0 50%; +} +.ui-pane-drop-top { + inset: 0 0 50% 0; +} +.ui-pane-drop-bottom { + inset: 50% 0 0 0; +} diff --git a/frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx b/frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx new file mode 100644 index 00000000..42d99be5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx @@ -0,0 +1,125 @@ +// — draws a split tree (`agent/terminal/split-layout.ts`): panes +// in rows and columns that fill their box, with a divider between neighbours +// that is dragged with the pointer or moved with the arrow keys. It owns the +// geometry only; what a pane shows is the caller's (`renderPane`). +import { useRef } from "preact/hooks"; +import { Fragment, type ComponentChildren } from "preact"; +import type { Node } from "../../agent/terminal/split-layout.js"; +import "./PaneTree.css"; + +function Divider({ + dir, + onMove, +}: { + dir: "row" | "column"; + /** Pixels moved along the split's axis since the last event. */ + onMove: (px: number) => void; +}) { + const last = useRef(null); + const along = (e: PointerEvent) => (dir === "row" ? e.clientX : e.clientY); + return ( +