Files
AgentHub/client/src/components/settings/ClaudeAgentHubSettings.tsx
T
nntrivi2001 a9c71cd905 feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
2026-10-08 12:22:14 +07:00

228 lines
7.9 KiB
TypeScript

/**
* @file ClaudeAgentHubSettings.tsx
* @description The dashboard's own settings that belong to Claude Code, shown
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
* plugin provides them), and a link to the Claude table on the Pricing page.
* Changing CLAUDE_HOME asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from "lucide-react";
import { api } from "../../lib/api";
import { Skeleton } from "../Skeleton";
import { HomeDirSetting } from "./HomeDirSetting";
import { PricingLink } from "./PricingSettings";
type HooksInfo = {
installed: boolean;
path: string;
hooks: Record<string, boolean>;
source?: "plugin";
};
/**
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
* the config it lists, which lives under that home).
* @returns The three sections, each with its own id.
*/
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
const { t } = useTranslation("settings");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hooks, setHooks] = useState<HooksInfo | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{
key: string;
message: string;
isError: boolean;
} | null>(null);
const load = useCallback(async () => {
try {
const infoRes = await api.settings.info();
setHooks(infoRes.hooks);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
load();
}, [load]);
// Hooks can be (re)installed from a terminal while this tab is open; keep the
// badge current the way the old Settings page did, every 10 s.
useEffect(() => {
const id = setInterval(() => {
if (!(document.visibilityState === "visible")) return;
api.settings
.info()
.then((r) => setHooks(r.hooks))
.catch(() => {});
}, 10000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!actionResult) return;
const timeout = setTimeout(() => setActionResult(null), 5000);
return () => clearTimeout(timeout);
}, [actionResult]);
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionLoading(key);
setActionResult(null);
try {
const message = await fn();
setActionResult({ key, message, isError: false });
await load();
} catch (err) {
setActionResult({
key,
message: t("messages.actionFailed", {
message: err instanceof Error ? err.message : t("messages.unknownError"),
}),
isError: true,
});
} finally {
setActionLoading(null);
}
};
const handleReinstallHooks = () =>
runAction("hooks", async () => {
const res = await api.settings.reinstallHooks();
return res.ok ? t("hooks.success") : t("hooks.failed");
});
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
return (
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
</div>
);
};
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
);
}
return (
<div className="space-y-8">
<HomeDirSetting
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("claudeHome.note")}
placeholder={t("claudeHome.placeholder")}
confirm={{ title: t("claudeHome.confirmTitle"), message: t("claudeHome.confirmBody") }}
load={() =>
api.settings.claudeHome
.get()
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
}
save={(p) =>
api.settings.claudeHome.set(p).then((r) => {
void load();
onHomeChanged?.();
return { value: r.claude_home, envOwned: !!r.env_owned };
})
}
/>
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{/* ─── HOOK CONFIGURATION ─── */}
<section id="hooks" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Plug className="w-4 h-4 text-fg-muted" />
{t("hooks.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
{hooks?.installed ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
</span>
)}
</div>
<button
onClick={handleReinstallHooks}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "hooks" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("hooks.reinstall")}
</button>
</div>
{actionBanner(["hooks"])}
{hooks && (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{Object.entries(hooks.hooks).map(([hook, active]) => (
<div
key={hook}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
>
{active ? (
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
) : (
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
)}
<span className="text-fg-secondary truncate">{hook}</span>
</div>
))}
</div>
{hooks.source === "plugin" ? (
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
) : (
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
)}
</>
)}
</div>
</section>
<PricingLink vendor="claude" />
</div>
);
}