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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file AiToolsSettings.test.tsx
|
||||
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
|
||||
* Set default, available rows only a homepage link, Refresh re-detects, and a
|
||||
* disabled tool's Settings tab disappears from the hub.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AiToolsSettings } from "../AiToolsSettings";
|
||||
import { api, type AiTool, type AiToolsView } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { aiTools: { list: vi.fn(), refresh: vi.fn(), update: vi.fn() } },
|
||||
};
|
||||
});
|
||||
|
||||
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
|
||||
id,
|
||||
label,
|
||||
command: id,
|
||||
homepageUrl: `https://${id}.example/`,
|
||||
installed: true,
|
||||
path: `/usr/bin/${id}`,
|
||||
enabled: true,
|
||||
source: "builtin",
|
||||
launcher: null,
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
...over,
|
||||
});
|
||||
const VIEW: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
||||
});
|
||||
|
||||
describe("AiToolsSettings", () => {
|
||||
it("splits installed tools from the ones available to install", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Available to install (1)")).toBeInTheDocument();
|
||||
const claude = screen.getByTestId("ai-tool-claude");
|
||||
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
|
||||
const aider = screen.getByTestId("ai-tool-aider");
|
||||
expect(within(aider).queryByRole("switch")).toBeNull();
|
||||
expect(within(aider).queryByText("Set default")).toBeNull();
|
||||
expect(within(aider).getByRole("link")).toHaveAttribute("href", "https://aider.example/");
|
||||
});
|
||||
|
||||
it("toggles a tool and sets the default through the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsHub — AI tool tabs", () => {
|
||||
it("hides the Codex tab while Codex is disabled", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user