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
@@ -0,0 +1,69 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const fail = (code: string) => Object.assign(new Error(code), { code });
beforeEach(() => {
vi.mocked(api.lanes.pickPath).mockReset();
vi.mocked(api.lanes.browse)
.mockReset()
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.error).mockReset();
});
describe("PathPickButton", () => {
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
render(<PathPickButton start=" /home/u " onPick={onPick} />);
const button = screen.getByTitle("Browse for a folder");
await userEvent.click(button);
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(onPick).toHaveBeenCalledTimes(1);
});
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
render(<PathPickButton onPick={vi.fn()} />);
await userEvent.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -2,9 +2,9 @@
* @file StatusBar.test.tsx
* @description Tests for the shell's bottom status bar: the live/disconnected
* state with the version (click opens the connection details), the running and
* needs-you counts from the shared lane list, today's spend from the
* pricing cost call's `daily_costs`, and on the Workspace page the open
* workspace's and the shown session's totals.
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* with its tokens and a per-model hover list, and on the Workspace page the
* open workspace's and the shown session's totals.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,6 +13,8 @@ import { act, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
const M = 1_000_000;
function today() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
@@ -21,24 +23,50 @@ function today() {
vi.mock("../../lib/api", () => ({
api: {
lanes: {
list: vi.fn().mockResolvedValue({
lanes: [
{ id: 1, status: "running", needs_action: null },
{ id: 2, status: "running", needs_action: "permission" },
{ id: 3, status: "idle", needs_action: null },
],
counts: {},
}),
sessions: {
// 3 active sessions, 1 of them waiting on the user.
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
sessions: [],
total: p.awaiting ? 1 : 3,
})),
},
pricing: {
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({
total_cost: opts?.lane ? 123.456 : 10,
breakdown: [],
daily_costs: [
{ date: "2000-01-01", cost: 9 },
{ date: today(), cost: 1.234 },
],
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number; date?: string }) => ({
total_cost: opts?.lane ? 123.456 : opts?.date === today() ? 1.234 : 10,
breakdown: opts?.lane
? []
: [
// Two buckets of one model fold into one line.
{
model: "claude-opus-4-8",
input_tokens: 1 * M,
output_tokens: 0,
cache_read_tokens: 10 * M,
cache_write_tokens: 0,
cost: 1,
matched_rule: null,
},
{
model: "claude-opus-4-8",
speed: "fast",
input_tokens: 0,
output_tokens: 1 * M,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.2,
matched_rule: null,
},
{
model: "claude-haiku-4-5",
input_tokens: 300_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.034,
matched_rule: null,
},
],
daily_costs: [],
})),
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7,
@@ -63,7 +91,13 @@ describe("StatusBar", () => {
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
@@ -79,7 +113,7 @@ describe("StatusBar", () => {
it("off the Workspace page shows no workspace or session cost", async () => {
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
await screen.findByText("Today $1.23");
await screen.findByText(/^Today \$1\.23/);
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
});
@@ -89,8 +123,8 @@ describe("StatusBar", () => {
"/workspace?lane=3&session=s-old",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
);
expect(await screen.findByText("Workspace $123.46")).toBeInTheDocument();
expect(await screen.findByText("Session $7.00")).toBeInTheDocument();
expect(await screen.findByText("Workspace $123.46 (0 Tokens)")).toBeInTheDocument();
expect(await screen.findByText(/^Session \$7\.00/)).toBeInTheDocument();
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
});
@@ -99,7 +133,7 @@ describe("StatusBar", () => {
"/workspace?lane=3&run=agenthub-lane-3-1",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
);
await screen.findByText("Workspace $123.46");
await screen.findByText(/^Workspace \$123\.46/);
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
act(() => {
window.dispatchEvent(
@@ -108,6 +142,6 @@ describe("StatusBar", () => {
})
);
});
expect(await screen.findByText("Session $4.50")).toBeInTheDocument();
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
});
});
@@ -81,6 +81,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -307,7 +308,7 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("shows a live tab whose session has no row yet as a New session, never a shell", async () => {
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation(
async () =>
({
@@ -341,27 +342,95 @@ describe("WorkspaceTree", () => {
await user.click(await screen.findByTestId("workspace-toggle-1"));
const fresh = await screen.findByTestId("workspace-session-new-agenthub-lane-1");
expect(fresh).toHaveTextContent("New session");
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-3")).toBeNull();
expect(screen.getByTestId("workspace-session-new-agenthub-lane-1-3")).toHaveTextContent(
"Terminal"
);
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-2")).toBeNull();
await user.click(fresh);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
});
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
vi.mocked(api.run.history).mockImplementation(
async () =>
({
items: [
{
id: "agenthub-lane-1-2",
tool: "shell",
status: "running",
started_at: "2026-10-06T10:00:00Z",
ended_at: null,
isLive: true,
},
{
id: "agenthub-lane-1-3@a",
tool: "shell",
status: "killed",
started_at: "2026-10-06T09:00:00Z",
ended_at: "2026-10-06T09:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@b",
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@c",
tool: "shell",
status: "exited",
started_at: "2026-10-06T07:00:00Z",
ended_at: "2026-10-06T07:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@d",
tool: "shell",
status: "exited",
started_at: "2026-10-06T06:00:00Z",
ended_at: "2026-10-06T06:05:00Z",
isLive: false,
},
],
}) as never
);
vi.mocked(api.lanes.action).mockImplementation(
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
expect(closed).toHaveTextContent("Terminal");
expect(api.run.history).toHaveBeenCalledWith(
expect.objectContaining({ laneId: 1, tool: "shell" })
);
// The live one is not history, and only the newest three are listed.
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
await user.click(closed);
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true");
// Lane 1 has a live tab: its row opens the tab, not the home.
// Lane 1 has a live tab: its row still opens the home.
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument();
// Lane 2 has none: its home.
await user.click(screen.getByTestId("workspace-open-2"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=2");
});
it("opens a workspace on the pane it last focused, and follows the page's run list", async () => {
it("opens a workspace's home even while it has live tabs", async () => {
vi.mocked(api.run.list).mockImplementation(async () => ({ items: [] }) as never);
localStorage.setItem(
"agenthub.workspace.paneLayout",
@@ -393,7 +462,7 @@ describe("WorkspaceTree", () => {
);
});
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
});
it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => {