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:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -11,6 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
@@ -18,6 +20,12 @@ import { PetSettings } from "./PetSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -65,6 +73,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
const ok = await confirmDialog({
|
||||
@@ -102,6 +115,37 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("terminalFont.title")}
|
||||
value={termFont}
|
||||
onChange={pickTermFont}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
value: f,
|
||||
label: t(`terminalFont.names.${f}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
* whether the dashboard starts at boot/login through the per-user service
|
||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||
* stops the running server.
|
||||
* stops the running server. Beside it, **Quit AgentHub** (issue #65, 9router's
|
||||
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
|
||||
* stops the server, which stays stopped until `agenthub start` — or the next
|
||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AutostartStatus } from "../../lib/api";
|
||||
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -36,6 +41,39 @@ export function AutostartSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const [tray, setTray] = useState<TrayStatus | null>(null);
|
||||
useEffect(() => {
|
||||
api.settings.tray
|
||||
.get()
|
||||
.then(setTray)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const changeTray = async (enabled: boolean) => {
|
||||
try {
|
||||
setTray(await api.settings.tray.set(enabled));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const [quitting, setQuitting] = useState(false);
|
||||
const quit = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("quit.confirmTitle"),
|
||||
message: t(status?.enabled ? "quit.confirmMessageAutostart" : "quit.confirmMessage"),
|
||||
confirmLabel: t("quit.button"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setQuitting(true);
|
||||
try {
|
||||
await api.settings.quit();
|
||||
toast.success(t("quit.done"));
|
||||
} catch (err) {
|
||||
setQuitting(false);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const note = !status
|
||||
? undefined
|
||||
: !status.available
|
||||
@@ -53,6 +91,28 @@ export function AutostartSettings() {
|
||||
disabled={!status?.available || status.foreign || saving}
|
||||
onChange={(v) => void change(v)}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("tray.label")}
|
||||
description={tray && !tray.available ? t("tray.unavailable") : t("tray.desc")}
|
||||
checked={!!tray?.available && tray.enabled}
|
||||
disabled={!tray?.available}
|
||||
onChange={(v) => void changeTray(v)}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("quit.label")}
|
||||
description={t("quit.desc")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
data-testid="quit-agenthub"
|
||||
disabled={quitting}
|
||||
onClick={() => void quit()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
>
|
||||
{t("quit.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
|
||||
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
|
||||
* plugin provides them), and a link to the Claude table on the Pricing page.
|
||||
* Changing CLAUDE_HOME asks first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { HomeDirSetting } from "./HomeDirSetting";
|
||||
import { PricingSettings } from "./PricingSettings";
|
||||
import { PricingLink } from "./PricingSettings";
|
||||
|
||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
type HooksInfo = {
|
||||
installed: boolean;
|
||||
path: string;
|
||||
hooks: Record<string, boolean>;
|
||||
source?: "plugin";
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
||||
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
|
||||
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
||||
* the config it lists, which lives under that home).
|
||||
* @returns The three sections, each with its own id.
|
||||
@@ -205,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
{hooks.source === "plugin" ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
<PricingLink vendor="claude" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,18 @@
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
|
||||
export type PricingVendor = "claude" | "openai" | "google";
|
||||
|
||||
/**
|
||||
* A tool page's pointer to its vendor's table on the one Pricing page — the
|
||||
* table itself lives only there, so a price is never shown (or edited) twice.
|
||||
*/
|
||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="card flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<DollarSign className="h-4 w-4 text-fg-muted" />
|
||||
{t("pricingLink.title")}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||
</div>
|
||||
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
||||
{t("pricingLink.open")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every mounted table's `load`: a change in one reloads them all, and loads
|
||||
// fired together share one request.
|
||||
const tables = new Set<() => void>();
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* @file UsersSettings.tsx
|
||||
* @description Settings → Users, 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`), and the audit log of staff reads of members' sessions
|
||||
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
||||
* `agenthub users …` on the host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function UsersSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch((e: Error) => setError(e.message));
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</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("users.name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
||||
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{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("users.tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
||||
{audit?.length === 0 && (
|
||||
<p className="py-2 text-sm text-fg-muted">{t("users.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
|
||||
it("the System mode is checked after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("radio", { name: /System/ }));
|
||||
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
|
||||
await user.click(screen.getByRole("radio", { name: "System" }));
|
||||
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
@@ -84,4 +84,21 @@ describe("AppearanceSettings", () => {
|
||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
||||
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
||||
await user.click(within(group).getByRole("radio", { name: "System font" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
|
||||
* first, says when start-at-login will bring the dashboard back, and only a
|
||||
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
|
||||
* switch, which goes straight to PUT /api/settings/tray.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { AutostartSettings } from "../AutostartSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import * as confirm from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
settings: {
|
||||
autostart: { get: vi.fn(), set: vi.fn() },
|
||||
tray: { get: vi.fn(), set: vi.fn() },
|
||||
quit: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
|
||||
const trayOff = { available: true, enabled: false, running: false };
|
||||
|
||||
describe("Quit AgentHub", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
|
||||
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
|
||||
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
|
||||
});
|
||||
|
||||
it("does nothing when the ask is cancelled", async () => {
|
||||
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
|
||||
render(<AutostartSettings />);
|
||||
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
|
||||
expect(api.settings.quit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quits once confirmed, warning that start-at-login brings it back", async () => {
|
||||
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
|
||||
render(<AutostartSettings />);
|
||||
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
|
||||
await screen.findByRole("switch", { checked: true });
|
||||
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
|
||||
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
|
||||
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("turns the status-area icon on from its switch", async () => {
|
||||
vi.mocked(api.settings.tray.set).mockResolvedValue({
|
||||
...trayOff,
|
||||
enabled: true,
|
||||
running: true,
|
||||
});
|
||||
render(<AutostartSettings />);
|
||||
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
|
||||
const icon = screen.getAllByRole("switch")[1]!;
|
||||
await waitFor(() => expect(icon).toBeEnabled());
|
||||
fireEvent.click(icon);
|
||||
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(icon).toBeChecked());
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.test.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @description Settings → Claude Code → AgentHub: the hook status with
|
||||
* Reinstall (named as the plugin's when the plugin provides the hooks),
|
||||
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
|
||||
* table itself is shown only there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
||||
|
||||
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
||||
function confirmYes() {
|
||||
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
||||
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const RULE = {
|
||||
model_pattern: "claude-opus-5-5%",
|
||||
display_name: "Claude Opus 5.5",
|
||||
input_per_mtok: 4,
|
||||
output_per_mtok: 20,
|
||||
cache_read_per_mtok: 0.2,
|
||||
cache_write_per_mtok: 5,
|
||||
cache_write_1h_per_mtok: 8,
|
||||
fast_input_per_mtok: 8,
|
||||
fast_output_per_mtok: 40,
|
||||
updated_at: "2026-09-28 10:00:00",
|
||||
} as ModelPricing;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -76,10 +65,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/pricing#pricing-claude"
|
||||
);
|
||||
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
|
||||
expect(api.pricing.list).not.toHaveBeenCalled();
|
||||
for (const id of ["hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
|
||||
vi.mocked(api.settings.info).mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
source: "plugin",
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { UserPromptSubmit: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
render(<ClaudeAgentHubSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
const note = await screen.findByText(/provided by the agenthub plugin/i);
|
||||
const hooks = note.closest("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
|
||||
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user