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,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