feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { ModelPicker } from "../components/run/RunSetup";
import { PricingSettings } from "../components/settings/PricingSettings";
import { PricingLink } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** Cards one tool adds below the generic ones. */
@@ -307,7 +307,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
</Card>
)}
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
{tool.modelVendor && <PricingLink vendor={tool.modelVendor} />}
{tool.configFiles > 0 && (
<Card title={t("toolTab.config")} Icon={FileCog}>
<ConfigFiles toolId={toolId} />
+3 -3
View File
@@ -11,7 +11,7 @@
* 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).
* A link to the OpenAI table on the Pricing page (`PricingLink`).
* Every card with action buttons also opens a right-click menu repeating
* them (same handlers — Delete still asks for confirmation) plus Copy
* name/path.
@@ -70,7 +70,7 @@ import {
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
import { PricingSettings } from "../components/settings/PricingSettings";
import { PricingLink } from "../components/settings/PricingSettings";
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
import {
Empty,
@@ -530,7 +530,7 @@ export function CodexConfig() {
}
/>
<CodexHooksSettings key={hooksKey} />
<PricingSettings vendor="openai" id="codex-pricing" />
<PricingLink vendor="openai" />
</div>
)}
+158 -90
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -55,7 +55,7 @@
* ----------------------------------------------------------------------------- */
import { Checkbox } from "../components/Checkbox";
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
DollarSign,
@@ -64,7 +64,6 @@ import {
HardDrive,
AlertTriangle,
RotateCcw,
Server,
Bell,
BellOff,
BellRing,
@@ -76,10 +75,6 @@ import {
ShieldCheck,
ShieldAlert,
ShieldX,
Clock,
Cpu,
Globe,
Wifi,
Activity,
Users,
Layers,
@@ -116,7 +111,7 @@ import {
SettingsSegmented,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import type { WSMessage } from "../lib/types";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
@@ -145,22 +140,13 @@ const NOTIF_TYPES: [NotifType, string][] = [
// ─── Helpers ───
interface SystemInfo {
db: { path: string; size: number; counts: Record<string, number> };
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
server: {
version: string;
uptime: number;
node_version: string;
platform: string;
ws_connections: number;
};
}
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
function formatUptime(seconds: number): string {
@@ -1133,85 +1119,167 @@ export function Settings({
)}
{section === "about" && (
<section id="about" className="scroll-mt-24">
<div className="mb-4">
<AutostartSettings />
</div>
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
<div id="about" className="space-y-6 scroll-mt-24">
<AutostartSettings />
{sysInfo ? (
<div className="card p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-accent" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.release")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary font-mono">
v{sysInfo.server.version}
</p>
{sysInfo.server.version !== __APP_VERSION__ && (
<p className="text-[10px] text-status-warning/90 mt-1">
{t("about.uiBuild", { version: __APP_VERSION__ })}
</p>
)}
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Clock className="w-4 h-4 text-accent" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.uptime")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{formatUptime(sysInfo.server.uptime)}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Cpu className="w-4 h-4 text-status-success" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.nodejs")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary font-mono">
{sysInfo.server.node_version}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Globe className="w-4 h-4 text-syntax-keyword" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.platform")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.platform}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Wifi className="w-4 h-4 text-status-warning" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.wsClients")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.ws_connections}
</p>
</div>
</div>
</div>
<SystemDetails info={sysInfo} />
) : (
<p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
)}
</section>
</div>
)}
</div>
);
}
/** A read-only value on the right of a System row. */
function Value({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
return (
<span className={`text-sm text-fg-primary ${mono ? "font-mono text-xs" : ""}`}>{children}</span>
);
}
/**
* The System page's facts: the server process, how AgentHub is installed (from
* the update check's last answer — it never fetches), and the database file.
*/
function SystemDetails({ info }: { info: SystemInfo }) {
const { t } = useTranslation("settings");
const [upd, setUpd] = useState<UpdateStatusPayload | null>(null);
useEffect(() => {
api.updates
.status()
.then(setUpd)
.catch(() => {});
}, []);
const srv = info.server;
const mem = srv.memory;
const heapLimit = mem ? (mem.heapLimit ?? mem.heapTotal) : 0;
const load = srv.cpu_load?.map((n) => n.toFixed(2)).join(" / ");
const kind = upd?.install_kind;
return (
<>
<SettingsSection id="server" title={t("about.server")}>
<SettingsRow
label={t("about.release")}
description={
srv.version !== __APP_VERSION__ ? (
<span className="text-status-warning/90">
{t("about.uiBuild", { version: __APP_VERSION__ })}
</span>
) : undefined
}
control={<Value mono>v{srv.version}</Value>}
/>
<SettingsRow
label={t("about.uptime")}
control={<Value>{formatUptime(srv.uptime)}</Value>}
/>
<SettingsRow
label={t("about.platform")}
control={
<Value mono>
{srv.platform}
{srv.arch ? ` · ${srv.arch}` : ""} · Node.js {srv.node_version}
</Value>
}
/>
{srv.cpus > 0 && (
<SettingsRow
label={t("about.cpu")}
control={<Value>{t("about.cpuValue", { count: srv.cpus, load })}</Value>}
/>
)}
{mem && (
<SettingsRow
label={t("about.memory")}
control={
<Value>
{t("about.memoryValue", {
rss: formatBytes(mem.rss),
used: formatBytes(mem.heapUsed),
limit: formatBytes(heapLimit),
})}
</Value>
}
/>
)}
{srv.total_mem > 0 && (
<SettingsRow
label={t("about.systemMemory")}
control={
<Value>
{t("about.systemMemoryValue", {
used: formatBytes(srv.total_mem - srv.free_mem),
total: formatBytes(srv.total_mem),
})}
</Value>
}
/>
)}
<SettingsRow
label={t("about.wsClients")}
description={t("about.wsClientsDesc")}
control={<Value>{srv.ws_connections}</Value>}
/>
</SettingsSection>
{upd && (
<SettingsSection id="install" title={t("about.install")}>
<SettingsRow
label={kind ? t(`about.installKind.${kind}`) : t("about.unknown")}
description={
upd.repo_root ? (
<span className="font-mono break-all">{upd.repo_root}</span>
) : undefined
}
control={upd.local_version ? <Value mono>v{upd.local_version}</Value> : null}
/>
{upd.latest_release && (
<SettingsRow
label={t("about.latest")}
control={
<span className="flex items-center gap-2">
<Value mono>{upd.latest_release}</Value>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
upd.update_available
? "bg-status-warning/10 text-status-warning"
: "bg-status-success/10 text-status-success"
}`}
>
{upd.update_available ? t("about.updateAvailable") : t("about.upToDate")}
</span>
</span>
}
/>
)}
</SettingsSection>
)}
<SettingsSection id="database" title={t("about.database")}>
<SettingsRow
label={t("about.dbFile")}
description={<span className="font-mono break-all">{info.db.path}</span>}
control={<Value>{formatBytes(info.db.size)}</Value>}
/>
{info.db.pragmas && (
<SettingsRow
label={t("about.journal")}
control={<Value mono>{info.db.pragmas.journal_mode}</Value>}
/>
)}
{info.db.load_stats && (
<SettingsRow
label={t("about.activity")}
control={<Value>{t("about.activityValue", info.db.load_stats)}</Value>}
/>
)}
</SettingsSection>
</>
);
}
/** "Clear all data": the one action here that empties the whole database, so
* it asks for a typed phrase (not a second click) and offers an export first. */
function ClearDataDialog({
+175 -178
View File
@@ -1,7 +1,7 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
* General / Agents / Data (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it — and selects its text when it already has focus —, a result list
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
@@ -10,7 +10,10 @@
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to Appearance (which also holds the language and the pet;
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
* `general` and `pet` redirect there). Small pages share one: System is the old
* About (`about` redirects), Git holds git logins and worktree suggestions
* (`git-credentials`, `worktrees`), Snapshots & reports is `reports`
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
@@ -31,17 +34,14 @@ import {
ArrowLeft,
Bell,
Database,
Info,
Bot,
Camera,
CalendarClock,
Coins,
GitBranch,
KeyRound,
Palette,
Router,
Search,
UsersRound,
Server,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -66,9 +66,8 @@ import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -86,7 +85,7 @@ const LEGACY_TOOL: Record<string, string> = {
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-proxy",
"git-credentials": "git-credentials",
"git-credentials": "git",
};
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
@@ -125,16 +124,15 @@ const MOVED: Record<string, string> = {
pet: "appearance#pet",
// The AI Router → AI Proxy rename.
"ai-router": "ai-proxy",
// Small pages merged into one.
about: "system",
snapshots: "reports#snapshots",
"git-credentials": "git",
worktrees: "git#worktrees",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -219,27 +217,12 @@ export function SettingsHub() {
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const allSections: Section[] = [
{
id: "data",
group: "general",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "about",
group: "general",
Icon: Info,
...s("about"),
render: () => <Settings section="about" />,
},
{
id: "appearance",
group: "general",
@@ -247,31 +230,6 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "snapshots",
group: "general",
Icon: Camera,
...s("snapshots"),
render: () => <SnapshotSettings />,
},
{
id: "reports",
group: "general",
Icon: CalendarClock,
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -279,6 +237,13 @@ export function SettingsHub() {
...s("notifications"),
render: () => <Settings section="notifications" />,
},
{
id: "system",
group: "general",
Icon: Server,
...s("system"),
render: () => <Settings section="about" />,
},
{
id: "agents",
group: "agents",
@@ -322,14 +287,21 @@ export function SettingsHub() {
})),
{
id: "ai-proxy",
group: "routing",
group: "agents",
Icon: Router,
...s("aiProxy"),
render: () => <AiProxyConfig />,
},
{
id: "data",
group: "data",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "pricing",
group: "cost",
group: "data",
Icon: Coins,
...s("pricing"),
render: () => (
@@ -354,18 +326,39 @@ export function SettingsHub() {
),
},
{
id: "git-credentials",
group: "git",
Icon: KeyRound,
...s("gitCredentials"),
render: () => <GitCredentialsConfig />,
id: "reports",
group: "data",
Icon: CalendarClock,
...s("reports"),
render: () => (
<div className="space-y-6">
<div id="snapshots" className="scroll-mt-24">
<SnapshotSettings />
</div>
{/* Scheduled reports read every user's sessions: staff only. */}
{!isMember && <ReportSettings />}
</div>
),
},
{
id: "worktrees",
group: "git",
id: "git",
group: "data",
Icon: GitBranch,
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: staff only. */}
{!isMember && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
</section>
)}
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>
</div>
),
},
];
// Server mode: a member cannot open the host's own configuration or the
@@ -429,128 +422,132 @@ export function SettingsHub() {
navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
};
const searchBox = (
<label className="relative block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-sm text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</label>
);
const searchResults = ranked && results.length > 0 && (
<ul
aria-label={t("search.results")}
className="card mt-2 divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
);
return (
<div className="space-y-4">
<div>
<Link
to="/workspace"
onClick={back}
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<label className="relative block md:w-56">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</label>
{ranked && (
<div className="md:w-96">
{results.length === 0 ? (
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
{t("search.noMatch", { query })}
</p>
) : (
<ul
aria-label={t("search.results")}
className="card divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
i === 0 ? "bg-surface-2" : ""
}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-6">
<nav
aria-label={t("settingsNav.label")}
className="sticky top-0 hidden max-h-[calc(100vh-8rem)] w-56 shrink-0 self-start overflow-y-auto md:block"
>
{ranked && shown.length === 0 && (
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
)}
{GROUPS.map((g) => {
const items = shown.filter((x) => x.group === g);
if (items.length === 0) return null;
return (
<div key={g} className="mb-3">
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t(`settingsNav.group.${g}`)}
</p>
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
id === active.id
? "bg-accent/15 text-accent"
: id === first
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{title}</span>
</Link>
))}
</div>
);
})}
</nav>
<div className="min-w-0 flex-1">
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="select mb-4 w-full py-1.5 md:hidden"
>
<div className="flex gap-8">
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
{searchBox}
{searchResults}
<nav aria-label={t("settingsNav.label")} className="mt-4 space-y-5">
{ranked && shown.length === 0 && (
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
)}
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</optgroup>
const items = shown.filter((x) => x.group === g);
if (items.length === 0) return null;
const label = t(`settingsNav.group.${g}`);
return (
<div key={g} role="group" aria-label={label}>
<p className="px-2 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="space-y-0.5">
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
id === active.id
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
: id === first
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
/>
<span className="truncate">{title}</span>
</Link>
))}
</div>
</div>
);
})}
</select>
<h1 className="text-2xl font-semibold text-fg-primary">{active.title}</h1>
<p className="mt-1 text-sm text-fg-muted">{active.description}</p>
<div className="mb-6 mt-4 border-b border-border" />
</nav>
</aside>
<div className="min-w-0 max-w-5xl flex-1 pb-10">
<div className="mb-4 md:hidden">
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="select w-full py-1.5"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</optgroup>
);
})}
</select>
</div>
<header className="mb-6 flex items-start gap-3 border-b border-border pb-5">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-accent">
<active.Icon className="h-5 w-5" />
</span>
<div className="min-w-0">
<h1 className="text-xl font-semibold text-fg-primary">{active.title}</h1>
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
</div>
</header>
{active.render()}
</div>
</div>
+456
View File
@@ -0,0 +1,456 @@
/**
* @file Users.tsx
* @description The Users page (`/users`, a sidebar item), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
* an admin — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
import { toast } from "../lib/toast";
import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "member"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
export function Users() {
const { t } = useTranslation("users");
const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null;
const isAdmin = me?.role === "admin";
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<Dialog | null>(null);
const load = useCallback(
() =>
Promise.all([
api.users
.list()
.then((r) => {
setUsers(r.users);
setError(null);
})
.catch((e: Error) => setError(e.message)),
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([])),
]),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
if (me && me.role !== "member") void load();
}, [me, load]);
// Local mode, or a member: there is no Users page for them.
if (!me || me.role === "member") return <Navigate to="/" replace />;
return (
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => setDialog({ kind: "add" })}
className="btn-primary"
>
<UserPlus className="w-4 h-4" /> {t("add")}
</button>
)}
</div>
</div>
<SettingsSection title={t("title")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("name")}</th>
<th className="py-2 pr-3 font-medium">{t("role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => {
const self = u.id === me.id;
return (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
</td>
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
{t(`roles.${u.role}`)}
</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
{isAdmin && (
<td className="py-1 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsSection>
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
{dialog && (
<AccountDialog
dialog={dialog}
users={users ?? []}
onClose={() => setDialog(null)}
onDone={(message) => {
toast.success(message);
setDialog(null);
void load();
}}
/>
)}
</div>
);
}
function RowAction({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className="btn-icon disabled:opacity-30"
>
{children}
</button>
);
}
/** One account change: its own fields, then the admin's password to confirm it. */
function AccountDialog({
dialog,
users,
onClose,
onDone,
}: {
dialog: Dialog;
users: ServerUserUsage[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const { kind, user } = dialog;
const name = user?.username ?? "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>(user?.role ?? "member");
const [laneId, setLaneId] = useState<number | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (kind !== "workspace") return;
api.lanes
.list()
.then((r) => setLanes(r.lanes))
.catch(() => setLanes([]));
}, [kind]);
const ready =
current.length > 0 &&
(kind === "add"
? username.trim().length > 0 && password.length > 0
: kind === "password"
? password.length > 0
: kind === "workspace"
? laneId !== null
: kind === "role"
? role !== user?.role
: true);
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
if (kind === "add") {
const r = await api.users.create({ username: username.trim(), password, role }, current);
onDone(t("done.created", { name: r.user.username }));
} else if (kind === "role" && user) {
await api.users.update(user.id, { role }, current);
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
} else if ((kind === "disable" || kind === "enable") && user) {
await api.users.update(user.id, { disabled: kind === "disable" }, current);
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
} else if (kind === "password" && user) {
await api.users.setPassword(user.id, password, current);
onDone(t("done.password", { name }));
} else if (kind === "workspace" && user && laneId !== null) {
await api.users.assignWorkspace(user.id, laneId, current);
onDone(t("done.workspace", { name }));
}
} catch (e) {
const err = e as Error & { code?: string };
setError(
err.code && t(`errors.${err.code}`, { defaultValue: "" })
? t(`errors.${err.code}`)
: err.message
);
} finally {
setBusy(false);
}
};
const titles: Record<DialogKind, string> = {
add: t("dialog.add"),
role: t("dialog.role", { name }),
disable: t("dialog.disable", { name }),
enable: t("dialog.enable", { name }),
password: t("dialog.password", { name }),
workspace: t("dialog.workspace", { name }),
};
const messages: Partial<Record<DialogKind, string>> = {
disable: t("dialog.disableBody", { name }),
password: t("dialog.passwordBody", { name }),
workspace: t("dialog.workspaceBody", { name }),
};
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<ConfirmModal
open
title={titles[kind]}
message={messages[kind]}
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
cancelLabel={t("common:cancel")}
destructive={kind === "disable"}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
{kind === "add" && (
<Field label={t("username")} hint={t("usernameHint")}>
<input
className="input w-full text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
aria-label={t("username")}
/>
</Field>
)}
{(kind === "add" || kind === "password") && (
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
aria-label={kind === "add" ? t("password") : t("newPassword")}
/>
</Field>
)}
{(kind === "add" || kind === "role") && (
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
<select
className="input w-full text-sm"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
aria-label={t("role")}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</Field>
)}
{kind === "workspace" && (
<Field label={t("workspace")}>
{lanes && lanes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
) : (
<select
className="input w-full text-sm"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("workspace")}
>
<option value="" />
{(lanes ?? []).map((l) => (
<option key={l.id} value={l.id}>
{(l.title || l.cwd) +
" — " +
(l.owner_id == null
? t("noOwner")
: t("ownedBy", { name: ownerName(l.owner_id) }))}
</option>
))}
</select>
)}
</Field>
)}
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
+25 -1
View File
@@ -67,7 +67,7 @@ import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDiscard } from "../lib/confirm";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
@@ -347,6 +347,29 @@ function ConsoleArea({
else onNewSession?.();
};
// A pane header's X: stops that one session, as a one-pane tab's X does —
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
const stopPane = async (slot: number) => {
const run = runOf(slot);
if (!run) return;
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(run) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
ended(slot);
} catch (err) {
toast.error(err);
}
};
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
// starts it) in the first free slot, placed on that side of the pane —
// unless the workspace changed while it started.
@@ -466,6 +489,7 @@ function ConsoleArea({
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
@@ -34,7 +34,7 @@ vi.mock("../../lib/api", () => ({
RUN_MODEL_CHOICES: [],
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
PricingLink: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
}));
const GEMINI: AiTool = {
+55 -1
View File
@@ -1,6 +1,7 @@
/**
* @file Settings.test.tsx
* @description The install-wide Settings pages: General and Notifications never
* @description The install-wide Settings pages: System shows the server's
* runtime, install and database facts; General and Notifications never
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
* coalesces a burst of activity instead of re-reading per event, a purge takes
* a whole number of days and runs only after a dry-run count is confirmed, and
@@ -38,9 +39,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
...actual,
api: {
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
updates: {
status: vi.fn(async () => ({
git_repo: false,
update_available: true,
install_kind: "plugin",
repo_root: "/home/u/.claude/plugins/cache/agenthub/9.9.8",
local_version: "9.9.8",
latest_release: "v9.9.9",
})),
},
settings: {
info: (...a: unknown[]) => info(...(a as [])),
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
autostart: { get: vi.fn(async () => ({ available: false })) },
tray: { get: vi.fn(async () => ({ available: false })) },
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
@@ -81,6 +94,47 @@ describe("Settings", () => {
expect(info).not.toHaveBeenCalled();
});
it("shows the server's runtime, install and database facts on System", async () => {
info.mockImplementationOnce(
async () =>
({
db: {
path: "/data/dashboard.db",
size: 5 * 1024 * 1024,
counts: {},
pragmas: { journal_mode: "wal" },
load_stats: { m5: 3, m15: 9, h1: 40 },
},
hooks: { installed: true, path: "", hooks: {} },
server: {
version: "1",
uptime: 3700,
node_version: "v26.0.0",
platform: "linux",
arch: "x64",
cpus: 8,
cpu_load: [0.5, 0.25, 0.125],
total_mem: 16 * 1024 ** 3,
free_mem: 4 * 1024 ** 3,
memory: { rss: 200 * 1024 ** 2, heapUsed: 50 * 1024 ** 2, heapLimit: 4 * 1024 ** 3 },
ws_connections: 2,
},
}) as never
);
render(<Settings section="about" />);
expect(await screen.findByText("/data/dashboard.db")).toBeInTheDocument();
expect(screen.getByText("1h 1m")).toBeInTheDocument();
expect(screen.getByText(/linux · x64 · Node.js v26.0.0/)).toBeInTheDocument();
expect(screen.getByText("8 cores · load 0.50 / 0.25 / 0.13")).toBeInTheDocument();
expect(screen.getByText("200.0 MB RSS · heap 50.0 MB of 4.0 GB")).toBeInTheDocument();
expect(screen.getByText("12.0 GB used of 16.0 GB")).toBeInTheDocument();
expect(screen.getByText("wal")).toBeInTheDocument();
expect(screen.getByText("3 in 5 min · 9 in 15 min · 40 in 1 h")).toBeInTheDocument();
expect(await screen.findByText("Claude Code plugin")).toBeInTheDocument();
expect(screen.getByText("v9.9.9")).toBeInTheDocument();
expect(screen.getByText("Update available")).toBeInTheDocument();
});
it("reads info once on Data and coalesces a burst of activity", async () => {
render(<Settings section="data" />);
await screen.findByText("/tmp/x.db");
@@ -2,12 +2,14 @@
* @file SettingsHub.test.tsx
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* unknown section falls back to Appearance, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* highlighted, the nav has three groups (General, Agents, Data) with no
* one-page group, Git and Snapshots & reports are one page each (their old
* paths redirect there), and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -31,7 +33,18 @@ vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({
GitCredentialsConfig: () => <div data-testid="git-credentials" />,
}));
vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({
WorktreeVisibilitySettings: () => <div data-testid="worktree-visibility" />,
}));
vi.mock("../../components/settings/SnapshotSettings", () => ({
SnapshotSettings: () => <div data-testid="snapshot-settings" />,
}));
vi.mock("../../components/settings/ReportSettings", () => ({
ReportSettings: () => <div data-testid="report-settings" />,
}));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
@@ -124,6 +137,10 @@ describe("SettingsHub", () => {
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
["/settings#ai-tools", "/settings/agents"],
["/settings/about", "/settings/system"],
["/settings/snapshots", "/settings/reports"],
["/settings/worktrees", "/settings/git"],
["/settings/git-credentials", "/settings/git"],
])("redirects %s to %s", async (from, to) => {
renderAt(from);
await screen.findByRole("heading", { level: 1 });
@@ -170,7 +187,7 @@ describe("SettingsHub", () => {
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
expect(links).toEqual(["Pricing"]);
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
"data-first-match",
"true"
@@ -234,18 +251,38 @@ describe("SettingsHub", () => {
);
});
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
it("groups the nav into General, Agents and Data, with no one-page group", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const groups = within(nav())
.getAllByRole("group")
.map((g) => [g.getAttribute("aria-label"), within(g).getAllByRole("link").length]);
expect(groups.map(([name]) => name)).toEqual(["General", "Agents", "Data"]);
for (const [, count] of groups) expect(count).toBeGreaterThan(1);
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links.slice(0, 3)).toEqual(["Appearance", "Notifications", "System"]);
expect(links).toEqual(expect.arrayContaining(["AI Proxy", "Pricing", "Git"]));
expect(links).not.toContain("About");
expect(links).not.toContain("Worktrees");
expect(links).not.toContain("Pet");
});
it("shows git logins and worktree suggestions on one Git page", async () => {
renderAt("/settings/git");
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
});
it("shows snapshots and scheduled reports on one page", async () => {
renderAt("/settings/reports");
await screen.findByRole("heading", { level: 1, name: "Snapshots & reports" });
expect(screen.getByTestId("snapshot-settings")).toBeInTheDocument();
expect(screen.getByTestId("report-settings")).toBeInTheDocument();
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
@@ -272,7 +309,9 @@ describe("SettingsHub", () => {
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
const msg = seen.find((x) => x.message.includes("nope"))?.message ?? "";
expect(msg).toContain("Appearance");
expect(msg).not.toContain("General");
off();
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* @file Users.test.tsx
* @description The Users page: local mode and a member are sent home; an
* analyst reads the table with no account controls; an admin adds a user only
* with their own password typed (the confirm stays disabled until then), sees
* a wrong password reported in the dialog, and cannot change their own role or
* disable themselves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, ServerUserUsage } from "../../lib/api";
let authState: AuthState = { mode: "local" };
vi.mock("../../components/AuthGate", () => ({
useAuth: () => ({ state: authState, logout: async () => {} }),
}));
const usage = { cost: 0, tokens: 0 };
const row = (id: number, username: string, role: ServerUserUsage["role"]): ServerUserUsage => ({
id,
username,
role,
disabled: false,
workspaces: 0,
sessions: 0,
last7d: usage,
last30d: usage,
});
const users = vi.hoisted(() => ({
list: vi.fn(),
audit: vi.fn(),
create: vi.fn(),
update: vi.fn(),
setPassword: vi.fn(),
assignWorkspace: vi.fn(),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
users,
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
});
import { Users } from "../Users";
function renderPage() {
return render(
<MemoryRouter initialEntries={["/users"]}>
<Routes>
<Route path="/users" element={<Users />} />
<Route path="/" element={<div data-testid="home" />} />
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
for (const fn of Object.values(users)) fn.mockReset();
users.list.mockResolvedValue({
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
});
users.audit.mockResolvedValue({ entries: [] });
});
describe("Users page", () => {
it("sends local mode and a member home", async () => {
authState = { mode: "local" };
renderPage();
expect(await screen.findByTestId("home")).toBeTruthy();
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
renderPage();
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
expect(users.list).not.toHaveBeenCalled();
});
it("shows an analyst the accounts with no controls", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderPage();
expect(await screen.findByText("mia")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Add user" })).toBeNull();
expect(screen.queryByRole("button", { name: "Reset password" })).toBeNull();
expect(screen.getByText("Analysts can view accounts but not change them.")).toBeTruthy();
});
it("adds a user only with the admin's own password", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.create.mockResolvedValue({
user: { id: 9, username: "neo", role: "member", disabled: false },
});
const ue = userEvent.setup();
renderPage();
await ue.click(await screen.findByRole("button", { name: "Add user" }));
const dialog = await screen.findByRole("dialog");
await ue.type(screen.getByLabelText("Username"), "neo");
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add user"
)!;
expect(confirm.hasAttribute("disabled")).toBe(true);
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(confirm.hasAttribute("disabled")).toBe(false);
await ue.click(confirm);
await waitFor(() =>
expect(users.create).toHaveBeenCalledWith(
{ username: "neo", password: "neo-pass-123", role: "member" },
"boss-pass"
)
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
});
it("reports a wrong password in the dialog and keeps it open", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.setPassword.mockRejectedValue(
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
);
const ue = userEvent.setup();
renderPage();
await screen.findByText("mia");
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
expect(await screen.findByRole("alert")).toHaveProperty(
"textContent",
"Your password is wrong."
);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
});
it("never offers an admin a role change or disable on their own row", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderPage();
await screen.findByText("mia");
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
expect(ownRole!.hasAttribute("disabled")).toBe(true);
expect(ownLock!.hasAttribute("disabled")).toBe(true);
expect(miaRole!.hasAttribute("disabled")).toBe(false);
expect(miaLock!.hasAttribute("disabled")).toBe(false);
});
});
@@ -758,6 +758,21 @@ describe("split panes", () => {
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("a pane's X stops just its session, without taking it out of the tab first", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await ctrlClick(screen.getByTestId("console-tab-2"));
withRuns([live(0), live(2)]);
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
await settle();
expect(api.run.kill).toHaveBeenCalledTimes(1);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(screen.queryByTestId("console-tab-1")).toBeNull();
});
it("drops a remembered pane whose session is gone", async () => {
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
@@ -2765,137 +2765,71 @@ exports[`screen snapshots > Claude Config 1`] = `
</p>
</div>
</section>
<section
class="scroll-mt-24"
id="pricing"
<div
class="card flex items-center justify-between gap-4 p-5"
>
<div
class="flex flex-wrap items-center justify-between gap-3 mb-4"
class="min-w-0"
>
<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"
<h3
class="flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<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-dollar-sign h-4 w-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"
>
<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>
<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
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Prices for every vendor's models live on one page.
</p>
</div>
<p
class="card mt-4 px-4 py-6 text-center text-xs text-fg-muted"
<a
class="btn-ghost shrink-0 text-xs"
href="/settings/pricing#pricing-claude"
>
No Claude model prices. Reset Defaults restores them.
</p>
</section>
Open Pricing
<svg
class="lucide lucide-arrow-right h-3.5 w-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 5 7 7-7 7"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
@@ -7142,333 +7076,404 @@ exports[`screen snapshots > Settings — about 1`] = `
<div
class="animate-fade-in space-y-8"
>
<section
class="scroll-mt-24"
<div
class="space-y-6 scroll-mt-24"
id="about"
>
<div
class="mb-4"
<section
class="card p-5 scroll-mt-24"
id="autostart"
>
<section
class="card p-5 scroll-mt-24"
id="autostart"
<h3
class="text-sm font-semibold text-fg-primary"
>
Startup
</h3>
<div
class="mt-2"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Startup
</h3>
<div
class="mt-2"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="min-w-0"
>
<div
class="min-w-0"
<p
class="text-sm text-fg-secondary"
>
<p
class="text-sm text-fg-secondary"
>
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
</div>
<div
class="shrink-0"
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
</div>
</section>
</div>
<p
class="text-xs text-fg-muted mb-4"
>
Server runtime information.
</p>
<div
class="card p-5"
>
<div
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-server w-4 h-4 text-accent"
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="8"
rx="2"
ry="2"
width="20"
x="2"
y="2"
/>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="14"
/>
<line
x1="6"
x2="6.01"
y1="6"
y2="6"
/>
<line
x1="6"
x2="6.01"
y1="18"
y2="18"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
Show AgentHub icon in the status area
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="true"
aria-label="Show AgentHub icon in the status area"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
/>
</button>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Quit AgentHub
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
</p>
</div>
<div
class="shrink-0"
>
<button
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
data-testid="quit-agenthub"
type="button"
>
Quit
</button>
</div>
</div>
</div>
</section>
<section
class="card p-5 scroll-mt-24"
id="server"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Server
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Release
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
<span
class="text-status-warning/90"
>
UI build v2.5.12 (rebuild client to match)
</span>
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary font-mono"
<div
class="shrink-0"
>
v
</p>
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.5.11 (rebuild client to match)
</p>
<span
class="text-sm text-fg-primary font-mono text-xs"
>
v
</span>
</div>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-clock w-4 h-4 text-accent"
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"
/>
<polyline
points="12 6 12 12 16 14"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
Uptime
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary"
>
0m
</p>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
>
<div
class="flex items-center gap-2 mb-1.5"
class="shrink-0"
>
<svg
class="lucide lucide-cpu w-4 h-4 text-status-success"
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"
<span
class="text-sm text-fg-primary "
>
<rect
height="16"
rx="2"
width="16"
x="4"
y="4"
/>
<rect
height="6"
rx="1"
width="6"
x="9"
y="9"
/>
<path
d="M15 2v2"
/>
<path
d="M15 20v2"
/>
<path
d="M2 15h2"
/>
<path
d="M2 9h2"
/>
<path
d="M20 15h2"
/>
<path
d="M20 9h2"
/>
<path
d="M9 2v2"
/>
<path
d="M9 20v2"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
>
Node.js
</p>
0m
</span>
</div>
<p
class="text-sm font-semibold text-fg-secondary font-mono"
>
v22.0.0
</p>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-globe w-4 h-4 text-syntax-keyword"
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 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
/>
<path
d="M2 12h20"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
Platform
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary"
<div
class="shrink-0"
>
linux
</p>
<span
class="text-sm text-fg-primary font-mono text-xs"
>
linux
· x64
· Node.js
v22.0.0
</span>
</div>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-wifi w-4 h-4 text-status-warning"
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 20h.01"
/>
<path
d="M2 8.82a15 15 0 0 1 20 0"
/>
<path
d="M5 12.859a10 10 0 0 1 14 0"
/>
<path
d="M8.5 16.429a5 5 0 0 1 7 0"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
WS Clients
CPU
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary"
<div
class="shrink-0"
>
0
</p>
<span
class="text-sm text-fg-primary "
>
1 cores · load 0.00 / 0.00 / 0.00
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Process memory
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0 B RSS · heap 0 B of 0 B
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Live connections
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Browser tabs and tools receiving live updates
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0
</span>
</div>
</div>
</div>
</div>
</section>
</section>
<section
class="card p-5 scroll-mt-24"
id="install"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Installation
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Unknown
</p>
</div>
<div
class="shrink-0"
/>
</div>
</div>
</section>
<section
class="card p-5 scroll-mt-24"
id="database"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Database
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Database file
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
<span
class="font-mono break-all"
>
/tmp/test.db
</span>
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0 B
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Journal mode
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary font-mono text-xs"
>
wal
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Events recorded
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0 in 5 min · 0 in 15 min · 0 in 1 h
</span>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
`;
@@ -338,6 +338,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
},
tray: {
get: r({ available: true, enabled: true, running: true }),
set: r({ available: true, enabled: false, running: false }),
},
privacy: {
get: r({
redact_secrets: false,
@@ -639,11 +643,11 @@ describe("screen snapshots", () => {
});
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.
// Hook status and CLAUDE_HOME belong to Claude Code (#40 part B): they sit
// under the Claude Code tab's Overview, not on Dashboard or on the other
// sub-tabs. Its prices live on the Pricing page; the Overview only links there.
describe("Claude Code's AgentHub settings", () => {
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
it("shows hooks and CLAUDE_HOME on the Overview only", async () => {
const dashboard = render(
<MemoryRouter initialEntries={["/settings"]}>
<Settings />
@@ -662,12 +666,14 @@ describe("Claude Code's AgentHub settings", () => {
);
await settle();
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
for (const id of ["pricing", "hooks", "claude-home"]) {
for (const id of ["hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
expect(container.querySelector("section#pricing")).toBeNull();
expect(container.querySelector('a[href="/settings/pricing#pricing-claude"]')).not.toBeNull();
fireEvent.click(screen.getByTestId("cc-tab-skills"));
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
expect(container.querySelector("section#hooks")).toBeNull();
});
});