mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 18:15:41 +00:00
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:
1 parent
7096a8e02c
commit
04354e833f
7 files changed
+380
-172
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user