6715fbd971
- EditorModal loads once per path (a parent re-render no longer reloads the file over the user's typing), refuses Save after a failed load, opens a truncated (>256 KB) file read-only, and asks before overwriting a file that changed on disk or could not be re-read. - Settings/Hooks editors unset only keys from the snapshot taken at Edit, warn when the file changed underneath, keep a renamed key's value, and refuse a new key named like a redacted one (env) or a read-only hook event, which used to replace the secret/hooks. - Project scope shows its resolved folder and is disabled inside the plugin cache. Account-profile, pricing-row and pet deletes confirm; two-click actions disarm after 3 s; unsaved drafts ask before closing. - Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and scrolls to + highlights the row; the active config tab is in the URL. - General splits into General / Data / About; Pricing moves to a Cost group with one shared load; config tabs group as Content / Extensions / Config with arrow-key roving; Promise.allSettled per tab. - Data: browser-preferences export/import (allowlisted keys), per-section reset, housekeeping dry-run first, clear-all behind a typed phrase. Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and dry_run returns exactly what the real run would delete. - Alerts can be edited; webhooks/alerts/remote-source failures toast; copies go through copyText; modals get dialog roles and useDialog. - Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of row controls, arrow keys move it, reduced motion honoured; the dead /run launch link goes to the workspace.
217 lines
7.6 KiB
TypeScript
217 lines
7.6 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, and Model Pricing for Claude models (the shared
|
|
* `PricingSettings` table, with Reset Defaults). 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 { PricingSettings } from "./PricingSettings";
|
|
|
|
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
|
|
|
/**
|
|
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
|
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
|
* the config it lists, which lives under that home).
|
|
* @returns The three sections, each with its own id.
|
|
*/
|
|
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
|
|
const { t } = useTranslation("settings");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hooks, setHooks] = useState<HooksInfo | null>(null);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [actionResult, setActionResult] = useState<{
|
|
key: string;
|
|
message: string;
|
|
isError: boolean;
|
|
} | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const infoRes = await api.settings.info();
|
|
setHooks(infoRes.hooks);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
// Hooks can be (re)installed from a terminal while this tab is open; keep the
|
|
// badge current the way the old Settings page did, every 10 s.
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
api.settings
|
|
.info()
|
|
.then((r) => setHooks(r.hooks))
|
|
.catch(() => {});
|
|
}, 10000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!actionResult) return;
|
|
const timeout = setTimeout(() => setActionResult(null), 5000);
|
|
return () => clearTimeout(timeout);
|
|
}, [actionResult]);
|
|
|
|
const runAction = async (key: string, fn: () => Promise<string>) => {
|
|
setActionLoading(key);
|
|
setActionResult(null);
|
|
try {
|
|
const message = await fn();
|
|
setActionResult({ key, message, isError: false });
|
|
await load();
|
|
} catch (err) {
|
|
setActionResult({
|
|
key,
|
|
message: t("messages.actionFailed", {
|
|
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
|
}),
|
|
isError: true,
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleReinstallHooks = () =>
|
|
runAction("hooks", async () => {
|
|
const res = await api.settings.reinstallHooks();
|
|
return res.ok ? t("hooks.success") : t("hooks.failed");
|
|
});
|
|
|
|
const actionBanner = (keys: string[]) => {
|
|
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
|
if (!match) return null;
|
|
return (
|
|
<div
|
|
className={`px-3 py-2 rounded-lg text-xs ${
|
|
match.isError
|
|
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
|
|
: "bg-status-success/10 border border-status-success/20 text-status-success"
|
|
}`}
|
|
>
|
|
{match.message}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-3" aria-busy="true">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-3 w-full" />
|
|
<Skeleton className="h-3 w-5/6" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<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>
|
|
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<PricingSettings vendor="claude" allowReset />
|
|
</div>
|
|
);
|
|
}
|