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
+70 -36
View File
@@ -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,
});
}