a9c71cd905
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.
228 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|