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:
@@ -60,6 +60,7 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DollarSign,
|
||||
Palette,
|
||||
Bot,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
@@ -120,6 +121,7 @@ import { RemoteSources } from "../components/RemoteSources";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import type { ModelPricing, WSMessage } from "../lib/types";
|
||||
|
||||
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
||||
@@ -131,6 +133,7 @@ const SETTINGS_SECTIONS: {
|
||||
Icon: typeof DollarSign;
|
||||
}[] = [
|
||||
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
||||
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
|
||||
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
|
||||
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
|
||||
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
||||
@@ -1081,6 +1084,8 @@ export function Settings() {
|
||||
|
||||
<AppearanceSettings />
|
||||
|
||||
<AiToolsSettings />
|
||||
|
||||
{/* ─── MODEL PRICING ─── */}
|
||||
<section id="pricing" className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
@@ -13,12 +13,15 @@ import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
Panel: ComponentType;
|
||||
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */
|
||||
aiTool?: string;
|
||||
}
|
||||
|
||||
const DASHBOARD_TAB: ToolTab = {
|
||||
@@ -31,8 +34,20 @@ const DASHBOARD_TAB: ToolTab = {
|
||||
/** Add a tool here to give it a tab; that is the whole extension point. */
|
||||
const TOOL_TABS: ToolTab[] = [
|
||||
DASHBOARD_TAB,
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{
|
||||
id: "claude-code",
|
||||
labelKey: "tools.claudeCode",
|
||||
Icon: Boxes,
|
||||
Panel: CcConfig,
|
||||
aiTool: "claude",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
labelKey: "tools.codex",
|
||||
Icon: SquareTerminal,
|
||||
Panel: CodexConfig,
|
||||
aiTool: "codex",
|
||||
},
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
{
|
||||
id: "git-credentials",
|
||||
@@ -50,7 +65,11 @@ export function SettingsHub() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [params, setParams] = useSearchParams();
|
||||
const requested = params.get("tool");
|
||||
const active = TOOL_TABS.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
|
||||
const aiTools = useAiTools();
|
||||
const tabs = TOOL_TABS.filter(
|
||||
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
|
||||
);
|
||||
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -58,7 +77,7 @@ export function SettingsHub() {
|
||||
ariaLabel={t("tools.label", "Tools")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
|
||||
tabs={TOOL_TABS.map(({ id, labelKey, Icon }) => ({
|
||||
tabs={tabs.map(({ id, labelKey, Icon }) => ({
|
||||
id,
|
||||
Icon,
|
||||
label: t(labelKey),
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
@@ -175,7 +175,7 @@ function ConsoleArea({
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
externalSessions: Session[];
|
||||
@@ -296,7 +296,7 @@ export function Workspace() {
|
||||
// would otherwise show nothing while two agents are working.
|
||||
const [externalSessions, setExternalSessions] = useState<Session[]>([]);
|
||||
const [binaryStatus, setBinaryStatus] = useState<Record<
|
||||
LaneTool,
|
||||
string,
|
||||
{ found: boolean; path: string | null }
|
||||
> | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
@@ -394,13 +394,25 @@ export function Workspace() {
|
||||
);
|
||||
}, [lanes, searchParams, setSearchParams]);
|
||||
|
||||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||||
// always probed: the console's no-lane state runs it.
|
||||
const laneToolsKey = Array.from(new Set(["claude", ...lanes.map((l) => l.tool || "claude")]))
|
||||
.sort()
|
||||
.join(",");
|
||||
useEffect(() => {
|
||||
Promise.all([api.run.binary("claude"), api.run.binary("codex"), api.run.binary("antigravity")])
|
||||
.then(([claude, codex, antigravity]) => setBinaryStatus({ claude, codex, antigravity }))
|
||||
const tools = laneToolsKey.split(",");
|
||||
Promise.all(tools.map((tool) => api.run.binary(tool)))
|
||||
.then((found) =>
|
||||
setBinaryStatus(Object.fromEntries(tools.map((tool, i) => [tool, found[i]!])))
|
||||
)
|
||||
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
||||
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
|
||||
// a CLI is missing from PATH — leave the probe unresolved rather than
|
||||
// showing a misleading "binary missing" banner for an unrelated fault.
|
||||
.catch(() => undefined);
|
||||
}, [laneToolsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.pipelines()
|
||||
.then((r) => setPipelineTemplates(r.pipelines))
|
||||
@@ -860,7 +872,7 @@ export function Workspace() {
|
||||
</select>
|
||||
)}
|
||||
{/* `agy` has no endpoint to point at a router. */}
|
||||
{currentLane.tool !== "antigravity" && <LaneRouterRow lane={currentLane} />}
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||
PipelineMap right below this header already draws both
|
||||
— the declared node as the current ring, the detected
|
||||
|
||||
@@ -41,7 +41,7 @@ const lane = (id: number, title: string): LaneFixture => ({
|
||||
const h = vi.hoisted(() => {
|
||||
const nativeProfile = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
label: "Native",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
@@ -205,10 +205,10 @@ describe("Workspace — the lane Model picker across a lane switch", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectLane(user, "Lane A");
|
||||
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
|
||||
await selectLane(user, "Lane B");
|
||||
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await user.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
await settle();
|
||||
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
|
||||
@@ -216,7 +216,7 @@ describe("Workspace — the lane Model picker across a lane switch", () => {
|
||||
// Back to the lane that was never switched. It is still native server-side,
|
||||
// so the picker must say so — the row is one instance shared by every lane.
|
||||
await selectLane(user, "Lane A");
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
// And nothing may have been written to lane A on the way.
|
||||
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
|
||||
});
|
||||
|
||||
@@ -6996,6 +6996,47 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</svg>
|
||||
Appearance
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-bot w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 8V4H8"
|
||||
/>
|
||||
<rect
|
||||
height="12"
|
||||
rx="2"
|
||||
width="16"
|
||||
x="4"
|
||||
y="8"
|
||||
/>
|
||||
<path
|
||||
d="M2 14h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 14h2"
|
||||
/>
|
||||
<path
|
||||
d="M15 13v2"
|
||||
/>
|
||||
<path
|
||||
d="M9 13v2"
|
||||
/>
|
||||
</svg>
|
||||
AI Tools
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
|
||||
type="button"
|
||||
@@ -8089,6 +8130,105 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="ai-tools"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-bot w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 8V4H8"
|
||||
/>
|
||||
<rect
|
||||
height="12"
|
||||
rx="2"
|
||||
width="16"
|
||||
x="4"
|
||||
y="8"
|
||||
/>
|
||||
<path
|
||||
d="M2 14h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 14h2"
|
||||
/>
|
||||
<path
|
||||
d="M15 13v2"
|
||||
/>
|
||||
<path
|
||||
d="M9 13v2"
|
||||
/>
|
||||
</svg>
|
||||
AI Tools
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5 space-y-5"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 mb-2"
|
||||
>
|
||||
<p
|
||||
class="text-xs font-medium text-fg-secondary"
|
||||
>
|
||||
Installed (0 detected)
|
||||
</p>
|
||||
<button
|
||||
class="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"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-refresh-cw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||
/>
|
||||
<path
|
||||
d="M21 3v5h-5"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||
/>
|
||||
<path
|
||||
d="M8 16H3v5"
|
||||
/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
class="divide-y divide-border rounded-md border border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="pricing"
|
||||
|
||||
Reference in New Issue
Block a user