feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop

- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start
  (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume
  keeps the tool its session ran on (`sessions.tool`). The workspace's
  `lanes.tool` is now only the default.
- The workspace home's prompt box is the one place a session starts. Its
  fields run in order: AI → AI Router (Native or a profile) → Model (the
  tool's own list, or the router's models) → Permission (Claude mode /
  Codex sandbox+approval / catalog bypass) → Effort.
- The pane's run form (RunSetup) is gone: New run, a single pane's exit and
  an empty split pane's New session all go to the home. RunSetup.tsx keeps
  PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop).
- The tool icon moved from the sidebar's workspace row to each session row
  (right edge, `session.tool`); the split-pane header shows the run's tool.
- An ended session opens in its workspace (`&session=`, SessionReplay.tsx):
  its conversation read-only plus a prompt box; only a SENT message resumes
  it (resumeSessionId + prompt, first free slot). The console is only
  hidden behind it.
- Analyst button beside Files/Changes in the right panel opens
  /sessions/:id for the opened session.
- Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from
  zonalogo.com).
- RouterPicker always shows (Native disabled for tools without router
  support); useAiRouters() exported.
- Tests: server start-with-tool cases; SessionReplay, home field order/tool/
  router models, empty pane, Analyst; dropped the cwd-field suites
  (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md,
  LANES.md, README, ARCHITECTURE, INSTALL.
This commit is contained in:
2026-10-02 15:29:38 +07:00
parent c507d37baf
commit 4802684d2f
34 changed files with 1137 additions and 2504 deletions
+61 -131
View File
@@ -1,14 +1,14 @@
/**
* @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
* and that no /stage endpoint is called.
* Verifies: lanes API contract, the home vs session view, split panes, and
* that a pane has no run form of its own (a new session starts from the home).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import { render, act, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
@@ -57,6 +57,12 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
@@ -323,6 +329,8 @@ async function renderWorkspace() {
beforeEach(() => {
recordedCalls.length = 0;
// The selected workspace is remembered in localStorage; each test starts on the first.
window.localStorage.clear();
lanesToReturn = [
{
id: 1,
@@ -382,133 +390,6 @@ describe("Workspace page — lane integration", () => {
expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
const startButton = runButton;
await user.click(startButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify /api/run/start was NOT called
const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
expect(runStartCalled).toBe(false);
});
it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to a path no lane owns
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/new/project/path");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify ensure was called BEFORE start
const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
const startIndex = recordedCalls.findIndex(
(c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
);
expect(ensureIndex).toBeLessThan(startIndex);
});
it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// After start, verify no /stage call was made
const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
expect(stageCalls).toHaveLength(0);
});
});
describe("Workspace layout", () => {
@@ -722,6 +603,55 @@ describe("Workspace — home and session view", () => {
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&session=s-ended"]}>
<Workspace />
<Routes>
<Route path="/sessions/:id" element={<div data-testid="analyst-page" />} />
<Route path="*" element={null} />
</Routes>
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("session-replay")).toHaveTextContent("s-ended");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// Analyst, beside Files and Changes, opens the session's analysis page.
await userEvent.setup().click(screen.getByTestId("panel-analyst"));
expect(await screen.findByTestId("analyst-page")).toBeInTheDocument();
});
it("has no Analyst target on a pane with no session yet", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.getByTestId("panel-analyst")).toBeDisabled();
});
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
// The run opens in pane 1, so pane 0 is left with nothing to show.
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1&pane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByPlaceholderText(/absolute path/)).toBeNull();
const empty = screen.getByTestId("pane-no-run");
await userEvent.setup().click(within(empty).getByRole("button", { name: "New session" }));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
expect(recordedCalls.some((c) => c.path.includes("/start"))).toBe(false);
});
it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation();