swarm-ui: groups of agent terminals: drag a terminal tab onto a terminal to split it, tmux style, with resizable panes, a split button, and a name you can edit
This commit is contained in:
parent
acc480c4f3
commit
839dbd642a
3 changed files with 1270 additions and 33 deletions
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue