feat(ai-tools): Settings → AI Tools, Default/Other lane picker, per-run bypass switch (#40 A1)

Settings → Dashboard gains an AI Tools section (installed tools with
Enabled/Disabled and Set default, available ones with a homepage link,
Refresh). useAiTools shares one catalog copy across the app and replaces
LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the
router row reads Native (<tool>) and disables AI Router profiles for tools
that cannot use one. The run form gives catalog tools resume and a red
per-run Bypass permissions switch (Codex too). Claude Code / Codex settings
tabs hide while that tool is disabled. The Workspace probes each lane tool's
CLI fresh.

Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md,
server/README.md, OpenAPI fragment + regenerated openapi.yaml.
This commit is contained in:
2026-09-28 11:19:30 +07:00
parent 77917203fd
commit fcfebe44f1
33 changed files with 1306 additions and 153 deletions
@@ -14,7 +14,8 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { CwdSuggestion } from "../../../lib/api";
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
@@ -31,10 +32,38 @@ vi.mock("../../../lib/api", () => ({
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn() },
run: { start: vi.fn() },
},
}));
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
id,
label,
command: id,
homepageUrl: null,
installed: true,
path: `/usr/bin/${id}`,
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "argv" },
resume: false,
bypass: false,
routerCapable: false,
...over,
});
const TOOLS: AiToolsView = {
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
],
};
function laneFixture(over: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
@@ -107,6 +136,8 @@ async function fillWorktreeForm(
}
beforeEach(() => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(TOOLS);
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
@@ -138,7 +169,7 @@ beforeEach(() => {
function nativeRouter() {
return {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -649,7 +680,7 @@ describe("AddLaneModal — AI Router", () => {
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
@@ -716,7 +747,7 @@ describe("AddLaneModal — a router profile that disappeared", () => {
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
@@ -751,7 +782,14 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /codex/i }));
const other = await screen.findByRole("combobox", { name: "Other…" });
// Installed + enabled only, and never the default itself.
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
"",
"codex",
"gemini",
]);
await user.selectOptions(other, "codex");
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
@@ -759,6 +797,24 @@ describe("AddLaneModal — tool selection", () => {
);
});
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini");
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
renderModal({});
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
"href",
"/settings#ai-tools"
);
});
it("defaults to claude code", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
@@ -378,7 +378,7 @@ describe("LaneToolsMenu", () => {
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native (Claude / Codex)",
label: "Native",
builtin: true,
baseUrl: "",
hasKey: false,
@@ -406,6 +406,16 @@ describe("LaneRouterRow — the Model picker", () => {
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers only native to a tool that cannot use an AI Router", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ tool: "gemini" })} />);
const select = await screen.findByDisplayValue("Native (gemini)");
const router = Array.from((select as HTMLSelectElement).options).find(
(o) => o.value === "9router"
);
expect(router?.disabled).toBe(true);
});
it("applies a switch through the router route, not a lane PATCH", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
@@ -417,9 +427,7 @@ describe("LaneRouterRow — the Model picker", () => {
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
@@ -440,9 +448,7 @@ describe("LaneRouterRow — the Model picker", () => {
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
@@ -464,9 +470,7 @@ describe("LaneRouterRow — the Model picker", () => {
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
@@ -487,14 +491,14 @@ describe("LaneRouterRow — the Model picker", () => {
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude / Codex)");
await screen.findByDisplayValue("Native (Claude)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
@@ -510,9 +514,7 @@ describe("LaneRouterRow — the Model picker", () => {
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
@@ -521,7 +523,7 @@ describe("LaneRouterRow — the Model picker", () => {
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
@@ -532,10 +534,8 @@ describe("LaneRouterRow — the Model picker", () => {
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
});
});