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>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* @file RouterPicker.test.tsx
|
||||
* @description The per-session AI Router picker: hidden with only Native or on
|
||||
* a tool that cannot use a router, listing the saved profiles otherwise,
|
||||
* @description The per-session AI Router picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
||||
* remembering the pick, and falling back to Native when the remembered
|
||||
* profile is gone; plus `routerArg`, which never sends native.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type { AiRouter } from "../../../lib/api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RouterPicker, routerArg, useSessionRouter } from "../RouterPicker";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -31,8 +32,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const native = { name: "native", label: "Native", builtin: true };
|
||||
const nine = { name: "9router", label: "9Router", builtin: false };
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -43,28 +44,32 @@ beforeEach(() => {
|
||||
|
||||
describe("RouterPicker", () => {
|
||||
it("lists the saved profiles for a tool that can use one", async () => {
|
||||
render(<RouterPicker tool="claude" value="9router" onChange={() => {}} />);
|
||||
render(
|
||||
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden with only Native saved", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] } as never);
|
||||
render(<RouterPicker tool="claude" value="native" onChange={() => {}} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("router-picker")).toBeNull();
|
||||
});
|
||||
|
||||
it("is hidden on a tool with no endpoint to point at a router", async () => {
|
||||
render(<RouterPicker tool="gemini" value="native" onChange={() => {}} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("router-picker")).toBeNull();
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
||||
render(
|
||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
const picker = screen.getByTestId("router-picker");
|
||||
expect(picker.querySelector("button")).toBeDisabled();
|
||||
expect(picker).not.toHaveTextContent("9Router");
|
||||
});
|
||||
|
||||
it("falls back to Native when the remembered profile is gone", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RouterPicker tool="claude" value="deleted" onChange={onChange} />);
|
||||
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||
});
|
||||
|
||||
it("useAiRouters reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiRouters());
|
||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
||||
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionRouter / routerArg", () => {
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
/**
|
||||
* @file RunSetup.test.tsx
|
||||
* @description Pins the props-only boundary of `RunSetup` after its move out of
|
||||
* `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns —
|
||||
* mode, prompt, cwd, model, permission mode, effort — reports its selection
|
||||
* through the matching callback and nowhere else, `onStart` fires from the Run
|
||||
* button, and a missing `claude` binary is surfaced purely from the
|
||||
* `binaryFound` prop (the panel runs no probe of its own).
|
||||
* @description The run-form pieces that outlived the pane's run form:
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply) and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { ModelPicker, PromptEditor } from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -25,30 +21,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
aiRouters: { list: r({ items: [] }) },
|
||||
aiTools: {
|
||||
list: r({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
resume: false,
|
||||
bypass: false,
|
||||
modelFlag: true,
|
||||
defaultModel: "sonnet",
|
||||
},
|
||||
{ id: "codex", label: "Codex", resume: false, bypass: true },
|
||||
{
|
||||
id: "antigravity",
|
||||
label: "Antigravity",
|
||||
resume: true,
|
||||
bypass: true,
|
||||
modelFlag: true,
|
||||
},
|
||||
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
|
||||
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
|
||||
}),
|
||||
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
|
||||
models: vi.fn(async (id: string) => {
|
||||
const lists: Record<string, { id: string; label: string }[]> = {
|
||||
@@ -57,15 +30,12 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
|
||||
],
|
||||
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
|
||||
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
|
||||
};
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
items: [
|
||||
{
|
||||
@@ -84,386 +54,29 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const SUGGESTIONS: CwdSuggestion[] = [
|
||||
{ kind: "home", path: "/Users/tester", label: "Home" },
|
||||
{ kind: "recent", path: "/Users/tester/projects/other", label: "other" },
|
||||
];
|
||||
|
||||
type Spies = ReturnType<typeof renderSetup>["spies"];
|
||||
|
||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||
const spies = {
|
||||
onPromptChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onPermissionModeChange: vi.fn(),
|
||||
onEffortChange: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onResumeSessionChange: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onSandboxChange: vi.fn(),
|
||||
onApprovalChange: vi.fn(),
|
||||
onResumeLast: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
laneId={1}
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
runHistory={[]}
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
approval="on-request"
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** Open the `Select` whose trigger currently shows `currentLabel`, then pick
|
||||
* the option labelled `optionLabel`. */
|
||||
function pickFromSelect(currentLabel: string, optionLabel: string) {
|
||||
fireEvent.click(screen.getByText(currentLabel));
|
||||
fireEvent.click(screen.getByText(optionLabel));
|
||||
}
|
||||
|
||||
/** Every callback except the named ones must stay untouched — a selection that
|
||||
* leaks into a sibling prop is exactly the wiring bug a move can introduce. */
|
||||
function onlyCalled(spies: Spies, ...called: (keyof Spies)[]) {
|
||||
for (const [name, spy] of Object.entries(spies)) {
|
||||
if (called.includes(name as keyof Spies)) continue;
|
||||
expect(spy, `${name} should not have fired`).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RunSetup — selections report through callbacks", () => {
|
||||
it("reports the prompt from the editor", () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||
fireEvent.change(box, { target: { value: "review the diff" } });
|
||||
expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff");
|
||||
onlyCalled(spies, "onPromptChange");
|
||||
});
|
||||
function renderEditor(value: string, slash = true) {
|
||||
const spies = { onChange: vi.fn(), onSubmit: vi.fn() };
|
||||
render(
|
||||
<PromptEditor
|
||||
value={value}
|
||||
placeholder="Ask anything…"
|
||||
slash={slash}
|
||||
fileCwd="/Users/tester"
|
||||
{...spies}
|
||||
/>
|
||||
);
|
||||
return spies;
|
||||
}
|
||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||
|
||||
it("reports the cwd from typing and from a suggestion", () => {
|
||||
const { spies } = renderSetup();
|
||||
const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder"));
|
||||
fireEvent.change(input, { target: { value: "/tmp/pro" } });
|
||||
expect(spies.onCwdChange).toHaveBeenCalledWith("/tmp/pro");
|
||||
fireEvent.click(screen.getByText("/Users/tester/projects/other"));
|
||||
expect(spies.onCwdChange).toHaveBeenLastCalledWith("/Users/tester/projects/other");
|
||||
onlyCalled(spies, "onCwdChange");
|
||||
});
|
||||
|
||||
it("reports the model from the picker, including a custom id", async () => {
|
||||
const { spies } = renderSetup();
|
||||
// The list is the tool's own, fetched from the server — aliases named
|
||||
// after the newest version, then pinned versions — never a hardcoded copy.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
|
||||
expect(spies.onModelChange).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
|
||||
target: { value: "claude-opus-5" },
|
||||
});
|
||||
expect(spies.onModelChange).toHaveBeenLastCalledWith("claude-opus-5");
|
||||
onlyCalled(spies, "onModelChange");
|
||||
});
|
||||
|
||||
it("reports the permission mode and the effort level", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.permissionAcceptEdits"), i18n.t("run:fields.permissionPlan"));
|
||||
expect(spies.onPermissionModeChange).toHaveBeenCalledWith("plan");
|
||||
pickFromSelect("Default (model decides)", "Medium");
|
||||
expect(spies.onEffortChange).toHaveBeenCalledWith("medium");
|
||||
onlyCalled(spies, "onPermissionModeChange", "onEffortChange");
|
||||
});
|
||||
|
||||
it("fires onStart from the Run button", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledTimes(1);
|
||||
onlyCalled(spies, "onStart");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — missing binary and other blocked states", () => {
|
||||
/** The Run button, found by its label rather than by DOM position. */
|
||||
function runButton(): HTMLButtonElement {
|
||||
return screen.getByText(i18n.t("run:actions.start")).closest("button") as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("disables Run when the claude binary was not found", () => {
|
||||
renderSetup({ binaryFound: false });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables Run when the binary is found and the form is complete", () => {
|
||||
renderSetup();
|
||||
expect(runButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("still disables Run without a prompt or without a cwd", () => {
|
||||
const { unmount } = renderSetup({ prompt: " " });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
unmount();
|
||||
|
||||
const noCwd = renderSetup({ cwd: "" });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
noCwd.unmount();
|
||||
});
|
||||
|
||||
it("shows the Starting… label while busy", () => {
|
||||
renderSetup({ busy: true });
|
||||
expect(screen.getByText(i18n.t("run:actions.starting"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — resume picker scopes sessions to the selected lane", () => {
|
||||
it("passes the lane's cwd as a filter and lists only that directory's sessions", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// The API call itself is what enforces the scope - the server filters by
|
||||
// cwd, so the picker must never fetch without one when a lane is selected.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-a" })
|
||||
);
|
||||
});
|
||||
|
||||
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// Filtered by the server, before its limit: a busy Codex lane at the same
|
||||
// directory cannot push Claude sessions off the page.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
|
||||
});
|
||||
|
||||
it("starts the resume immediately when a session is picked", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||
|
||||
expect(spies.onResumeSessionChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "sess-in-lane" })
|
||||
);
|
||||
// No prompt typed, no Run click - the pick itself is the start, and it
|
||||
// carries the session's own cwd rather than the form's.
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" })
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no prompt when resuming, even with text in the box", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||
|
||||
// The resumed session is the input; anything left in the box would be an
|
||||
// extra turn the user never asked for.
|
||||
expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the typed cwd when no lane is selected", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester" })
|
||||
);
|
||||
});
|
||||
|
||||
it("lists everything when no lane is selected and cwd is empty", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined, cwd: "" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ cwd: undefined }));
|
||||
});
|
||||
|
||||
it("re-fetches with the new cwd when the selected lane changes", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
const { rerender } = renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
laneId={1}
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
approval="on-request"
|
||||
onPromptChange={vi.fn()}
|
||||
onCwdChange={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPermissionModeChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onResumeSessionChange={vi.fn()}
|
||||
onResumeFromHistory={vi.fn()}
|
||||
onSandboxChange={vi.fn()}
|
||||
onApprovalChange={vi.fn()}
|
||||
onResumeLast={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-b" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — resume keeps the session's model", () => {
|
||||
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
|
||||
|
||||
it("sends no model when it is still the tool's default", async () => {
|
||||
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
|
||||
await screen.findByText(i18n.t("run:fields.model"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a model the user picked", async () => {
|
||||
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — per-tool fields", () => {
|
||||
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
expect(screen.getByText(/sandbox/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/approval/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/permission mode/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps permission mode for a claude lane", () => {
|
||||
renderSetup({ tool: "claude" });
|
||||
expect(screen.getByText(/permission mode/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/sandbox/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends codex fields on start", async () => {
|
||||
const onStart = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderSetup({ tool: "codex", onStart, prompt: "go" });
|
||||
await user.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sandbox: "workspace-write", approval: "on-request" })
|
||||
);
|
||||
expect(onStart.mock.calls[0]?.[0]?.permissionMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls onResumeLast when a codex lane's resume-last button is clicked", async () => {
|
||||
const onResumeLast = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderSetup({ tool: "codex", onResumeLast });
|
||||
// Switch to resume mode
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
// Click the resume-last button
|
||||
await user.click(screen.getByText(i18n.t("run:resume.lastOption")));
|
||||
expect(onResumeLast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(/ anything…$/);
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
describe("PromptEditor — / and @ suggestions", () => {
|
||||
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const spies = renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
@@ -473,30 +86,30 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the run will start in, not the server's own.
|
||||
// Read for the directory the session will start in, not the server's own.
|
||||
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
expect(spies.onChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
// Enter picked the suggestion; it did not send the prompt.
|
||||
expect(spies.onStart).not.toHaveBeenCalled();
|
||||
expect(spies.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests files under the cwd on @", async () => {
|
||||
it("suggests files under the folder on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||
const spies = renderEditor("look at @ma");
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
expect(spies.onChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
});
|
||||
|
||||
it("offers no slash commands on a codex lane", async () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
it("offers no slash commands where they do not apply", async () => {
|
||||
renderEditor("", false);
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
@@ -504,78 +117,40 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — antigravity lanes", () => {
|
||||
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
|
||||
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
|
||||
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
|
||||
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
|
||||
describe("ModelPicker", () => {
|
||||
it("lists the tool's own models, and Custom… reports only the typed id", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelPicker value="" onChange={onChange} tool="claude" />);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
|
||||
expect(onChange).toHaveBeenCalledWith("sonnet");
|
||||
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gemini-3.1-pro",
|
||||
effort: undefined,
|
||||
permissionMode: undefined,
|
||||
})
|
||||
);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
|
||||
target: { value: "claude-opus-5" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith("claude-opus-5");
|
||||
});
|
||||
|
||||
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
|
||||
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
|
||||
await screen.findByPlaceholderText("Ask Gemini anything…");
|
||||
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — codex lanes", () => {
|
||||
it("offers the models Codex itself lists", async () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
render(<ModelPicker value="" onChange={() => {}} tool="codex" />);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a fresh codex run instead of resuming the last one", () => {
|
||||
const { spies } = renderSetup({ tool: "codex" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledOnce();
|
||||
expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — catalog tools", () => {
|
||||
it("offers a per-run bypass switch with a warning, and sends it", async () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", prompt: "go" });
|
||||
const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") });
|
||||
expect(box).not.toBeChecked();
|
||||
expect(screen.queryByText(/without asking/)).toBeNull();
|
||||
fireEvent.click(box);
|
||||
expect(screen.getByText(/Antigravity will run every command/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBe(true);
|
||||
// Resume is offered because the catalog gives the tool a resume flag.
|
||||
expect(screen.getByText(i18n.t("run:resume.resumeOption"))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out resume and bypass for a tool with neither", async () => {
|
||||
const { spies } = renderSetup({ tool: "gemini", prompt: "go" });
|
||||
expect(await screen.findByPlaceholderText("Ask Gemini anything…")).toBeTruthy();
|
||||
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
|
||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives Codex the switch but not Claude", async () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
expect(await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gives Claude no bypass switch (it has the bypassPermissions mode)", async () => {
|
||||
renderSetup({ tool: "claude" });
|
||||
await screen.findByPlaceholderText("Ask Claude Code anything…");
|
||||
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
|
||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
||||
render(
|
||||
<ModelPicker
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
tool="claude"
|
||||
choices={[{ id: "glm-4.6", label: "glm-4.6" }]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
expect(await screen.findByText("glm-4.6")).toBeTruthy();
|
||||
expect(screen.queryByText("Sonnet (latest · 5.5)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user