feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview

- AI Tools moves from a Settings section into a modal behind a button at the
  Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs,
  /settings#ai-tools); rows get an enable switch, Default badge, and an
  Update button for every tool whose catalog names an updater (no version
  lookup on open); not-installed tools fold behind a disclosure, a finished
  job's log folds to one line; shared AiToolIcon
- codex updates through its own `codex update`, openclaw/hermes gain
  `update --yes`; update-all runs every installed updater and skips an npm
  one only when npm does not manage the binary
- Add-lane tool picker is one button showing the chosen tool that opens the
  enabled tools as buttons
- Claude Code / Codex AgentHub sub-tab folded into Overview (home editor,
  hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME;
  HomeDirSetting shows the saved value only while the field differs
- Preview/Raw toggle for markdown files in the config FileViewer and the
  Changes panel's whole-file view (MarkdownPreview: react-markdown +
  remark-gfm, raw HTML never run, frontmatter as a code block)
- docs and the CLAUDE.md marker updated
This commit is contained in:
2026-10-01 10:11:43 +07:00
parent 058c574138
commit c5aba094bd
55 changed files with 2985 additions and 853 deletions
+42
View File
@@ -0,0 +1,42 @@
/**
* @file AiToolIcon.tsx
* @description An AI tool's icon: the server's cached favicon
* (GET /api/ai-tools/:id/icon), or the label's first letter when there is
* none. Shared by the AI Tools modal and the Add-lane tool picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api, type AiTool } from "../lib/api";
/**
* @param props.tool - The catalog entry.
* @param props.className - Size classes (default `h-8 w-8`).
*/
export function AiToolIcon({
tool,
className = "h-8 w-8",
}: {
tool: Pick<AiTool, "id" | "label">;
className?: string;
}) {
// Keyed by id: one instance showing another tool must retry its icon.
const [failedId, setFailedId] = useState<string | null>(null);
if (failedId !== tool.id) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailedId(tool.id)}
className={`${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`}
/>
);
}
return (
<span
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
>
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @file MarkdownPreview.tsx
* @description A markdown file rendered for reading (GitHub-flavoured:
* tables, task lists, strikethrough, autolinks), shared by the config
* explorer's file viewer and the lane Changes panel. Raw HTML in the file is
* shown as text, never run (react-markdown without rehype-raw), and links
* open in a new tab. A leading YAML frontmatter block (skills, agents) is
* shown as a code block instead of being misread as a heading. `isMarkdown`
* says which paths get the preview toggle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/** Whether `path` names a markdown file. */
export function isMarkdown(path: string | null | undefined): boolean {
return !!path && /\.(md|markdown|mdx)$/i.test(path);
}
/** Split a leading `---` frontmatter block off the body. */
function splitFrontmatter(text: string): { front: string | null; body: string } {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
return m ? { front: m[1]!, body: text.slice(m[0].length) } : { front: null, body: text };
}
export function MarkdownPreview({ text, className = "" }: { text: string; className?: string }) {
const { front, body } = splitFrontmatter(text);
return (
<div data-testid="markdown-preview" className={`md-preview ${className}`}>
{front !== null && <pre className="md-frontmatter">{front}</pre>}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noreferrer noopener" />
),
}}
>
{body}
</ReactMarkdown>
</div>
);
}
+17 -5
View File
@@ -2,10 +2,11 @@
* @file PageTabs.tsx
* @description Shared top-of-page tab bar used by the pages that merged several
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
* the owning page keeps the active tab in its own query param.
* the owning page keeps the active tab in its own query param. `actions` sits at
* the bar's right edge, outside the tablist.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import type { ComponentType, ReactNode } from "react";
export interface PageTab {
id: string;
@@ -18,6 +19,8 @@ interface PageTabsProps {
activeId: string;
onSelect: (id: string) => void;
ariaLabel: string;
/** Buttons at the right edge of the bar (not tabs). */
actions?: ReactNode;
}
/**
@@ -25,12 +28,14 @@ interface PageTabsProps {
* @param props - Tabs to render, the active tab id, and the selection callback.
* @returns The tab bar element.
*/
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
return (
export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions }: PageTabsProps) {
const list = (
<div
role="tablist"
aria-label={ariaLabel}
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
}`}
>
{tabs.map(({ id, label, Icon }) => {
const isActive = id === activeId;
@@ -54,4 +59,11 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
})}
</div>
);
if (!actions) return list;
return (
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
{list}
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* @file MarkdownPreview.test.tsx
* @description The markdown preview: GFM renders (headings, tables, task
* lists), raw HTML stays text, links open in a new tab, frontmatter is a
* code block; and the config FileViewer offers Raw / Preview only for a
* markdown file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { FileViewer } from "../config/FileViewer";
describe("MarkdownPreview", () => {
it("renders GFM: headings, tables and task lists", () => {
render(
<MarkdownPreview
text={"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n"}
/>
);
expect(screen.getByRole("heading", { level: 1, name: "Title" })).toBeInTheDocument();
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("never runs raw HTML and opens links in a new tab", () => {
const { container } = render(
<MarkdownPreview
text={'<script>alert(1)</script><img src=x onerror="alert(1)">\n\n[x](https://a.example)'}
/>
);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("img")).toBeNull();
const link = screen.getByRole("link", { name: "x" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer noopener");
});
it("shows frontmatter as a block, not as a heading", () => {
const { container } = render(
<MarkdownPreview text={"---\nname: demo\ndescription: d\n---\n\nBody text\n"} />
);
expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo");
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByText("Body text")).toBeInTheDocument();
});
it("knows a markdown path", () => {
expect(isMarkdown("/a/SKILL.md")).toBe(true);
expect(isMarkdown("README.markdown")).toBe(true);
expect(isMarkdown("config.toml")).toBe(false);
expect(isMarkdown(null)).toBe(false);
});
});
describe("FileViewer — markdown preview", () => {
const data = { text: "# Hello\n", truncated: false } as never;
it("switches a markdown file between raw and preview", async () => {
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByTestId("markdown-preview")).toBeNull();
expect(screen.getByText("# Hello")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
expect(screen.queryByTestId("markdown-preview")).toBeNull();
});
it("offers no preview for another file type", () => {
render(<FileViewer state={{ path: "/x/config.toml", data, error: null }} onClose={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Preview" })).toBeNull();
});
});
+25 -3
View File
@@ -3,15 +3,17 @@
* @description Read-only file-content modal for the config explorer. Takes
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch.
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
* (`MarkdownPreview`); Raw is the default.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle } from "lucide-react";
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
export function FileViewer({
state,
@@ -21,6 +23,8 @@ export function FileViewer({
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
const [preview, setPreview] = useState(false);
const canPreview = isMarkdown(state.path) && !!state.data;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -42,6 +46,17 @@ export function FileViewer({
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
{canPreview && (
<button
type="button"
aria-pressed={preview}
onClick={() => setPreview((v) => !v)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
{preview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{preview ? t("common.raw") : t("common.preview")}
</button>
)}
<CopyButton value={state.path} />
<button
onClick={onClose}
@@ -59,6 +74,13 @@ export function FileViewer({
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : preview && canPreview ? (
<>
<MarkdownPreview text={state.data.text} />
{state.data.truncated && (
<p className="text-[11px] text-fg-muted italic">{t("common.truncated")}</p>
)}
</>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
+56 -32
View File
@@ -19,7 +19,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { ChevronDown, FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
@@ -27,6 +27,7 @@ import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -115,7 +116,17 @@ export function AddLaneModal({
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
const tool = pickedTool ?? defaultTool;
const otherTools = aiTools.tools.filter((x) => x.installed && x.enabled && x.id !== defaultTool);
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
.filter((x) => x.installed && x.enabled)
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
const [toolListOpen, setToolListOpen] = useState(false);
const pickTool = (id: string) => {
setPickedTool(id === defaultTool ? null : id);
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
setToolListOpen(false);
};
const noTools = aiTools.loaded && aiTools.defaultId === null;
const toolRouterCapable =
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
@@ -431,38 +442,51 @@ export function AddLaneModal({
</a>
</p>
) : (
<div className="flex items-center gap-1.5">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={tool === defaultTool}
label={t("addLaneToolDefault", { tool: aiTools.label(defaultTool) })}
onClick={() => setPickedTool(null)}
/>
</div>
{otherTools.length > 0 && (
<select
aria-label={t("addLaneToolOther")}
value={tool === defaultTool ? "" : tool}
onChange={(e) => {
const next = e.target.value || null;
setPickedTool(next);
// A tool with no endpoint to rewrite runs native only
// (see router-apply.js).
if (next && !aiTools.byId(next)?.routerCapable) setRouter("native");
}}
className={`rounded-md border bg-surface-2 px-2 py-1 text-xs ${
tool !== defaultTool
? "border-accent text-accent"
: "border-border text-fg-secondary"
}`}
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
<option value="">{t("addLaneToolOther")}</option>
{otherTools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</select>
</div>
)}
</div>
)}
@@ -5,7 +5,8 @@
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
@@ -38,7 +39,9 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Code2,
Columns2,
Eye,
FileText,
FoldVertical,
Folder,
@@ -53,6 +56,7 @@ import {
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
@@ -435,6 +439,9 @@ export function LaneChangesPanel({
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
@@ -1429,6 +1436,18 @@ export function LaneChangesPanel({
binary={current.binary}
/>
)}
{whole && text && draft === null && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text;
only the diff, which has no whole text to click, keeps
@@ -1442,7 +1461,8 @@ export function LaneChangesPanel({
{t(noEditReason(text))}
</span>
) : draft === null && whole ? (
text && (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
@@ -1503,7 +1523,11 @@ export function LaneChangesPanel({
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
/>
) : whole ? (
text ? (
text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
) : text ? (
<LaneFileView
path={text.path}
lines={text.lines}
@@ -10,7 +10,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
@@ -32,7 +32,7 @@ vi.mock("../../../lib/api", () => ({
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
aiTools: { list: vi.fn() },
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
run: { start: vi.fn() },
},
}));
@@ -784,14 +784,20 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
const other = await screen.findByRole("combobox", { name: "Other…" });
// Installed + enabled only, and never the default itself.
expect(Array.from((other as HTMLSelectElement).options).map((o) => o.value)).toEqual([
"",
"codex",
"gemini",
]);
await user.selectOptions(other, "codex");
// One button showing the default; it opens every installed + enabled tool.
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
expect(
within(list)
.getAllByRole("button")
.map((b) => b.textContent)
).toEqual(["Claude Code", "Codex", "Gemini"]);
await user.click(within(list).getByRole("button", { name: "Codex" }));
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
@@ -802,7 +808,8 @@ describe("AddLaneModal — tool selection", () => {
it("offers only Native for a tool that cannot use an AI Router", async () => {
renderModal({});
const user = userEvent.setup();
await user.selectOptions(await screen.findByRole("combobox", { name: "Other…" }), "gemini");
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
await user.click(screen.getByRole("button", { name: "Gemini" }));
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
});
@@ -321,6 +321,34 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "README.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Hello", "", "- one"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-view");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-view")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -0,0 +1,67 @@
/**
* @file AiToolsModal.tsx
* @description The AI Tools dialog opened from the Settings tab bar's right
* edge: a header with the title, description and a close button around
* `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a
* nested ConfirmModal already handled (defaultPrevented) is left to it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Bot, X } from "lucide-react";
import { AiToolsSettings } from "./AiToolsSettings";
/**
* @param props.onClose - Called on Escape, a backdrop click or the X.
* @returns The modal.
*/
export function AiToolsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("settings");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="ai-tools-title"
onClick={(e) => e.stopPropagation()}
className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2
id="ai-tools-title"
className="flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<Bot className="h-4 w-4 text-fg-muted" />
{t("aiTools.title")}
</h2>
<p className="mt-1 text-xs text-fg-muted">{t("aiTools.description")}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("common:close")}
className="rounded-md p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<AiToolsSettings />
</div>
</div>
);
}
+200 -197
View File
@@ -1,44 +1,32 @@
/**
* @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, Set default and, when the catalog names one, Update with a
* new-version badge — and the ones available to install, with Install where
* the catalog knows how. Install/Update/Update tools confirm the exact command
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
* The ones available to install fold behind a disclosure, with Install where
* the catalog knows how. Install/Update/Update all confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
* A disabled tool is hidden from Add lane; existing lanes keep working.
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; 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, Download, ExternalLink, RefreshCw } from "lucide-react";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
import { AiToolIcon } from "../AiToolIcon";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
function Avatar({ tool }: { tool: AiTool }) {
const [failed, setFailed] = useState(false);
if (!failed) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailed(true)}
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
/>
);
}
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">
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
const GHOST_BTN =
"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 hover:text-fg-primary disabled:opacity-50";
function Homepage({ tool }: { tool: AiTool }) {
const { t } = useTranslation("settings");
@@ -57,12 +45,51 @@ function Homepage({ tool }: { tool: AiTool }) {
);
}
function Switch({
tool,
disabled,
onToggle,
}: {
tool: AiTool;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation("settings");
return (
<button
type="button"
role="switch"
aria-checked={tool.enabled}
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
disabled={disabled}
onClick={onToggle}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
tool.enabled ? "bg-accent" : "bg-surface-4"
}`}
>
<span
aria-hidden
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
tool.enabled ? "left-[18px]" : "left-0.5"
}`}
/>
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
</button>
);
}
function JobLog({ job }: { job: AiToolJob }) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLPreElement | null>(null);
// A finished job folds to its status line; running or failed stays open.
const [open, setOpen] = useState(job.status !== "ok");
useEffect(() => {
if (job.status === "ok") setOpen(false);
}, [job.status]);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [job.log]);
}, [job.log, open]);
const state =
job.status === "running"
? t("aiTools.jobRunning")
@@ -71,18 +98,29 @@ function JobLog({ job }: { job: AiToolJob }) {
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
return (
<div className="mt-2 w-full">
<p
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
>
{state}
</p>
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
<div className="flex items-center gap-2 text-[11px]">
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
{state}
</span>
{job.status !== "running" && (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
</button>
)}
</div>
{open && (
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-0 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
)}
</div>
);
}
@@ -93,31 +131,16 @@ export function AiToolsSettings() {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
const [confirm, setConfirm] = useState<Confirm | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
const [showAvailable, setShowAvailable] = useState(false);
// 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?.();
}, []);
// Deferred like useAiTools' load: an unreachable server (or a mocked api
// without these calls) degrades to "no job, no badges", never a throw.
const loadVersions = () =>
Promise.resolve()
.then(() => api.aiTools.versions())
.then(setVersions)
.catch(() => undefined);
// A page opened mid-job picks it up; versions are cached a day server-side.
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
// unreachable server (or a mocked api without it) means "no job".
useEffect(() => {
Promise.resolve()
.then(() => api.aiTools.job())
.then(setJob)
.catch(() => undefined);
void loadVersions();
}, []);
const running = job?.status === "running";
@@ -127,10 +150,7 @@ export function AiToolsSettings() {
try {
const j = await api.aiTools.job();
setJob(j);
if (j?.status !== "running") {
void reload();
void loadVersions();
}
if (j?.status !== "running") void reload();
} catch {
/* next tick */
}
@@ -160,14 +180,10 @@ export function AiToolsSettings() {
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
return "";
};
// Mirrors the server's update-all: badged npm tools, then every self updater.
const updateAllTools = tools.filter(
(x) =>
x.installed &&
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
);
const canUpdate = (tool: AiTool) =>
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
// Mirrors the server's update-all: every installed tool with an updater.
const updateAllTools = installed.filter((x) => x.update);
const startConfirmed = async () => {
const c = confirm;
@@ -186,8 +202,6 @@ export function AiToolsSettings() {
const jobBusy = running || busy !== null;
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
const confirmLines =
confirm?.kind === "update-all"
@@ -197,149 +211,139 @@ export function AiToolsSettings() {
: [];
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>
<div className="flex items-center gap-2">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
disabled={jobBusy || updateAllTools.length === 0}
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"
disabled={jobBusy}
className={GHOST_BTN}
>
<Download className="w-3.5 h-3.5" />
{t("aiTools.updateTools")}
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
<button
type="button"
onClick={() =>
void run("refresh", async () => {
await refresh();
await loadVersions();
})
}
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>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<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>}
{logFor(null)}
{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;
const v = versions[tool.id];
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
>
<Avatar tool={tool} />
<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} />
{v?.updateAvailable && v.latest && (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
{t("aiTools.newVersion", { version: v.latest })}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
{v?.installed ? ` · v${v.installed}` : ""}
{tool.update?.kind === "npm" && v && !v.npmManaged && (
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
)}
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{logFor(null)}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
return (
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
<div className="flex items-center gap-3">
<div
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
>
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("aiTools.default")}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
</div>
</div>
</div>
{canUpdate(tool) && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.update")}
</button>
)}
{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 })}
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<Switch
tool={tool}
disabled={busy !== null}
onClick={() =>
onToggle={() =>
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>
{logFor(tool.id)}
</li>
);
})}
</ul>
</div>
/>
</div>
{logFor(tool.id)}
</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>
{available.length > 0 && (
<div>
<button
type="button"
aria-expanded={showAvailable}
onClick={() => setShowAvailable((s) => !s)}
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
/>
{t("aiTools.available", { count: available.length })}
</button>
{showAvailable && (
<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 flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
}`}
>
<Avatar tool={tool} />
<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">
<AiToolIcon tool={tool} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
<span className="block truncate font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
<button
type="button"
@@ -350,14 +354,13 @@ export function AiToolsSettings() {
{t("aiTools.install")}
</button>
)}
<Homepage tool={tool} />
{logFor(tool.id)}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirm !== null}
@@ -384,6 +387,6 @@ export function AiToolsSettings() {
{confirmLines.join("\n")}
</pre>
</ConfirmModal>
</section>
</div>
);
}
@@ -1,6 +1,6 @@
/**
* @file AntigravityHooksSettings.tsx
* @description Settings → Antigravity → AgentHub → Antigravity hooks: whether
* @description Settings → Antigravity → Antigravity hooks: whether
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
* written until the user clicks; agy asks for no trust, and has no permission
@@ -1,11 +1,10 @@
/**
* @file ClaudeAgentHubSettings.tsx
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
* that belong to Claude Code — Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
* reads transcripts and config from. Moved here from Settings → Dashboard
* (#40 part B).
* @description The dashboard's own settings that belong to Claude Code, shown
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -125,7 +124,32 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
return (
<div className="space-y-8">
<PricingSettings vendor="claude" allowReset />
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
placeholder={t("claudeHome.placeholder")}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
})
}
/>
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
@@ -192,32 +216,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
</div>
</section>
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
placeholder={t("claudeHome.placeholder")}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
const missing = r.routers?.missing ?? [];
return {
value: r.claude_home,
envOwned: !!r.env_owned,
warning: missing.length
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
: null,
};
})
}
/>
<PricingSettings vendor="claude" allowReset />
</div>
);
}
@@ -1,6 +1,6 @@
/**
* @file CodexHooksSettings.tsx
* @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's
* @description Settings → Codex → Overview → Codex hooks: whether AgentHub's
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
* Install/Reinstall and Remove. Nothing is written until the user clicks;
* Codex itself asks the user to trust the hooks before it runs them, and this
@@ -1,8 +1,9 @@
/**
* @file HomeDirSetting.tsx
* @description One editable home directory (CLAUDE_HOME in Claude Code →
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
* server's refusal inline, the value in effect, and a warning when the real
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
* server's refusal inline, the saved value while the field differs from it,
* and a warning when the real
* environment sets the variable (the environment wins at the next start, so a
* saved value would not survive a restart). The server validates and persists;
* this component only loads, submits and reports.
@@ -105,7 +106,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
{current && (
{/* The saved value, only while the field says something else. */}
{current && input !== current.value && (
<p className="text-xs text-fg-muted">
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
</p>
@@ -1,7 +1,8 @@
/**
* @file PricingSettings.tsx
* @description Model Pricing — the per-model rates every session's cost is
* computed from — as shown in a tool's Settings → AgentHub sub-tab: Claude
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
* and Codex, the Antigravity tab itself): Claude
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
* **Show all models** switch lists every row, and a row added outside the
@@ -1,9 +1,12 @@
/**
* @file AiToolsSettings.test.tsx
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
* Set default, available rows a homepage link, Refresh re-detects, a
* disabled tool's Settings tab disappears from the hub, and Install/Update
* confirm the exact command, then show the polled job log.
* @description The AI Tools modal: the Settings tab bar's button and the
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
* enable switch and Set default, available rows fold behind a disclosure with
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
* disappears from the hub, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,7 +32,6 @@ vi.mock("../../../lib/api", async (importOriginal) => {
updateTool: vi.fn(),
updateAll: vi.fn(),
job: vi.fn(),
versions: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
},
@@ -71,7 +73,6 @@ beforeEach(() => {
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
vi.mocked(api.aiTools.versions).mockReset().mockResolvedValue({});
vi.mocked(api.aiTools.install).mockReset();
vi.mocked(api.aiTools.updateTool).mockReset();
vi.mocked(api.aiTools.updateAll).mockReset();
@@ -93,7 +94,7 @@ 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();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude");
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
const aider = screen.getByTestId("ai-tool-aider");
@@ -120,10 +121,8 @@ describe("AiToolsSettings", () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByTestId("ai-tool-claude");
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1);
});
});
@@ -154,11 +153,9 @@ describe("AiToolsSettings — install and update", () => {
};
beforeEach(() => {
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
vi.mocked(api.aiTools.versions).mockResolvedValue({
codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false },
gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true },
});
});
const openAvailable = async () =>
userEvent.click(await screen.findByRole("button", { name: /Available to install/ }));
it("confirms the exact npm command, then polls and shows the log", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -169,6 +166,7 @@ describe("AiToolsSettings — install and update", () => {
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
@@ -182,6 +180,8 @@ describe("AiToolsSettings — install and update", () => {
vi.advanceTimersByTime(1100);
});
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done
await user.click(within(qwen).getByRole("button", { name: "Show log" }));
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
await act(async () => {
@@ -196,6 +196,7 @@ describe("AiToolsSettings — install and update", () => {
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
@@ -203,35 +204,34 @@ describe("AiToolsSettings — install and update", () => {
);
});
it("badges a newer npm version and offers no npm update outside npm", async () => {
it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />);
const gemini = await screen.findByTestId("ai-tool-gemini");
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
const codex = screen.getByTestId("ai-tool-codex");
expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument();
expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull();
for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
}
});
it("Update tools lists the badged npm tools and the self updaters", async () => {
it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByText("v0.61.0 available");
await user.click(screen.getByRole("button", { name: "Update tools" }));
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
expect(cmd).not.toHaveTextContent("@openai/codex");
expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest");
});
it("disables Update tools when nothing has an update", async () => {
it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled();
await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
await openAvailable();
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
expect(img).toBeTruthy();
@@ -256,4 +256,42 @@ describe("SettingsHub — AI tool tabs", () => {
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
});
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(screen.queryByRole("dialog")).toBeNull();
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens the modal for the /settings#ai-tools deep link", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
});
});
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("C")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
});
});
@@ -7,7 +7,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
@@ -43,11 +43,15 @@ describe("HomeDirSetting", () => {
expect(button).toBeDisabled();
expect(container.querySelector("section#x-home")).not.toBeNull();
// The saved value shows only while the field says something else.
expect(screen.queryByText("/a", { selector: "code" })).toBeNull();
await user.clear(input);
await user.type(input, "/b");
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
await user.click(button);
expect(saveFn).toHaveBeenCalledWith("/b");
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText("/a", { selector: "code" })).toBeNull());
expect(screen.getByDisplayValue("/b")).toBeInTheDocument();
expect(screen.queryByText(/set by the environment/)).toBeNull();
});