feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)

- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
This commit is contained in:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
@@ -2,12 +2,20 @@
* @file laneOrderStorage.test.ts
* @description Tests for the Workspace lane strip's stored card order: the
* storage round-trip, and the two rules that keep a stale stored order from
* losing a lane — unknown ids land at the end, vanished ids are skipped.
* losing a lane — unknown ids land at the end, vanished ids are skipped — plus
* the drop math: a drop lands between items by the pointer's position.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
import {
readLaneOrder,
writeLaneOrder,
sortByStoredOrder,
moveLane,
insertionIndex,
moveBefore,
} from "../laneOrderStorage";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
@@ -72,4 +80,21 @@ describe("moveLane", () => {
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
});
it("places a drop before the first item whose middle is past the pointer", () => {
const mids = [10, 30, 50];
expect(insertionIndex(mids, 0)).toBe(0);
expect(insertionIndex(mids, 20)).toBe(1); // a gap between two items
expect(insertionIndex(mids, 49)).toBe(2);
expect(insertionIndex(mids, 90)).toBe(3); // past the last: the end
});
it("moves an item to just before an insertion point", () => {
expect(moveBefore([1, 2, 3, 4], 0, 2)).toEqual([2, 1, 3, 4]);
expect(moveBefore([1, 2, 3, 4], 0, 4)).toEqual([2, 3, 4, 1]);
expect(moveBefore([1, 2, 3, 4], 3, 0)).toEqual([4, 1, 2, 3]);
// Before itself or right after itself: no move.
expect(moveBefore([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
expect(moveBefore([1, 2, 3], 1, 2)).toEqual([1, 2, 3]);
});
});
+124 -11
View File
@@ -3,26 +3,36 @@
* @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
* 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
* 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,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
openInGroups,
openInLayout,
pane,
parseGroups,
parseLayout,
pruneGroups,
pruneLayout,
saveLayout,
removeSlot,
reorderGroups,
saveGroups,
setRatio,
updateActive,
split,
swapInLayout,
} from "../paneLayout";
@@ -182,9 +192,112 @@ describe("pruneLayout", () => {
});
});
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, 1, 2, 3], [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);
@@ -192,7 +305,7 @@ describe("parseLayout / storage", () => {
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: { 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",
@@ -208,16 +321,16 @@ describe("parseLayout / storage", () => {
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("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(loadLayout(1)).toEqual({ root: null, focus: null });
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});