Files
AgentHub/client/src/lib/__tests__/paneLayout.test.ts
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

449 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file paneLayout.test.ts
* @description Tests for the session view's split-pane tree: opening a run
* (replace the focused pane, or split it), closing a pane and handing focus to
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* dropping one tab onto another (joined in a fixed order: right, under the
* right pane, under the left pane), and the per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
type PaneGroups,
type PaneLayout,
type PaneNode,
EMPTY_GROUPS,
MAX_PANES,
closeInLayout,
detachSlot,
joinGroup,
layoutRects,
leaves,
loadGroups,
moveInLayout,
openInGroups,
openInLayout,
pane,
parseGroups,
parseLayout,
pruneGroups,
pruneLayout,
removeSlot,
reorderGroups,
saveGroups,
setRatio,
updateActive,
split,
swapInLayout,
} from "../paneLayout";
const one: PaneLayout = { root: pane(0), focus: 0 };
describe("openInLayout", () => {
it("starts a single pane on an empty layout", () => {
const l = openInLayout({ root: null, focus: null }, 2, "replace");
expect(l).toEqual({ root: pane(2), focus: 2 });
});
it("only focuses a slot that is already shown", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
expect(openInLayout(l, 1, "split")).toEqual({ ...l, focus: 1 });
});
it("replaces the focused pane on a plain open", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 1 };
const out = openInLayout(l, 3, "replace");
expect(out.root).toEqual(split("row", pane(0), pane(3)));
expect(out.focus).toBe(3);
});
it("fills a split open into the 2×2 grid: right, under the right, under the left", () => {
let l: PaneLayout = one;
l = openInLayout(l, 1, "split");
expect(l.root).toEqual(split("row", pane(0), pane(1)));
// The focus is on the left pane: the grid order wins, not the focus.
l = openInLayout({ ...l, focus: 0 }, 2, "split");
expect(l.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
l = openInLayout(l, 3, "split");
expect(l.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(l.focus).toBe(3);
});
it("a split open fills the one gap of a three-pane layout, whatever its shape", () => {
const left: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
expect(openInLayout(left, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
// A dragged divider does not move the gap.
const wide: PaneLayout = {
root: split("row", pane(0), split("col", pane(1), pane(2)), 0.3),
focus: 1,
};
expect(openInLayout(wide, 3, "split").root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)), 0.3)
);
});
it("replaces instead of splitting once MAX_PANES are shown", () => {
let l: PaneLayout = one;
for (const s of [1, 2, 3]) l = openInLayout(l, s, "split");
expect(leaves(l.root)).toHaveLength(MAX_PANES);
// A fifth run cannot exist (4 slots), but a stale focus must still be safe.
const out = openInLayout({ ...l, focus: 9 }, 3, "split");
expect(leaves(out.root)).toHaveLength(MAX_PANES);
});
it("falls back to the first pane when the stored focus is not in the tree", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 7 };
expect(openInLayout(l, 2, "replace").root).toEqual(split("row", pane(2), pane(1)));
});
});
describe("closeInLayout", () => {
it("hands focus to the pane that takes the closed one's place", () => {
const l: PaneLayout = {
root: split("row", pane(0), split("col", pane(1), pane(2))),
focus: 0,
};
const out = closeInLayout(l, 0);
expect(out.root).toEqual(split("col", pane(1), pane(2)));
expect(out.focus).toBe(1);
});
it("keeps focus when another pane closes", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
expect(closeInLayout(l, 1)).toEqual({ root: pane(0), focus: 0 });
});
it("empties the layout when the last pane closes", () => {
expect(closeInLayout(one, 0)).toEqual({ root: null, focus: null });
});
it("ignores a slot that is not shown", () => {
expect(closeInLayout(one, 3)).toBe(one);
});
});
describe("swapInLayout / moveInLayout", () => {
it("swaps two shown panes", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
expect(swapInLayout(l, 0, 1).root).toEqual(split("row", pane(1), pane(0)));
});
it("puts a hidden run into the target pane when swapping", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
expect(swapInLayout(l, 3, 1).root).toEqual(split("row", pane(0), pane(3)));
});
it("moves a pane to another pane's edge", () => {
const l: PaneLayout = { root: split("row", pane(0), split("row", pane(1), pane(2))), focus: 2 };
const out = moveInLayout(l, 0, 2, "bottom");
expect(out.root).toEqual(split("row", pane(1), split("col", pane(2), pane(0))));
expect(out.focus).toBe(0);
});
it("puts the moved pane first on a left or top edge", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
expect(moveInLayout(l, 1, 0, "top").root).toEqual(split("col", pane(1), pane(0)));
});
it("does nothing when a pane is dropped on itself", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
expect(moveInLayout(l, 1, 1, "left")).toBe(l);
});
it("refuses to add a fifth pane", () => {
let l: PaneLayout = one;
for (const s of [1, 2, 3]) l = openInLayout(l, s, "split");
const four = { ...l, root: closeInLayout(l, 3).root };
// 3 shown + slot 3 hidden: moving the hidden one in is allowed …
expect(leaves(moveInLayout({ ...four, focus: 0 }, 3, 0, "right").root)).toHaveLength(4);
// … a full layout keeps its shape.
expect(moveInLayout(l, 9, 0, "right")).toBe(l);
});
});
describe("setRatio / layoutRects", () => {
it("clamps a dragged ratio so no pane can vanish", () => {
const root = split("row", pane(0), pane(1));
expect((setRatio(root, "", 0.01) as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.1);
expect((setRatio(root, "", 0.99) as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.9);
});
it("resizes the split a path names", () => {
const root = split("row", pane(0), split("col", pane(1), pane(2)));
const out = setRatio(root, "b", 0.25) as Extract<PaneNode, { type: "split" }>;
expect((out.b as Extract<PaneNode, { type: "split" }>).ratio).toBe(0.25);
expect(out.ratio).toBe(0.5);
});
it("turns the tree into pane rectangles and divider lines", () => {
const root = split("row", pane(0), split("col", pane(1), pane(2), 0.25), 0.6);
const { panes, dividers } = layoutRects(root);
expect(panes).toEqual([
{ slot: 0, x: 0, y: 0, w: 0.6, h: 1 },
{ slot: 1, x: 0.6, y: 0, w: 0.4, h: 0.25 },
{ slot: 2, x: 0.6, y: 0.25, w: 0.4, h: 0.75 },
]);
expect(dividers).toEqual([
{ path: "", dir: "row", at: 0.6, box: { x: 0, y: 0, w: 1, h: 1 } },
{ path: "b", dir: "col", at: 0.25, box: { x: 0.6, y: 0, w: 0.4, h: 1 } },
]);
});
it("has nothing to draw for an empty layout", () => {
expect(layoutRects(null)).toEqual({ panes: [], dividers: [] });
});
});
describe("pruneLayout", () => {
it("drops panes whose run is gone, keeping the pending ones", () => {
const l: PaneLayout = {
root: split("row", pane(0), split("row", pane(1), pane(2))),
focus: 1,
};
const out = pruneLayout(l, new Set([0]), new Set([2]));
expect(out.root).toEqual(split("row", pane(0), pane(2)));
expect(out.focus).toBe(2);
});
it("returns the same object when nothing is gone", () => {
expect(pruneLayout(one, new Set([0]), new Set())).toBe(one);
});
});
describe("pane groups (tabs of up to four panes)", () => {
const single = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
const g2: PaneGroups = { groups: [single(0), single(1)], active: 0 };
it("opens a new session as a tab of its own after the active one", () => {
const out = openInGroups(g2, 5, false);
expect(out.groups.map((g) => leaves(g.root))).toEqual([[0], [5], [1]]);
expect(out.active).toBe(1);
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = {
groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }],
active: 0,
};
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
});
it("a split open joins the active tab, and the tab it left goes", () => {
const out = openInGroups(g2, 1, true);
expect(out.groups).toHaveLength(1);
expect(leaves(out.groups[0]!.root)).toEqual([0, 1]);
expect(out.groups[0]!.focus).toBe(1);
});
it("a split open on a full tab opens its own tab instead", () => {
let g: PaneGroups = { groups: [single(0)], active: 0 };
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 3, 1, 2], [4]]);
expect(out.active).toBe(1);
});
it("a pane leaving the active tab becomes a tab of its own next to it", () => {
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 0,
};
const out = updateActive(g, (l) => closeInLayout(l, 1));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [1], [2]]);
expect(out.active).toBe(0);
});
it("a pane moved in from another tab leaves that tab", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 0 };
const out = updateActive(g, (l) => moveInLayout(l, 2, 0, "bottom"));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 2], [1]]);
expect(out.active).toBe(0);
});
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({
groups: [single(1), single(2)],
active: 0,
});
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 1,
};
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
expect(pruneGroups(g, new Set([0, 1, 2]), new Set())).toBe(g);
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)],
active: 1,
};
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
const moved = reorderGroups(out, 2, 0);
expect(moved.groups.map((x) => leaves(x.root))).toEqual([[2], [1], [0]]);
expect(moved.active).toBe(1);
});
});
describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = {
groups: [
{ root: split("row", pane(0), pane(9)), focus: 9 },
{ root: pane(4), focus: 4 },
],
active: 1,
};
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }],
active: 0,
});
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
EMPTY_GROUPS
);
const out = parseGroups({
groups: [
{ root: pane(1), focus: 1 },
{ root: pane(1), focus: 1 },
],
active: 1,
});
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
it("keeps the stored active tab selected when a tab before it is dropped", () => {
const b = { root: pane(2), focus: 2 };
const c = { root: pane(3), focus: 3 };
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }, b, c], active: 1 })).toEqual({
groups: [b, c],
active: 0,
});
expect(parseGroups({ groups: [b, c], active: 7 }).active).toBe(1);
});
it("accepts a valid stored layout", () => {
const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 };
expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l);
});
it("rejects junk, out-of-range slots, duplicates and oversized trees", () => {
expect(parseLayout(null)).toEqual({ root: null, focus: null });
expect(parseLayout({ root: { type: "pane", slot: 10 }, focus: 10 }).root).toBeNull();
expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull();
const five = split(
"row",
split("row", pane(0), pane(1)),
split("row", pane(2), split("row", pane(3), pane(0)))
);
expect(parseLayout({ root: five, focus: 0 }).root).toBeNull();
});
it("clamps a stored ratio and repairs a focus that is not shown", () => {
const out = parseLayout({ root: { ...split("row", pane(0), pane(1)), ratio: 5 }, focus: 3 });
expect(out.root).toEqual(split("row", pane(0), pane(1), 0.9));
expect(out.focus).toBe(0);
});
it("keeps one set of tabs per workspace", () => {
saveGroups(1, { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
saveGroups(2, { groups: [one], active: 0 });
expect(loadGroups(1).groups[0]?.focus).toBe(1);
expect(loadGroups(2)).toEqual({ groups: [one], active: 0 });
expect(loadGroups(3)).toEqual(EMPTY_GROUPS);
});
it("survives unreadable storage", () => {
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
describe("joinGroup", () => {
const solo = (s: number): PaneLayout => ({ root: pane(s), focus: s });
const tabs = (...slots: number[]): PaneGroups => ({ groups: slots.map(solo), active: 0 });
it("joins tabs in order: side by side, under the right pane, under the left one", () => {
let g = tabs(0, 1, 2, 3);
g = joinGroup(g, [1], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), pane(1)));
g = joinGroup(g, [2], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
g = joinGroup(g, [3], 0);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(g).toEqual({ groups: [g.groups[0]], active: 0 });
expect(g.groups[0]?.focus).toBe(3);
});
it("fills the gap under the right pane when the left one is already split", () => {
const combo: PaneLayout = {
root: split("row", split("col", pane(0), pane(1)), pane(2)),
focus: 0,
};
const g = joinGroup({ groups: [combo, solo(3)], active: 1 }, [3], 0);
expect(g.groups).toHaveLength(1);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3)))
);
});
it("activates the target tab and takes a pane out of a combo", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
expect(g.groups.map((l) => leaves(l.root))).toEqual([[0], [1, 2]]);
const h = joinGroup({ groups: [solo(0), combo], active: 1 }, [2], 0);
expect(h).toEqual({
groups: [
{ root: split("row", pane(0), pane(2)), focus: 2 },
{ root: pane(1), focus: 1 },
],
active: 0,
});
});
it("joins a whole combo, and refuses a fifth pane", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 1 }, [1, 2], 0);
expect(g.groups).toHaveLength(1);
expect(leaves(g.groups[0]?.root ?? null)).toEqual([0, 1, 2]);
const full = joinGroup(g, [], 0);
expect(full).toBe(g);
const four = joinGroup({ ...g, groups: [...g.groups, solo(3), solo(4)] }, [3], 0);
expect(joinGroup(four, [4], 0)).toBe(four);
});
});