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
+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>
);
}