b7f3735b29
- 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.
449 lines
17 KiB
TypeScript
449 lines
17 KiB
TypeScript
/**
|
||
* @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);
|
||
});
|
||
});
|