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
+46 -27
View File
@@ -7,7 +7,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, within } from "@testing-library/react";
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -56,6 +56,8 @@ let nextRunId = 1;
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
// Hoisted with the mock: a workspace holds ten sessions (MAX_PANE_SLOT 9).
const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
@@ -63,7 +65,7 @@ vi.mock("../../components/lanes/startSession", () => ({
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
return SLOTS.find((s) => !busy.has(s)) ?? null;
},
// The server picks the slot (`slot: "auto"`); stand in for it with the first
// one the mocked run list leaves free.
@@ -75,7 +77,7 @@ vi.mock("../../components/lanes/startSession", () => ({
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => r.slot ?? 0)
);
const slot = [0, 1, 2, 3].find((x) => !busy.has(x)) ?? 0;
const slot = SLOTS.find((x) => !busy.has(x)) ?? 0;
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
},
@@ -621,9 +623,13 @@ describe("console tabs", () => {
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("disables + once all four slots are taken", async () => {
it("keeps + enabled past four sessions and disables it once all ten slots are taken", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeEnabled();
cleanup();
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
@@ -685,7 +691,7 @@ describe("split panes", () => {
const stored = () =>
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
it("Ctrl+click on a tab opens it in a split beside the focused pane, and remembers it", async () => {
it("Ctrl+click on a tab brings it into the tab on screen as a split, one combo tab", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-0"]);
@@ -696,29 +702,41 @@ describe("split panes", () => {
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
// The other pane's tab is marked as shown; the focused one is the active tab.
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-0").parentElement).toHaveAttribute("data-shown", "true");
expect(stored().root).toMatchObject({ type: "split", dir: "row" });
// The two tabs became ONE tab holding both panes.
expect(screen.queryByTestId("console-tab-1")).toBeNull();
expect(screen.getByTestId("console-tab-0-1")).toHaveAttribute("aria-selected", "true");
expect(stored().groups).toHaveLength(1);
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
});
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", async () => {
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
await settle();
expect(shownPanes()).toEqual(["pane-1", "pane-2"]);
expect(screen.getByTestId("pane-2")).toHaveAttribute("data-focused", "true");
expect(shownPanes()).toEqual(["pane-2"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-2");
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("closing a pane keeps its session as a tab; closing a tab closes its pane", async () => {
it("a tab holds at most four panes; a fifth split opens a tab of its own", async () => {
withRuns([live(0), live(1), live(2), live(3), live(4)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
for (const s of [1, 2, 3]) await ctrlClick(screen.getByTestId(`console-tab-${s}`));
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]);
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
});
it("closing a pane takes it out into a tab of its own; closing a tab stops every session in it", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
@@ -731,12 +749,13 @@ describe("split panes", () => {
expect(api.run.kill).not.toHaveBeenCalled();
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
withRuns([live(0), live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-2"));
withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(shownPanes()).toEqual(["pane-0"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("drops a remembered pane whose session is gone", async () => {
@@ -812,7 +831,7 @@ describe("split panes", () => {
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
await right(screen.getByTestId("pane-header-1"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Move to own tab"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled();
@@ -896,7 +915,7 @@ describe("split panes", () => {
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
await settle();
expect(screen.getByTestId("run-param").textContent).toBe("");
expect(stored().root).toEqual({ type: "pane", slot: 0 });
expect(stored().groups[0].root).toEqual({ type: "pane", slot: 0 });
// The last one too: still the home.
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
await settle();
@@ -908,7 +927,7 @@ describe("split panes", () => {
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
withRuns([]);
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
});
@@ -937,8 +956,8 @@ describe("split panes", () => {
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
});
it("offers no new terminal in a split once every slot is busy", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
it("offers no new terminal in a split once all ten slots are busy", async () => {
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
@@ -2618,6 +2618,32 @@ exports[`screen snapshots > Claude Config 1`] = `
type="text"
value="/home/test/.claude"
/>
<button
aria-label="Browse for a folder"
class="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
title="Browse for a folder"
type="button"
>
<svg
class="lucide lucide-folder-open h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
</button>
<div
class="contents"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
@@ -5452,7 +5478,7 @@ exports[`screen snapshots > Run 1`] = `
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
>
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
@@ -6955,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.0.1 (rebuild client to match)
UI build v2.1.0 (rebuild client to match)
</p>
</div>
<div
@@ -9935,7 +9961,7 @@ exports[`screen snapshots > Workflows 1`] = `
No workspace has recorded a stage yet. Create one with
<code>
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>