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
+80
View File
@@ -0,0 +1,80 @@
/**
* @file useAiTools — the AI Tools catalog (GET /api/ai-tools) shared by every
* component that names a lane's tool or offers one: the Add-lane modal, the
* run form, the lane strip and Settings → AI Tools. One module-level copy, so
* N components mean one request; refetched on window focus and after every
* change made through `update`/`refresh`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api, type AiTool, type AiToolsView } from "../lib/api";
let view: AiToolsView | null = null;
let inflight: Promise<void> | null = null;
const listeners = new Set<() => void>();
function publish(next: AiToolsView) {
view = next;
listeners.forEach((l) => l());
}
function load(): Promise<void> {
if (!inflight) {
inflight = Promise.resolve()
.then(() => api.aiTools.list())
.then(publish)
// Unreachable server: keep what we had; every caller has a fallback.
.catch(() => undefined)
.finally(() => {
inflight = null;
});
}
return inflight;
}
function subscribe(l: () => void) {
listeners.add(l);
if (listeners.size === 1) window.addEventListener("focus", onFocus);
return () => {
listeners.delete(l);
if (listeners.size === 0) window.removeEventListener("focus", onFocus);
};
}
function onFocus() {
void load();
}
/** Built-in names for the three tools a lane may carry before the catalog loads. */
const FALLBACK: Record<string, string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
};
/** Test seam: forget the cached catalog. */
export function __resetAiTools() {
view = null;
inflight = null;
}
export function useAiTools() {
const v = useSyncExternalStore(subscribe, () => view);
useEffect(() => {
if (!view) void load();
}, []);
const tools: AiTool[] = v?.tools ?? [];
const byId = (id: string) => tools.find((t) => t.id === id) ?? null;
return {
tools,
loaded: v !== null,
defaultId: v?.default ?? null,
byId,
/** A tool's display name; the id itself for a tool the catalog no longer knows. */
label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id,
reload: load,
refresh: async () => publish(await api.aiTools.refresh()),
update: async (id: string, body: { enabled?: boolean; default?: true }) =>
publish(await api.aiTools.update(id, body)),
};
}