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,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>
|
||||
|
||||
Reference in New Issue
Block a user