fix(hooks/ui): native script editor + redesigned detail + error toast

User report: hook detail page ugly, script not syntax-highlighted, edit
dialog crashes silently with no toast.

- Replace react-simple-code-editor (React 19 compat issues — likely cause
  of the silent edit-dialog crash) with a native textarea + <pre> overlay.
  Same UX: caret over highlighted source, scroll-synced. Drops the dep
  entirely and removes a render-path black box. Bundle size went DOWN
  (62 KB → was 65 KB before Phase 06; net ~+1 KB for Prism core).
- Import prismjs/themes/prism-tomorrow.css so token colors actually show.
  Editor surface uses #2d2d2d to match the Tomorrow theme palette.
- Harden HookFormDialog reset against null `initial.config` — previous
  cast threw when the row was inserted with no config payload.
- Redesign HookOverviewTab:
  - Header card with event/handler/scope chips + status pill
  - Metadata grid (timeout/on_timeout/priority/source/version/timestamps)
  - Handler-specific cards: script source rendered via the read-only
    ScriptEditor (full Prism highlight), HTTP shows url/method/body,
    prompt shows template + model. No more raw JSON dump.
  - Extract HttpConfigCard / PromptConfigCard subcomponents to keep
    types narrow at the unknown→string boundary.
- ErrorBoundary now toast.error()s when it catches — user sees the
  failure visually instead of just the generic fallback card. Full stack
  still logs to console for devs.
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent f3d6c99dda
commit 7096a8e02c
6 files changed
+259 -83

No files matched your search

-1
View File
@@ -45,7 +45,6 @@
"react-i18next": "^17.0.1",
"react-markdown": "^10.1.0",
"react-router": "^7.13.2",
"react-simple-code-editor": "^0.14.1",
"recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
+3 -14
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
prismjs: ^1.30.0
importers:
.:
@@ -101,9 +104,6 @@ importers:
react-router:
specifier: ^7.13.2
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-simple-code-editor:
specifier: ^0.14.1
version: 0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1)
@@ -2915,12 +2915,6 @@ packages:
react-dom:
optional: true
react-simple-code-editor@0.14.1:
resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -6525,11 +6519,6 @@ snapshots:
optionalDependencies:
react-dom: 19.2.4(react@19.2.4)
react-simple-code-editor@0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
get-nonce: 1.0.1
@@ -2,6 +2,7 @@ import { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import i18n from "@/i18n";
import { toast } from "@/stores/use-toast-store";
interface Props {
children: ReactNode;
@@ -22,6 +23,11 @@ export class ErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[ErrorBoundary]", error, info.componentStack);
// Surface the error name + first line of message via toast so users see a
// visual indicator instead of just the fallback card. Full stack stays in
// console for devs; toast keeps it short to avoid wall-of-text.
const msg = (error.message || "Unknown error").split("\n")[0]?.slice(0, 200);
toast.error(error.name || "Render error", msg);
}
private handleRetry = () => {
@@ -64,7 +64,10 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
useEffect(() => {
if (open) {
if (initial) {
const cfg = initial.config as Record<string, unknown>;
// Defensive: backend may emit config as null when a row was inserted
// without a config payload. Treat null/undefined as empty so the
// property reads below don't crash the edit dialog.
const cfg = (initial.config ?? {}) as Record<string, unknown>;
// Legacy `command` rows coerce to `http` in the form — user cannot save as
// `command` (enum narrowed post-Wave-1). Phase 07 auto-disables them regardless.
const handlerType: HookFormData["handler_type"] =
@@ -1,79 +1,178 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import type { HookConfig } from "@/hooks/use-hooks";
import { ScriptEditor } from "./script-editor";
import { SystemBadge } from "./system-badge";
interface HookOverviewTabProps {
hook: HookConfig;
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
const HANDLER_COLORS: Record<string, string> = {
command: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
http: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
prompt: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
script: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
};
const EVENT_COLORS: Record<string, string> = {
session_start: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
user_prompt_submit: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
pre_tool_use: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
post_tool_use: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
stop: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
subagent_start: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
subagent_stop: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
};
function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-2 border-b last:border-b-0">
<span className="w-36 shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="text-xs">{children}</span>
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{label}</p>
<div className="text-sm">{children}</div>
</div>
);
}
export function HookOverviewTab({ hook }: HookOverviewTabProps) {
const { t } = useTranslation("hooks");
const cfg = hook.config as Record<string, unknown> | undefined;
const scriptSource = hook.handler_type === "script" ? ((cfg?.source as string) ?? "") : "";
return (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<InfoRow label={t("table.event")}>
<span className="font-mono">{hook.event}</span>
</InfoRow>
<InfoRow label={t("table.type")}>
<Badge variant="outline">{hook.handler_type}</Badge>
</InfoRow>
<InfoRow label={t("table.scope")}>
<div className="space-y-6">
{/* Header card — primary identity + status */}
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${EVENT_COLORS[hook.event] ?? "bg-muted text-muted-foreground"}`}
>
{hook.event}
</span>
<span
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${HANDLER_COLORS[hook.handler_type] ?? "bg-muted text-muted-foreground"}`}
>
{hook.handler_type}
</span>
<Badge variant="outline">{hook.scope}</Badge>
</InfoRow>
{hook.source === "builtin" && <SystemBadge />}
<div className="flex-1" />
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
hook.enabled
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-muted text-muted-foreground"
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${hook.enabled ? "bg-emerald-500" : "bg-muted-foreground"}`} />
{hook.enabled ? "Enabled" : "Disabled"}
</span>
</div>
{hook.matcher && (
<InfoRow label={t("table.matcher")}>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{hook.matcher}</code>
</InfoRow>
<div className="mt-3 space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("table.matcher")}</p>
<code className="block rounded bg-muted px-2 py-1 text-xs font-mono">{hook.matcher}</code>
</div>
)}
{hook.if_expr && (
<InfoRow label={t("form.ifExpr")}>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{hook.if_expr}</code>
</InfoRow>
<div className="mt-3 space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.ifExpr")}</p>
<code className="block rounded bg-muted px-2 py-1 text-xs font-mono">{hook.if_expr}</code>
</div>
)}
<InfoRow label={t("form.timeout")}>
<span className="font-mono">{hook.timeout_ms}ms</span>
{" · "}
<span className="text-muted-foreground">{t("form.onTimeout")}: {hook.on_timeout}</span>
</InfoRow>
<InfoRow label={t("form.priority")}>
<span className="font-mono">{hook.priority}</span>
</InfoRow>
<InfoRow label={t("table.enabled")}>
<span className={hook.enabled ? "text-emerald-600" : "text-muted-foreground"}>
{hook.enabled ? "Yes" : "No"}
</span>
</InfoRow>
<InfoRow label="Source">
<Badge variant="secondary">{hook.source}</Badge>
</InfoRow>
<InfoRow label="Version">
<span className="font-mono">v{hook.version}</span>
</InfoRow>
<InfoRow label="Created">
<span>{new Date(hook.created_at).toLocaleString()}</span>
</InfoRow>
<InfoRow label="Updated">
<span>{new Date(hook.updated_at).toLocaleString()}</span>
</InfoRow>
</div>
{/* Config preview */}
{hook.config && Object.keys(hook.config).length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
<pre className="overflow-x-auto rounded border bg-muted/40 p-3 text-xs font-mono">
{JSON.stringify(hook.config, null, 2)}
</pre>
{/* Metadata grid */}
<div className="rounded-lg border bg-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetaItem label={t("form.timeout")}>
<span className="font-mono">{hook.timeout_ms}ms</span>
</MetaItem>
<MetaItem label={t("form.onTimeout")}>
<Badge variant="outline" className="capitalize">{hook.on_timeout}</Badge>
</MetaItem>
<MetaItem label={t("form.priority")}>
<span className="font-mono">{hook.priority}</span>
</MetaItem>
<MetaItem label="Source">
<Badge variant="secondary" className="capitalize">{hook.source}</Badge>
</MetaItem>
<MetaItem label="Version">
<span className="font-mono">v{hook.version}</span>
</MetaItem>
<MetaItem label="Created">
<span className="text-muted-foreground">{new Date(hook.created_at).toLocaleString()}</span>
</MetaItem>
<MetaItem label="Updated">
<span className="text-muted-foreground">{new Date(hook.updated_at).toLocaleString()}</span>
</MetaItem>
</div>
</div>
{/* Handler-specific content */}
{hook.handler_type === "script" && scriptSource && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t("form.scriptSource")}</p>
<ScriptEditor value={scriptSource} onChange={() => {}} readOnly minLines={Math.min(20, scriptSource.split("\n").length + 1)} />
</div>
)}
{hook.handler_type === "http" && cfg && <HttpConfigCard cfg={cfg} t={t} />}
{hook.handler_type === "prompt" && cfg && <PromptConfigCard cfg={cfg} t={t} />}
</div>
);
}
// Narrow Record<string, unknown> values to strings before rendering. JSX
// won't accept `unknown` directly even when the surrounding `&&` proves
// truthiness, so we extract typed locals at the boundary.
type TFn = (key: string) => string;
function HttpConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn }) {
const url = typeof cfg.url === "string" ? cfg.url : "";
const method = typeof cfg.method === "string" ? cfg.method : "";
const bodyTemplate = typeof cfg.body_template === "string" ? cfg.body_template : "";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
{url && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.url")}</p>
<code className="block rounded bg-muted px-2 py-1 text-xs font-mono break-all">{url}</code>
</div>
)}
{method && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.method")}</p>
<Badge variant="outline" className="font-mono">{method}</Badge>
</div>
)}
{bodyTemplate && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.bodyTemplate")}</p>
<pre className="overflow-x-auto rounded bg-muted px-2 py-1 text-xs font-mono">{bodyTemplate}</pre>
</div>
)}
</div>
);
}
function PromptConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn }) {
const promptTemplate = typeof cfg.prompt_template === "string" ? cfg.prompt_template : "";
const model = typeof cfg.model === "string" ? cfg.model : "";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
{promptTemplate && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.promptTemplate")}</p>
<pre className="overflow-x-auto rounded bg-muted px-2 py-1 text-xs whitespace-pre-wrap">{promptTemplate}</pre>
</div>
)}
{model && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.model")}</p>
<Badge variant="outline">{model}</Badge>
</div>
)}
</div>
@@ -1,7 +1,9 @@
import { useMemo } from "react";
import Editor from "react-simple-code-editor";
import { useId, useMemo, useRef, type ChangeEvent, type UIEvent } from "react";
import Prism from "prismjs";
import "prismjs/components/prism-javascript";
// Tomorrow theme matches the dark editor surface defined below; tokens get
// proper color without requiring app-wide CSS coordination.
import "prismjs/themes/prism-tomorrow.css";
import { useTranslation } from "react-i18next";
interface ScriptEditorProps {
@@ -13,6 +15,7 @@ interface ScriptEditorProps {
// string unchanged.
error?: string;
readOnly?: boolean;
minLines?: number;
}
// parseLineCol extracts {line, col} from a goja compile-error string. Format
@@ -25,26 +28,96 @@ export function parseLineCol(err?: string): { line: number; col: number } | null
return { line: parseInt(m[1], 10), col: parseInt(m[2], 10) };
}
export function ScriptEditor({ value, onChange, error, readOnly }: ScriptEditorProps) {
// Minimal native overlay editor: a textarea sits on top of a <pre> showing
// the highlighted source. Keeping this in-house instead of pulling in
// react-simple-code-editor avoids a React 19 compat surface and lets the
// component lazy-create Prism grammar without crashing when the language
// hasn't loaded yet.
export function ScriptEditor({
value,
onChange,
error,
readOnly,
minLines = 12,
}: ScriptEditorProps) {
const { t } = useTranslation("hooks");
const id = useId();
const preRef = useRef<HTMLPreElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const loc = useMemo(() => parseLineCol(error), [error]);
const safeValue = value ?? "";
// Keep a trailing newline so the highlighted layer matches textarea height
// when the file ends without one (avoids the last line getting clipped).
const displayValue =
safeValue.length === 0 || safeValue.endsWith("\n") ? safeValue : safeValue + "\n";
const highlighted = useMemo(() => {
const lang = Prism.languages.javascript;
if (!lang) return escapeHtml(displayValue);
try {
return Prism.highlight(displayValue, lang, "javascript");
} catch {
return escapeHtml(displayValue);
}
}, [displayValue]);
const handleScroll = (e: UIEvent<HTMLTextAreaElement>) => {
if (!preRef.current) return;
preRef.current.scrollTop = e.currentTarget.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
if (!readOnly) onChange(e.target.value);
};
return (
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("form.scriptSource")}</label>
<div className="rounded-lg border bg-muted/30 overflow-hidden">
<Editor
value={value}
onValueChange={readOnly ? () => {} : onChange}
highlight={(code) => {
const lang = Prism.languages.javascript;
return lang ? Prism.highlight(code, lang, "javascript") : code;
<label htmlFor={id} className="text-sm font-medium">
{t("form.scriptSource")}
</label>
<div
className="relative rounded-lg border bg-[#2d2d2d] overflow-hidden"
style={{ minHeight: `${minLines * 1.5}em` }}
>
<pre
ref={preRef}
aria-hidden="true"
className="m-0 overflow-auto p-3 text-xs leading-6 font-mono pointer-events-none"
style={{
color: "#ccc",
background: "transparent",
whiteSpace: "pre",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
padding={12}
textareaClassName="outline-none"
>
<code
className="language-javascript"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
</pre>
<textarea
ref={taRef}
id={id}
value={safeValue}
onChange={handleChange}
onScroll={handleScroll}
readOnly={readOnly}
className="text-base md:text-sm font-mono min-h-[240px] leading-relaxed"
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="absolute inset-0 w-full h-full resize-none border-0 bg-transparent p-3 text-xs leading-6 font-mono outline-none"
style={{
color: "transparent",
caretColor: "#ccc",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
whiteSpace: "pre",
overflow: "auto",
}}
placeholder="function handle(event) {\n return { decision: 'allow' };\n}"
/>
</div>
{error ? (
@@ -59,3 +132,10 @@ export function ScriptEditor({ value, onChange, error, readOnly }: ScriptEditorP
</div>
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}