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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user