feat(hooks/ui): wider form modal + dialog test panel + collapsible beta card

User-reported polish on the hooks page.

- Form modal: widen from sm:max-w-lg → sm:max-w-3xl lg:max-w-4xl. Pair
  Event/Scope and Matcher/if_expr into 2-col grids; collapse
  timeout/on_timeout/priority/enabled into a single 4-col row on lg+.
  Reads the same on mobile (single column).
- Test panel: replaced the floating bottom-right drawer with a centered
  Dialog (sm:max-w-4xl). Two-column layout — left is the sample event
  form with per-field hints, right is the result with empty-state
  illustration, prominent decision pill, separated reason / stdout /
  stderr / error / updatedInput sections. Run button is now full-width
  with a Play icon. New i18n strings cover all hints + empty/error
  states (en/vi/zh).
- Beta info card: top-of-page collapsible explainer with three "how it
  works" points (event → handler → test). Persists collapsed state in
  localStorage so dismissed users don't see it expand on every reload.
  i18n in en/vi/zh under the new beta.* namespace.
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent 7096a8e02c
commit 04354e833f
7 files changed
+380 -172

No files matched your search

+24 -3
View File
@@ -3,6 +3,18 @@
"subtitle": "Lifecycle hooks: block, observe, inject context across agent events",
"empty": "No hooks configured yet",
"nav": "Hooks",
"beta": {
"title": "Hooks (Beta)",
"badge": "Beta",
"description": "Hooks let you intercept agent lifecycle events to allow, block, or rewrite what happens. Currently in beta — expect rough edges and watch your audit log.",
"howItWorksTitle1": "1. Pick an event",
"howItWorksBody1": "Each hook fires on one event such as user_prompt_submit, pre_tool_use, or stop. Blocking events wait for your decision before continuing.",
"howItWorksTitle2": "2. Choose a handler",
"howItWorksBody2": "Script runs sandboxed JavaScript. HTTP posts to your webhook. Prompt asks an LLM. Built-in hooks (System badge) ship with GoClaw and only the enabled toggle is editable.",
"howItWorksTitle3": "3. Test before enabling",
"howItWorksBody3": "Use Run hook with a sample event to preview the decision, the duration, and any input mutations — nothing touches a real session until enabled.",
"collapse": "Hide"
},
"actions": {
"create": "Create hook",
"edit": "Edit",
@@ -65,11 +77,20 @@
"test": {
"title": "Test hook",
"sampleEvent": "Sample event",
"sampleEventHint": "Craft a fake event to fire the hook against — nothing here touches a real session.",
"toolName": "Tool name",
"toolNameHint": "Used by pre/post_tool_use matchers (e.g. bash, edit, web_search).",
"toolInput": "Tool input (JSON)",
"rawInput": "Raw input",
"fire": "Fire",
"firing": "Firing\u2026",
"toolInputHint": "Arguments passed to the tool. Must be valid JSON.",
"rawInput": "Raw user message",
"rawInputPlaceholder": "Optional — the user's prompt text",
"rawInputHint": "Used by user_prompt_submit hooks. Leave blank for tool events.",
"parseError": "Tool input must be valid JSON",
"resultTitle": "Result",
"resultHint": "What the dispatcher returned, including any input mutations from builtin hooks.",
"emptyState": "Run the hook to see how it would respond.",
"fire": "Run hook",
"firing": "Running\u2026",
"decision": "Decision",
"duration": "Duration",
"reason": "Reason",
+24 -3
View File
@@ -3,6 +3,18 @@
"subtitle": "Hook vòng đời: chặn, quan sát, tiêm context qua các sự kiện agent",
"empty": "Chưa có hook nào được cấu hình",
"nav": "Hooks",
"beta": {
"title": "Hooks (Beta)",
"badge": "Beta",
"description": "Hooks cho phép bạn chen vào các sự kiện vòng đời của agent để cho phép, chặn hoặc viết lại nội dung. Đang trong giai đoạn beta — có thể còn lỗi, hãy theo dõi audit log.",
"howItWorksTitle1": "1. Chọn sự kiện",
"howItWorksBody1": "Mỗi hook gắn với một sự kiện như user_prompt_submit, pre_tool_use, hoặc stop. Sự kiện blocking sẽ chờ quyết định của bạn trước khi tiếp tục.",
"howItWorksTitle2": "2. Chọn loại xử lý",
"howItWorksBody2": "Script chạy JavaScript trong sandbox. HTTP gọi webhook của bạn. Prompt hỏi LLM. Hook dựng sẵn (badge Hệ thống) do GoClaw cung cấp — chỉ chỉnh được toggle bật/tắt.",
"howItWorksTitle3": "3. Test trước khi bật",
"howItWorksBody3": "Dùng Run hook với sự kiện mẫu để xem trước quyết định, thời gian chạy và mutation đầu vào — không động vào phiên thật cho tới khi bật.",
"collapse": "Ẩn"
},
"actions": {
"create": "Tạo hook",
"edit": "Sửa",
@@ -65,11 +77,20 @@
"test": {
"title": "Kiểm tra hook",
"sampleEvent": "Sự kiện mẫu",
"sampleEventHint": "Tạo sự kiện giả để chạy thử hook — không ảnh hưởng phiên thật nào.",
"toolName": "Tên công cụ",
"toolNameHint": "Dùng cho matcher của pre/post_tool_use (ví dụ: bash, edit, web_search).",
"toolInput": "Đầu vào công cụ (JSON)",
"rawInput": "Đầu vào thô",
"fire": "Kích hoạt",
"firing": "Đang kích hoạt\u2026",
"toolInputHint": "Tham số truyền vào công cụ. Phải là JSON hợp lệ.",
"rawInput": "Tin nhắn người dùng",
"rawInputPlaceholder": "Tùy chọn — nội dung prompt của user",
"rawInputHint": "Dùng cho hook user_prompt_submit. Để trống nếu là sự kiện công cụ.",
"parseError": "Đầu vào công cụ phải là JSON hợp lệ",
"resultTitle": "Kết quả",
"resultHint": "Phản hồi từ dispatcher, bao gồm cả mutation đầu vào từ hook builtin.",
"emptyState": "Bấm Run để xem hook phản hồi thế nào.",
"fire": "Chạy hook",
"firing": "Đang chạy\u2026",
"decision": "Quyết định",
"duration": "Thời gian",
"reason": "Lý do",
+24 -3
View File
@@ -3,6 +3,18 @@
"subtitle": "生命周期钩子:在 Agent 事件中拦截、观察、注入上下文",
"empty": "暂无已配置的钩子",
"nav": "Hooks",
"beta": {
"title": "Hooks(公测)",
"badge": "Beta",
"description": "钩子让你拦截 Agent 生命周期事件以放行、阻止或改写其行为。当前处于公测阶段 — 可能存在问题,请关注审计日志。",
"howItWorksTitle1": "1. 选择事件",
"howItWorksBody1": "每个钩子绑定一个事件,例如 user_prompt_submit、pre_tool_use 或 stop。阻塞型事件会等待你的决策后再继续。",
"howItWorksTitle2": "2. 选择处理器",
"howItWorksBody2": "Script 在沙箱中运行 JavaScript。HTTP 调用你的 Webhook。Prompt 询问 LLM。内置钩子(系统徽章)由 GoClaw 提供 — 仅可切换启用状态。",
"howItWorksTitle3": "3. 启用前先测试",
"howItWorksBody3": "使用「运行钩子」配合示例事件预览决策、耗时及输入修改 — 启用前不会触及任何真实会话。",
"collapse": "收起"
},
"actions": {
"create": "创建钩子",
"edit": "编辑",
@@ -65,11 +77,20 @@
"test": {
"title": "测试钩子",
"sampleEvent": "示例事件",
"sampleEventHint": "构造一个虚拟事件来运行钩子 — 不会触及任何真实会话。",
"toolName": "工具名称",
"toolNameHint": "用于 pre/post_tool_use 的匹配器(如 bash、edit、web_search)。",
"toolInput": "工具输入(JSON)",
"rawInput": "原始输入",
"fire": "触发",
"firing": "触发中\u2026",
"toolInputHint": "传递给工具的参数,必须是有效 JSON。",
"rawInput": "用户原始消息",
"rawInputPlaceholder": "可选 — 用户的提示文本",
"rawInputHint": "用于 user_prompt_submit 钩子。工具事件请留空。",
"parseError": "工具输入必须是有效的 JSON",
"resultTitle": "结果",
"resultHint": "调度器的返回结果,包括来自内置钩子的输入修改。",
"emptyState": "运行钩子以查看响应。",
"fire": "运行钩子",
"firing": "运行中\u2026",
"decision": "决策",
"duration": "耗时",
"reason": "原因",
@@ -0,0 +1,93 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
const LS_KEY = "goclaw:hooks:beta-card-collapsed";
function loadCollapsed(): boolean {
try {
return localStorage.getItem(LS_KEY) === "1";
} catch {
return false;
}
}
function saveCollapsed(v: boolean) {
try {
localStorage.setItem(LS_KEY, v ? "1" : "0");
} catch {
// ignore quota/private-mode errors
}
}
// BetaInfoCard explains what the Hooks feature does for end users. Renders at
// the top of the hooks list page. State persists per-browser via localStorage
// so dismissed users don't see it expand on every reload.
export function BetaInfoCard() {
const { t } = useTranslation("hooks");
const [collapsed, setCollapsed] = useState<boolean>(loadCollapsed);
const toggle = () => {
const next = !collapsed;
setCollapsed(next);
saveCollapsed(next);
};
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/10">
<button
type="button"
onClick={toggle}
className="flex w-full items-center gap-2 px-4 py-3 text-left"
aria-expanded={!collapsed}
>
<Sparkles className="h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t("beta.title")}
</span>
<span className="rounded-full bg-blue-200/80 px-1.5 py-0.5 text-2xs font-semibold uppercase text-blue-800 dark:bg-blue-800/40 dark:text-blue-200">
{t("beta.badge")}
</span>
<div className="flex-1" />
{collapsed ? (
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : (
<ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
)}
</button>
{!collapsed && (
<div className="space-y-3 px-4 pb-4 text-sm text-blue-900/90 dark:text-blue-100/90">
<p>{t("beta.description")}</p>
<div className="grid gap-3 sm:grid-cols-3">
<BetaPoint title={t("beta.howItWorksTitle1")} body={t("beta.howItWorksBody1")} />
<BetaPoint title={t("beta.howItWorksTitle2")} body={t("beta.howItWorksBody2")} />
<BetaPoint title={t("beta.howItWorksTitle3")} body={t("beta.howItWorksBody3")} />
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={toggle}
className="h-7 text-xs text-blue-700 hover:bg-blue-100 hover:text-blue-900 dark:text-blue-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-100"
>
{t("beta.collapse")}
</Button>
</div>
</div>
)}
</div>
);
}
function BetaPoint({ title, body }: { title: string; body: string }) {
return (
<div className="rounded border border-blue-200/60 bg-white/60 p-3 dark:border-blue-900/40 dark:bg-blue-950/30">
<p className="text-xs font-semibold text-blue-900 dark:text-blue-100">{title}</p>
<p className="mt-1 text-xs text-blue-800/80 dark:text-blue-200/70">{body}</p>
</div>
);
}
@@ -104,7 +104,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] flex flex-col max-sm:inset-0 max-sm:rounded-none sm:max-w-lg">
<DialogContent className="max-h-[90vh] flex flex-col max-sm:inset-0 max-sm:rounded-none sm:max-w-3xl lg:max-w-4xl">
<DialogHeader>
<DialogTitle>{initial ? t("form.title_edit") : t("form.title_create")}</DialogTitle>
</DialogHeader>
@@ -116,21 +116,39 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</div>
)}
{/* Event */}
<div className="space-y-1.5">
<Label>{t("form.event")}</Label>
<Controller control={control} name="event" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOOK_EVENTS.map((e) => (
<SelectItem key={e} value={e}>{e}</SelectItem>
))}
</SelectContent>
</Select>
)} />
{/* Event + Scope side-by-side on wider viewports */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("form.event")}</Label>
<Controller control={control} name="event" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOOK_EVENTS.map((e) => (
<SelectItem key={e} value={e}>{e}</SelectItem>
))}
</SelectContent>
</Select>
)} />
</div>
{/* Scope — global hidden for non-master callers (advisory UI hint; backend enforces). */}
<div className="space-y-1.5">
<Label>{t("form.scope")}</Label>
<Controller control={control} name="scope" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
)} />
</div>
</div>
{/* Handler type — `command` intentionally absent post-Wave-1. Lite users keep
@@ -142,7 +160,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex gap-4"
className="flex flex-wrap gap-4"
disabled={isBuiltin}
>
{(["script", "http", "prompt"] as const).map((ht) => (
@@ -157,48 +175,31 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
)} />
</div>
{/* Scope — global hidden for non-master callers (advisory UI hint; backend enforces). */}
<div className="space-y-1.5">
<Label>{t("form.scope")}</Label>
<Controller control={control} name="scope" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={isBuiltin}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
)} />
</div>
{/* Matcher */}
<div className="space-y-1.5">
<Label>{t("form.matcher")}</Label>
<Input
{...register("matcher")}
placeholder="^bash$"
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
{errors.matcher
? <p className="text-xs text-destructive">{errors.matcher.message}</p>
: <p className="text-xs text-muted-foreground">{t("form.matcherHint")}</p>
}
</div>
{/* if_expr */}
<div className="space-y-1.5">
<Label>{t("form.ifExpr")}</Label>
<Input
{...register("if_expr")}
placeholder='tool_input.path.startsWith("/etc")'
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
<p className="text-xs text-muted-foreground">{t("form.ifExprHint")}</p>
{/* Matcher + if_expr side-by-side */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("form.matcher")}</Label>
<Input
{...register("matcher")}
placeholder="^bash$"
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
{errors.matcher
? <p className="text-xs text-destructive">{errors.matcher.message}</p>
: <p className="text-xs text-muted-foreground">{t("form.matcherHint")}</p>
}
</div>
<div className="space-y-1.5">
<Label>{t("form.ifExpr")}</Label>
<Input
{...register("if_expr")}
placeholder='tool_input.path.startsWith("/etc")'
className="text-base md:text-sm font-mono"
disabled={isBuiltin}
/>
<p className="text-xs text-muted-foreground">{t("form.ifExprHint")}</p>
</div>
</div>
{/* Handler-specific sub-forms */}
@@ -303,8 +304,10 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</div>
)}
{/* Timeout + on_timeout */}
<div className="grid grid-cols-2 gap-3">
{/* Timeout / on_timeout / priority on one row when wide, stacks on mobile.
Enabled toggle stays actionable on every row (only field user can
change for builtin hooks). */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1.5">
<Label>{t("form.timeout")}</Label>
<Input
@@ -329,11 +332,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</Select>
)} />
</div>
</div>
{/* Priority + enabled — enabled stays actionable even on builtin rows. */}
<div className="flex items-center gap-4">
<div className="flex-1 space-y-1.5">
<div className="space-y-1.5">
<Label>{t("form.priority")}</Label>
<Input
type="number"
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, FlaskConical, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -59,7 +60,7 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
try {
toolInput = JSON.parse(toolInputRaw);
} catch {
setParseError("Tool input must be valid JSON");
setParseError(t("test.parseError"));
return;
}
@@ -73,107 +74,147 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
};
return (
<div className="space-y-4">
<p className="text-sm font-medium">{t("test.sampleEvent")}</p>
<div className="grid gap-5 lg:grid-cols-2">
{/* ── Left: sample event input ─────────────────────────────── */}
<section className="space-y-3 rounded-lg border bg-card p-4">
<header className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t("test.sampleEvent")}</p>
<p className="text-xs text-muted-foreground">{t("test.sampleEventHint")}</p>
</div>
</header>
{/* Tool name */}
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolName")}</Label>
<Input
value={toolName}
onChange={(e) => setToolName(e.target.value)}
placeholder="bash"
className="text-base md:text-sm font-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolName")}</Label>
<Input
value={toolName}
onChange={(e) => setToolName(e.target.value)}
placeholder="bash"
className="text-base md:text-sm font-mono"
/>
<p className="text-2xs text-muted-foreground">{t("test.toolNameHint")}</p>
</div>
{/* Tool input */}
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolInput")}</Label>
<Textarea
value={toolInputRaw}
onChange={(e) => setToolInputRaw(e.target.value)}
rows={5}
placeholder='{"command": "ls -la"}'
className="text-base md:text-sm font-mono"
/>
{parseError && <p className="text-xs text-destructive">{parseError}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolInput")}</Label>
<Textarea
value={toolInputRaw}
onChange={(e) => setToolInputRaw(e.target.value)}
rows={6}
placeholder='{"command": "ls -la"}'
className="text-base md:text-sm font-mono"
/>
{parseError ? (
<p className="text-xs text-destructive">{parseError}</p>
) : (
<p className="text-2xs text-muted-foreground">{t("test.toolInputHint")}</p>
)}
</div>
{/* Raw input (optional) */}
<div className="space-y-1.5">
<Label className="text-xs">{t("test.rawInput")}</Label>
<Input
value={rawInput}
onChange={(e) => setRawInput(e.target.value)}
placeholder="Optional raw user message"
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.rawInput")}</Label>
<Input
value={rawInput}
onChange={(e) => setRawInput(e.target.value)}
placeholder={t("test.rawInputPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-2xs text-muted-foreground">{t("test.rawInputHint")}</p>
</div>
<Button
onClick={handleFire}
disabled={testMutation.isPending}
size="sm"
className="gap-1.5"
>
{testMutation.isPending ? t("test.firing") : t("test.fire")}
</Button>
<Button
onClick={handleFire}
disabled={testMutation.isPending}
className="w-full gap-1.5"
>
<Play className="h-3.5 w-3.5" />
{testMutation.isPending ? t("test.firing") : t("test.fire")}
</Button>
</section>
{/* Result */}
{result && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<span className="text-xs text-muted-foreground">{t("test.decision")}: </span>
{/* ── Right: result ────────────────────────────────────────── */}
<section className="space-y-3 rounded-lg border bg-card p-4">
<header>
<p className="text-sm font-medium">{t("test.resultTitle")}</p>
<p className="text-xs text-muted-foreground">{t("test.resultHint")}</p>
</header>
{!result && !testMutation.isPending && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<FlaskConical className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("test.emptyState")}</p>
</div>
)}
{testMutation.isPending && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground animate-pulse">{t("test.firing")}</p>
</div>
)}
{result && (
<div className="space-y-4">
{/* Decision pill row */}
<div className="flex flex-wrap items-center gap-3">
<span
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold ${DECISION_STYLES[result.decision] ?? "bg-muted text-muted-foreground"}`}
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-semibold ${DECISION_STYLES[result.decision] ?? "bg-muted text-muted-foreground"}`}
>
{t(`decision.${result.decision}`)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t("test.duration")}: <span className="font-mono">{result.durationMs}ms</span>
</div>
{result.statusCode != null && (
<div className="text-xs text-muted-foreground">
{t("test.statusCode")}: <Badge variant="outline">{result.statusCode}</Badge>
<span className="font-medium">{t("test.duration")}:</span>{" "}
<span className="font-mono">{result.durationMs}ms</span>
</div>
{result.statusCode != null && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">{t("test.statusCode")}:</span>{" "}
<Badge variant="outline">{result.statusCode}</Badge>
</div>
)}
</div>
{result.reason && (
<div className="rounded border bg-muted/40 p-3">
<p className="text-2xs uppercase tracking-wide text-muted-foreground mb-1">{t("test.reason")}</p>
<p className="text-sm">{result.reason}</p>
</div>
)}
{result.stdout && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("test.stdout")}</p>
<pre className="overflow-x-auto rounded bg-muted p-2 text-xs font-mono">{result.stdout}</pre>
</div>
)}
{result.stderr && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-destructive">{t("test.stderr")}</p>
<pre className="overflow-x-auto rounded bg-destructive/10 p-2 text-xs font-mono text-destructive">{result.stderr}</pre>
</div>
)}
{result.error && (
<div className="flex items-start gap-2 rounded border border-destructive/30 bg-destructive/5 p-3">
<AlertCircle className="h-4 w-4 shrink-0 text-destructive mt-0.5" />
<p className="text-xs text-destructive">{result.error}</p>
</div>
)}
{result.updatedInput && (
<div className="space-y-1">
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("test.updatedInput")}</p>
<HookDiffViewer
before={(() => {
try { return JSON.parse(toolInputRaw); } catch { return {}; }
})()}
after={result.updatedInput}
/>
</div>
)}
</div>
{result.reason && (
<p className="text-sm text-muted-foreground">{result.reason}</p>
)}
{result.stdout && (
<div className="space-y-1">
<p className="text-xs font-medium">{t("test.stdout")}</p>
<pre className="overflow-x-auto rounded bg-muted p-2 text-xs">{result.stdout}</pre>
</div>
)}
{result.stderr && (
<div className="space-y-1">
<p className="text-xs font-medium text-destructive">{t("test.stderr")}</p>
<pre className="overflow-x-auto rounded bg-destructive/10 p-2 text-xs text-destructive">{result.stderr}</pre>
</div>
)}
{result.error && (
<p className="text-xs text-destructive">{result.error}</p>
)}
{result.updatedInput && (
<HookDiffViewer
before={(() => {
try { return JSON.parse(toolInputRaw); } catch { return {}; }
})()}
after={result.updatedInput}
/>
)}
</div>
)}
)}
</section>
</div>
);
}
+20 -8
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { Webhook, Plus, RefreshCw, ArrowLeft, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
@@ -21,6 +22,7 @@ import { HookFormDialog } from "./components/hook-form-dialog";
import { HookTestPanel } from "./components/hook-test-panel";
import { HookOverviewTab } from "./components/hook-overview-tab";
import { HookHistoryTable } from "./components/hook-history-table";
import { BetaInfoCard } from "./components/beta-info-card";
import type { HookFormData } from "@/schemas/hooks.schema";
const HOOK_EVENTS = [
@@ -260,6 +262,10 @@ export function HooksPage() {
}
/>
<div className="mt-4">
<BetaInfoCard />
</div>
{/* Filters */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<SearchInput value={search} onChange={setSearch} placeholder={t("filters.event")} className="max-w-xs" />
@@ -345,15 +351,21 @@ export function HooksPage() {
/>
)}
{/* Inline test drawer / panel (list view) */}
{/* Test dialog — centered modal with 2-col input/result layout. */}
{testTarget && (
<div className="fixed inset-x-0 bottom-0 z-40 rounded-t-xl border-t bg-background p-4 shadow-2xl sm:inset-auto sm:bottom-6 sm:right-6 sm:w-[480px] sm:rounded-xl sm:border">
<div className="mb-3 flex items-center justify-between">
<p className="font-medium text-sm">{t("test.title")}: <span className="font-mono">{testTarget.event}</span></p>
<Button variant="ghost" size="xs" onClick={() => setTestTarget(null)}>✕</Button>
</div>
<HookTestPanel hook={testTarget} />
</div>
<Dialog open onOpenChange={(o) => { if (!o) setTestTarget(null); }}>
<DialogContent className="max-h-[90vh] flex flex-col max-sm:inset-0 max-sm:rounded-none sm:max-w-4xl lg:max-w-5xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
{t("test.title")}
<span className="font-mono text-sm text-muted-foreground">{testTarget.event}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
<HookTestPanel hook={testTarget} />
</div>
</DialogContent>
</Dialog>
)}
</div>
);