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:
@@ -43,8 +43,8 @@ import type {
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
} from "../../lib/api";
|
||||
import type { Session, Lane, LaneTool } from "../../lib/types";
|
||||
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||
import type { Session, Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
@@ -69,7 +69,7 @@ export interface LaneConsolePaneProps {
|
||||
showLaneSelector: boolean;
|
||||
onLaneIdChange: (id: number) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||
@@ -150,7 +150,8 @@ export function LaneConsolePane({
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const toolLabel = LANE_TOOL_LABEL[laneTool];
|
||||
const aiTools = useAiTools();
|
||||
const toolLabel = aiTools.label(laneTool);
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -313,6 +314,7 @@ export function LaneConsolePane({
|
||||
sandbox: args.sandbox,
|
||||
approval: args.approval,
|
||||
resumeLast: args.resumeLast,
|
||||
bypass: args.bypass,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
// A code-only check: the server's message is human prose ("lane
|
||||
@@ -566,12 +568,13 @@ export function LaneConsolePane({
|
||||
/>
|
||||
</header>
|
||||
|
||||
{binaryStatus && !binaryStatus[laneTool]?.found && (
|
||||
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
|
||||
{binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>
|
||||
{t("binary.missing", {
|
||||
cli: laneTool === "antigravity" ? "agy" : laneTool,
|
||||
cli: aiTools.byId(laneTool)?.command ?? laneTool,
|
||||
tool: toolLabel,
|
||||
})}
|
||||
</span>
|
||||
@@ -613,7 +616,9 @@ export function LaneConsolePane({
|
||||
onSandboxChange={setSandbox}
|
||||
approval={approval}
|
||||
onApprovalChange={setApproval}
|
||||
onResumeLast={() => onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true })}
|
||||
onResumeLast={(bypass) =>
|
||||
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
|
||||
@@ -56,7 +56,7 @@ import type {
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { Select } from "../Select";
|
||||
|
||||
export interface SlashCommand {
|
||||
@@ -389,9 +389,10 @@ interface RunSetupProps {
|
||||
onSandboxChange: (s: CodexSandbox) => void;
|
||||
approval: CodexApproval;
|
||||
onApprovalChange: (a: CodexApproval) => void;
|
||||
/** Codex lanes resume with `--last`: the sessions table holds Claude
|
||||
/** Every non-Claude lane resumes its tool's LAST session (`codex resume
|
||||
* --last`, a catalog tool's `resume` args): the sessions table holds Claude
|
||||
* sessions only, so there is nothing for the picker to list. */
|
||||
onResumeLast: () => void;
|
||||
onResumeLast: (bypass?: boolean) => void;
|
||||
}
|
||||
|
||||
export function RunSetup(props: RunSetupProps) {
|
||||
@@ -399,6 +400,15 @@ export function RunSetup(props: RunSetupProps) {
|
||||
const atCap = false; // TODO: re-add when concurrency info is available
|
||||
const isResume = !!props.resumeSession;
|
||||
const [resumePicked, setResumePicked] = useState(isResume);
|
||||
const aiTools = useAiTools();
|
||||
const entry = aiTools.byId(props.tool);
|
||||
// Claude and Codex keep their hand-written fields; every other catalog tool
|
||||
// gets the plain form — cwd, prompt, resume and bypass when it has them.
|
||||
const catalogTool = props.tool !== "claude" && props.tool !== "codex";
|
||||
const canResume = !catalogTool || !!entry?.resume;
|
||||
// Per run, never remembered: the form starts unticked every time it mounts.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
const canBypass = props.tool !== "claude" && !!entry?.bypass;
|
||||
// Keep "resume picked" in sync with the parent. Parent set a resume session
|
||||
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
|
||||
// is shown and the selection is visible.
|
||||
@@ -431,13 +441,13 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
{/* `agy` takes no model, permission or effort flag. */}
|
||||
{props.tool !== "antigravity" && (
|
||||
{/* A catalog tool takes no model, permission or effort flag. */}
|
||||
{!catalogTool && (
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
)}
|
||||
{props.tool === "antigravity" ? null : props.tool === "codex" ? (
|
||||
{catalogTool ? null : props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
@@ -476,7 +486,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{props.tool !== "antigravity" && (
|
||||
{!catalogTool && (
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
@@ -490,23 +500,25 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{canResume && (
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
@@ -520,7 +532,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
// Pass the picked session explicitly: the parent's state
|
||||
// update has not landed yet on this tick, so reading
|
||||
// props.resumeSession here would resume nothing.
|
||||
if (s && !props.busy) handleStart(props, s);
|
||||
if (s && !props.busy) handleStart(props, s, bypass);
|
||||
}}
|
||||
// Fall back to the typed cwd when no lane is selected — otherwise
|
||||
// an unfiltered picker lists every session from every repo.
|
||||
@@ -528,7 +540,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => props.onResumeLast()}
|
||||
onClick={() => props.onResumeLast(canBypass && bypass ? true : undefined)}
|
||||
disabled={props.busy}
|
||||
className="px-3 py-1.5 text-[11px] rounded-md bg-surface-2 border border-border hover:border-accent/50 text-fg-primary hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -547,8 +559,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
<PromptEditor
|
||||
value={props.prompt}
|
||||
onChange={props.onPromptChange}
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal", { tool: LANE_TOOL_LABEL[props.tool] })}
|
||||
onSubmit={() => handleStart(props, undefined, canBypass && bypass)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
|
||||
rows={5}
|
||||
slash={props.tool === "claude"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
@@ -558,7 +570,27 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
{canBypass && (
|
||||
<div className="px-4 pb-3">
|
||||
<label className="inline-flex items-center gap-2 text-[11.5px] text-status-danger cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bypass}
|
||||
onChange={(e) => setBypass(e.target.checked)}
|
||||
className="accent-[rgb(var(--status-danger))]"
|
||||
/>
|
||||
{t("fields.bypass")}
|
||||
</label>
|
||||
{bypass && (
|
||||
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("hint.bypassWarning", { tool: aiTools.label(props.tool) })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.tool === "codex" && !bypass && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("hint.sandboxWarning")}</span>
|
||||
@@ -583,7 +615,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStart(props)}
|
||||
onClick={() => handleStart(props, undefined, canBypass && bypass)}
|
||||
disabled={
|
||||
!props.binaryFound ||
|
||||
// A resume needs no prompt — the session it continues is the input.
|
||||
@@ -612,25 +644,27 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
/** `session` overrides `props.resumeSession` for the auto-start fired straight
|
||||
* out of the picker, before the parent's state has caught up. */
|
||||
function handleStart(props: RunSetupProps, session?: Session) {
|
||||
function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean) {
|
||||
const resume = session ?? props.resumeSession;
|
||||
const codex = props.tool === "codex";
|
||||
const agy = props.tool === "antigravity";
|
||||
// Any catalog tool: none of Claude's or Codex's own flags apply.
|
||||
const other = props.tool !== "claude" && !codex;
|
||||
props.onStart({
|
||||
laneId: props.laneId,
|
||||
// A resume is pinned to its own session's folder — that's what the locked
|
||||
// cwd field shows, so it's what gets sent.
|
||||
cwd: resume?.cwd || props.cwd || undefined,
|
||||
model: agy ? undefined : props.model || undefined,
|
||||
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
|
||||
model: other ? undefined : props.model || undefined,
|
||||
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
|
||||
sandbox: codex ? props.sandbox : undefined,
|
||||
approval: codex ? props.approval : undefined,
|
||||
effort: agy ? undefined : props.effort || undefined,
|
||||
resumeSessionId: codex || agy ? undefined : resume?.id || undefined,
|
||||
effort: other ? undefined : props.effort || undefined,
|
||||
resumeSessionId: codex || other ? undefined : resume?.id || undefined,
|
||||
// A resume continues a session that already has its own history — sending
|
||||
// whatever is sitting in the prompt box would fire an extra turn nobody
|
||||
// asked for, so a resume carries no prompt at all.
|
||||
initialPrompt: resume ? undefined : props.prompt || undefined,
|
||||
bypass: bypass || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
aiTools: {
|
||||
list: r({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", resume: false, bypass: false },
|
||||
{ id: "codex", label: "Codex", resume: false, bypass: true },
|
||||
{ id: "antigravity", label: "Antigravity", resume: true, bypass: true },
|
||||
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
|
||||
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: {
|
||||
@@ -443,6 +454,41 @@ describe("RunSetup — codex lanes", () => {
|
||||
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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user