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:
@@ -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