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
@@ -0,0 +1,184 @@
/**
* @file AiToolsSettings.tsx
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
* (GET /api/ai-tools), split into the ones found on PATH — each with
* Enabled/Disabled and Set default — and the ones available to install, which
* only link to their homepage. Refresh re-reads the login shell's PATH and
* re-detects. A disabled tool is hidden from Add lane and the server refuses a
* new lane for it; existing lanes keep working.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import type { AiTool } from "../../lib/api";
function Avatar({ label }: { label: string }) {
return (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
{label.charAt(0).toUpperCase()}
</span>
);
}
function Homepage({ tool }: { tool: AiTool }) {
const { t } = useTranslation("settings");
if (!tool.homepageUrl) return null;
return (
<a
href={tool.homepageUrl}
target="_blank"
rel="noreferrer noopener"
title={t("aiTools.homepage")}
aria-label={t("aiTools.homepageOf", { tool: tool.label })}
className="text-fg-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
);
}
export function AiToolsSettings() {
const { t } = useTranslation("settings");
const { tools, loaded, defaultId, refresh, update } = useAiTools();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
// The Add-lane modal links here as /settings#ai-tools; the section renders
// after the browser's own hash jump, so it has to scroll itself into view.
useEffect(() => {
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
}, []);
const run = async (key: string, fn: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
return (
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Bot className="w-4 h-4 text-fg-muted" />
{t("aiTools.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("aiTools.description")}</p>
<div className="card p-5 space-y-5">
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border rounded-md border border-border">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
>
<Avatar label={tool.label} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
{tool.label}
<Homepage tool={tool} />
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
</div>
</div>
{isDefault ? (
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
) : (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.setDefault")}
</button>
)}
<button
type="button"
role="switch"
aria-checked={tool.enabled}
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
disabled={busy !== null}
onClick={() =>
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
}
className={`w-24 rounded-md border px-2 py-1 text-xs disabled:opacity-50 ${
tool.enabled
? "border-status-success/40 bg-status-success/10 text-status-success"
: "border-border bg-surface-2 text-fg-muted"
}`}
>
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
</button>
</li>
);
})}
</ul>
</div>
{available.length > 0 && (
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t("aiTools.available", { count: available.length })}
</p>
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{available.map((tool) => (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5"
>
<Avatar label={tool.label} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{tool.label}
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
<Homepage tool={tool} />
</li>
))}
</ul>
</div>
)}
</div>
</section>
);
}