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
+67 -75
View File
@@ -7,8 +7,9 @@
* low-risk text-file surfaces (skills, agents, commands, output styles,
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
* hooks-in-settings, and settings.json files stay read-only - those have
* concurrent-write races with the live CLI. Its AgentHub sub-tab holds the
* dashboard's own Claude Code settings (pricing, hooks, CLAUDE_HOME).
* concurrent-write races with the live CLI. Its Overview also holds the
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -59,7 +60,6 @@ import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
Gauge,
RefreshCw,
Search,
Sparkles,
@@ -181,8 +181,7 @@ type TabKey =
| "hooks"
| "keybindings"
| "settings"
| "memory"
| "agenthub";
| "memory";
interface TabDef {
key: TabKey;
@@ -203,7 +202,6 @@ const TABS: TabDef[] = [
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
];
interface PageState {
@@ -243,7 +241,7 @@ const EMPTY_STATE: PageState = {
export function CcConfig() {
const { t } = useTranslation("ccConfig");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
const [scope, setScope] = useState<CcScope>("all");
const [data, setData] = useState<PageState>(EMPTY_STATE);
@@ -530,72 +528,72 @@ export function CcConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
{/* AgentHub's own Claude Code settings are cards of their own, not a
list over this tab's search box and scope. */}
{tab === "agenthub" ? (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
) : (
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isMutable(tab) && tab !== "memory" && (
<button
onClick={() => openCreate(tabToArtifactType(tab))}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isMutable(tab) && tab !== "memory" && (
<button
onClick={() => openCreate(tabToArtifactType(tab))}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
CLAUDE.md cards below are this machine's user + current-project
files, while this creates one in ANOTHER project's directory. */}
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateMemory={(s) => openCreate("memory", s)}
onEditAuto={openEditAuto}
onDeleteAuto={openDeleteAuto}
onCreateAuto={openCreateAuto}
onChanged={fetchAll}
onToast={setToast}
/>
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateMemory={(s) => openCreate("memory", s)}
onEditAuto={openEditAuto}
onDeleteAuto={openDeleteAuto}
onCreateAuto={openCreateAuto}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */}
{tab === "overview" && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
@@ -1069,12 +1067,6 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="sky"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
<RootRow
icon={FolderTree}
tone="emerald"
+73 -85
View File
@@ -8,10 +8,10 @@
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
* split between editable artifacts and CLI-driven surfaces. Codex has no
* "project-local" scope and no per-project file-based memory - its scope
* union is exactly "user" | "project". Its **AgentHub** sub-tab holds the
* dashboard's own Codex settings: Model Pricing for OpenAI models (the shared
* `PricingSettings` table), AgentHub's Codex hooks (`CodexHooksSettings`)
* and CODEX_HOME.
* union is exactly "user" | "project". Its **Overview** also holds the
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
* Pricing for OpenAI models (the shared `PricingSettings` table).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,6 @@ import { useTranslation } from "react-i18next";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
Gauge,
RefreshCw,
Search,
Sparkles,
@@ -82,16 +81,7 @@ import type {
CodexSkill,
} from "../lib/api";
type TabKey =
| "overview"
| "skills"
| "agents"
| "rules"
| "memory"
| "config"
| "mcp"
| "plugins"
| "agenthub";
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
interface TabDef {
key: TabKey;
@@ -108,7 +98,6 @@ const TABS: TabDef[] = [
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
@@ -160,7 +149,7 @@ export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
const [hooksKey, setHooksKey] = useState(0);
@@ -400,12 +389,72 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
{/* AgentHub's own Codex settings are cards of their own, not a list. A
new CODEX_HOME re-reads every list, since they all live under it. */}
{tab === "agenthub" ? (
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isNamedArtifact(tab) && (
<button
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("list.newButton")}
</button>
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
cliError={cliError}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateScopeFile={openCreate}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
</div>
{/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME
re-reads every list, since they all live under it. */}
{tab === "overview" && (
<div key={agenthubKey} className="space-y-8">
<PricingSettings vendor="openai" id="codex-pricing" />
<CodexHooksSettings key={hooksKey} />
<HomeDirSetting
id="codex-home"
title={tSettings("codexHome.title")}
@@ -431,68 +480,8 @@ export function CodexConfig() {
})
}
/>
</div>
) : (
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{isNamedArtifact(tab) && (
<button
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("list.newButton")}
</button>
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
data={data}
search={search}
cliError={cliError}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
onCreateScopeFile={openCreate}
onChanged={fetchAll}
onToast={setToast}
/>
</div>
<CodexHooksSettings key={hooksKey} />
<PricingSettings vendor="openai" id="codex-pricing" />
</div>
)}
@@ -829,7 +818,6 @@ function OverviewPanel({ overview }: { overview: CodexOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow icon={FolderTree} label={t("overview.codexHome")} value={roots.codexHome} />
<RootRow
icon={FolderTree}
label={t("overview.projectCodexDir")}
+1 -6
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, AI Tools, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`).
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`). AI Tools is a modal opened from the tab bar (`SettingsHub.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -60,7 +60,6 @@ import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Bot,
RefreshCw,
Database,
HardDrive,
@@ -112,7 +111,6 @@ 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 { WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
@@ -124,7 +122,6 @@ const SETTINGS_SECTIONS: {
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
{
@@ -626,8 +623,6 @@ export function Settings() {
<AppearanceSettings />
<AiToolsSettings />
{/* ─── LANES ─── */}
<section id="lanes" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
+30 -3
View File
@@ -1,12 +1,13 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) 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.
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) 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. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Bot,
Boxes,
KeyRound,
Orbit,
@@ -22,6 +23,7 @@ import { AntigravityConfig } from "./AntigravityConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolsModal } from "../components/settings/AiToolsModal";
interface ToolTab {
id: string;
@@ -85,6 +87,19 @@ export function SettingsHub() {
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
);
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const location = useLocation();
const navigate = useNavigate();
const [aiToolsOpen, setAiToolsOpen] = useState(false);
useEffect(() => {
if (location.hash === "#ai-tools") setAiToolsOpen(true);
}, [location.hash]);
const closeAiTools = useCallback(() => {
setAiToolsOpen(false);
// Drop the deep link, or a reload would open the modal again.
if (location.hash) navigate({ search: location.search }, { replace: true });
}, [location.hash, location.search, navigate]);
return (
<div className="space-y-6">
@@ -97,9 +112,21 @@ export function SettingsHub() {
Icon,
label: t(labelKey),
}))}
actions={
<button
type="button"
onClick={() => setAiToolsOpen(true)}
aria-haspopup="dialog"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Bot className="h-3.5 w-3.5" />
{t("aiTools.title")}
</button>
}
/>
<active.Panel />
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
</div>
);
}
@@ -123,7 +123,7 @@ afterEach(() => {
});
describe("CodexConfig", () => {
it("edits CODEX_HOME on the AgentHub sub-tab and reports lanes whose router layer failed", async () => {
it("edits CODEX_HOME on the Overview and reports lanes whose router layer failed", async () => {
stubCodexApi({});
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
codexHomeMock.set.mockResolvedValue({
@@ -136,7 +136,8 @@ describe("CodexConfig", () => {
await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length;
await userEvent.click(screen.getByRole("tab", { name: /agenthub/i }));
// No AgentHub sub-tab: its cards sit under the Overview, the page's first tab.
expect(screen.queryByRole("tab", { name: /agenthub/i })).toBeNull();
const input = await screen.findByDisplayValue("/home/.codex");
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
@@ -58,6 +58,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
@@ -1474,35 +1474,6 @@ exports[`screen snapshots > Claude Config 1`] = `
0
</span>
</button>
<button
aria-selected="false"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-agenthub"
role="tab"
>
<svg
class="lucide lucide-gauge 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 14 4-4"
/>
<path
d="M3.34 19a10 10 0 1 1 17.32 0"
/>
</svg>
<span>
AgentHub
</span>
</button>
</div>
<div
class="pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 opacity-0"
@@ -1526,86 +1497,6 @@ exports[`screen snapshots > Claude Config 1`] = `
<div
class="grid grid-cols-1 md:grid-cols-2 gap-2"
>
<div
class="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden"
>
<div
aria-hidden="true"
class="absolute left-0 top-0 bottom-0 w-1 bg-sky-500/40"
/>
<span
class="w-7 h-7 rounded-md bg-sky-500/10 flex items-center justify-center flex-shrink-0 ml-1.5"
>
<svg
class="lucide lucide-folder-tree w-3.5 h-3.5 text-sky-700 dark:text-sky-300"
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="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"
/>
<path
d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"
/>
<path
d="M3 5a2 2 0 0 0 2 2h3"
/>
<path
d="M3 3v13a2 2 0 0 0 2 2h3"
/>
</svg>
</span>
<div
class="min-w-0 flex-1"
>
<div
class="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary"
>
Claude home
</div>
<div
class="font-mono text-[11px] text-fg-secondary truncate"
>
/home/test/.claude
</div>
</div>
<button
class="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title="Copy path"
>
<svg
class="lucide lucide-copy w-3 h-3"
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"
>
<rect
height="14"
rx="2"
ry="2"
width="14"
x="8"
y="8"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
/>
</svg>
</button>
</div>
<div
class="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden"
>
@@ -2602,6 +2493,378 @@ exports[`screen snapshots > Claude Config 1`] = `
</div>
</div>
</div>
<div
class="space-y-8"
>
<section
class="scroll-mt-24"
id="claude-home"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-folder-open 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="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
CLAUDE_HOME
</h3>
<p
class="text-xs text-fg-muted mb-1"
>
Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.
</p>
<p
class="text-[11px] text-fg-muted italic mb-4 leading-snug"
>
Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).
</p>
<div
class="card p-5 space-y-4 "
>
<div
class="flex items-center gap-3"
>
<input
aria-label="CLAUDE_HOME"
class="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
placeholder="/path/to/claude-home"
type="text"
value="/home/test/.claude"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
>
Save
</button>
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="hooks"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-plug 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 22v-5"
/>
<path
d="M9 8V2"
/>
<path
d="M15 8V2"
/>
<path
d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"
/>
</svg>
Hook Configuration
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
Claude Code hooks forward events to the dashboard in real time.
</p>
<div
class="card p-5 space-y-4"
>
<div
class="flex items-center justify-between flex-wrap gap-3"
>
<div
class="flex items-center gap-3"
>
<span
class="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full"
>
<svg
class="lucide lucide-circle-check-big 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="M21.801 10A10 10 0 1 1 17 3.335"
/>
<path
d="m9 11 3 3L22 4"
/>
</svg>
All hooks installed
</span>
</div>
<button
class="btn-ghost text-xs disabled:opacity-50"
>
<svg
class="lucide lucide-rotate-ccw 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 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
/>
<path
d="M3 3v5h5"
/>
</svg>
Reinstall Hooks
</button>
</div>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2"
/>
<p
class="text-[11px] text-fg-muted font-mono truncate"
>
/tmp/settings.json
</p>
</div>
</section>
<section
class="scroll-mt-24"
id="pricing"
>
<div
class="flex flex-wrap items-center justify-between gap-3 mb-4"
>
<div>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
>
<svg
class="lucide lucide-dollar-sign 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"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
Model Pricing
<button
aria-label="How model pricing works"
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
type="button"
>
<svg
class="lucide lucide-info 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"
>
<circle
cx="12"
cy="12"
r="10"
/>
<path
d="M12 16v-4"
/>
<path
d="M12 8h.01"
/>
</svg>
</button>
</h3>
<p
class="text-xs text-fg-muted mt-0.5"
>
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
</p>
</div>
<div
class="flex items-center gap-2"
>
<button
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
>
<svg
class="lucide lucide-rotate-ccw w-3 h-3"
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 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
/>
<path
d="M3 3v5h5"
/>
</svg>
Reset Defaults
</button>
<button
class="btn-primary text-xs disabled:opacity-50"
>
<svg
class="lucide lucide-plus 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="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add Model
</button>
</div>
</div>
<div
class="card overflow-x-auto mt-4"
>
<table
class="w-full min-w-[1000px]"
>
<thead>
<tr
class="border-b border-border text-left"
>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Pattern
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Model
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Input
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Output
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Read
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Write (5m)
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Cache Write (1h)
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Fast Input
</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
>
Fast Output
</th>
<th
class="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody
class="divide-y divide-border"
>
<tr>
<td
class="px-4 py-6 text-center text-xs text-fg-muted"
colspan="10"
>
No Claude model prices. Reset Defaults restores them.
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</div>
`;
@@ -7025,47 +7288,6 @@ 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 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
type="button"
@@ -8078,141 +8300,6 @@ 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>
<div
class="flex items-center gap-2"
>
<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"
disabled=""
type="button"
>
<svg
class="lucide lucide-download 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline
points="7 10 12 15 17 10"
/>
<line
x1="12"
x2="12"
y1="15"
y2="3"
/>
</svg>
Update tools
</button>
<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>
</div>
<ul
class="divide-y divide-border rounded-md border border-border"
/>
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="lanes"
@@ -591,9 +591,10 @@ describe("screen snapshots", () => {
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they live behind the Claude Code tab's AgentHub sub-tab, not on Dashboard.
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.
describe("Claude Code's AgentHub settings", () => {
it("shows pricing, hooks and CLAUDE_HOME on the AgentHub sub-tab only", async () => {
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
const dashboard = render(
<MemoryRouter initialEntries={["/settings"]}>
<Settings />
@@ -611,12 +612,13 @@ describe("Claude Code's AgentHub settings", () => {
</MemoryRouter>
);
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
fireEvent.click(screen.getByTestId("cc-tab-agenthub"));
await settle();
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
fireEvent.click(screen.getByTestId("cc-tab-skills"));
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
});
});