c5aba094bd
- AI Tools moves from a Settings section into a modal behind a button at the Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs, /settings#ai-tools); rows get an enable switch, Default badge, and an Update button for every tool whose catalog names an updater (no version lookup on open); not-installed tools fold behind a disclosure, a finished job's log folds to one line; shared AiToolIcon - codex updates through its own `codex update`, openclaw/hermes gain `update --yes`; update-all runs every installed updater and skips an npm one only when npm does not manage the binary - Add-lane tool picker is one button showing the chosen tool that opens the enabled tools as buttons - Claude Code / Codex AgentHub sub-tab folded into Overview (home editor, hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME; HomeDirSetting shows the saved value only while the field differs - Preview/Raw toggle for markdown files in the config FileViewer and the Changes panel's whole-file view (MarkdownPreview: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) - docs and the CLAUDE.md marker updated
200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
/**
|
|
* @file CodexHooksSettings.tsx
|
|
* @description Settings → Codex → Overview → Codex hooks: whether AgentHub's
|
|
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
|
|
* Install/Reinstall and Remove. Nothing is written until the user clicks;
|
|
* Codex itself asks the user to trust the hooks before it runs them, and this
|
|
* card says so while they wait, with a Refresh to re-read once they have
|
|
* (#40 part C2).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
Plug,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Trash2,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import { api } from "../../lib/api";
|
|
import type { CodexHooksStatus } from "../../lib/types";
|
|
import { Skeleton } from "../Skeleton";
|
|
|
|
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
|
|
|
/**
|
|
* The Codex hooks card.
|
|
* @returns A section with id `codex-hooks` for deep links.
|
|
*/
|
|
export function CodexHooksSettings() {
|
|
const { t } = useTranslation("settings");
|
|
const [status, setStatus] = useState<CodexHooksStatus | null>(null);
|
|
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.settings.codexHooks
|
|
.get()
|
|
.then(setStatus)
|
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
}, []);
|
|
|
|
const run = async (kind: "install" | "remove" | "get") => {
|
|
setBusy(kind);
|
|
setError(null);
|
|
try {
|
|
setStatus(await api.settings.codexHooks[kind]());
|
|
} catch (err) {
|
|
setError(
|
|
t("codexHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
|
);
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
// Some of ours present but not all (an event added since, or one taken out
|
|
// by hand): still show them, and still offer Remove.
|
|
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
|
const eventLabel = (e: { installed: boolean; trusted: boolean }) =>
|
|
!e.installed
|
|
? t("codexHooks.eventMissing")
|
|
: e.trusted
|
|
? t("codexHooks.eventTrusted")
|
|
: t("codexHooks.eventUntrusted");
|
|
|
|
const chip = !status?.installed ? (
|
|
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
|
<XCircle className="w-3.5 h-3.5" /> {t("codexHooks.notInstalled")}
|
|
</span>
|
|
) : status.trusted ? (
|
|
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
|
<CheckCircle className="w-3.5 h-3.5" /> {t("codexHooks.trusted")}
|
|
</span>
|
|
) : (
|
|
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
|
<AlertTriangle className="w-3.5 h-3.5" /> {t("codexHooks.untrusted")}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<section id="codex-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("codexHooks.title")}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-4">{t("codexHooks.description")}</p>
|
|
|
|
<div className="card p-5 space-y-4">
|
|
{!status && !error ? (
|
|
<div className="space-y-2" aria-busy="true">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-3 w-5/6" />
|
|
</div>
|
|
) : null}
|
|
|
|
{status && !status.supported ? (
|
|
<p className="text-xs text-fg-muted">{t("codexHooks.unsupported")}</p>
|
|
) : null}
|
|
|
|
{status && status.supported ? (
|
|
<>
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
{chip}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => run("get")}
|
|
disabled={busy !== null}
|
|
className="btn-ghost text-xs disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
|
{t("codexHooks.refresh")}
|
|
</button>
|
|
<button
|
|
onClick={() => run("install")}
|
|
disabled={busy !== null || !status.readable}
|
|
className="btn-ghost text-xs disabled:opacity-50"
|
|
>
|
|
{busy === "install" ? (
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
)}
|
|
{status.installed ? t("codexHooks.reinstall") : t("codexHooks.install")}
|
|
</button>
|
|
{anyInstalled ? (
|
|
<button
|
|
onClick={() => run("remove")}
|
|
disabled={busy !== null}
|
|
className="btn-ghost text-xs disabled:opacity-50"
|
|
>
|
|
{busy === "remove" ? (
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("codexHooks.remove")}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{!status.readable ? (
|
|
<p className="text-xs text-status-danger">
|
|
{t("codexHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
|
</p>
|
|
) : null}
|
|
|
|
{status.installed && !status.trusted ? (
|
|
<p className="text-xs text-fg-secondary">{t("codexHooks.trustNote")}</p>
|
|
) : null}
|
|
|
|
{anyInstalled ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
|
{Object.entries(status.events).map(([event, e]) => (
|
|
<div
|
|
key={event}
|
|
title={eventLabel(e)}
|
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
|
>
|
|
{!e.installed ? (
|
|
<XCircle
|
|
aria-hidden="true"
|
|
className="w-3 h-3 text-status-danger flex-shrink-0"
|
|
/>
|
|
) : e.trusted ? (
|
|
<CheckCircle
|
|
aria-hidden="true"
|
|
className="w-3 h-3 text-status-success flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<AlertTriangle
|
|
aria-hidden="true"
|
|
className="w-3 h-3 text-status-warning flex-shrink-0"
|
|
/>
|
|
)}
|
|
<span className="text-fg-secondary truncate">{event}</span>
|
|
<span className="sr-only">{eventLabel(e)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
|
</>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|