Files
AgentHub/client/src/lib/paneLayout.ts
T
nntrivi2001 f6f1bf4a76 feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default,
  saved per account): the session view's tab bar holds every workspace's
  live sessions, a combo may mix them, and focusing a pane selects its
  workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once
  under `all` in agenthub.workspace.paneLayout.
- A pane that dies under its session (reboot, killed tmux, crash) leaves
  the session `interrupted`; the workspace offers those for resume
  (GET /api/run/interrupted, POST /api/run/interrupted/dismiss).
- Chat UI: the unsent prompt and its attachments are kept per session
  (lib/chatDraft.ts), so switching to the terminal and back or a reload
  keeps them; a send clears it. Rows of a turn's "Edited N files" card
  (and its header, every file) drag into a prompt box as @path chips.
- Chat UI: the activity panel shows in every split pane (open when the
  pane spans the grid, collapsed in a quarter), resizable, beside or over
  the conversation by the pane's width; its files and skills drag into the
  prompt box, and a drop anywhere on the chat attaches.
- Chat UI: a screenshot the session read outside the workspace previews
  (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text
  files are served back as text/plain; long prompts and answers are no
  longer cut at 10 KB (tool output still is); a new live session shows an
  empty chat until its first hook instead of "Session not found".
- Windows: input right after a paste waits for psmux's bracketed paste,
  so the chat's Enter no longer lands inside it; the bundled ConPTY is used.
- Changes panel: a Workspace context fold (instructions, files, folders).

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00

450 lines
19 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.ts
* @description The session view's split panes, VS Code style: a binary tree
* whose leaves are pane SLOTS (0–9, 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 session view's TABS are groups of such trees (`PaneGroups`): each tab
* holds one to `MAX_PANES` panes, every live session sits in exactly one tab,
* and a session on its own is a one-pane tab — splitting or dropping one tab
* onto another's pane merges them into one tab, closing a pane of a tab puts
* its session back in a tab of its own. The tabs are a viewer preference,
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
* stored pre-tab single layout reads as one tab), never on the server.
*
* With Settings → Appearance → "Tabs from every workspace" on, ONE set of tabs
* (stored under `all`) holds every workspace's sessions: a leaf is then a
* `paneKey` (lane id × `MAX_SLOTS` + slot) instead of a bare slot.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Panes in one tab. */
export const MAX_PANES = 4;
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
export const MAX_SLOTS = 10;
/** A pane's id in the every-workspace tabs: unique across lanes, still a number. */
export const paneKey = (laneId: number, slot: number) => laneId * MAX_SLOTS + slot;
/** The slot of a leaf — itself, or a `paneKey`'s slot part. */
export const slotOfKey = (key: number) => key % MAX_SLOTS;
/** The tabs' storage name: a lane id, or `all` for the every-workspace tabs. */
export type GroupsScope = number | "all";
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);
}
/** The pane a new one splits, and on which side, filling a 2×2 grid in
* order: right of a lone pane, under the right one, under the left one. The
* biggest pane by the tree's SHAPE (dragged ratios ignored; a tie goes to the
* later pane) is the gap — so a three-pane tab of any shape fills its one
* missing quarter. A tall pane splits down, any other to the right. */
function nextSpot(root: PaneNode): [number, Edge] {
const even = (n: PaneNode): PaneNode =>
n.type === "pane" ? n : { ...n, ratio: 0.5, a: even(n.a), b: even(n.b) };
const best = layoutRects(even(root)).panes.reduce((m, r) => (r.w * r.h >= m.w * m.h ? r : m));
return [best.slot, best.h > best.w ? "bottom" : "right"];
}
/** Adds `slot` to the layout at the grid's next place (`nextSpot`), focused. */
function placeInLayout(layout: PaneLayout, slot: number): PaneLayout {
if (!layout.root) return { root: pane(slot), focus: slot };
const [target, edge] = nextSpot(layout.root);
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/**
* Shows `slot`: focuses it when it is already on screen; otherwise `split`
* adds it at the grid's next place (`nextSpot`, 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 };
if (mode === "split" && shown.length < MAX_PANES) return placeInLayout(layout, slot);
const target = focusOf(layout) as number;
return { root: mapLeaves(layout.root, (s) => pane(s === target ? slot : s)), 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, max: number): 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
? pane(n.slot as number)
: null;
}
if (n.type !== "split" || (n.dir !== "row" && n.dir !== "col")) return null;
const a = parseNode(n.a, max);
const b = parseNode(n.b, max);
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, max = MAX_SLOTS): PaneLayout {
if (!raw || typeof raw !== "object") return EMPTY;
const root = parseNode((raw as { root?: unknown }).root, max);
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 }) };
}
// ── Tabs: groups of up to MAX_PANES panes ─────────────────────────────
/** The tabs, each a split tree, and the one on screen. */
export interface PaneGroups {
groups: PaneLayout[];
active: number;
}
export const EMPTY_GROUPS: PaneGroups = { groups: [], active: 0 };
const solo = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
/** The tab on screen (an empty layout when there is none). */
export const activeLayout = (g: PaneGroups): PaneLayout => g.groups[g.active] ?? EMPTY;
/** The tab holding `slot`, or -1. */
export const groupOf = (g: PaneGroups, slot: number): number =>
g.groups.findIndex((l) => leaves(l.root).includes(slot));
/** Drops emptied tabs. The active tab stays active; when it went, the tab
* before it takes over (the first one when it was first). */
function compact(groups: PaneLayout[], active: number): PaneGroups {
const kept = groups.filter((l) => l.root);
if (!kept.length) return EMPTY_GROUPS;
if (groups[active]?.root) return { groups: kept, active: kept.indexOf(groups[active]) };
const before = groups.slice(0, active).filter((l) => l.root).length;
return { groups: kept, active: Math.max(0, Math.min(before - 1, kept.length - 1)) };
}
/**
* Changes the tab on screen with a per-tree operation (`moveInLayout`,
* `swapInLayout`, `closeInLayout`, `setRatio`…). A session the change brings
* in leaves the tab it was in; one it takes out gets a tab of its own next to
* this one — closing a pane never hides a running session.
*/
export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout): PaneGroups {
const base = g.groups.length ? g : { groups: [EMPTY], active: 0 };
const cur = base.groups[base.active] ?? EMPTY;
const next = fn(cur);
if (next === cur) return g;
const before = leaves(cur.root);
const after = leaves(next.root);
const added = after.filter((s) => !before.includes(s));
const solos = before.filter((s) => !after.includes(s)).map(solo);
const groups = base.groups.map((l, i) =>
i === base.active ? next : added.reduce((acc, s) => closeInLayout(acc, s), l)
);
// An emptied tab is replaced by what left it, in its place.
if (next.root) groups.splice(base.active + 1, 0, ...solos);
else groups.splice(base.active, 1, ...solos);
return compact(groups, base.active);
}
/**
* Shows `slot`. Its own tab is activated (and the slot focused there); a
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
* click) brings it into the active tab at the grid's next place instead —
* unless that tab is already full, when it falls back to the plain open.
*/
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
const cur = activeLayout(g);
if (split && cur.root && (leaves(cur.root).includes(slot) || leaves(cur.root).length < MAX_PANES))
return updateActive(g, (l) => openInLayout(l, slot, "split"));
const at = groupOf(g, slot);
if (at >= 0) {
const groups = [...g.groups];
groups[at] = { ...(groups[at] as PaneLayout), focus: slot };
return { groups, active: at };
}
const i = g.groups.length ? g.active + 1 : 0;
const groups = [...g.groups];
groups.splice(i, 0, solo(slot));
return { groups, active: i };
}
/** A session ended: its pane goes, and its tab when that was the last one. */
export function removeSlot(g: PaneGroups, slot: number): PaneGroups {
const at = groupOf(g, slot);
if (at < 0) return g;
const groups = [...g.groups];
groups[at] = closeInLayout(groups[at] as PaneLayout, slot);
return compact(groups, g.active);
}
/** Takes `slot` out of its tab into a tab of its own, inserted before the tab
* now at `at` (`groups.length` = last), and shows it. */
export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups {
const from = groupOf(g, slot);
if (from < 0) return g;
const groups = g.groups.map((l, i) => (i === from ? closeInLayout(l, slot) : l));
groups.splice(at, 0, solo(slot));
return compact(groups, at);
}
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
* into the tab at `to`, which becomes active. Same object back when nothing
* would move or the tab would pass `MAX_PANES`. */
export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroups {
const dst = g.groups[to];
if (!dst) return g;
const moving = slots.filter((s) => {
const from = groupOf(g, s);
return from >= 0 && from !== to;
});
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
const groups = g.groups.map((l, i) =>
i === to ? moving.reduce(placeInLayout, l) : moving.reduce(closeInLayout, l)
);
return compact(groups, to);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
const groups = [...g.groups];
const [moved] = groups.splice(from, 1);
groups.splice(at > from ? at - 1 : at, 0, moved as PaneLayout);
return { groups, active: groups.indexOf(g.groups[g.active] as PaneLayout) };
}
/**
* Drops every pane whose session is neither `live` nor `keep` (see
* `pruneLayout`) and the tabs that leaves empty, and gives every live session
* no tab holds one of its own. Same object back when nothing changed.
*/
export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>): PaneGroups {
let changed = false;
const groups = g.groups.map((l) => {
const n = pruneLayout(l, live, keep);
if (n !== l) changed = true;
return n;
});
const tabbed = new Set(groups.flatMap((l) => leaves(l.root)));
const missing = [...live].filter((s) => !tabbed.has(s)).sort((a, b) => a - b);
if (!changed && !missing.length) return g;
return compact([...groups, ...missing.map(solo)], g.active);
}
/** Stored tabs, checked: a malformed tab, or one showing a session an
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
export function parseGroups(raw: unknown, max = MAX_SLOTS): PaneGroups {
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
const r = raw as { groups?: unknown; active?: unknown };
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
const rawActive = Number.isInteger(r.active) ? (r.active as number) : 0;
const seen = new Set<number>();
const groups: PaneLayout[] = [];
// `active` indexes the STORED list; count only the tabs kept before it, so
// a dropped tab ahead of it does not shift the selection onto its neighbour.
let active = 0;
list.forEach((item, i) => {
const l = parseLayout(item, max);
const shown = leaves(l.root);
if (!l.root || shown.some((s) => seen.has(s))) return;
shown.forEach((s) => seen.add(s));
if (i < rawActive) active++;
groups.push(l);
});
if (!groups.length) return EMPTY_GROUPS;
return { groups, active: Math.min(active, groups.length - 1) };
}
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 loadGroups(laneId: GroupsScope): PaneGroups {
return parseGroups(
readAll()[String(laneId)],
laneId === "all" ? Number.MAX_SAFE_INTEGER : MAX_SLOTS
);
}
export function saveGroups(laneId: GroupsScope, g: PaneGroups): void {
const all = readAll();
if (g.groups.length) all[String(laneId)] = g;
else delete all[String(laneId)];
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* disabled storage: the tabs live for this page only */
}
}