feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
+11
-3
@@ -186,6 +186,13 @@ client/
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||
│ │ ├── settings/ # Settings panels, among them:
|
||||
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → AgentHub: pricing, hook status, CLAUDE_HOME
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → AgentHub → Codex hooks card
|
||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
@@ -195,6 +202,7 @@ client/
|
||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||
│ │ ├── Workflows.tsx
|
||||
│ │ ├── Settings.tsx
|
||||
│ │ ├── AntigravityConfig.tsx # Settings → Antigravity: Gemini pricing + Antigravity hooks
|
||||
│ │ └── NotFound.tsx
|
||||
│ │
|
||||
│ ├── lib/ # Core utilities & business logic
|
||||
@@ -210,7 +218,7 @@ client/
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||
│ ├── i18n/ # Internationalization (en / vi)
|
||||
│ ├── App.tsx # Root component + router setup
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
|
||||
@@ -239,7 +247,7 @@ graph TB
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
||||
Settings[Settings<br/>pricing + notifications + hooks]
|
||||
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → AgentHub, Antigravity:<br/>each tool's pricing + hooks]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
||||
@@ -285,7 +293,7 @@ sequenceDiagram
|
||||
|
||||
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
|
||||
|
||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → AgentHub), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||
|
||||
### State Strategy
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
|
||||
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
|
||||
export function SessionToolBadge({ tool }: { tool?: string | null }) {
|
||||
if (!tool || tool === "claude") return null;
|
||||
return <ToolChip tool={tool} />;
|
||||
}
|
||||
|
||||
// Split out so the catalog hook only mounts for a non-Claude session.
|
||||
function ToolChip({ tool }: { tool: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const { label } = useAiTools();
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("toolBadgeTitle", { tool: label(tool) })}
|
||||
>
|
||||
{label(tool)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file SessionToolBadge.test.tsx
|
||||
* @description The session tool chip: nothing for a Claude Code session (so
|
||||
* every hook row renders as before), the tool's name for a Codex one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionToolBadge } from "../SessionToolBadge";
|
||||
|
||||
vi.mock("../../lib/api", () => ({ api: { aiTools: { list: () => new Promise(() => {}) } } }));
|
||||
|
||||
describe("SessionToolBadge", () => {
|
||||
it("renders nothing for a Claude or unset tool", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<SessionToolBadge tool="claude" />
|
||||
<SessionToolBadge tool={undefined} />
|
||||
</>
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("names a Codex session's tool", () => {
|
||||
render(<SessionToolBadge tool="codex" />);
|
||||
expect(screen.getByText("Codex")).toHaveAttribute("title", "Recorded by Codex");
|
||||
});
|
||||
});
|
||||
@@ -893,7 +893,10 @@ function SessionPicker({
|
||||
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
||||
fetchedForCwd.current = cwd;
|
||||
api.sessions
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
||||
// `claude --resume` can only resume a Claude session; a Codex lane at the
|
||||
// same directory records its own sessions there too. Filtered by the
|
||||
// server, before its limit.
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd, tool: "claude" })
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [open, cwd, sessions]);
|
||||
|
||||
@@ -236,6 +236,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
);
|
||||
});
|
||||
|
||||
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// Filtered by the server, before its limit: a busy Codex lane at the same
|
||||
// directory cannot push Claude sessions off the page.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
|
||||
});
|
||||
|
||||
it("starts the resume immediately when a session is picked", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.tsx
|
||||
* @description Settings → Antigravity → AgentHub → Antigravity hooks: whether
|
||||
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
|
||||
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
|
||||
* written until the user clicks; agy asks for no trust, and has no permission
|
||||
* hook — the note says both (#40 part D).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../lib/types";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
|
||||
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
||||
|
||||
/**
|
||||
* The Antigravity hooks card.
|
||||
* @returns A section with id `antigravity-hooks` for deep links.
|
||||
*/
|
||||
export function AntigravityHooksSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AntigravityHooksStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.antigravityHooks
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
|
||||
const run = async (kind: "install" | "remove" | "get") => {
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await api.settings.antigravityHooks[kind]());
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("antigravityHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||
const chip = !status?.installed ? (
|
||||
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||
<XCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.notInstalled")}
|
||||
</span>
|
||||
) : status.enabled ? (
|
||||
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.installed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("antigravityHooks.disabled")}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="antigravity-hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("antigravityHooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("antigravityHooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
{!status && !error ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && !status.supported ? (
|
||||
<p className="text-xs text-fg-muted">{t("antigravityHooks.unsupported")}</p>
|
||||
) : null}
|
||||
|
||||
{status && status.supported ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{chip}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => run("get")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
||||
{t("antigravityHooks.refresh")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run("install")}
|
||||
disabled={busy !== null || !status.readable}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{status.installed
|
||||
? t("antigravityHooks.reinstall")
|
||||
: t("antigravityHooks.install")}
|
||||
</button>
|
||||
{anyInstalled ? (
|
||||
<button
|
||||
onClick={() => run("remove")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("antigravityHooks.remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.readable ? (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("antigravityHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-fg-secondary">{t("antigravityHooks.note")}</p>
|
||||
|
||||
{anyInstalled ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(status.events).map(([event, e]) => {
|
||||
const label = e.installed
|
||||
? t("antigravityHooks.eventInstalled")
|
||||
: t("antigravityHooks.eventMissing");
|
||||
return (
|
||||
<div
|
||||
key={event}
|
||||
title={label}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{e.installed ? (
|
||||
<CheckCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{event}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
|
||||
* that belong to Claude Code — Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
|
||||
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
|
||||
* reads transcripts and config from. Moved here from Settings → Dashboard
|
||||
* (#40 part B).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { HomeDirSetting } from "./HomeDirSetting";
|
||||
import { PricingSettings } from "./PricingSettings";
|
||||
|
||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
|
||||
/**
|
||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
||||
* @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.
|
||||
*/
|
||||
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hooks, setHooks] = useState<HooksInfo | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const infoRes = await api.settings.info();
|
||||
setHooks(infoRes.hooks);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Hooks can be (re)installed from a terminal while this tab is open; keep the
|
||||
// badge current the way the old Settings page did, every 10 s.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
api.settings
|
||||
.info()
|
||||
.then((r) => setHooks(r.hooks))
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionResult) return;
|
||||
const timeout = setTimeout(() => setActionResult(null), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [actionResult]);
|
||||
|
||||
const runAction = async (key: string, fn: () => Promise<string>) => {
|
||||
setActionLoading(key);
|
||||
setActionResult(null);
|
||||
try {
|
||||
const message = await fn();
|
||||
setActionResult({ key, message, isError: false });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionResult({
|
||||
key,
|
||||
message: t("messages.actionFailed", {
|
||||
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
||||
}),
|
||||
isError: true,
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstallHooks = () =>
|
||||
runAction("hooks", async () => {
|
||||
const res = await api.settings.reinstallHooks();
|
||||
return res.ok ? t("hooks.success") : t("hooks.failed");
|
||||
});
|
||||
|
||||
const actionBanner = (keys: string[]) => {
|
||||
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
||||
if (!match) return null;
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg text-xs ${
|
||||
match.isError
|
||||
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
|
||||
: "bg-status-success/10 border border-status-success/20 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{match.message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
{error && (
|
||||
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── HOOK CONFIGURATION ─── */}
|
||||
<section id="hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("hooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{hooks?.installed ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReinstallHooks}
|
||||
disabled={actionLoading !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "hooks" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("hooks.reinstall")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionBanner(["hooks"])}
|
||||
|
||||
{hooks && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{Object.entries(hooks.hooks).map(([hook, active]) => (
|
||||
<div
|
||||
key={hook}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{active ? (
|
||||
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{hook}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeDirSetting
|
||||
id="claude-home"
|
||||
title={t("claudeHome.title")}
|
||||
description={t("claudeHome.description")}
|
||||
note={t("cursorPathsNote")}
|
||||
placeholder={t("claudeHome.placeholder")}
|
||||
load={() =>
|
||||
api.settings.claudeHome
|
||||
.get()
|
||||
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
|
||||
}
|
||||
save={(p) =>
|
||||
api.settings.claudeHome.set(p).then((r) => {
|
||||
void load();
|
||||
onHomeChanged?.();
|
||||
const missing = r.routers?.missing ?? [];
|
||||
return {
|
||||
value: r.claude_home,
|
||||
envOwned: !!r.env_owned,
|
||||
warning: missing.length
|
||||
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's
|
||||
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
|
||||
* Install/Reinstall and Remove. Nothing is written until the user clicks;
|
||||
* Codex itself asks the user to trust the hooks before it runs them, and this
|
||||
* card says so while they wait, with a Refresh to re-read once they have
|
||||
* (#40 part C2).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../lib/types";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
|
||||
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
||||
|
||||
/**
|
||||
* The Codex hooks card.
|
||||
* @returns A section with id `codex-hooks` for deep links.
|
||||
*/
|
||||
export function CodexHooksSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<CodexHooksStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.codexHooks
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
|
||||
const run = async (kind: "install" | "remove" | "get") => {
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await api.settings.codexHooks[kind]());
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("codexHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Some of ours present but not all (an event added since, or one taken out
|
||||
// by hand): still show them, and still offer Remove.
|
||||
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||
const eventLabel = (e: { installed: boolean; trusted: boolean }) =>
|
||||
!e.installed
|
||||
? t("codexHooks.eventMissing")
|
||||
: e.trusted
|
||||
? t("codexHooks.eventTrusted")
|
||||
: t("codexHooks.eventUntrusted");
|
||||
|
||||
const chip = !status?.installed ? (
|
||||
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||
<XCircle className="w-3.5 h-3.5" /> {t("codexHooks.notInstalled")}
|
||||
</span>
|
||||
) : status.trusted ? (
|
||||
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("codexHooks.trusted")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("codexHooks.untrusted")}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="codex-hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("codexHooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("codexHooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
{!status && !error ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && !status.supported ? (
|
||||
<p className="text-xs text-fg-muted">{t("codexHooks.unsupported")}</p>
|
||||
) : null}
|
||||
|
||||
{status && status.supported ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{chip}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => run("get")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
||||
{t("codexHooks.refresh")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run("install")}
|
||||
disabled={busy !== null || !status.readable}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{status.installed ? t("codexHooks.reinstall") : t("codexHooks.install")}
|
||||
</button>
|
||||
{anyInstalled ? (
|
||||
<button
|
||||
onClick={() => run("remove")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("codexHooks.remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.readable ? (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("codexHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status.installed && !status.trusted ? (
|
||||
<p className="text-xs text-fg-secondary">{t("codexHooks.trustNote")}</p>
|
||||
) : null}
|
||||
|
||||
{anyInstalled ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(status.events).map(([event, e]) => (
|
||||
<div
|
||||
key={event}
|
||||
title={eventLabel(e)}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{!e.installed ? (
|
||||
<XCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||
/>
|
||||
) : e.trusted ? (
|
||||
<CheckCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-warning flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{event}</span>
|
||||
<span className="sr-only">{eventLabel(e)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file HomeDirSetting.tsx
|
||||
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
||||
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
|
||||
* server's refusal inline, the value in effect, and a warning when the real
|
||||
* environment sets the variable (the environment wins at the next start, so a
|
||||
* saved value would not survive a restart). The server validates and persists;
|
||||
* this component only loads, submits and reports.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, FolderOpen } from "lucide-react";
|
||||
|
||||
export interface HomeDirValue {
|
||||
value: string;
|
||||
envOwned: boolean;
|
||||
/** Shown after a save that succeeded only in part (e.g. a lane not updated). */
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** `<section id>` — a deep-link target. */
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional second line under the description. */
|
||||
note?: string;
|
||||
placeholder: string;
|
||||
load: () => Promise<HomeDirValue>;
|
||||
save: (path: string) => Promise<HomeDirValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An editable home directory with its own load/save cycle.
|
||||
* @returns The section, or its skeleton-free shell while loading.
|
||||
*/
|
||||
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [current, setCurrent] = useState<HomeDirValue | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
load()
|
||||
.then((v) => {
|
||||
if (!live) return;
|
||||
setCurrent(v);
|
||||
setInput(v.value);
|
||||
})
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `load` is a stable api call per mount; re-running on identity would loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!current || input === current.value) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await save(input);
|
||||
setCurrent(next);
|
||||
setInput(next.value);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("homeDir.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-1">{description}</p>
|
||||
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
|
||||
|
||||
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
aria-label={title}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !current || input === current.value}
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
{current && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
||||
</p>
|
||||
)}
|
||||
{current?.envOwned && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("homeDir.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
{current?.warning && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{current.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* @file PricingSettings.tsx
|
||||
* @description Model Pricing — the per-model rates every session's cost is
|
||||
* computed from — as shown in a tool's Settings → AgentHub sub-tab: Claude
|
||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
||||
* **Show all models** switch lists every row, and a row added outside the
|
||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||
* automatic sync owns (OpenAI's, Gemini's) straight after.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { getCurrentLocale } from "../../lib/format";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import type { ModelPricing, WSMessage } from "../../lib/types";
|
||||
|
||||
interface EditRow {
|
||||
model_pattern: string;
|
||||
display_name: string;
|
||||
input_per_mtok: string;
|
||||
output_per_mtok: string;
|
||||
cache_read_per_mtok: string;
|
||||
cache_write_per_mtok: string;
|
||||
cache_write_1h_per_mtok: string;
|
||||
fast_input_per_mtok: string;
|
||||
fast_output_per_mtok: string;
|
||||
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
|
||||
// values are ignored). Generic: any model can carry a promo window.
|
||||
intro_until: string;
|
||||
intro_input_per_mtok: string;
|
||||
intro_output_per_mtok: string;
|
||||
intro_cache_read_per_mtok: string;
|
||||
intro_cache_write_per_mtok: string;
|
||||
intro_cache_write_1h_per_mtok: string;
|
||||
}
|
||||
|
||||
const emptyRow: EditRow = {
|
||||
model_pattern: "",
|
||||
display_name: "",
|
||||
input_per_mtok: "0",
|
||||
output_per_mtok: "0",
|
||||
cache_read_per_mtok: "0",
|
||||
cache_write_per_mtok: "0",
|
||||
cache_write_1h_per_mtok: "0",
|
||||
fast_input_per_mtok: "0",
|
||||
fast_output_per_mtok: "0",
|
||||
intro_until: "",
|
||||
intro_input_per_mtok: "0",
|
||||
intro_output_per_mtok: "0",
|
||||
intro_cache_read_per_mtok: "0",
|
||||
intro_cache_write_per_mtok: "0",
|
||||
intro_cache_write_1h_per_mtok: "0",
|
||||
};
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const normalized =
|
||||
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
|
||||
const d = new Date(normalized);
|
||||
return d.toLocaleString(getCurrentLocale(), {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
||||
* three-section explanation: how prices are applied, how pattern matching
|
||||
* works, and — for Claude — a reminder that prices must be edited manually
|
||||
* when Anthropic publishes new rates; another vendor's rows come from
|
||||
* LiteLLM's price list, so its tab says that instead. All copy is i18n-driven
|
||||
* (settings.pricing.tooltip.*).
|
||||
*
|
||||
* The popover is fixed-positioned and clamped to the viewport so it never
|
||||
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
|
||||
* the Workflows stat tooltips.
|
||||
*/
|
||||
function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
const claude = vendor === "claude";
|
||||
const { t } = useTranslation("settings");
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
|
||||
const positionPopover = useCallback(() => {
|
||||
const btn = buttonRef.current;
|
||||
const pop = popoverRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const w = pop?.offsetWidth ?? 320;
|
||||
const h = pop?.offsetHeight ?? 240;
|
||||
const margin = 8;
|
||||
|
||||
let left = r.right - w; // right-align with the icon
|
||||
if (left < margin) left = margin;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
let top = r.bottom + 8;
|
||||
if (top + h > window.innerHeight - margin) {
|
||||
top = Math.max(margin, r.top - h - 8);
|
||||
}
|
||||
setPos({ left, top });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
positionPopover();
|
||||
const onScroll = () => positionPopover();
|
||||
const onResize = () => positionPopover();
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
const raf = requestAnimationFrame(positionPopover);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, positionPopover]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={t("pricing.tooltip.title")}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
className="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"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top, width: 320 }}
|
||||
>
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
||||
|
||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.howItWorks")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.howItWorksBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.patternsTitle")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.patternsBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t(claude ? "pricing.tooltip.manualUpdates" : "pricing.tooltip.synced")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t(claude ? "pricing.tooltip.manualUpdatesBody" : "pricing.tooltip.syncedBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.apiPricing")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug">
|
||||
{t(claude ? "pricing.tooltip.apiPricingBody" : "pricing.tooltip.apiPricingOtherBody")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type PricingVendor = "claude" | "openai" | "google";
|
||||
|
||||
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
|
||||
claude: /^claude-/,
|
||||
openai: /^(gpt-|o\d|codex-)/,
|
||||
google: /^gemini-/,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vendor: PricingVendor;
|
||||
/** Offer Reset Defaults (it reseeds Claude's rates and resets only `claude-…` rows). */
|
||||
allowReset?: boolean;
|
||||
/** `<section id>` — a deep-link target. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pricing table filtered to one vendor's rows.
|
||||
* @returns The section, or a skeleton while the rules load.
|
||||
*/
|
||||
export function PricingSettings({ vendor, allowReset = false, id = "pricing" }: Props) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [pricing, setPricing] = useState<ModelPricing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
||||
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.pricing.list();
|
||||
setPricing(res.pricing);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// A remote source can bring its own pricing rules; reload when one syncs.
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionResult) return;
|
||||
const timeout = setTimeout(() => setActionResult(null), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [actionResult]);
|
||||
|
||||
const ours = (pattern: string) => VENDOR_PATTERN[vendor].test(pattern);
|
||||
const rows = showAll ? pricing : pricing.filter((p) => ours(p.model_pattern));
|
||||
const hidden = pricing.length - pricing.filter((p) => ours(p.model_pattern)).length;
|
||||
|
||||
const startEdit = (rule: ModelPricing) => {
|
||||
setAdding(false);
|
||||
setEditingPattern(rule.model_pattern);
|
||||
setEditRow({
|
||||
model_pattern: rule.model_pattern,
|
||||
display_name: rule.display_name,
|
||||
input_per_mtok: String(rule.input_per_mtok),
|
||||
output_per_mtok: String(rule.output_per_mtok),
|
||||
cache_read_per_mtok: String(rule.cache_read_per_mtok),
|
||||
cache_write_per_mtok: String(rule.cache_write_per_mtok),
|
||||
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
|
||||
fast_input_per_mtok: String(rule.fast_input_per_mtok),
|
||||
fast_output_per_mtok: String(rule.fast_output_per_mtok),
|
||||
intro_until: rule.intro_until ?? "",
|
||||
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
|
||||
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
|
||||
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
|
||||
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
|
||||
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
|
||||
});
|
||||
};
|
||||
|
||||
const startAdd = () => {
|
||||
setEditingPattern(null);
|
||||
setAdding(true);
|
||||
setEditRow({ ...emptyRow });
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
|
||||
setError(t("pricing.validationRequired"));
|
||||
return;
|
||||
}
|
||||
const introUntil = editRow.intro_until.trim();
|
||||
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
|
||||
setError(t("pricing.introUntilInvalid"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.pricing.upsert({
|
||||
model_pattern: editRow.model_pattern.trim(),
|
||||
display_name: editRow.display_name.trim(),
|
||||
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
|
||||
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
|
||||
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
|
||||
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
|
||||
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
|
||||
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
|
||||
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
|
||||
// Always send the intro block so the UI is authoritative for it: an
|
||||
// empty date clears the promo, a valid date persists the intro rates.
|
||||
intro_until: introUntil || null,
|
||||
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
|
||||
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
|
||||
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
|
||||
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
|
||||
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
|
||||
});
|
||||
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (pattern: string) => {
|
||||
try {
|
||||
await api.pricing.delete(pattern);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPricing = async () => {
|
||||
setResetting(true);
|
||||
setActionResult(null);
|
||||
setConfirmReset(false);
|
||||
try {
|
||||
// Only the Claude tab offers the button, and it resets only Claude's rows.
|
||||
const res = await api.settings.resetPricing("claude");
|
||||
setActionResult({
|
||||
key: "reset-pricing",
|
||||
message: t("pricing.resetResult", {
|
||||
count: res.pricing.filter((p) => ours(p.model_pattern)).length,
|
||||
}),
|
||||
isError: false,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionResult({
|
||||
key: "reset-pricing",
|
||||
message: t("messages.actionFailed", {
|
||||
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
||||
}),
|
||||
isError: true,
|
||||
});
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastUpdated =
|
||||
rows.length > 0
|
||||
? rows.reduce(
|
||||
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
|
||||
rows[0]!.updated_at
|
||||
)
|
||||
: null;
|
||||
|
||||
const isEditing = editingPattern !== null || adding;
|
||||
|
||||
const renderEditCells = () => (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.model_pattern}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
|
||||
placeholder={t(`pricing.placeholders.${vendor}.pattern`)}
|
||||
disabled={editingPattern !== null}
|
||||
className="input w-full text-sm font-mono disabled:opacity-50"
|
||||
autoFocus={adding}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.display_name}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
|
||||
placeholder={t(`pricing.placeholders.${vendor}.name`)}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.input_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.output_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_read_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_write_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_write_1h_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.fast_input_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.fast_output_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
|
||||
title={t("common:save")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
title={t("common:cancel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
||||
// Second edit row: the time-limited introductory-rate block. Rendered under
|
||||
// the standard-rate cells whenever a row is being edited/added. Leaving the
|
||||
// date empty means "no promo" — the rate inputs are then ignored. This is the
|
||||
// ONLY place intro rates are entered, and it works for any model pattern (not
|
||||
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
||||
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<input
|
||||
type={opts.date ? "text" : "number"}
|
||||
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
||||
value={editRow[key]}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
|
||||
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
const renderIntroEditRow = () => (
|
||||
<tr className="bg-surface-3">
|
||||
<td colSpan={10} className="px-4 pb-3 pt-2">
|
||||
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
|
||||
{t("pricing.introRatesTitle")}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{introField("intro_until", "pricing.introUntil", { date: true })}
|
||||
{introField("intro_input_per_mtok", "common:token.input")}
|
||||
{introField("intro_output_per_mtok", "common:token.output")}
|
||||
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
|
||||
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
|
||||
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const actionBanner = (keys: string[]) => {
|
||||
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
||||
if (!match) return null;
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg text-xs ${
|
||||
match.isError
|
||||
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
|
||||
: "bg-status-success/10 border border-status-success/20 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{match.message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-fg-muted" />
|
||||
{t("pricing.title")}
|
||||
<PricingInfoTooltip vendor={vendor} />
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hidden > 0 && (
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
{t("pricing.showAll", { count: hidden })}
|
||||
</label>
|
||||
)}
|
||||
{allowReset && (
|
||||
<button
|
||||
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
|
||||
disabled={isEditing || resetting}
|
||||
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
||||
confirmReset
|
||||
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{confirmReset ? t("pricing.resetConfirm") : t("pricing.resetDefaults")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={startAdd}
|
||||
disabled={isEditing}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionBanner(["reset-pricing"])}
|
||||
|
||||
<div className="card overflow-x-auto mt-4">
|
||||
<table className="w-full min-w-[1000px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("pricing.pattern")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:cost.model")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.input")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.output")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.cacheRead")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite5m")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite1h")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastInput")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastOutput")}
|
||||
</th>
|
||||
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.length === 0 && !adding && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-6 text-center text-xs text-fg-muted">
|
||||
{t(`pricing.empty.${vendor}`)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((rule) =>
|
||||
editingPattern === rule.model_pattern ? (
|
||||
<Fragment key={rule.model_pattern}>
|
||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||
{renderIntroEditRow()}
|
||||
</Fragment>
|
||||
) : (
|
||||
<tr key={rule.model_pattern} className="hover:bg-surface-4 transition-colors group">
|
||||
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
||||
{rule.model_pattern}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||
{rule.display_name}
|
||||
{rule.intro_until && (
|
||||
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
||||
{t("pricing.introNote", {
|
||||
input: rule.intro_input_per_mtok,
|
||||
output: rule.intro_output_per_mtok,
|
||||
until: rule.intro_until,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.input_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_input_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.output_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_output_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_read_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_read_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_1h_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_1h_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEdit(rule)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRule(rule.model_pattern)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{adding && (
|
||||
<>
|
||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||
{renderIntroEditRow()}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-fg-muted mt-3">
|
||||
{t("pricing.lastUpdated")}
|
||||
{formatTimestamp(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.test.tsx
|
||||
* @description Settings → Antigravity → Antigravity hooks: not installed until
|
||||
* the user clicks Install, then "Installed" with the note that agy asks no
|
||||
* trust and has no permission hook; a hook switched off in hooks.json is
|
||||
* reported; Refresh re-reads; Remove; a server refusal shown inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AntigravityHooksSettings } from "../AntigravityHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { antigravityHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
function status(installed: boolean, enabled = installed): AntigravityHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.gemini/config/hooks.json",
|
||||
script: "/home/u/.gemini/config/agenthub-hook.sh",
|
||||
installed,
|
||||
enabled,
|
||||
events: Object.fromEntries(
|
||||
["PreInvocation", "PostToolUse", "Stop"].map((e) => [e, { installed }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const hooks = api.settings.antigravityHooks;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(hooks.get).mockReset().mockResolvedValue(status(false));
|
||||
vi.mocked(hooks.install).mockReset().mockResolvedValue(status(true));
|
||||
vi.mocked(hooks.remove).mockReset().mockResolvedValue(status(false));
|
||||
});
|
||||
|
||||
describe("AntigravityHooksSettings", () => {
|
||||
it("installs on a click, and says agy has no permission hook", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
expect(screen.getByText(/no hook for a permission prompt/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(hooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.getByText("PreInvocation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PreToolUse")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a hook switched off, re-reads on Refresh, and removes", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText(/switched off/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/switched off/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(hooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.install).mockRejectedValue(new Error("hooks.json is not a JSON object"));
|
||||
render(<AntigravityHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
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";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: {
|
||||
info: vi.fn(),
|
||||
reinstallHooks: vi.fn(),
|
||||
resetPricing: vi.fn(),
|
||||
claudeHome: { get: vi.fn(), set: vi.fn() },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { SessionStart: true, Stop: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
vi.mocked(api.settings.claudeHome.get)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ claude_home: "/home/u/.claude" });
|
||||
vi.mocked(api.settings.claudeHome.set)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
|
||||
vi.mocked(api.settings.resetPricing).mockReset();
|
||||
vi.mocked(api.settings.reinstallHooks)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
|
||||
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
|
||||
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
await user.click(within(hooks).getByRole("button"));
|
||||
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
|
||||
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
|
||||
ok: true,
|
||||
claude_home: "/srv/claude",
|
||||
routers: { missing: ["7 (api): my-router"] },
|
||||
});
|
||||
const onHomeChanged = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
|
||||
expect(onHomeChanged).toHaveBeenCalledTimes(1);
|
||||
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] });
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.test.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: not installed until
|
||||
* the user clicks Install, then "waiting for your trust" with the note on how
|
||||
* Codex asks for it, "trusted" once Codex's config says so, Remove, and a
|
||||
* server refusal shown inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { CodexHooksSettings } from "../CodexHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { codexHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
const EVENTS = ["SessionStart", "PreToolUse", "PermissionRequest", "Stop"];
|
||||
function status(installed: boolean, trusted: boolean): CodexHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.codex/hooks.json",
|
||||
script: "/home/u/.codex/agenthub-hook.sh",
|
||||
installed,
|
||||
trusted: installed && trusted,
|
||||
events: Object.fromEntries(
|
||||
EVENTS.map((e) => [e, { installed, trusted: installed && trusted }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.settings.codexHooks.get).mockReset().mockResolvedValue(status(false, false));
|
||||
vi.mocked(api.settings.codexHooks.install).mockReset().mockResolvedValue(status(true, false));
|
||||
vi.mocked(api.settings.codexHooks.remove).mockReset().mockResolvedValue(status(false, false));
|
||||
});
|
||||
|
||||
describe("CodexHooksSettings", () => {
|
||||
it("installs on a click, then says Codex still has to trust them", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(api.settings.codexHooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Trust all and continue/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trusted hooks, and removes them", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Trust all and continue/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(api.settings.codexHooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-reads the status on Refresh, e.g. once the user trusted the hooks in Codex", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still offers Remove when only some of the hooks are there", async () => {
|
||||
const partial = status(true, false);
|
||||
partial.installed = false;
|
||||
partial.events.Stop = { installed: false, trusted: false };
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(partial);
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
// The chip, plus the missing event's screen-reader label.
|
||||
expect(screen.getAllByText("Not installed")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.install).mockRejectedValue(
|
||||
new Error("/home/u/.codex/hooks.json is not a JSON object; fix or move it first")
|
||||
);
|
||||
render(<CodexHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file HomeDirSetting.test.tsx
|
||||
* @description The shared editable home directory: loads the value in effect,
|
||||
* Save is disabled until the path changes, a server refusal shows inline and
|
||||
* changes nothing, an env-owned variable warns that a save will not survive a
|
||||
* restart, and a partial-success warning from `save` is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
function setup(load: HomeDirValue, save: (p: string) => Promise<HomeDirValue>) {
|
||||
const saveFn = vi.fn(save);
|
||||
const view = render(
|
||||
<HomeDirSetting
|
||||
id="x-home"
|
||||
title="X_HOME"
|
||||
description="desc"
|
||||
placeholder="/path"
|
||||
load={() => Promise.resolve(load)}
|
||||
save={saveFn}
|
||||
/>
|
||||
);
|
||||
return { saveFn, ...view };
|
||||
}
|
||||
|
||||
describe("HomeDirSetting", () => {
|
||||
it("saves a changed path and shows the value now in effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { saveFn, container } = setup({ value: "/a", envOwned: false }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: false })
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(container.querySelector("section#x-home")).not.toBeNull();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(button);
|
||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a refusal inline and keeps the old value", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: false }, () =>
|
||||
Promise.reject(new Error("Too broad for X_HOME: / contains your home directory"))
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText(/Too broad for X_HOME/)).toBeInTheDocument();
|
||||
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the environment owns the variable, and shows a save's warning", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: true }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: true, warning: "lane 3 not updated" })
|
||||
);
|
||||
expect(await screen.findByText(/set by the environment/)).toBeInTheDocument();
|
||||
const input = screen.getByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file PricingSettings.test.tsx
|
||||
* @description The shared Model Pricing table: each tool's tab shows its own
|
||||
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
||||
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
||||
* offered only where asked for (the Claude tab).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { PricingSettings } from "../PricingSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: { resetPricing: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const rule = (model_pattern: string, display_name: string) =>
|
||||
({
|
||||
model_pattern,
|
||||
display_name,
|
||||
input_per_mtok: 1,
|
||||
output_per_mtok: 2,
|
||||
cache_read_per_mtok: 0.1,
|
||||
cache_write_per_mtok: 0,
|
||||
cache_write_1h_per_mtok: 0,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
updated_at: "2026-09-29 10:00:00",
|
||||
}) as ModelPricing;
|
||||
|
||||
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
|
||||
vi.mocked(api.pricing.upsert)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PricingSettings", () => {
|
||||
it("shows the vendor's rows, and the rest behind Show all models", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
|
||||
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
|
||||
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says where OpenAI prices come from when there are none", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("turns Show all on when a row outside the filter is added", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-5.6-sol%");
|
||||
await user.click(screen.getByRole("button", { name: /Add Model/i }));
|
||||
const [pattern, name] = screen.getAllByRole("textbox");
|
||||
await user.type(pattern!, "deepseek-v4%");
|
||||
await user.type(name!, "DeepSeek V4");
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
|
||||
});
|
||||
await user.click(screen.getByTitle(/save/i));
|
||||
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
|
||||
});
|
||||
|
||||
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
|
||||
render(<PricingSettings vendor="claude" allowReset />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,8 @@
|
||||
"hooks": "Hooks",
|
||||
"keybindings": "Keybindings",
|
||||
"settings": "Settings",
|
||||
"memory": "Memory"
|
||||
"memory": "Memory",
|
||||
"agenthub": "AgentHub"
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"memory": "Memory",
|
||||
"config": "Config",
|
||||
"mcp": "MCP servers",
|
||||
"plugins": "Plugins"
|
||||
"plugins": "Plugins",
|
||||
"agenthub": "AgentHub"
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
|
||||
@@ -39,5 +39,6 @@
|
||||
"timeline": "Timeline ({{shown}}/{{total}})",
|
||||
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
||||
},
|
||||
"remoteSourceBadgeTitle": "Collected from a remote machine"
|
||||
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||
"toolBadgeTitle": "Recorded by {{tool}}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"subtitle": "Manage pricing, notifications, data, and hooks",
|
||||
"subtitle": "Manage appearance, AI tools, notifications and data",
|
||||
"exportData": "Export Data",
|
||||
"acrossSessions": "Across all tracked sessions",
|
||||
"basedOnUsage": "Based on per-model token usage",
|
||||
@@ -23,8 +23,6 @@
|
||||
"resetResult": "Reset to {{count}} default rules",
|
||||
"addModel": "Add Model",
|
||||
"pattern": "Pattern",
|
||||
"patternPlaceholder": "claude-opus-4%",
|
||||
"namePlaceholder": "Claude Opus 4",
|
||||
"validationRequired": "Pattern and display name are required",
|
||||
"lastUpdated": "Last updated: ",
|
||||
"tooltip": {
|
||||
@@ -36,21 +34,41 @@
|
||||
"manualUpdates": "You must update prices manually",
|
||||
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
|
||||
"apiPricing": "Reference only - API rates",
|
||||
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill."
|
||||
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.",
|
||||
"synced": "Prices are filled in automatically",
|
||||
"syncedBody": "A model a session uses with no rule of its own is priced from LiteLLM's public price list the first time it is seen. That row is never updated afterwards: edit it here when the vendor's rates change. Already-stored sessions keep the price that was applied at ingest time.",
|
||||
"apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill."
|
||||
},
|
||||
"cacheWrite5m": "Cache Write (5m)",
|
||||
"cacheWrite1h": "Cache Write (1h)",
|
||||
"fastInput": "Fast Input",
|
||||
"fastOutput": "Fast Output"
|
||||
"fastOutput": "Fast Output",
|
||||
"showAll": "Show all models ({{count}} more)",
|
||||
"empty": {
|
||||
"claude": "No Claude model prices. Reset Defaults restores them.",
|
||||
"openai": "No OpenAI model prices yet. They are added automatically from LiteLLM's price list once a Codex session uses a model, or add one yourself.",
|
||||
"google": "No Gemini model prices yet. They are added automatically from LiteLLM's price list once an Antigravity session uses a model, or add one yourself."
|
||||
},
|
||||
"placeholders": {
|
||||
"claude": {
|
||||
"pattern": "claude-opus-4%",
|
||||
"name": "Claude Opus 4"
|
||||
},
|
||||
"openai": {
|
||||
"pattern": "gpt-5%",
|
||||
"name": "GPT 5"
|
||||
},
|
||||
"google": {
|
||||
"pattern": "gemini-3.8-flash%",
|
||||
"name": "Gemini 3.8 Flash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeHome": {
|
||||
"title": "CLAUDE_HOME",
|
||||
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"current": "Current:",
|
||||
"placeholder": "/path/to/claude-home",
|
||||
"saveFailed": "Failed to update CLAUDE_HOME"
|
||||
"routersMissing": "Saved. These lanes name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
|
||||
},
|
||||
"hooks": {
|
||||
"title": "Hook Configuration",
|
||||
@@ -450,6 +468,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"antigravity": "Antigravity",
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
@@ -556,5 +575,54 @@
|
||||
"colSaved": "Saved",
|
||||
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
||||
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
||||
},
|
||||
"homeDir": {
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"current": "Current:",
|
||||
"saveFailed": "Failed to save the directory",
|
||||
"envOwned": "This variable is set by the environment the dashboard started in, or by the checkout's .env — either wins at every start: a value saved here lasts until the next restart."
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex lane with it.",
|
||||
"restartNote": "A codex lane already running keeps the home it started with, and shows as idle, until it is restarted.",
|
||||
"placeholder": "/path/to/codex-home",
|
||||
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
|
||||
},
|
||||
"codexHooks": {
|
||||
"title": "Codex hooks",
|
||||
"description": "Let Codex report to AgentHub as it works: each tool call in the session's timeline, \"Codex needs your permission\" on the lane card and as a notification, and running/idle the moment a turn starts or ends. Tokens and cost come from Codex's journals either way.",
|
||||
"notInstalled": "Not installed",
|
||||
"untrusted": "Installed — waiting for your trust in Codex",
|
||||
"trusted": "Installed and trusted",
|
||||
"trustNote": "Codex runs a hook only after you trust it. The next time Codex starts it asks you to review new hooks: choose Trust all and continue (or run /hooks). Until then Codex skips them.",
|
||||
"install": "Install",
|
||||
"reinstall": "Reinstall",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"eventTrusted": "Trusted in Codex",
|
||||
"eventUntrusted": "Not trusted in Codex yet",
|
||||
"eventMissing": "Not installed",
|
||||
"unsupported": "Codex hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
|
||||
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
|
||||
"failed": "Could not change Codex hooks: {{message}}"
|
||||
},
|
||||
"antigravityHooks": {
|
||||
"title": "Antigravity hooks",
|
||||
"description": "Let Antigravity's CLI (agy) report to AgentHub as it works: each tool call in the session's timeline, and running/idle the moment a run starts or ends. Tokens and cost come from agy's conversation files either way.",
|
||||
"notInstalled": "Not installed",
|
||||
"installed": "Installed",
|
||||
"disabled": "Installed but switched off in hooks.json",
|
||||
"note": "agy loads hooks at its next start and asks for no trust. The file is global, so the Antigravity IDE runs the hook too; AgentHub ignores what the IDE sends. agy has no hook for a permission prompt, so an Antigravity lane cannot show \"needs your permission\".",
|
||||
"install": "Install",
|
||||
"reinstall": "Reinstall",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"eventInstalled": "Installed",
|
||||
"eventMissing": "Not installed",
|
||||
"unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
|
||||
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
|
||||
"failed": "Could not change Antigravity hooks: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"hooks": "Hooks",
|
||||
"keybindings": "Phím tắt",
|
||||
"settings": "Cài đặt",
|
||||
"memory": "Bộ nhớ"
|
||||
"memory": "Bộ nhớ",
|
||||
"agenthub": "AgentHub"
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"memory": "Bộ nhớ",
|
||||
"config": "Cấu hình",
|
||||
"mcp": "Máy chủ MCP",
|
||||
"plugins": "Plugin"
|
||||
"plugins": "Plugin",
|
||||
"agenthub": "AgentHub"
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
|
||||
@@ -38,5 +38,6 @@
|
||||
"conversation": "Hội thoại",
|
||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
||||
}
|
||||
},
|
||||
"toolBadgeTitle": "Ghi lại bởi {{tool}}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Cài đặt",
|
||||
"subtitle": "Quản lý định giá, thông báo, dữ liệu và hooks",
|
||||
"subtitle": "Quản lý giao diện, công cụ AI, thông báo và dữ liệu",
|
||||
"exportData": "Xuất dữ liệu",
|
||||
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
|
||||
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
|
||||
@@ -23,8 +23,6 @@
|
||||
"resetResult": "Đã đặt lại về {{count}} quy tắc mặc định",
|
||||
"addModel": "Thêm mô hình",
|
||||
"pattern": "Mẫu",
|
||||
"patternPlaceholder": "claude-opus-4%",
|
||||
"namePlaceholder": "Claude Opus 4",
|
||||
"validationRequired": "Bắt buộc nhập mẫu và tên hiển thị",
|
||||
"lastUpdated": "Cập nhật lần cuối: ",
|
||||
"tooltip": {
|
||||
@@ -36,21 +34,41 @@
|
||||
"manualUpdates": "Bạn phải cập nhật giá thủ công",
|
||||
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
|
||||
"apiPricing": "Chỉ để tham khảo - đơn giá API",
|
||||
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn."
|
||||
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.",
|
||||
"synced": "Giá được điền tự động",
|
||||
"syncedBody": "Một model mà phiên dùng nhưng chưa có quy tắc riêng sẽ được lấy giá từ bảng giá công khai của LiteLLM ở lần đầu gặp. Dòng đó không bao giờ tự cập nhật lại: hãy sửa ở đây khi nhà cung cấp đổi giá. Các phiên đã lưu vẫn giữ mức giá áp dụng lúc ghi nhận.",
|
||||
"apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn."
|
||||
},
|
||||
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
|
||||
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
|
||||
"fastInput": "Fast Input",
|
||||
"fastOutput": "Fast Output"
|
||||
"fastOutput": "Fast Output",
|
||||
"showAll": "Hiện mọi model (thêm {{count}})",
|
||||
"empty": {
|
||||
"claude": "Chưa có giá model Claude. Nút Khôi phục mặc định sẽ đưa chúng trở lại.",
|
||||
"openai": "Chưa có giá model OpenAI. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Codex dùng model đó, hoặc bạn tự thêm.",
|
||||
"google": "Chưa có giá model Gemini. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Antigravity dùng model đó, hoặc bạn tự thêm."
|
||||
},
|
||||
"placeholders": {
|
||||
"claude": {
|
||||
"pattern": "claude-opus-4%",
|
||||
"name": "Claude Opus 4"
|
||||
},
|
||||
"openai": {
|
||||
"pattern": "gpt-5%",
|
||||
"name": "GPT 5"
|
||||
},
|
||||
"google": {
|
||||
"pattern": "gemini-3.8-flash%",
|
||||
"name": "Gemini 3.8 Flash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeHome": {
|
||||
"title": "CLAUDE_HOME",
|
||||
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"current": "Hiện tại:",
|
||||
"placeholder": "/đường-dẫn/tới/claude-home",
|
||||
"saveFailed": "Cập nhật CLAUDE_HOME thất bại"
|
||||
"routersMissing": "Đã lưu. Các lane này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho lane: {{lanes}}"
|
||||
},
|
||||
"hooks": {
|
||||
"title": "Cấu hình hook",
|
||||
@@ -450,6 +468,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"antigravity": "Antigravity",
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
@@ -556,5 +575,54 @@
|
||||
"colSaved": "Đã lưu",
|
||||
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
||||
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
||||
},
|
||||
"homeDir": {
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"current": "Hiện tại:",
|
||||
"saveFailed": "Không lưu được thư mục",
|
||||
"envOwned": "Biến này được đặt bởi môi trường lúc khởi động dashboard, hoặc bởi file .env của bản checkout, và nguồn đó luôn được ưu tiên mỗi lần khởi động: giá trị lưu ở đây chỉ giữ đến lần khởi động lại tiếp theo."
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi lane codex với thư mục này.",
|
||||
"restartNote": "Lane codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
|
||||
"placeholder": "/đường-dẫn/tới/codex-home",
|
||||
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
|
||||
},
|
||||
"codexHooks": {
|
||||
"title": "Hook của Codex",
|
||||
"description": "Cho Codex báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, \"Codex cần bạn cho phép\" trên thẻ lane và qua thông báo, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ nhật ký của Codex dù có hook hay không.",
|
||||
"notInstalled": "Chưa cài",
|
||||
"untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex",
|
||||
"trusted": "Đã cài và được tin cậy",
|
||||
"trustNote": "Codex chỉ chạy một hook sau khi bạn tin cậy nó. Lần tới Codex khởi động, nó sẽ hỏi bạn xem lại các hook mới: chọn Trust all and continue (hoặc gõ /hooks). Trước đó Codex bỏ qua chúng.",
|
||||
"install": "Cài đặt",
|
||||
"reinstall": "Cài lại",
|
||||
"refresh": "Làm mới",
|
||||
"remove": "Gỡ bỏ",
|
||||
"eventTrusted": "Đã được tin cậy trong Codex",
|
||||
"eventUntrusted": "Chưa được tin cậy trong Codex",
|
||||
"eventMissing": "Chưa cài",
|
||||
"unsupported": "Hook của Codex từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
|
||||
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
|
||||
"failed": "Không đổi được hook của Codex: {{message}}"
|
||||
},
|
||||
"antigravityHooks": {
|
||||
"title": "Hook của Antigravity",
|
||||
"description": "Cho CLI của Antigravity (agy) báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ file hội thoại của agy dù có hook hay không.",
|
||||
"notInstalled": "Chưa cài",
|
||||
"installed": "Đã cài",
|
||||
"disabled": "Đã cài nhưng đang tắt trong hooks.json",
|
||||
"note": "agy nạp hook ở lần khởi động tiếp theo và không hỏi tin cậy. File này dùng chung, nên Antigravity IDE cũng chạy hook; AgentHub bỏ qua những gì IDE gửi. agy không có hook cho lời hỏi quyền, nên lane Antigravity không thể hiện \"cần bạn cấp quyền\".",
|
||||
"install": "Cài đặt",
|
||||
"reinstall": "Cài lại",
|
||||
"refresh": "Làm mới",
|
||||
"remove": "Gỡ bỏ",
|
||||
"eventInstalled": "Đã cài",
|
||||
"eventMissing": "Chưa cài",
|
||||
"unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
|
||||
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
|
||||
"failed": "Không đổi được hook của Antigravity: {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
+54
-8
@@ -393,6 +393,8 @@ import type {
|
||||
AlertEvent,
|
||||
AlertRule,
|
||||
Analytics,
|
||||
CodexHooksStatus,
|
||||
AntigravityHooksStatus,
|
||||
CostResult,
|
||||
DashboardEvent,
|
||||
Lane,
|
||||
@@ -676,6 +678,8 @@ export const api = {
|
||||
status?: string;
|
||||
q?: string;
|
||||
cwd?: string;
|
||||
/** One CLI's sessions only (`claude`, `codex`, `antigravity`). */
|
||||
tool?: string;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
limit?: number;
|
||||
@@ -686,6 +690,7 @@ export const api = {
|
||||
if (params?.status) qs.set("status", params.status);
|
||||
if (params?.q) qs.set("q", params.q);
|
||||
if (params?.cwd) qs.set("cwd", params.cwd);
|
||||
if (params?.tool) qs.set("tool", params.tool);
|
||||
if (params?.sort_by) qs.set("sort_by", params.sort_by);
|
||||
// `!== undefined` (not truthiness) so an explicit `sort_desc: false` is preserved.
|
||||
if (params?.sort_desc !== undefined) qs.set("sort_desc", String(params.sort_desc));
|
||||
@@ -998,17 +1003,56 @@ export const api = {
|
||||
* GET /api/settings/claude-home - the currently configured Claude home path.
|
||||
* @returns `{ claude_home }` — the absolute path the server reads config from.
|
||||
*/
|
||||
get: () => request<{ claude_home: string }>("/settings/claude-home"),
|
||||
get: () => request<{ claude_home: string; env_owned?: boolean }>("/settings/claude-home"),
|
||||
/**
|
||||
* PUT /api/settings/claude-home - repoint the server at a new Claude home.
|
||||
* @param path New absolute `~/.claude` root the server should read from.
|
||||
* @returns `{ ok, claude_home }` — success flag and the accepted path.
|
||||
* @returns `{ ok, claude_home, routers }` — the accepted path, and the
|
||||
* lanes whose AI Router profile the new home's store does not have.
|
||||
*/
|
||||
set: (path: string) =>
|
||||
request<{ ok: boolean; claude_home: string }>("/settings/claude-home", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
request<{
|
||||
ok: boolean;
|
||||
claude_home: string;
|
||||
env_owned?: boolean;
|
||||
routers: { missing: string[] };
|
||||
}>("/settings/claude-home", { method: "PUT", body: JSON.stringify({ path }) }),
|
||||
},
|
||||
/** The Codex home: same shape as `claudeHome`, plus the AI Router layers
|
||||
* a PUT re-wrote into the new home for codex lanes. */
|
||||
codexHome: {
|
||||
/** GET /api/settings/codex-home */
|
||||
get: () => request<{ codex_home: string; env_owned: boolean }>("/settings/codex-home"),
|
||||
/** PUT /api/settings/codex-home */
|
||||
set: (path: string) =>
|
||||
request<{
|
||||
ok: boolean;
|
||||
codex_home: string;
|
||||
env_owned: boolean;
|
||||
routers: { updated: number; failed: string[] };
|
||||
}>("/settings/codex-home", { method: "PUT", body: JSON.stringify({ path }) }),
|
||||
},
|
||||
/** AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json` — changed only on a
|
||||
* click; Codex asks the user to trust them before they run. */
|
||||
codexHooks: {
|
||||
/** GET /api/settings/codex-hooks */
|
||||
get: () => request<CodexHooksStatus>("/settings/codex-hooks"),
|
||||
/** POST /api/settings/codex-hooks — install, or reinstall */
|
||||
install: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "POST" }),
|
||||
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
|
||||
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
|
||||
},
|
||||
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
|
||||
* only on a click; agy runs it with no trust step. */
|
||||
antigravityHooks: {
|
||||
/** GET /api/settings/antigravity-hooks */
|
||||
get: () => request<AntigravityHooksStatus>("/settings/antigravity-hooks"),
|
||||
/** POST /api/settings/antigravity-hooks — install, or reinstall */
|
||||
install: () =>
|
||||
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "POST" }),
|
||||
/** DELETE /api/settings/antigravity-hooks — AgentHub's named hook only */
|
||||
remove: () =>
|
||||
request<AntigravityHooksStatus>("/settings/antigravity-hooks", { method: "DELETE" }),
|
||||
},
|
||||
/**
|
||||
* POST /api/settings/clear-data - DESTRUCTIVE: wipes sessions/agents/
|
||||
@@ -1063,11 +1107,13 @@ export const api = {
|
||||
* Wipes user-customized pricing rules and reseeds the shipped defaults;
|
||||
* returns the resulting rule set so the Pricing UI can re-render.
|
||||
*
|
||||
* @returns `{ ok, pricing }` — the full default rule list now in effect.
|
||||
* @param scope - `"claude"` resets only `claude-…` rows; omitted, every row.
|
||||
* @returns `{ ok, pricing }` — the full rule list now in effect.
|
||||
*/
|
||||
resetPricing: () =>
|
||||
resetPricing: (scope?: "claude") =>
|
||||
request<{ ok: boolean; pricing: ModelPricing[] }>("/settings/reset-pricing", {
|
||||
method: "POST",
|
||||
...(scope && { body: JSON.stringify({ scope }) }),
|
||||
}),
|
||||
/**
|
||||
* Direct download URL for GET /api/settings/export (a full DB dump);
|
||||
|
||||
@@ -629,6 +629,40 @@ export type EffectiveSessionStatus = SessionStatus | typeof AWAITING_STATUS;
|
||||
// `Agent` is one process within it (the main loop or a spawned subagent). Both
|
||||
// are created lazily on their first hook event and mutated as hooks stream in.
|
||||
|
||||
/** GET/POST/DELETE /api/settings/codex-hooks: AgentHub's hooks in Codex. */
|
||||
export interface CodexHooksStatus {
|
||||
/** False on Windows: the hook command is a POSIX shell script. */
|
||||
supported: boolean;
|
||||
/** False when hooks.json exists but is not a JSON object (see `error`). */
|
||||
readable: boolean;
|
||||
error?: string;
|
||||
/** `$CODEX_HOME/hooks.json` and the forwarder script it points at. */
|
||||
path: string;
|
||||
script: string;
|
||||
/** Every event has AgentHub's hook, and the script exists. */
|
||||
installed: boolean;
|
||||
/** Codex's config.toml holds a trust entry for every one of them. */
|
||||
trusted: boolean;
|
||||
events: Record<string, { installed: boolean; trusted: boolean }>;
|
||||
}
|
||||
|
||||
/** GET /api/settings/antigravity-hooks — AgentHub's named hook for agy. */
|
||||
export interface AntigravityHooksStatus {
|
||||
/** False on Windows: the hook command is a POSIX shell script. */
|
||||
supported: boolean;
|
||||
/** False when hooks.json exists but is not a JSON object (see `error`). */
|
||||
readable: boolean;
|
||||
error?: string;
|
||||
/** `~/.gemini/config/hooks.json` and the forwarder script it points at. */
|
||||
path: string;
|
||||
script: string;
|
||||
/** Every event runs AgentHub's forwarder, and the script exists. */
|
||||
installed: boolean;
|
||||
/** Installed and not switched off (`"enabled": false`) in the file. */
|
||||
enabled: boolean;
|
||||
events: Record<string, { installed: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Claude Code CLI invocation tracked by the dashboard - one row per top-level
|
||||
* `claude` process, created on its first hook event (or on import) and updated
|
||||
@@ -695,6 +729,11 @@ export interface Session {
|
||||
* dashboard's own machine; any other value is a remote source id (see the
|
||||
* Remote Data Sources feature / `remote_sources`). Maps to `sessions.source`. */
|
||||
source?: string;
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* journals and hooks) or `"antigravity"` (agy's conversation files and
|
||||
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file AntigravityConfig.tsx
|
||||
* @description Settings → Antigravity: AgentHub's own settings for the
|
||||
* Antigravity CLI (`agy`) — Model Pricing for Gemini models (the shared
|
||||
* `PricingSettings` table) and AgentHub's Antigravity hooks
|
||||
* (`AntigravityHooksSettings`). agy has no config surface AgentHub edits, so
|
||||
* there is no explorer and no sub-tab bar, only these cards (#40 part D).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
|
||||
|
||||
/** The Antigravity settings tab. */
|
||||
export function AntigravityConfig() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PricingSettings vendor="google" id="antigravity-pricing" />
|
||||
<AntigravityHooksSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
* low-risk text-file surfaces (skills, agents, commands, output styles,
|
||||
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
|
||||
* hooks-in-settings, and settings.json files stay read-only - those have
|
||||
* concurrent-write races with the live CLI.
|
||||
* concurrent-write races with the live CLI. Its AgentHub sub-tab holds the
|
||||
* dashboard's own Claude Code settings (pricing, hooks, CLAUDE_HOME).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -58,6 +59,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import {
|
||||
Boxes,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
@@ -110,6 +112,7 @@ import { MdItemList } from "../components/config/MdItemList";
|
||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
||||
import {
|
||||
Empty,
|
||||
SkeletonRows,
|
||||
@@ -178,7 +181,8 @@ type TabKey =
|
||||
| "hooks"
|
||||
| "keybindings"
|
||||
| "settings"
|
||||
| "memory";
|
||||
| "memory"
|
||||
| "agenthub";
|
||||
|
||||
interface TabDef {
|
||||
key: TabKey;
|
||||
@@ -199,6 +203,7 @@ const TABS: TabDef[] = [
|
||||
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
|
||||
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
|
||||
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
|
||||
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
|
||||
];
|
||||
|
||||
interface PageState {
|
||||
@@ -238,6 +243,8 @@ const EMPTY_STATE: PageState = {
|
||||
export function CcConfig() {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [tab, setTab] = useState<TabKey>("overview");
|
||||
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
|
||||
const [agenthubKey, setAgenthubKey] = useState(0);
|
||||
const [scope, setScope] = useState<CcScope>("all");
|
||||
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -505,7 +512,10 @@ export function CcConfig() {
|
||||
lastUpdated={lastUpdated}
|
||||
scope={scope}
|
||||
onScopeChange={setScope}
|
||||
onRefresh={fetchAll}
|
||||
onRefresh={() => {
|
||||
void fetchAll();
|
||||
setAgenthubKey((k) => k + 1);
|
||||
}}
|
||||
onOpenBackups={() => setBackupsOpen(true)}
|
||||
onOpenProfiles={() => setProfilesOpen(true)}
|
||||
wsConnected={wsConnected}
|
||||
@@ -520,67 +530,73 @@ export function CcConfig() {
|
||||
|
||||
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{tab !== "overview" && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<Search className="w-4 h-4 text-fg-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.search")}
|
||||
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
title={t("common.clearSearch")}
|
||||
aria-label={t("common.clearSearch")}
|
||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isMutable(tab) && tab !== "memory" && (
|
||||
<button
|
||||
onClick={() => openCreate(tabToArtifactType(tab))}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("edit.newButton")}
|
||||
</button>
|
||||
)}
|
||||
{/* The memory tab's own "new" is a different thing: the two
|
||||
{/* AgentHub's own Claude Code settings are cards of their own, not a
|
||||
list over this tab's search box and scope. */}
|
||||
{tab === "agenthub" ? (
|
||||
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{tab !== "overview" && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<Search className="w-4 h-4 text-fg-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.search")}
|
||||
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
title={t("common.clearSearch")}
|
||||
aria-label={t("common.clearSearch")}
|
||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isMutable(tab) && tab !== "memory" && (
|
||||
<button
|
||||
onClick={() => openCreate(tabToArtifactType(tab))}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("edit.newButton")}
|
||||
</button>
|
||||
)}
|
||||
{/* The memory tab's own "new" is a different thing: the two
|
||||
CLAUDE.md cards below are this machine's user + current-project
|
||||
files, while this creates one in ANOTHER project's directory. */}
|
||||
{tab === "memory" && (
|
||||
<button
|
||||
onClick={() => setProjectMemoryOpen(true)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("projectMemory.newButton")}
|
||||
</button>
|
||||
)}
|
||||
{tab === "memory" && (
|
||||
<button
|
||||
onClick={() => setProjectMemoryOpen(true)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("projectMemory.newButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<TabPanel
|
||||
tab={tab}
|
||||
data={data}
|
||||
search={search}
|
||||
onOpenFile={openViewer}
|
||||
onEdit={openEdit}
|
||||
onDelete={openDelete}
|
||||
onCreateMemory={(s) => openCreate("memory", s)}
|
||||
onEditAuto={openEditAuto}
|
||||
onDeleteAuto={openDeleteAuto}
|
||||
onCreateAuto={openCreateAuto}
|
||||
onChanged={fetchAll}
|
||||
onToast={setToast}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<TabPanel
|
||||
tab={tab}
|
||||
data={data}
|
||||
search={search}
|
||||
onOpenFile={openViewer}
|
||||
onEdit={openEdit}
|
||||
onDelete={openDelete}
|
||||
onCreateMemory={(s) => openCreate("memory", s)}
|
||||
onEditAuto={openEditAuto}
|
||||
onDeleteAuto={openDeleteAuto}
|
||||
onCreateAuto={openCreateAuto}
|
||||
onChanged={fetchAll}
|
||||
onToast={setToast}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
|
||||
{editor && (
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
|
||||
* split between editable artifacts and CLI-driven surfaces. Codex has no
|
||||
* "project-local" scope and no per-project file-based memory - its scope
|
||||
* union is exactly "user" | "project".
|
||||
* union is exactly "user" | "project". Its **AgentHub** sub-tab holds the
|
||||
* dashboard's own Codex settings: Model Pricing for OpenAI models (the shared
|
||||
* `PricingSettings` table), AgentHub's Codex hooks (`CodexHooksSettings`)
|
||||
* and CODEX_HOME.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -17,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import {
|
||||
Boxes,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
@@ -51,6 +55,9 @@ import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||
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 { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
|
||||
import {
|
||||
Empty,
|
||||
SkeletonRows,
|
||||
@@ -75,7 +82,16 @@ import type {
|
||||
CodexSkill,
|
||||
} from "../lib/api";
|
||||
|
||||
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
|
||||
type TabKey =
|
||||
| "overview"
|
||||
| "skills"
|
||||
| "agents"
|
||||
| "rules"
|
||||
| "memory"
|
||||
| "config"
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
| "agenthub";
|
||||
|
||||
interface TabDef {
|
||||
key: TabKey;
|
||||
@@ -92,6 +108,7 @@ const TABS: TabDef[] = [
|
||||
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
|
||||
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
|
||||
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
|
||||
{ key: "agenthub", icon: Gauge, i18nKey: "tabs.agenthub" },
|
||||
];
|
||||
|
||||
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
|
||||
@@ -141,7 +158,12 @@ interface NamedArtifactItem {
|
||||
|
||||
export function CodexConfig() {
|
||||
const { t } = useTranslation("codexConfig");
|
||||
const { t: tSettings } = useTranslation("settings");
|
||||
const [tab, setTab] = useState<TabKey>("overview");
|
||||
// Bumped by Refresh: the AgentHub sub-tab loads on mount, so a new key reloads it.
|
||||
const [agenthubKey, setAgenthubKey] = useState(0);
|
||||
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
|
||||
const [hooksKey, setHooksKey] = useState(0);
|
||||
const [scope, setScope] = useState<CodexScope>("all");
|
||||
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -361,7 +383,10 @@ export function CodexConfig() {
|
||||
lastUpdated={lastUpdated}
|
||||
scope={scope}
|
||||
onScopeChange={setScope}
|
||||
onRefresh={fetchAll}
|
||||
onRefresh={() => {
|
||||
void fetchAll();
|
||||
setAgenthubKey((k) => k + 1);
|
||||
}}
|
||||
onOpenBackups={() => setBackupsOpen(true)}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
@@ -375,67 +400,101 @@ export function CodexConfig() {
|
||||
|
||||
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<Search className="w-4 h-4 text-fg-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.search")}
|
||||
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
||||
/>
|
||||
{search && (
|
||||
{/* AgentHub's own Codex settings are cards of their own, not a list. A
|
||||
new CODEX_HOME re-reads every list, since they all live under it. */}
|
||||
{tab === "agenthub" ? (
|
||||
<div key={agenthubKey} className="space-y-8">
|
||||
<PricingSettings vendor="openai" id="codex-pricing" />
|
||||
<CodexHooksSettings key={hooksKey} />
|
||||
<HomeDirSetting
|
||||
id="codex-home"
|
||||
title={tSettings("codexHome.title")}
|
||||
description={tSettings("codexHome.description")}
|
||||
note={tSettings("codexHome.restartNote")}
|
||||
placeholder={tSettings("codexHome.placeholder")}
|
||||
load={() =>
|
||||
api.settings.codexHome
|
||||
.get()
|
||||
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
|
||||
}
|
||||
save={(p) =>
|
||||
api.settings.codexHome.set(p).then((r) => {
|
||||
void fetchAll();
|
||||
setHooksKey((k) => k + 1);
|
||||
return {
|
||||
value: r.codex_home,
|
||||
envOwned: r.env_owned,
|
||||
warning: r.routers.failed.length
|
||||
? tSettings("codexHome.routersFailed", { lanes: r.routers.failed.join(", ") })
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<Search className="w-4 h-4 text-fg-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("common.search")}
|
||||
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
title={t("common.clearSearch")}
|
||||
aria-label={t("common.clearSearch")}
|
||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isNamedArtifact(tab) && (
|
||||
<button
|
||||
onClick={() => openCreate(tab)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("list.newButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The memory tab hides the search bar, but still needs its own
|
||||
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
|
||||
the user/project files listed below. */}
|
||||
{tab === "memory" && (
|
||||
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
title={t("common.clearSearch")}
|
||||
aria-label={t("common.clearSearch")}
|
||||
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isNamedArtifact(tab) && (
|
||||
<button
|
||||
onClick={() => openCreate(tab)}
|
||||
onClick={() => setProjectMemoryOpen(true)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("list.newButton")}
|
||||
{t("projectMemory.newButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<TabPanel
|
||||
tab={tab}
|
||||
data={data}
|
||||
search={search}
|
||||
cliError={cliError}
|
||||
onOpenFile={openViewer}
|
||||
onEdit={openEdit}
|
||||
onDelete={openDelete}
|
||||
onCreateScopeFile={openCreate}
|
||||
onChanged={fetchAll}
|
||||
onToast={setToast}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The memory tab hides the search bar, but still needs its own
|
||||
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
|
||||
the user/project files listed below. */}
|
||||
{tab === "memory" && (
|
||||
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => setProjectMemoryOpen(true)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("projectMemory.newButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<TabPanel
|
||||
tab={tab}
|
||||
data={data}
|
||||
search={search}
|
||||
cliError={cliError}
|
||||
onOpenFile={openViewer}
|
||||
onEdit={openEdit}
|
||||
onDelete={openDelete}
|
||||
onCreateScopeFile={openCreate}
|
||||
onChanged={fetchAll}
|
||||
onToast={setToast}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupsOpen && (
|
||||
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
|
||||
|
||||
@@ -85,6 +85,7 @@ import { AgentCard } from "../components/AgentCard";
|
||||
import { SessionOverview } from "../components/SessionOverview";
|
||||
import { ConversationView } from "../components/conversation/ConversationView";
|
||||
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { CopyButton } from "../components/event-views/primitives";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
@@ -589,6 +590,7 @@ export function SessionDetail() {
|
||||
<h2 className="text-xl font-semibold text-fg-primary">
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</h2>
|
||||
<SessionToolBadge tool={session.tool} />
|
||||
<SessionStatusBadge
|
||||
status={effectiveSessionStatus(session)}
|
||||
reason={sessionAwaitingReason(session)}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import { SessionStatusBadge } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
||||
@@ -434,6 +435,7 @@ export function Sessions() {
|
||||
<p className="text-sm font-medium text-fg-secondary">
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</p>
|
||||
<SessionToolBadge tool={session.tool} />
|
||||
{session.source && session.source !== "local" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
||||
|
||||
+12
-766
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration.
|
||||
* @description Settings → Dashboard: the install-wide settings — total cost, appearance, AI Tools, lanes housekeeping, import, remote sources, Sagi, notifications, alerts, data management and system information. Per-tool settings live in that tool's own tab (Claude Code's pricing, hooks and CLAUDE_HOME: `components/settings/ClaudeAgentHubSettings.tsx`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -55,27 +55,20 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragment } from "react";
|
||||
import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DollarSign,
|
||||
Palette,
|
||||
Bot,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
RefreshCw,
|
||||
Database,
|
||||
Plug,
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
XCircle,
|
||||
Server,
|
||||
Bell,
|
||||
BellOff,
|
||||
@@ -99,8 +92,6 @@ import {
|
||||
Coins,
|
||||
BarChart3,
|
||||
Settings as SettingsIcon,
|
||||
FolderOpen,
|
||||
Info,
|
||||
PiggyBank,
|
||||
History,
|
||||
ChevronLeft,
|
||||
@@ -111,7 +102,7 @@ import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { sagiPrefs } from "../components/Sagi/prefs";
|
||||
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
|
||||
import { fmt, fmtCost } from "../lib/format";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
|
||||
import { notify } from "../lib/notify";
|
||||
@@ -122,7 +113,7 @@ import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import type { ModelPricing, WSMessage } from "../lib/types";
|
||||
import type { WSMessage } from "../lib/types";
|
||||
|
||||
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
||||
// `<section id>` rendered below; the TOC scroll-spies the active one.
|
||||
@@ -134,10 +125,7 @@ const SETTINGS_SECTIONS: {
|
||||
}[] = [
|
||||
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
|
||||
{ id: "ai-tools", labelKey: "aiTools.title", Icon: Bot },
|
||||
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
|
||||
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
|
||||
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
||||
{ id: "claude-home", labelKey: "claudeHome.title", Icon: FolderOpen },
|
||||
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
|
||||
{
|
||||
id: "remote-sources",
|
||||
@@ -154,44 +142,6 @@ const SETTINGS_SECTIONS: {
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
interface EditRow {
|
||||
model_pattern: string;
|
||||
display_name: string;
|
||||
input_per_mtok: string;
|
||||
output_per_mtok: string;
|
||||
cache_read_per_mtok: string;
|
||||
cache_write_per_mtok: string;
|
||||
cache_write_1h_per_mtok: string;
|
||||
fast_input_per_mtok: string;
|
||||
fast_output_per_mtok: string;
|
||||
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
|
||||
// values are ignored). Generic: any model can carry a promo window.
|
||||
intro_until: string;
|
||||
intro_input_per_mtok: string;
|
||||
intro_output_per_mtok: string;
|
||||
intro_cache_read_per_mtok: string;
|
||||
intro_cache_write_per_mtok: string;
|
||||
intro_cache_write_1h_per_mtok: string;
|
||||
}
|
||||
|
||||
const emptyRow: EditRow = {
|
||||
model_pattern: "",
|
||||
display_name: "",
|
||||
input_per_mtok: "0",
|
||||
output_per_mtok: "0",
|
||||
cache_read_per_mtok: "0",
|
||||
cache_write_per_mtok: "0",
|
||||
cache_write_1h_per_mtok: "0",
|
||||
fast_input_per_mtok: "0",
|
||||
fast_output_per_mtok: "0",
|
||||
intro_until: "",
|
||||
intro_input_per_mtok: "0",
|
||||
intro_output_per_mtok: "0",
|
||||
intro_cache_read_per_mtok: "0",
|
||||
intro_cache_write_per_mtok: "0",
|
||||
intro_cache_write_1h_per_mtok: "0",
|
||||
};
|
||||
|
||||
interface SystemInfo {
|
||||
db: { path: string; size: number; counts: Record<string, number> };
|
||||
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
@@ -204,19 +154,6 @@ interface SystemInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const normalized =
|
||||
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
|
||||
const d = new Date(normalized);
|
||||
return d.toLocaleString(getCurrentLocale(), {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -306,121 +243,11 @@ function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
||||
* three-section explanation: how prices are applied, how pattern matching
|
||||
* works, and a reminder that prices must be edited manually when Anthropic
|
||||
* publishes new rates. All copy is i18n-driven (settings.pricing.tooltip.*).
|
||||
*
|
||||
* The popover is fixed-positioned and clamped to the viewport so it never
|
||||
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
|
||||
* the Workflows stat tooltips.
|
||||
*/
|
||||
function PricingInfoTooltip() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
|
||||
const positionPopover = useCallback(() => {
|
||||
const btn = buttonRef.current;
|
||||
const pop = popoverRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const w = pop?.offsetWidth ?? 320;
|
||||
const h = pop?.offsetHeight ?? 240;
|
||||
const margin = 8;
|
||||
|
||||
let left = r.right - w; // right-align with the icon
|
||||
if (left < margin) left = margin;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
let top = r.bottom + 8;
|
||||
if (top + h > window.innerHeight - margin) {
|
||||
top = Math.max(margin, r.top - h - 8);
|
||||
}
|
||||
setPos({ left, top });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
positionPopover();
|
||||
const onScroll = () => positionPopover();
|
||||
const onResize = () => positionPopover();
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
const raf = requestAnimationFrame(positionPopover);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, positionPopover]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={t("pricing.tooltip.title")}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
className="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"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top, width: 320 }}
|
||||
>
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
||||
|
||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.howItWorks")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.howItWorksBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.patternsTitle")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.patternsBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.manualUpdates")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.manualUpdatesBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.apiPricing")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug">{t("pricing.tooltip.apiPricingBody")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [pricing, setPricing] = useState<ModelPricing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
||||
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalCost, setTotalCost] = useState<number | null>(null);
|
||||
const [sysInfo, setSysInfo] = useState<SystemInfo | null>(null);
|
||||
@@ -440,11 +267,7 @@ export function Settings() {
|
||||
const [abandonHours, setAbandonHours] = useState("24");
|
||||
const [purgeDays, setPurgeDays] = useState("90");
|
||||
const [gcDryRun, setGcDryRun] = useState(false);
|
||||
const [claudeHome, setClaudeHomeState] = useState("");
|
||||
const [claudeHomeInput, setClaudeHomeInput] = useState("");
|
||||
const [claudeHomeSaving, setClaudeHomeSaving] = useState(false);
|
||||
const [claudeHomeError, setClaudeHomeError] = useState<string | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<string>("pricing");
|
||||
const [activeSection, setActiveSection] = useState<string>("appearance");
|
||||
const tocRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tocOverflow, setTocOverflow] = useState({ left: false, right: false });
|
||||
|
||||
@@ -504,17 +327,9 @@ export function Settings() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([
|
||||
api.pricing.list(),
|
||||
api.pricing.totalCost(),
|
||||
api.settings.info(),
|
||||
api.settings.claudeHome.get(),
|
||||
]);
|
||||
setPricing(pricingRes.pricing);
|
||||
const [costRes, infoRes] = await Promise.all([api.pricing.totalCost(), api.settings.info()]);
|
||||
setTotalCost(costRes.total_cost);
|
||||
setSysInfo(infoRes);
|
||||
setClaudeHomeState(claudeHomeRes.claude_home);
|
||||
setClaudeHomeInput(claudeHomeRes.claude_home);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
@@ -583,91 +398,6 @@ export function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (rule: ModelPricing) => {
|
||||
setAdding(false);
|
||||
setEditingPattern(rule.model_pattern);
|
||||
setEditRow({
|
||||
model_pattern: rule.model_pattern,
|
||||
display_name: rule.display_name,
|
||||
input_per_mtok: String(rule.input_per_mtok),
|
||||
output_per_mtok: String(rule.output_per_mtok),
|
||||
cache_read_per_mtok: String(rule.cache_read_per_mtok),
|
||||
cache_write_per_mtok: String(rule.cache_write_per_mtok),
|
||||
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
|
||||
fast_input_per_mtok: String(rule.fast_input_per_mtok),
|
||||
fast_output_per_mtok: String(rule.fast_output_per_mtok),
|
||||
intro_until: rule.intro_until ?? "",
|
||||
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
|
||||
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
|
||||
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
|
||||
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
|
||||
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
|
||||
});
|
||||
};
|
||||
|
||||
const startAdd = () => {
|
||||
setEditingPattern(null);
|
||||
setAdding(true);
|
||||
setEditRow({ ...emptyRow });
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
|
||||
setError(t("pricing.validationRequired"));
|
||||
return;
|
||||
}
|
||||
const introUntil = editRow.intro_until.trim();
|
||||
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
|
||||
setError(t("pricing.introUntilInvalid"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.pricing.upsert({
|
||||
model_pattern: editRow.model_pattern.trim(),
|
||||
display_name: editRow.display_name.trim(),
|
||||
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
|
||||
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
|
||||
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
|
||||
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
|
||||
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
|
||||
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
|
||||
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
|
||||
// Always send the intro block so the UI is authoritative for it: an
|
||||
// empty date clears the promo, a valid date persists the intro rates.
|
||||
intro_until: introUntil || null,
|
||||
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
|
||||
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
|
||||
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
|
||||
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
|
||||
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
|
||||
});
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (pattern: string) => {
|
||||
try {
|
||||
await api.pricing.delete(pattern);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async (key: string, fn: () => Promise<string>) => {
|
||||
setActionLoading(key);
|
||||
setActionResult(null);
|
||||
@@ -696,24 +426,12 @@ export function Settings() {
|
||||
return t("danger.clearedResult", { count: total });
|
||||
});
|
||||
|
||||
const handleReinstallHooks = () =>
|
||||
runAction("hooks", async () => {
|
||||
const res = await api.settings.reinstallHooks();
|
||||
return res.ok ? t("hooks.success") : t("hooks.failed");
|
||||
});
|
||||
|
||||
const handleGc = () =>
|
||||
runAction("gc", async () => {
|
||||
const res = await api.settings.gc({ dryRun: gcDryRun });
|
||||
return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
|
||||
});
|
||||
|
||||
const handleResetPricing = () =>
|
||||
runAction("reset-pricing", async () => {
|
||||
const res = await api.settings.resetPricing();
|
||||
return t("pricing.resetResult", { count: res.pricing.length });
|
||||
});
|
||||
|
||||
const handleCleanup = () =>
|
||||
runAction("cleanup", async () => {
|
||||
const params: { abandon_hours?: number; purge_days?: number } = {};
|
||||
@@ -731,188 +449,6 @@ export function Settings() {
|
||||
return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean");
|
||||
});
|
||||
|
||||
const handleSaveClaudeHome = async () => {
|
||||
if (claudeHomeInput === claudeHome) return;
|
||||
setClaudeHomeSaving(true);
|
||||
setClaudeHomeError(null);
|
||||
try {
|
||||
const res = await api.settings.claudeHome.set(claudeHomeInput);
|
||||
setClaudeHomeState(res.claude_home);
|
||||
setClaudeHomeInput(res.claude_home);
|
||||
} catch (err) {
|
||||
setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed"));
|
||||
} finally {
|
||||
setClaudeHomeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastUpdated =
|
||||
pricing.length > 0
|
||||
? pricing.reduce(
|
||||
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
|
||||
pricing[0]!.updated_at
|
||||
)
|
||||
: null;
|
||||
|
||||
const isEditing = editingPattern !== null || adding;
|
||||
|
||||
const renderEditCells = () => (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.model_pattern}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
|
||||
placeholder={t("pricing.patternPlaceholder")}
|
||||
disabled={editingPattern !== null}
|
||||
className="input w-full text-sm font-mono disabled:opacity-50"
|
||||
autoFocus={adding}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.display_name}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
|
||||
placeholder={t("pricing.namePlaceholder")}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.input_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.output_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_read_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_write_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_write_1h_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.fast_input_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.fast_output_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
|
||||
title={t("common:save")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
title={t("common:cancel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
||||
// Second edit row: the time-limited introductory-rate block. Rendered under
|
||||
// the standard-rate cells whenever a row is being edited/added. Leaving the
|
||||
// date empty means "no promo" — the rate inputs are then ignored. This is the
|
||||
// ONLY place intro rates are entered, and it works for any model pattern (not
|
||||
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
||||
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<input
|
||||
type={opts.date ? "text" : "number"}
|
||||
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
||||
value={editRow[key]}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
|
||||
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
const renderIntroEditRow = () => (
|
||||
<tr className="bg-surface-3">
|
||||
<td colSpan={10} className="px-4 pb-3 pt-2">
|
||||
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
|
||||
{t("pricing.introRatesTitle")}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{introField("intro_until", "pricing.introUntil", { date: true })}
|
||||
{introField("intro_input_per_mtok", "common:token.input")}
|
||||
{introField("intro_output_per_mtok", "common:token.output")}
|
||||
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
|
||||
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
|
||||
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const actionBanner = (keys: string[]) => {
|
||||
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
||||
if (!match) return null;
|
||||
@@ -1053,6 +589,12 @@ export function Settings() {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost summary card */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
@@ -1086,264 +628,6 @@ export function Settings() {
|
||||
|
||||
<AiToolsSettings />
|
||||
|
||||
{/* ─── MODEL PRICING ─── */}
|
||||
<section id="pricing" className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-fg-muted" />
|
||||
{t("pricing.title")}
|
||||
<PricingInfoTooltip />
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
confirmAction === "reset-pricing"
|
||||
? handleResetPricing()
|
||||
: setConfirmAction("reset-pricing")
|
||||
}
|
||||
disabled={isEditing || actionLoading !== null}
|
||||
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
||||
confirmAction === "reset-pricing"
|
||||
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{confirmAction === "reset-pricing"
|
||||
? t("pricing.resetConfirm")
|
||||
: t("pricing.resetDefaults")}
|
||||
</button>
|
||||
<button
|
||||
onClick={startAdd}
|
||||
disabled={isEditing}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionBanner(["reset-pricing"])}
|
||||
|
||||
<div className="card overflow-x-auto mt-4">
|
||||
<table className="w-full min-w-[1000px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("pricing.pattern")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:cost.model")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.input")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.output")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.cacheRead")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite5m")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite1h")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastInput")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastOutput")}
|
||||
</th>
|
||||
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{pricing.map((rule) =>
|
||||
editingPattern === rule.model_pattern ? (
|
||||
<Fragment key={rule.model_pattern}>
|
||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||
{renderIntroEditRow()}
|
||||
</Fragment>
|
||||
) : (
|
||||
<tr
|
||||
key={rule.model_pattern}
|
||||
className="hover:bg-surface-4 transition-colors group"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
||||
{rule.model_pattern}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||
{rule.display_name}
|
||||
{rule.intro_until && (
|
||||
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
||||
{t("pricing.introNote", {
|
||||
input: rule.intro_input_per_mtok,
|
||||
output: rule.intro_output_per_mtok,
|
||||
until: rule.intro_until,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.input_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_input_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.output_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_output_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_read_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_read_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_1h_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_1h_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEdit(rule)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRule(rule.model_pattern)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{adding && (
|
||||
<>
|
||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||
{renderIntroEditRow()}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-fg-muted mt-3">
|
||||
{t("pricing.lastUpdated")}
|
||||
{formatTimestamp(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ─── HOOK CONFIGURATION ─── */}
|
||||
<section id="hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("hooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{sysInfo?.hooks.installed ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReinstallHooks}
|
||||
disabled={actionLoading !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "hooks" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("hooks.reinstall")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionBanner(["hooks"])}
|
||||
|
||||
{sysInfo && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{Object.entries(sysInfo.hooks.hooks).map(([hook, active]) => (
|
||||
<div
|
||||
key={hook}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{active ? (
|
||||
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{hook}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{sysInfo.hooks.path}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── LANES ─── */}
|
||||
<section id="lanes" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
@@ -1379,44 +663,6 @@ export function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── CLAUDE HOME ─── */}
|
||||
<section id="claude-home" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
||||
{t("claudeHome.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-1">{t("claudeHome.description")}</p>
|
||||
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={claudeHomeInput}
|
||||
onChange={(e) => {
|
||||
setClaudeHomeInput(e.target.value);
|
||||
setClaudeHomeError(null);
|
||||
}}
|
||||
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
||||
placeholder={t("claudeHome.placeholder")}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveClaudeHome}
|
||||
disabled={claudeHomeSaving || claudeHomeInput === claudeHome}
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")}
|
||||
</button>
|
||||
</div>
|
||||
{claudeHomeError && <p className="text-xs text-status-danger">{claudeHomeError}</p>}
|
||||
{claudeHome && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("claudeHome.current")} <code className="text-fg-secondary">{claudeHome}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── IMPORT HISTORY ─── */}
|
||||
<section id="import" className="scroll-mt-24">
|
||||
<ImportHistory />
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. A tool's tab is hidden while that tool is disabled in Settings → AI Tools.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import {
|
||||
Boxes,
|
||||
KeyRound,
|
||||
Orbit,
|
||||
Router,
|
||||
Settings as SettingsIcon,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AntigravityConfig } from "./AntigravityConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -48,6 +56,13 @@ const TOOL_TABS: ToolTab[] = [
|
||||
Panel: CodexConfig,
|
||||
aiTool: "codex",
|
||||
},
|
||||
{
|
||||
id: "antigravity",
|
||||
labelKey: "tools.antigravity",
|
||||
Icon: Orbit,
|
||||
Panel: AntigravityConfig,
|
||||
aiTool: "antigravity",
|
||||
},
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
{
|
||||
id: "git-credentials",
|
||||
|
||||
@@ -501,7 +501,9 @@ export function Workspace() {
|
||||
.then((r) => setActiveRuns(keepIfSame(r)))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
|
||||
// Attach/resume go through `claude`, so a Codex session is not offered
|
||||
// (filtered by the server, before its limit).
|
||||
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true, tool: "claude" })
|
||||
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
@@ -46,6 +46,27 @@ const codexConfigMock = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const codexHomeMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
const codexHooksMock = vi.hoisted(() => ({
|
||||
get: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/.codex/hooks.json",
|
||||
script: "/home/.codex/agenthub-hook.sh",
|
||||
installed: false,
|
||||
trusted: false,
|
||||
events: {},
|
||||
})
|
||||
),
|
||||
install: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
@@ -53,6 +74,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...(actual.api as Record<string, unknown>),
|
||||
codexConfig: codexConfigMock,
|
||||
settings: {
|
||||
...((actual.api as { settings: Record<string, unknown> }).settings ?? {}),
|
||||
codexHome: codexHomeMock,
|
||||
codexHooks: codexHooksMock,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -97,6 +123,33 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CodexConfig", () => {
|
||||
it("edits CODEX_HOME on the AgentHub sub-tab and reports lanes whose router layer failed", async () => {
|
||||
stubCodexApi({});
|
||||
codexHomeMock.get.mockResolvedValue({ codex_home: "/home/.codex", env_owned: false });
|
||||
codexHomeMock.set.mockResolvedValue({
|
||||
ok: true,
|
||||
codex_home: "/work/.codex",
|
||||
env_owned: false,
|
||||
routers: { updated: 1, failed: ["4 (api): EDIR"] },
|
||||
});
|
||||
render(<CodexConfig />);
|
||||
await settle();
|
||||
const overviewCalls = codexConfigMock.overview.mock.calls.length;
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: /agenthub/i }));
|
||||
const input = await screen.findByDisplayValue("/home/.codex");
|
||||
expect(await screen.findByText("Codex hooks")).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "/work/.codex");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(codexHomeMock.set).toHaveBeenCalledWith("/work/.codex");
|
||||
expect(await screen.findByText(/4 \(api\): EDIR/)).toBeInTheDocument();
|
||||
// Every list lives under CODEX_HOME, so a new one re-reads them.
|
||||
expect(codexConfigMock.overview.mock.calls.length).toBeGreaterThan(overviewCalls);
|
||||
});
|
||||
|
||||
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
|
||||
stubCodexApi({ skills: [], mcp: { servers: [] } });
|
||||
render(<CodexConfig />);
|
||||
|
||||
@@ -1474,6 +1474,35 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
0
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-agenthub"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-gauge w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m12 14 4-4"
|
||||
/>
|
||||
<path
|
||||
d="M3.34 19a10 10 0 1 1 17.32 0"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
AgentHub
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 opacity-0"
|
||||
@@ -6867,7 +6896,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Manage pricing, notifications, data, and hooks
|
||||
Manage appearance, AI tools, notifications and data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6951,7 +6980,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
class="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
@@ -7037,65 +7066,6 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</svg>
|
||||
AI Tools
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-dollar-sign 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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plug w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 22v-5"
|
||||
/>
|
||||
<path
|
||||
d="M9 8V2"
|
||||
/>
|
||||
<path
|
||||
d="M15 8V2"
|
||||
/>
|
||||
<path
|
||||
d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"
|
||||
/>
|
||||
</svg>
|
||||
Hook Configuration
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
type="button"
|
||||
@@ -7134,28 +7104,6 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</svg>
|
||||
Lanes
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open 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="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
CLAUDE_HOME
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
type="button"
|
||||
@@ -8265,308 +8213,6 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="pricing"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 mb-4"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-dollar-sign w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="2"
|
||||
y2="22"
|
||||
/>
|
||||
<path
|
||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
||||
/>
|
||||
</svg>
|
||||
Model Pricing
|
||||
<button
|
||||
aria-label="How model pricing works"
|
||||
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-0.5"
|
||||
>
|
||||
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-rotate-ccw w-3 h-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||||
/>
|
||||
<path
|
||||
d="M3 3v5h5"
|
||||
/>
|
||||
</svg>
|
||||
Reset Defaults
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="M12 5v14"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="card overflow-x-auto mt-4"
|
||||
>
|
||||
<table
|
||||
class="w-full min-w-[1000px]"
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
class="border-b border-border text-left"
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
|
||||
>
|
||||
Pattern
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
|
||||
>
|
||||
Model
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Input
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Output
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Cache Read
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Cache Write (5m)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Cache Write (1h)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Fast Input
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right"
|
||||
>
|
||||
Fast Output
|
||||
</th>
|
||||
<th
|
||||
class="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-border"
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="hooks"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plug w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 22v-5"
|
||||
/>
|
||||
<path
|
||||
d="M9 8V2"
|
||||
/>
|
||||
<path
|
||||
d="M15 8V2"
|
||||
/>
|
||||
<path
|
||||
d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"
|
||||
/>
|
||||
</svg>
|
||||
Hook Configuration
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
Claude Code hooks forward events to the dashboard in real time.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5 space-y-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-wrap gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-circle-check-big w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21.801 10A10 10 0 1 1 17 3.335"
|
||||
/>
|
||||
<path
|
||||
d="m9 11 3 3L22 4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
All hooks installed
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-rotate-ccw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||||
/>
|
||||
<path
|
||||
d="M3 3v5h5"
|
||||
/>
|
||||
</svg>
|
||||
Reinstall Hooks
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2"
|
||||
/>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted font-mono truncate"
|
||||
>
|
||||
/tmp/settings.json
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="lanes"
|
||||
@@ -8654,73 +8300,6 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="claude-home"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-folder-open w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
CLAUDE_HOME
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-1"
|
||||
>
|
||||
Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.
|
||||
</p>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted italic mb-4 leading-snug"
|
||||
>
|
||||
Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).
|
||||
</p>
|
||||
<div
|
||||
class="card p-5 space-y-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<input
|
||||
class="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
||||
placeholder="/path/to/claude-home"
|
||||
type="text"
|
||||
value="/home/test/.claude"
|
||||
/>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Current:
|
||||
|
||||
<code
|
||||
class="text-fg-secondary"
|
||||
>
|
||||
/home/test/.claude
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="import"
|
||||
|
||||
@@ -590,6 +590,36 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||
// they live behind the Claude Code tab's AgentHub sub-tab, not on Dashboard.
|
||||
describe("Claude Code's AgentHub settings", () => {
|
||||
it("shows pricing, hooks and CLAUDE_HOME on the AgentHub sub-tab only", async () => {
|
||||
const dashboard = render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<Settings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(dashboard.container.querySelector(`section#${id}`)).toBeNull();
|
||||
}
|
||||
dashboard.unmount();
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={["/cc-config"]}>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(container.querySelector("section#pricing")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("cc-tab-agenthub"));
|
||||
await settle();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The page opens on Overview; the plugin cards live behind the Plugins tab.
|
||||
async function openPluginsTab() {
|
||||
render(
|
||||
|
||||
Reference in New Issue
Block a user