diff --git a/ui/web/package.json b/ui/web/package.json index 3100368c..b160cece 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -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", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 6738c83e..dc8efaae 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -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 diff --git a/ui/web/src/components/shared/error-boundary.tsx b/ui/web/src/components/shared/error-boundary.tsx index 94bbaeda..db03396f 100644 --- a/ui/web/src/components/shared/error-boundary.tsx +++ b/ui/web/src/components/shared/error-boundary.tsx @@ -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 { 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 = () => { diff --git a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx index 361153ae..3faebd69 100644 --- a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx +++ b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx @@ -64,7 +64,10 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo useEffect(() => { if (open) { if (initial) { - const cfg = initial.config as Record; + // 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; // 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"] = diff --git a/ui/web/src/pages/hooks/components/hook-overview-tab.tsx b/ui/web/src/pages/hooks/components/hook-overview-tab.tsx index 6159ed78..ccf76b24 100644 --- a/ui/web/src/pages/hooks/components/hook-overview-tab.tsx +++ b/ui/web/src/pages/hooks/components/hook-overview-tab.tsx @@ -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 = { + 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 = { + 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 ( -
- {label} - {children} +
+

{label}

+
{children}
); } export function HookOverviewTab({ hook }: HookOverviewTabProps) { const { t } = useTranslation("hooks"); + const cfg = hook.config as Record | undefined; + const scriptSource = hook.handler_type === "script" ? ((cfg?.source as string) ?? "") : ""; return ( -
-
- - {hook.event} - - - {hook.handler_type} - - +
+ {/* Header card — primary identity + status */} +
+
+ + {hook.event} + + + {hook.handler_type} + {hook.scope} - + {hook.source === "builtin" && } +
+ + + {hook.enabled ? "Enabled" : "Disabled"} + +
{hook.matcher && ( - - {hook.matcher} - +
+

{t("table.matcher")}

+ {hook.matcher} +
)} {hook.if_expr && ( - - {hook.if_expr} - +
+

{t("form.ifExpr")}

+ {hook.if_expr} +
)} - - {hook.timeout_ms}ms - {" · "} - {t("form.onTimeout")}: {hook.on_timeout} - - - {hook.priority} - - - - {hook.enabled ? "Yes" : "No"} - - - - {hook.source} - - - v{hook.version} - - - {new Date(hook.created_at).toLocaleString()} - - - {new Date(hook.updated_at).toLocaleString()} -
- {/* Config preview */} - {hook.config && Object.keys(hook.config).length > 0 && ( -
-

{t("tabs.config")}

-
-            {JSON.stringify(hook.config, null, 2)}
-          
+ {/* Metadata grid */} +
+
+ + {hook.timeout_ms}ms + + + {hook.on_timeout} + + + {hook.priority} + + + {hook.source} + + + v{hook.version} + + + {new Date(hook.created_at).toLocaleString()} + + + {new Date(hook.updated_at).toLocaleString()} + +
+
+ + {/* Handler-specific content */} + {hook.handler_type === "script" && scriptSource && ( +
+

{t("form.scriptSource")}

+ {}} readOnly minLines={Math.min(20, scriptSource.split("\n").length + 1)} /> +
+ )} + + {hook.handler_type === "http" && cfg && } + {hook.handler_type === "prompt" && cfg && } +
+ ); +} + +// Narrow Record 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; 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 ( +
+

{t("tabs.config")}

+ {url && ( +
+

{t("form.url")}

+ {url} +
+ )} + {method && ( +
+

{t("form.method")}

+ {method} +
+ )} + {bodyTemplate && ( +
+

{t("form.bodyTemplate")}

+
{bodyTemplate}
+
+ )} +
+ ); +} + +function PromptConfigCard({ cfg, t }: { cfg: Record; t: TFn }) { + const promptTemplate = typeof cfg.prompt_template === "string" ? cfg.prompt_template : ""; + const model = typeof cfg.model === "string" ? cfg.model : ""; + return ( +
+

{t("tabs.config")}

+ {promptTemplate && ( +
+

{t("form.promptTemplate")}

+
{promptTemplate}
+
+ )} + {model && ( +
+

{t("form.model")}

+ {model}
)}
diff --git a/ui/web/src/pages/hooks/components/script-editor.tsx b/ui/web/src/pages/hooks/components/script-editor.tsx index e985705a..2e390a70 100644 --- a/ui/web/src/pages/hooks/components/script-editor.tsx +++ b/ui/web/src/pages/hooks/components/script-editor.tsx @@ -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
 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(null);
+  const taRef = useRef(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) => {
+    if (!preRef.current) return;
+    preRef.current.scrollTop = e.currentTarget.scrollTop;
+    preRef.current.scrollLeft = e.currentTarget.scrollLeft;
+  };
+
+  const handleChange = (e: ChangeEvent) => {
+    if (!readOnly) onChange(e.target.value);
+  };
+
   return (
     
- -
- {} : onChange} - highlight={(code) => { - const lang = Prism.languages.javascript; - return lang ? Prism.highlight(code, lang, "javascript") : code; + +
+ +