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
@@ -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();
});
});