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
@@ -1,6 +1,8 @@
/**
* @file LaneConsolePane.test.tsx
* @description Test suite for the LaneConsolePane component
* @description Test suite for the LaneConsolePane component: a pane shows its
* lane's terminal or an empty state whose New session goes to the workspace
* home — a pane has no run form of its own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -91,7 +93,6 @@ function baseProps() {
codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" },
},
cwdSuggestions: [],
activeRuns: { items: [] },
wsConnected: true,
};
@@ -102,14 +103,21 @@ describe("LaneConsolePane", () => {
vi.clearAllMocks();
});
it("starts a run through /api/lanes/<id>/start, not /api/run/start", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-1",
it("has no run form: an empty pane's New session goes to the workspace home", () => {
const onNewSession = vi.fn();
render(<LaneConsolePane {...baseProps()} onNewSession={onNewSession} />);
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
expect(screen.queryByRole("textbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "New session" }));
expect(onNewSession).toHaveBeenCalledOnce();
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("goes back to the home when a single pane's run exits, and on New run", async () => {
const live = {
id: "agenthub-lane-1",
laneId: 1,
status: "running",
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
@@ -118,81 +126,25 @@ describe("LaneConsolePane", () => {
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(<LaneConsolePane {...baseProps()} />);
// Set cwd and prompt
const cwdInput = screen.getByPlaceholderText(/type to search/i);
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
fireEvent.change(promptTextarea, { target: { value: "test prompt" } });
// Find and click the Run button (the main start button in RunSetup)
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))
tool: "claude" as const,
};
const onNewSession = vi.fn();
render(
<LaneConsolePane
{...baseProps()}
activeRuns={{ items: [live] }}
onNewSession={onNewSession}
homeOnExit
/>
);
expect(api.run.start).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")
);
});
it("starts a run on the tool's default model from AI Tools settings", async () => {
__resetAiTools();
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
default: "claude",
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
});
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
target: { value: "/workspace/a" },
});
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ model: "sonnet" })
)
);
__resetAiTools();
});
it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await screen.findByTestId("terminal-view");
fireEvent.click(screen.getByRole("button", { name: "New run" }));
expect(onNewSession).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("fake-pane-exit"));
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
expect(onNewSession).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
@@ -227,25 +179,12 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-2")
);
// A lane with no live run falls back to its setup form, not the previous
// A lane with no live run falls back to an empty pane, not the previous
// lane's terminal.
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
});
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
// Split view restores its pane lanes from localStorage, so a pane can
// render with a laneId before GET /api/lanes has answered. laneId never
// changes afterwards — only the resolved lane does.
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
const { rerender } = render(<LaneConsolePane {...props} />);
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
expect(cwdInput.value).toBe("/home/tester");
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
});
it("shows a lane dropdown only when showLaneSelector is true", () => {
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
@@ -254,50 +193,12 @@ describe("LaneConsolePane", () => {
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
});
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
it("renders the console body when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument();
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
});
it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => {
// Picking a pipeline used to write its driving slash command (claude) or a
// stage checklist (codex) into the box — an extra prompt sent on start.
const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
const codexLane = {
...LANE,
tool: "codex" as const,
pipeline_skill: "/agenthub:ship-feature-lane",
pipeline_nodes: [
{
id: "implement",
label: "Implement",
icon: "code",
gate: false,
state: "pending" as const,
},
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
],
};
const props = { ...baseProps(), lanes: [claudeLane] };
const { rerender } = render(<LaneConsolePane {...props} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toBe(""));
// A typed requirement still survives a template switch.
fireEvent.change(prompt, { target: { value: "add a button" } });
rerender(
<LaneConsolePane
{...props}
lanes={[{ ...claudeLane, pipeline_skill: "/agenthub:ship-feature-lane" }]}
/>
);
await waitFor(() => expect(prompt.value).toBe("add a button"));
rerender(<LaneConsolePane {...props} lanes={[codexLane]} />);
await waitFor(() => expect(prompt.value).toBe("add a button"));
});
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
const HISTORY_ITEM = {
id: "run-old",
@@ -442,30 +343,6 @@ describe("LaneConsolePane", () => {
);
});
it("starts into its own slot and shows the session the server opened", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "agenthub-lane-1" },
run_id: "agenthub-lane-1-1",
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
render(<LaneConsolePane {...baseProps()} slot={1} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ slot: 1 })
)
);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-1"
)
);
});
it("leaves runs other panes show out of Active runs", () => {
render(
<MemoryRouter>