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:
2026-09-29 16:09:19 +07:00
parent 7ec5a7cd85
commit 969b06253c
95 changed files with 9278 additions and 1696 deletions
+22
View File
@@ -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>
);
}
+76 -60
View File
@@ -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 && (
+116 -57
View File
@@ -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)} />
+2
View File
@@ -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)}
+2
View File
@@ -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
View File
@@ -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 />
+17 -2
View File
@@ -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",
+3 -1
View File
@@ -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(