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
16 changed files with 1270 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
314
frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts
Normal file
314
frontend/packages/swarm-ui/src/agent/terminal/split-layout.ts
Normal 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]);
|
||||
}
|
||||
23
frontend/packages/swarm-ui/src/agent/terminal/termDrag.ts
Normal file
23
frontend/packages/swarm-ui/src/agent/terminal/termDrag.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -281,3 +281,8 @@
|
|||
:root[data-motion="allow"] .shell-body {
|
||||
animation: shell-page-enter 160ms ease;
|
||||
}
|
||||
|
||||
.shell-tab-rename {
|
||||
width: 10em;
|
||||
font: inherit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
59
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css
Normal file
59
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.css
Normal 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;
|
||||
}
|
||||
125
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx
Normal file
125
frontend/packages/swarm-ui/src/ui/pane-tree/PaneTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
240
frontend/packages/swarm-ui/test/split-layout.test.mjs
Normal file
240
frontend/packages/swarm-ui/test/split-layout.test.mjs
Normal 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));
|
||||
});
|
||||
Loading…
Reference in a new issue