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.
This commit is contained in:
2026-10-03 15:13:06 +07:00
parent 1597c110d4
commit 326dfa9d19
15 changed files with 1656 additions and 140 deletions
+223
View File
@@ -0,0 +1,223 @@
/**
* @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 });
});
});
+250
View File
@@ -0,0 +1,250 @@
/**
* @file paneLayout.ts
* @description The session view's split panes, VS Code style: a binary tree
* whose leaves are pane SLOTS (0–3, one tmux session each — see
* `server/lib/pty-run.js`) and whose inner nodes split their box in a row
* (side by side) or a column (stacked) at a ratio. Pure functions only: the
* page keeps the layout in state, and `layoutRects` turns it into the
* rectangles `PaneGrid` draws every pane at — each pane is an absolutely
* placed sibling, so reshaping the tree never re-parents (and so never
* remounts) a live terminal. A slot appears at most once: one terminal is
* never attached twice. The layout is a viewer preference, kept per workspace
* in `localStorage` (`agenthub.workspace.paneLayout`), never on the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** As many panes as a workspace has tmux slots. */
export const MAX_PANES = 4;
const MIN_RATIO = 0.1;
const STORAGE_KEY = "agenthub.workspace.paneLayout";
export type SplitDir = "row" | "col";
export type Edge = "left" | "right" | "top" | "bottom";
export type PaneNode =
| { type: "pane"; slot: number }
| { type: "split"; dir: SplitDir; ratio: number; a: PaneNode; b: PaneNode };
/** The tree plus the focused pane — the one a plain open replaces. */
export interface PaneLayout {
root: PaneNode | null;
focus: number | null;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
export const EMPTY_LAYOUT: PaneLayout = { root: null, focus: null };
const EMPTY = EMPTY_LAYOUT;
const clampRatio = (r: number) => Math.min(1 - MIN_RATIO, Math.max(MIN_RATIO, r));
export const pane = (slot: number): PaneNode => ({ type: "pane", slot });
export const split = (dir: SplitDir, a: PaneNode, b: PaneNode, ratio = 0.5): PaneNode => ({
type: "split",
dir,
ratio,
a,
b,
});
/** The slots shown, in reading order. */
export function leaves(node: PaneNode | null): number[] {
if (!node) return [];
return node.type === "pane" ? [node.slot] : [...leaves(node.a), ...leaves(node.b)];
}
function mapLeaves(node: PaneNode, fn: (slot: number) => PaneNode): PaneNode {
if (node.type === "pane") return fn(node.slot);
return { ...node, a: mapLeaves(node.a, fn), b: mapLeaves(node.b, fn) };
}
/** Takes `slot`'s leaf out; its sibling takes the parent's place. `heir` is
* the first slot of whatever now fills that space. */
function removeLeaf(node: PaneNode, slot: number): { root: PaneNode | null; heir: number | null } {
if (node.type === "pane")
return node.slot === slot ? { root: null, heir: null } : { root: node, heir: null };
if (node.a.type === "pane" && node.a.slot === slot)
return { root: node.b, heir: leaves(node.b)[0] ?? null };
if (node.b.type === "pane" && node.b.slot === slot)
return { root: node.a, heir: leaves(node.a)[0] ?? null };
const a = removeLeaf(node.a, slot);
if (a.root !== node.a) return { root: { ...node, a: a.root as PaneNode }, heir: a.heir };
const b = removeLeaf(node.b, slot);
return { root: b.root === node.b ? node : { ...node, b: b.root as PaneNode }, heir: b.heir };
}
/** Splits `target`'s leaf, putting `slot` on its `edge` side. */
function splitLeaf(node: PaneNode, target: number, slot: number, edge: Edge): PaneNode {
const dir: SplitDir = edge === "left" || edge === "right" ? "row" : "col";
const first = edge === "left" || edge === "top";
return mapLeaves(node, (s) =>
s === target
? first
? split(dir, pane(slot), pane(s))
: split(dir, pane(s), pane(slot))
: pane(s)
);
}
function focusOf(layout: PaneLayout): number | null {
const shown = leaves(layout.root);
return layout.focus !== null && shown.includes(layout.focus) ? layout.focus : (shown[0] ?? null);
}
/**
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
* opens it to the right of the focused pane (while there is room) and
* `replace` puts it in the focused pane.
*/
export function openInLayout(
layout: PaneLayout,
slot: number,
mode: "replace" | "split"
): PaneLayout {
if (!layout.root) return { root: pane(slot), focus: slot };
const shown = leaves(layout.root);
if (shown.includes(slot)) return { ...layout, focus: slot };
const target = focusOf(layout) as number;
const root =
mode === "split" && shown.length < MAX_PANES
? splitLeaf(layout.root, target, slot, "right")
: mapLeaves(layout.root, (s) => pane(s === target ? slot : s));
return { root, focus: slot };
}
/** Closes `slot`'s pane (its session keeps running as a tab). */
export function closeInLayout(layout: PaneLayout, slot: number): PaneLayout {
if (!layout.root || !leaves(layout.root).includes(slot)) return layout;
const { root, heir } = removeLeaf(layout.root, slot);
if (!root) return EMPTY;
return { root, focus: layout.focus === slot ? heir : focusOf({ root, focus: layout.focus }) };
}
/** Swaps `slot` into `target`'s pane; when `slot` was shown elsewhere, the two
* panes trade places. `slot` ends up focused. */
export function swapInLayout(layout: PaneLayout, slot: number, target: number): PaneLayout {
if (!layout.root || slot === target) return layout;
return {
root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s === slot ? target : s)),
focus: slot,
};
}
/** Moves `slot` (shown or not) onto `target`'s `edge`, splitting that pane. */
export function moveInLayout(
layout: PaneLayout,
slot: number,
target: number,
edge: Edge
): PaneLayout {
if (!layout.root || slot === target) return layout;
const shown = leaves(layout.root);
if (!shown.includes(target)) return layout;
if (!shown.includes(slot) && shown.length >= MAX_PANES) return layout;
const rest = shown.includes(slot) ? removeLeaf(layout.root, slot).root : layout.root;
return { root: splitLeaf(rest as PaneNode, target, slot, edge), focus: slot };
}
/** Drops every pane whose slot is neither `live` nor `pending` (just opened,
* not yet in the run list). Same object back when nothing went. */
export function pruneLayout(
layout: PaneLayout,
live: Set<number>,
pending: Set<number>
): PaneLayout {
let out = layout;
for (const s of leaves(layout.root))
if (!live.has(s) && !pending.has(s)) out = closeInLayout(out, s);
return out;
}
/** Sets the ratio of the split at `path` ("" = root, then "a"/"b" per level). */
export function setRatio(node: PaneNode, path: string, ratio: number): PaneNode {
if (node.type !== "split") return node;
if (!path) return { ...node, ratio: clampRatio(ratio) };
const side = path[0] as "a" | "b";
return { ...node, [side]: setRatio(node[side], path.slice(1), ratio) };
}
/**
* Where to draw everything, as fractions of the grid's box: each pane's
* rectangle, and each split's divider — `at` is its ratio, `box` the split's
* own rectangle, which is what a drag converts a pointer position against.
*/
export function layoutRects(node: PaneNode | null): {
panes: (Rect & { slot: number })[];
dividers: { path: string; dir: SplitDir; at: number; box: Rect }[];
} {
const panes: (Rect & { slot: number })[] = [];
const dividers: { path: string; dir: SplitDir; at: number; box: Rect }[] = [];
const walk = (n: PaneNode, box: Rect, path: string) => {
if (n.type === "pane") {
panes.push({ slot: n.slot, ...box });
return;
}
dividers.push({ path, dir: n.dir, at: n.ratio, box });
if (n.dir === "row") {
const w = box.w * n.ratio;
walk(n.a, { ...box, w }, path + "a");
walk(n.b, { ...box, x: box.x + w, w: box.w - w }, path + "b");
} else {
const h = box.h * n.ratio;
walk(n.a, { ...box, h }, path + "a");
walk(n.b, { ...box, y: box.y + h, h: box.h - h }, path + "b");
}
};
if (node) walk(node, { x: 0, y: 0, w: 1, h: 1 }, "");
return { panes, dividers };
}
function parseNode(raw: unknown): PaneNode | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.type === "pane") {
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_PANES
? pane(n.slot as number)
: null;
}
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
const a = parseNode(n.a);
const b = parseNode(n.b);
if (!a || !b) return null;
const ratio = typeof n.ratio === "number" && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5;
return split(n.dir, a, b, ratio);
}
/** A stored layout, checked: anything malformed, a slot out of range, a slot
* shown twice or more than `MAX_PANES` panes is no layout at all. */
export function parseLayout(raw: unknown): PaneLayout {
if (!raw || typeof raw !== "object") return EMPTY;
const root = parseNode((raw as { root?: unknown }).root);
const shown = leaves(root);
if (!root || shown.length > MAX_PANES || new Set(shown).size !== shown.length) return EMPTY;
const focus = (raw as { focus?: unknown }).focus;
return { root, focus: focusOf({ root, focus: typeof focus === "number" ? focus : null }) };
}
function readAll(): Record<string, unknown> {
try {
const all = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "{}");
return all && typeof all === "object" ? all : {};
} catch {
return {};
}
}
export function loadLayout(laneId: number): PaneLayout {
return parseLayout(readAll()[String(laneId)]);
}
export function saveLayout(laneId: number, layout: PaneLayout): void {
const all = readAll();
if (layout.root) all[String(laneId)] = layout;
else delete all[String(laneId)];
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* disabled storage: the layout lives for this page only */
}
}