Files
AgentHub/client/src/components/settings/CodexHooksSettings.tsx
T
nntrivi2001 c5aba094bd feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview
- 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
2026-10-01 10:11:43 +07:00

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>
);
}