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
|
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||||
its agent's details.
|
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
|
### Review
|
||||||
|
|
||||||
The **review** card on the **jobs** tab is the one place for the
|
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 { Shell } from "./shell/Shell.js";
|
||||||
import { useGuardedLocation } from "./shell/useGuardedLocation.js";
|
import { useGuardedLocation } from "./shell/useGuardedLocation.js";
|
||||||
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||||
|
import { AgentGroupPage } from "./pages/agents/AgentGroupPage.js";
|
||||||
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
||||||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||||
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
||||||
|
|
@ -43,6 +44,7 @@ export function App() {
|
||||||
</Route>
|
</Route>
|
||||||
))}
|
))}
|
||||||
<Route path="/agents" component={AgentsPage} />
|
<Route path="/agents" component={AgentsPage} />
|
||||||
|
<Route path="/agents/group/:id" component={AgentGroupPage} />
|
||||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||||
<Route
|
<Route
|
||||||
path="/agents/:name/subagents/:subagent/terminal"
|
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,
|
termPaths,
|
||||||
} from "../../agent/terminal/AgentTermPreview.js";
|
} from "../../agent/terminal/AgentTermPreview.js";
|
||||||
import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.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 { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||||
|
import { PaneDropTarget } from "../../ui/pane-tree/PaneDropTarget.js";
|
||||||
import "./AgentTerminalPage.css";
|
import "./AgentTerminalPage.css";
|
||||||
|
|
||||||
// Also mounted at `/agents/:name/subagents/:subagent/terminal`, for one of
|
// Also mounted at `/agents/:name/subagents/:subagent/terminal`, for one of
|
||||||
|
|
@ -55,28 +63,50 @@ export function AgentTerminalPage({
|
||||||
// own.
|
// own.
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const badgeRef = useRef<HTMLSpanElement>(null);
|
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 (
|
return (
|
||||||
<div class="ui-agent-terminal-page">
|
<div class="ui-agent-terminal-page">
|
||||||
<AgentTermPreview
|
{/* Another agent's terminal tab dropped on a side of this terminal starts
|
||||||
agentName={name}
|
a group of the two, split that way. A subagent's terminal takes no
|
||||||
subagent={subagent}
|
drop. */}
|
||||||
fullHeight
|
{subagent === undefined ? (
|
||||||
leading={
|
<PaneDropTarget
|
||||||
<>
|
agent={name}
|
||||||
<span ref={badgeRef}>
|
onDrop={(side, dropped) => {
|
||||||
<Badge
|
const group = groups.create(
|
||||||
icon={<AgentIcon name={name} small />}
|
splitPane(pane(name), name, dropped, side),
|
||||||
value={name}
|
);
|
||||||
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
|
open({ href: groupHref(group.id), label: groupTitle(group) });
|
||||||
expanded={detailOpen}
|
}}
|
||||||
onClick={() => setDetailOpen((o) => !o)}
|
>
|
||||||
/>
|
{terminal}
|
||||||
</span>
|
</PaneDropTarget>
|
||||||
<SubagentsMenu key={name} agentName={name} current={subagent} />
|
) : (
|
||||||
</>
|
terminal
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
<AgentDetailPopover
|
<AgentDetailPopover
|
||||||
agent={name}
|
agent={name}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
|
|
|
||||||
|
|
@ -281,3 +281,8 @@
|
||||||
:root[data-motion="allow"] .shell-body {
|
:root[data-motion="allow"] .shell-body {
|
||||||
animation: shell-page-enter 160ms ease;
|
animation: shell-page-enter 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-tab-rename {
|
||||||
|
width: 10em;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,13 @@ import {
|
||||||
} from "@hive/shared/solid-backgrounds.js";
|
} from "@hive/shared/solid-backgrounds.js";
|
||||||
import { isFullBleedRoute, visibleNavItems } from "./routes.js";
|
import { isFullBleedRoute, visibleNavItems } from "./routes.js";
|
||||||
import { useDynamicTabs } from "./useDynamicTabs.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 { useSwarmName } from "./useSwarmName.js";
|
||||||
import "./Shell.css";
|
import "./Shell.css";
|
||||||
|
|
||||||
|
|
@ -151,17 +158,59 @@ function DynamicTabLink({
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
onClose,
|
onClose,
|
||||||
|
agent,
|
||||||
|
onRename,
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClose: () => void;
|
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 (
|
return (
|
||||||
<span class={"shell-tab" + (active ? " shell-tab-active" : "")}>
|
<span
|
||||||
<Link href={href} className="shell-tab-link">
|
class={"shell-tab" + (active ? " shell-tab-active" : "")}
|
||||||
{label}
|
draggable={agent !== undefined && !editing}
|
||||||
</Link>
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shell-tab-close"
|
class="shell-tab-close"
|
||||||
|
|
@ -210,6 +259,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
// list above, so they get their own small component (`DynamicTabLink`)
|
// list above, so they get their own small component (`DynamicTabLink`)
|
||||||
// rather than reusing `NavLink`.
|
// rather than reusing `NavLink`.
|
||||||
const { tabs, close: closeTab } = useDynamicTabs();
|
const { tabs, close: closeTab } = useDynamicTabs();
|
||||||
|
const termGroups = useTermGroups();
|
||||||
|
|
||||||
// The components tab is a developer page: it goes while "hide developer
|
// The components tab is a developer page: it goes while "hide developer
|
||||||
// details" is on. The page itself still answers at `/components`.
|
// details" is on. The page itself still answers at `/components`.
|
||||||
|
|
@ -410,15 +460,32 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="open agent terminals"
|
aria-label="open agent terminals"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => {
|
||||||
<DynamicTabLink
|
// A group's tab shows the group's live name, not the label it
|
||||||
key={tab.href}
|
// was opened with, so a rename shows at once.
|
||||||
href={tab.href}
|
const groupId = groupIdOf(tab.href);
|
||||||
label={tab.label}
|
const group =
|
||||||
active={tab.href === location}
|
groupId === null ? undefined : termGroups.find(groupId);
|
||||||
onClose={() => closeTab(tab.href)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,17 @@
|
||||||
const FULL_BLEED_ROUTE_PATTERN =
|
const FULL_BLEED_ROUTE_PATTERN =
|
||||||
/^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
|
/^\/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
|
// The start page (`/`) is full-bleed too: its rain backdrop fills the whole
|
||||||
// body below the header.
|
// body below the header.
|
||||||
export function isFullBleedRoute(location: string): boolean {
|
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
|
// 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", ""), "/swarm");
|
||||||
assert.equal(movedHref("/notifications/some-flow", ""), null);
|
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