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,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();
});
});