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
16 changed files with 1270 additions and 33 deletions

View file

@ -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/<id>`. 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

View file

@ -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() {
</Route>
))}
<Route path="/agents" component={AgentsPage} />
<Route path="/agents/group/:id" component={AgentGroupPage} />
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
<Route
path="/agents/:name/subagents/:subagent/terminal"

View file

@ -0,0 +1,314 @@
// The layout of a group of agent terminals, tmux style: a tree whose leaves
// are one agent's terminal and whose inner nodes split their box into a row or
// a column of children with sizes. Pure data and functions, so `npm test` can
// run them; the group page draws the tree and calls these on each drag, drop
// and divider move. Every function returns a new tree and leaves its input
// alone.
/** The edge of a pane a terminal is dropped on or split towards. */
export type Side = "left" | "right" | "top" | "bottom";
export interface Pane {
kind: "pane";
agent: string;
}
export interface Split {
kind: "split";
/** `row`: children side by side; `column`: children stacked. */
dir: "row" | "column";
children: Node[];
/** One share of the box per child, adding up to 1. */
sizes: number[];
}
export type Node = Pane | Split;
/** A saved group: its layout and the name the header tab shows. */
export interface Group {
id: string;
/** What the operator typed; empty while the group goes by its agents. */
name: string;
root: Node;
}
/**
* The smallest share a divider can leave a pane. Not a minimum pane size (the
* operator chose none): it only keeps a pane from vanishing and being
* impossible to drag back out.
*/
export const MIN_SHARE = 0.02;
export const pane = (agent: string): Pane => ({ 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<string>,
): 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<string>): Node | null {
if (typeof value !== "object" || value === null) return null;
const v = value as Record<string, unknown>;
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<string>();
for (const raw of value) {
if (typeof raw !== "object" || raw === null) continue;
const g = raw as Record<string, unknown>;
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]);
}

View file

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

View file

@ -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<unknown>(KEY, []));
export function useTermGroups() {
const [raw, setRaw] = useLocalSetting<unknown>(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));
},
};
}

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}

View file

@ -281,3 +281,8 @@
:root[data-motion="allow"] .shell-body {
animation: shell-page-enter 160ms ease;
}
.shell-tab-rename {
width: 10em;
font: inherit;
}

View file

@ -43,6 +43,13 @@ import {
} from "@hive/shared/solid-backgrounds.js";
import { isFullBleedRoute, visibleNavItems } from "./routes.js";
import { useDynamicTabs } from "./useDynamicTabs.js";
import {
groupIdOf,
groupTitle,
terminalAgentOf,
} from "../agent/terminal/split-layout.js";
import { endAgentDrag, startAgentDrag } from "../agent/terminal/termDrag.js";
import { useTermGroups } from "../agent/terminal/useTermGroups.js";
import { useSwarmName } from "./useSwarmName.js";
import "./Shell.css";
@ -151,17 +158,59 @@ function DynamicTabLink({
label,
active,
onClose,
agent,
onRename,
}: {
href: string;
label: string;
active: boolean;
onClose: () => 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 (
<span class={"shell-tab" + (active ? " shell-tab-active" : "")}>
<Link href={href} className="shell-tab-link">
{label}
</Link>
<span
class={"shell-tab" + (active ? " shell-tab-active" : "")}
draggable={agent !== undefined && !editing}
onDragStart={
agent === undefined ? undefined : (e) => startAgentDrag(e, agent)
}
onDragEnd={agent === undefined ? undefined : endAgentDrag}
>
{editing && onRename !== undefined ? (
<input
class="shell-tab-rename"
aria-label="group name"
value={label}
ref={(el) => 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);
}}
/>
) : (
<Link
href={href}
className="shell-tab-link"
draggable={false}
title={onRename === undefined ? undefined : "double click to rename"}
onDblClick={
onRename === undefined ? undefined : () => setEditing(true)
}
>
{label}
</Link>
)}
<button
type="button"
class="shell-tab-close"
@ -210,6 +259,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
// list above, so they get their own small component (`DynamicTabLink`)
// rather than reusing `NavLink`.
const { tabs, close: closeTab } = useDynamicTabs();
const termGroups = useTermGroups();
// The components tab is a developer page: it goes while "hide developer
// details" is on. The page itself still answers at `/components`.
@ -410,15 +460,32 @@ export function Shell({ children }: { children: ComponentChildren }) {
role="group"
aria-label="open agent terminals"
>
{tabs.map((tab) => (
<DynamicTabLink
key={tab.href}
href={tab.href}
label={tab.label}
active={tab.href === location}
onClose={() => closeTab(tab.href)}
/>
))}
{tabs.map((tab) => {
// A group's tab shows the group's live name, not the label it
// was opened with, so a rename shows at once.
const groupId = groupIdOf(tab.href);
const group =
groupId === null ? undefined : termGroups.find(groupId);
return (
<DynamicTabLink
key={tab.href}
href={tab.href}
label={group === undefined ? tab.label : groupTitle(group)}
active={tab.href === location}
agent={terminalAgentOf(tab.href) ?? undefined}
onRename={
groupId === null
? undefined
: (name) => termGroups.rename(groupId, name.trim())
}
onClose={() => {
// Closing a group's tab is closing the group.
if (groupId !== null) termGroups.remove(groupId);
closeTab(tab.href);
}}
/>
);
})}
</div>
)}
</nav>

View file

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

View file

@ -0,0 +1,69 @@
// <PaneDropTarget> — 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<Side | null>(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 (
<div
class="ui-pane-drop"
onDragOver={(e) => {
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 : (
<div
class={`ui-pane-drop-zone ui-pane-drop-${side}`}
aria-hidden="true"
/>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,125 @@
// <PaneTree> — 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<number | null>(null);
const along = (e: PointerEvent) => (dir === "row" ? e.clientX : e.clientY);
return (
<div
class={`ui-pane-divider ui-pane-divider-${dir}`}
role="separator"
aria-orientation={dir === "row" ? "vertical" : "horizontal"}
tabIndex={0}
onPointerDown={(e) => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
last.current = along(e);
}}
onPointerMove={(e) => {
if (last.current === null) return;
const now = along(e);
onMove(now - last.current);
last.current = now;
}}
onPointerUp={() => {
last.current = null;
}}
onPointerCancel={() => {
last.current = null;
}}
onKeyDown={(e) => {
const back = dir === "row" ? "ArrowLeft" : "ArrowUp";
const fwd = dir === "row" ? "ArrowRight" : "ArrowDown";
if (e.key !== back && e.key !== fwd) return;
e.preventDefault();
// A fixed nudge, turned into a share of the box like a drag.
onMove(e.key === fwd ? 20 : -20);
}}
/>
);
}
function Branch({
node,
path,
renderPane,
onResize,
}: {
node: Node;
path: number[];
renderPane: (agent: string) => ComponentChildren;
onResize: (path: number[], index: number, delta: number) => void;
}) {
const box = useRef<HTMLDivElement>(null);
if (node.kind === "pane") {
return (
<div class="ui-pane-tree-pane" key={node.agent}>
{renderPane(node.agent)}
</div>
);
}
const size = () => {
const el = box.current;
if (el === null) return 1;
return Math.max(1, node.dir === "row" ? el.clientWidth : el.clientHeight);
};
return (
<div class={`ui-pane-tree-split ui-pane-tree-${node.dir}`} ref={box}>
{node.children.map((child, i) => (
<Fragment key={child.kind === "pane" ? child.agent : `split-${i}`}>
{i > 0 ? (
<Divider
dir={node.dir}
onMove={(px) => onResize(path, i - 1, px / size())}
/>
) : null}
<div
class="ui-pane-tree-cell"
style={{ flex: `${node.sizes[i] ?? 0} 1 0` }}
>
<Branch
node={child}
path={[...path, i]}
renderPane={renderPane}
onResize={onResize}
/>
</div>
</Fragment>
))}
</div>
);
}
export function PaneTree({
root,
renderPane,
onResize,
}: {
root: Node;
renderPane: (agent: string) => ComponentChildren;
/** The divider after child `index` of the split at `path` moved by `delta`, a share of that split's box. */
onResize: (path: number[], index: number, delta: number) => void;
}) {
return (
<div class="ui-pane-tree">
<Branch
node={root}
path={[]}
renderPane={renderPane}
onResize={onResize}
/>
</div>
);
}

View file

@ -74,3 +74,8 @@ test("the old notifications list goes to the swarm tab, the editor pages stay",
assert.equal(movedHref("/notifications", ""), "/swarm");
assert.equal(movedHref("/notifications/some-flow", ""), null);
});
test("a group of agent terminals is full-bleed", () => {
assert.equal(isFullBleedRoute("/agents/group/ab12cd34"), true);
assert.equal(isFullBleedRoute("/agents/group"), false);
});

View file

@ -0,0 +1,240 @@
// The layout of a group of agent terminals: splitting, removing, resizing, naming
// and reading a saved group back. Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
agentsIn,
groupTitle,
MIN_SHARE,
pane,
parseGroups,
pruneMissing,
removeAgent,
resizeAt,
sideAt,
splitPane,
groupHref,
groupIdOf,
terminalAgentOf,
} from "../src/agent/terminal/split-layout.ts";
const row = (children, sizes) => ({
kind: "split",
dir: "row",
children,
sizes: sizes ?? children.map(() => 1 / children.length),
});
const column = (children, sizes) => ({
...row(children, sizes),
dir: "column",
});
const near = (got, want) =>
assert.ok(Math.abs(got - want) < 1e-9, `${got} is not ${want}`);
test("dropping on the right edge puts the agent beside the pane, in half its box", () => {
const got = splitPane(pane("foo"), "foo", "bar", "right");
assert.deepEqual(got, row([pane("foo"), pane("bar")], [0.5, 0.5]));
});
test("the left and top edges put the agent before the pane, the bottom edge below it", () => {
assert.deepEqual(agentsIn(splitPane(pane("foo"), "foo", "bar", "left")), [
"bar",
"foo",
]);
assert.equal(splitPane(pane("foo"), "foo", "bar", "top").dir, "column");
assert.deepEqual(
splitPane(pane("foo"), "foo", "bar", "bottom"),
column([pane("foo"), pane("bar")], [0.5, 0.5]),
);
});
test("splitting again the same way adds a child and halves only the pane's share", () => {
const two = row([pane("a"), pane("b")], [0.7, 0.3]);
const got = splitPane(two, "a", "c", "right");
assert.equal(got.kind, "split");
assert.deepEqual(agentsIn(got), ["a", "c", "b"]);
near(got.sizes[0], 0.35);
near(got.sizes[1], 0.35);
near(got.sizes[2], 0.3);
});
test("splitting the other way nests a split inside the pane's box", () => {
const two = row([pane("a"), pane("b")], [0.6, 0.4]);
const got = splitPane(two, "b", "c", "bottom");
assert.deepEqual(got.sizes, [0.6, 0.4]);
assert.deepEqual(got.children[1], column([pane("b"), pane("c")], [0.5, 0.5]));
});
test("an agent already in the group is moved, not copied, and the gap closes", () => {
const three = row([pane("a"), pane("b"), pane("c")]);
const got = splitPane(three, "a", "c", "top");
assert.deepEqual(agentsIn(got).sort(), ["a", "b", "c"]);
assert.equal(agentsIn(got).filter((x) => x === "c").length, 1);
assert.equal(got.kind, "split");
assert.equal(got.dir, "row");
assert.deepEqual(got.children[0], column([pane("c"), pane("a")], [0.5, 0.5]));
});
test("dropping an agent on its own pane, or on a pane that is not there, changes nothing", () => {
const two = row([pane("a"), pane("b")]);
assert.equal(splitPane(two, "a", "a", "left"), two);
assert.equal(splitPane(two, "zzz", "c", "left"), two);
});
test("removing an agent hands its share to the others and a lone child replaces its split", () => {
const three = row([pane("a"), pane("b"), pane("c")], [0.5, 0.25, 0.25]);
const two = removeAgent(three, "a");
assert.deepEqual(two, row([pane("b"), pane("c")], [0.5, 0.5]));
assert.deepEqual(removeAgent(two, "b"), pane("c"));
assert.equal(removeAgent(pane("c"), "c"), null);
assert.equal(removeAgent(two, "nobody") !== null, true);
});
test("removing the last pane of a nested split lets its sibling take the place and merge upwards", () => {
const nested = row([pane("a"), column([pane("b"), pane("c")])], [0.4, 0.6]);
const got = removeAgent(nested, "c");
assert.deepEqual(got, row([pane("a"), pane("b")], [0.4, 0.6]));
// a column that ends up inside a column joins it
const deep = column([
row([pane("a"), column([pane("b"), pane("c")])]),
pane("d"),
]);
assert.deepEqual(agentsIn(removeAgent(deep, "a")), ["b", "c", "d"]);
assert.equal(removeAgent(deep, "a").dir, "column");
});
test("a divider moves share from one neighbour to the other and stops short of zero", () => {
const two = row([pane("a"), pane("b")], [0.5, 0.5]);
const moved = resizeAt(two, [], 0, 0.2);
near(moved.sizes[0], 0.7);
near(moved.sizes[1], 0.3);
const far = resizeAt(two, [], 0, 5);
near(far.sizes[0], 1 - MIN_SHARE);
near(far.sizes[1], MIN_SHARE);
const back = resizeAt(two, [], 0, -5);
near(back.sizes[0], MIN_SHARE);
assert.equal(resizeAt(two, [], 7, 0.1), two);
});
test("a divider of a nested split is found by its path", () => {
const nested = row([pane("a"), column([pane("b"), pane("c")])]);
const got = resizeAt(nested, [1], 0, 0.25);
assert.deepEqual(got.sizes, [0.5, 0.5]);
near(got.children[1].sizes[0], 0.75);
near(got.children[1].sizes[1], 0.25);
});
test("a saved layout loses the agents that no longer exist", () => {
const three = row([pane("a"), pane("b"), pane("c")]);
assert.deepEqual(agentsIn(pruneMissing(three, new Set(["a", "c"]))), [
"a",
"c",
]);
assert.equal(pruneMissing(three, new Set()), null);
assert.equal(pruneMissing(three, new Set(["a", "b", "c"])), three);
});
test("a group goes by its own name, else by its agents", () => {
const root = row([pane("foo"), pane("bar")]);
assert.equal(groupTitle({ id: "1", name: "", root }), "foo + bar");
assert.equal(groupTitle({ id: "1", name: " ", root }), "foo + bar");
assert.equal(groupTitle({ id: "1", name: "infra", root }), "infra");
});
test("a saved group is read back, and what is malformed of it is dropped", () => {
const good = {
id: "g1",
name: "infra",
root: {
kind: "split",
dir: "row",
sizes: [3, 1],
children: [
{ kind: "pane", agent: "a" },
{ kind: "pane", agent: "b" },
],
},
};
const [got] = parseGroups([good]);
assert.equal(got.name, "infra");
near(got.root.sizes[0], 0.75);
near(got.root.sizes[1], 0.25);
assert.deepEqual(parseGroups("nope"), []);
assert.deepEqual(
parseGroups([null, 5, { id: "", root: good.root }, { id: "x" }]),
[],
);
// the same agent twice, a bad direction, a bad child, missing sizes
const odd = parseGroups([
{
id: "g2",
root: {
kind: "split",
dir: "row",
children: [
{ kind: "pane", agent: "a" },
{ kind: "pane", agent: "a" },
{ kind: "pane", agent: "c" },
7,
],
},
},
{
id: "g3",
root: {
kind: "split",
dir: "diagonal",
children: [{ kind: "pane", agent: "a" }],
},
},
{ id: "g1", root: { kind: "pane", agent: "z" } },
]);
assert.deepEqual(
odd.map((g) => g.id),
["g2", "g1"],
);
assert.deepEqual(agentsIn(odd[0].root), ["a", "c"]);
near(odd[0].root.sizes[0], 0.5);
assert.equal(odd[0].name, "");
});
test("a drop falls on the nearest edge of the pane, the diagonals dividing the four", () => {
assert.equal(sideAt(0.1, 0.5), "left");
assert.equal(sideAt(0.9, 0.5), "right");
assert.equal(sideAt(0.5, 0.1), "top");
assert.equal(sideAt(0.5, 0.9), "bottom");
assert.equal(sideAt(0.3, 0.1), "top");
assert.equal(sideAt(0.1, 0.3), "left");
assert.equal(sideAt(0.9, 0.7), "right");
});
test("a group's header tab and an agent's terminal tab are told apart by their paths", () => {
assert.equal(groupHref("ab12"), "/agents/group/ab12");
assert.equal(groupIdOf("/agents/group/ab12"), "ab12");
assert.equal(groupIdOf("/agents/group/"), null);
assert.equal(groupIdOf("/agents/iris/terminal"), null);
assert.equal(terminalAgentOf("/agents/iris/terminal"), "iris");
assert.equal(terminalAgentOf("/agents/iris/subagents/x/terminal"), null);
assert.equal(terminalAgentOf("/agents/group/ab12"), null);
});
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);
assert.ok(grown.sizes[1] > 0.005);
assert.ok(grown.sizes.every((s) => s >= 0));
near(
grown.sizes.reduce((x, y) => x + y, 0),
1,
);
const shrunk = resizeAt(tiny, [], 0, -0.5);
assert.deepEqual(shrunk.sizes, tiny.sizes);
const both = resizeAt(
row([pane("a"), pane("b")], [0.005, 0.005]),
[],
0,
0.3,
);
assert.ok(both.sizes.every((s) => s >= 0));
});