Files
AgentHub/client/src/lib/__tests__/paneLayout.test.ts
T
nntrivi2001 326dfa9d19 feat(workspace): VS Code-style split panes, edge-to-edge session view
The console splits into up to four panes: a binary split tree of tmux
slots (client/src/lib/paneLayout.ts) drawn by PaneGrid.tsx, every pane an
absolutely placed sibling keyed by slot so splitting, swapping, resizing
or closing never remounts a live terminal. Ctrl/Cmd+click on a tab, a +
item or a sidebar session row opens it beside the focused pane (router
state, never the URL); a pane header drags onto another pane's edge to
split there or onto its middle to swap, picks another live session,
splits right/down (a not-shown session or a new terminal), or closes the
pane only. Dividers drag or take arrow keys. ?run= is the focused pane:
every layout change goes through change(), which pushes the URL with
focusOnly instead of an effect watching the focus (that ping-ponged with
open requests). The layout is kept per workspace in localStorage.

Only the focused pane's terminal takes the keyboard when it appears, and
the keyboard follows a focus change. A pane closes when its terminal
reports the exit; the run list prunes only a remembered layout on a
workspace's first read and a just-opened slot that never got listed, so
one empty read cannot wipe a split. A run ending on the home or a replay
closes its pane without leaving the page.

The session view now fills the page edge to edge: no page padding, the
tab bar and the right panel's activity bar one h-10 strip, and the tab
bar, pane headers and right panel on the sidebar/status bar colour
(surface-1) around terminals on the page colour.
2026-10-03 15:13:06 +07:00

224 lines
8.4 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, 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 PaneLayout,
type PaneNode,
MAX_PANES,
closeInLayout,
layoutRects,
leaves,
loadLayout,
moveInLayout,
openInLayout,
pane,
parseLayout,
pruneLayout,
saveLayout,
setRatio,
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("splits the focused pane to the right on a split open", () => {
const l: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
const out = openInLayout(l, 2, "split");
expect(out.root).toEqual(split("row", split("row", pane(0), pane(2)), pane(1)));
expect(out.focus).toBe(2);
});
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("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
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: 7 }, focus: 7 }).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 layout per workspace", () => {
saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 });
saveLayout(2, one);
expect(loadLayout(1).focus).toBe(1);
expect(loadLayout(2)).toEqual(one);
expect(loadLayout(3)).toEqual({ root: null, focus: null });
});
it("survives unreadable storage", () => {
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
expect(loadLayout(1)).toEqual({ root: null, focus: null });
});
});