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