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:
@@ -25,7 +25,8 @@ import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -109,7 +110,15 @@ export function AddLaneModal({
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const toolRouterCapable =
|
||||
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
@@ -130,7 +139,7 @@ export function AddLaneModal({
|
||||
setBase("");
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -414,27 +423,49 @@ export function AddLaneModal({
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={tool === "claude"}
|
||||
label={t("addLaneToolClaude")}
|
||||
onClick={() => setTool("claude")}
|
||||
/>
|
||||
<Seg
|
||||
active={tool === "codex"}
|
||||
label={t("addLaneToolCodex")}
|
||||
onClick={() => setTool("codex")}
|
||||
/>
|
||||
<Seg
|
||||
active={tool === "antigravity"}
|
||||
label={t("addLaneToolAntigravity")}
|
||||
onClick={() => {
|
||||
setTool("antigravity");
|
||||
// `agy` cannot run behind an AI Router (see router-apply.js).
|
||||
setRouter("native");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings#ai-tools" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={tool === defaultTool}
|
||||
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
|
||||
onClick={() => setPickedTool(null)}
|
||||
/>
|
||||
</div>
|
||||
{otherTools.length > 0 && (
|
||||
<select
|
||||
aria-label={t("addLaneToolOther")}
|
||||
value={tool === defaultTool ? "" : tool}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value || null;
|
||||
setPickedTool(next);
|
||||
// A tool with no endpoint to rewrite runs native only
|
||||
// (see router-apply.js).
|
||||
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
|
||||
}}
|
||||
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
|
||||
tool !== defaultTool
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<option value="">{t("addLaneToolOther")}</option>
|
||||
{otherTools.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -640,17 +671,17 @@ export function AddLaneModal({
|
||||
hid it. The pipeline picker is gone entirely: every lane is created
|
||||
on `default` (the server's own default) and the template is changed
|
||||
from the lane's own header afterwards. */}
|
||||
<div
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
hidden={tool === "antigravity"}
|
||||
>
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2">
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{
|
||||
value: "native",
|
||||
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
|
||||
},
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
@@ -658,10 +689,16 @@ export function AddLaneModal({
|
||||
option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
const disabled =
|
||||
option.value === "router" && (!routersLoaded || !toolRouterCapable);
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
title={
|
||||
option.value === "router" && !toolRouterCapable
|
||||
? t("router.nativeOnly", { tool: aiTools.label(tool) })
|
||||
: undefined
|
||||
}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
|
||||
Reference in New Issue
Block a user