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:
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user