feat(hooks): name field + beta explainer modal + UI polish

- PG migration 000054 + SQLite v21→v22: add nullable name column
- HookConfig.Name in Go struct, PG/SQLite scan/insert, WS handlers
- Builtin seed writes spec ID as hook name
- UI: name input in create/edit form, displayed in hook list row
- Beta card: "Learn more" button opens modal explaining hooks×skills×MCP
- Default handler_type changed from http to script
- Script editor section: max-h-[50vh] with scroll, border always visible
- i18n: all new keys in en/vi/zh
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent c0acfa1bd9
commit df82fafb28
19 files changed
+204 -70

No files matched your search

+3
View File
@@ -239,6 +239,9 @@ func (m *HookMethods) handleUpdate(ctx context.Context, client *gateway.Client,
// columns). Used pre-validation so the Validate() contract runs on the
// full, merged config exactly as it will exist after Update commits.
func applyHookPatch(cur hooks.HookConfig, p map[string]any) hooks.HookConfig {
if v, ok := p["name"].(string); ok {
cur.Name = v
}
if v, ok := p["event"].(string); ok && v != "" {
cur.Event = hooks.HookEvent(v)
}
+1
View File
@@ -94,6 +94,7 @@ func reconcile(
cfg := hooks.HookConfig{
ID: id,
TenantID: hooks.SentinelTenantID,
Name: s.ID,
Event: hooks.HookEvent(event),
HandlerType: hooks.HandlerScript,
Scope: hooks.Scope(s.Scope),
+1
View File
@@ -164,6 +164,7 @@ type HookConfig struct {
Event HookEvent `json:"event"`
HandlerType HandlerType `json:"handler_type"`
Scope Scope `json:"scope"`
Name string `json:"name,omitempty"`
// Config holds handler-specific options (command path, HTTP URL, prompt template).
Config map[string]any `json:"config"`
Matcher string `json:"matcher,omitempty"`
+15 -9
View File
@@ -67,24 +67,27 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU
tid = tenantIDForInsert(ctx)
}
var matcher, ifExpr *string
var matcher, ifExpr, name *string
if cfg.Matcher != "" {
matcher = &cfg.Matcher
}
if cfg.IfExpr != "" {
ifExpr = &cfg.IfExpr
}
if cfg.Name != "" {
name = &cfg.Name
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO agent_hooks
(id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,1,$14,$15,$16,$17,$17)`,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,1,$14,$15,$16,$17,$18,$18)`,
id, tid, cfg.AgentID, string(cfg.Scope), string(cfg.Event), string(cfg.HandlerType),
cfgJSON, matcher, ifExpr, cfg.TimeoutMS, string(cfg.OnTimeout),
cfg.Priority, cfg.Enabled, string(cfg.Source), metaJSON, cfg.CreatedBy,
cfg.Priority, cfg.Enabled, string(cfg.Source), metaJSON, name, cfg.CreatedBy,
now,
)
if err != nil {
@@ -100,7 +103,7 @@ func (s *PGHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookCon
q := `
SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at
FROM agent_hooks WHERE id = $1`
args := []any{id}
@@ -132,7 +135,7 @@ func (s *PGHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookCon
func (s *PGHookStore) List(ctx context.Context, filter hooks.ListFilter) ([]hooks.HookConfig, error) {
q := `SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at FROM agent_hooks WHERE 1=1`
var args []any
n := 1
@@ -342,7 +345,7 @@ func (s *PGHookStore) ResolveForEvent(ctx context.Context, event hooks.Event) ([
rows, err := s.db.QueryContext(ctx, `
SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at
FROM agent_hooks
WHERE enabled = TRUE AND event = $1
@@ -449,7 +452,7 @@ func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
scope, event string
handlerType, onTimeout string
source string
matcher, ifExpr sql.NullString
matcher, ifExpr, name sql.NullString
cfgJSON, metaJSON []byte
)
err := row.Scan(
@@ -458,7 +461,7 @@ func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
&cfgJSON, &matcher, &ifExpr,
&cfg.TimeoutMS, &onTimeout,
&cfg.Priority, &cfg.Enabled, &cfg.Version,
&source, &metaJSON, &createdBy,
&source, &metaJSON, &name, &createdBy,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
@@ -479,6 +482,9 @@ func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
if ifExpr.Valid {
cfg.IfExpr = ifExpr.String
}
if name.Valid {
cfg.Name = name.String
}
if len(cfgJSON) > 0 {
_ = json.Unmarshal(cfgJSON, &cfg.Config)
+15 -9
View File
@@ -68,7 +68,7 @@ func (s *SqliteHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uui
tid = tenantIDForInsert(ctx)
}
var agentID, createdBy, matcher, ifExpr *string
var agentID, createdBy, matcher, ifExpr, name *string
if cfg.AgentID != nil {
s := cfg.AgentID.String()
agentID = &s
@@ -83,20 +83,23 @@ func (s *SqliteHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uui
if cfg.IfExpr != "" {
ifExpr = &cfg.IfExpr
}
if cfg.Name != "" {
name = &cfg.Name
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO agent_hooks
(id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?,?)`,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?,?,?)`,
id.String(), tid.String(), agentID,
string(cfg.Scope), string(cfg.Event), string(cfg.HandlerType),
string(cfgJSON), matcher, ifExpr,
cfg.TimeoutMS, string(cfg.OnTimeout),
cfg.Priority, cfg.Enabled,
string(cfg.Source), string(metaJSON), createdBy,
string(cfg.Source), string(metaJSON), name, createdBy,
now, now,
)
if err != nil {
@@ -112,7 +115,7 @@ func (s *SqliteHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.Hoo
q := `
SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at
FROM agent_hooks WHERE id = ?`
args := []any{id.String()}
@@ -143,7 +146,7 @@ func (s *SqliteHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.Hoo
func (s *SqliteHookStore) List(ctx context.Context, filter hooks.ListFilter) ([]hooks.HookConfig, error) {
q := `SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at FROM agent_hooks WHERE 1=1`
var args []any
@@ -343,7 +346,7 @@ func (s *SqliteHookStore) ResolveForEvent(ctx context.Context, event hooks.Event
rows, err := s.db.QueryContext(ctx, `
SELECT id, tenant_id, agent_id, scope, event, handler_type,
config, matcher, if_expr, timeout_ms, on_timeout,
priority, enabled, version, source, metadata, created_by,
priority, enabled, version, source, metadata, name, created_by,
created_at, updated_at
FROM agent_hooks
WHERE enabled = 1 AND event = ?
@@ -455,7 +458,7 @@ func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
scope, event string
handlerType, onTimeout string
source string
matcher, ifExpr sql.NullString
matcher, ifExpr, name sql.NullString
cfgStr, metaStr string
enabledInt int
createdAt, updatedAt sqliteTime
@@ -466,7 +469,7 @@ func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
&cfgStr, &matcher, &ifExpr,
&cfg.TimeoutMS, &onTimeout,
&cfg.Priority, &enabledInt, &cfg.Version,
&source, &metaStr, &createdByStr,
&source, &metaStr, &name, &createdByStr,
&createdAt, &updatedAt,
)
if err != nil {
@@ -505,6 +508,9 @@ func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
if ifExpr.Valid {
cfg.IfExpr = ifExpr.String
}
if name.Valid {
cfg.Name = name.String
}
if cfgStr != "" {
_ = json.Unmarshal([]byte(cfgStr), &cfg.Config)
+4 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 21
const SchemaVersion = 22
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -468,6 +468,9 @@ WHERE context_pruning IS NOT NULL
// rebuild runs in rebuildAgentHooksV21 triggered after the v20→21 migration
// tx commits (parallel to backfillV16 at EnsureSchema:677).
20: `SELECT 1;`,
// Version 21 → 22: add name column to agent_hooks.
21: `ALTER TABLE agent_hooks ADD COLUMN name TEXT;`,
}
// addAgentHooksTables is the SQLite incremental migration for schema v19 → v20.
+1
View File
@@ -1595,6 +1595,7 @@ CREATE TABLE IF NOT EXISTS agent_hooks (
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed', 'builtin')),
metadata TEXT NOT NULL DEFAULT '{}',
name TEXT,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 53
const RequiredSchemaVersion uint = 54
@@ -0,0 +1,2 @@
-- 000054 down — Remove name column from agent_hooks.
ALTER TABLE agent_hooks DROP COLUMN IF EXISTS name;
@@ -0,0 +1,6 @@
-- 000054 — Add `name` column to agent_hooks.
--
-- User-facing label for hooks. Nullable so existing rows don't break.
-- Builtin hooks set name from the YAML spec at seed time.
ALTER TABLE agent_hooks ADD COLUMN IF NOT EXISTS name VARCHAR(255);
+1
View File
@@ -9,6 +9,7 @@ export interface HookConfig {
id: string;
tenant_id: string;
agent_id?: string | null;
name?: string;
event: string;
handler_type: "command" | "http" | "prompt" | "script";
scope: "global" | "tenant" | "agent";
+13 -1
View File
@@ -13,7 +13,17 @@
"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"
"collapse": "Hide",
"learnMore": "How hooks work with Skills & MCP",
"modalTitle": "Hooks + Skills + MCP",
"modalIntro": "Hooks integrate with GoClaw's Skill system and MCP (Model Context Protocol) servers to give you fine-grained control over the agent pipeline.",
"modalSkillTitle": "Hooks × Skills",
"modalSkillBody": "When a skill is invoked, the agent fires pre_tool_use and post_tool_use events. A hook on pre_tool_use can inspect the skill name and arguments, then allow, block, or rewrite them before execution. Post-tool hooks observe results for logging or chaining.",
"modalMcpTitle": "Hooks × MCP Servers",
"modalMcpBody": "MCP tools registered from external servers appear as regular tools in the agent pipeline. Hooks fire on the same pre_tool_use / post_tool_use events — no special wiring needed. Use a matcher regex to target specific MCP tool names (e.g. mcp__.*).",
"modalBuiltinTitle": "Built-in hooks (System)",
"modalBuiltinBody": "Built-in hooks like the PII Redactor run as sandboxed scripts with elevated privileges: they can rewrite user input (rawInput) and tool arguments before the agent sees them. User-created scripts cannot — this is enforced at the dispatcher level.",
"modalClose": "Got it"
},
"actions": {
"create": "Create hook",
@@ -42,6 +52,8 @@
"form": {
"title_create": "Create Hook",
"title_edit": "Edit Hook",
"name": "Name",
"namePlaceholder": "e.g. Block sensitive tools",
"event": "Event",
"handlerType": "Handler Type",
"scope": "Scope",
+13 -1
View File
@@ -13,7 +13,17 @@
"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"
"collapse": "Ẩn",
"learnMore": "Hooks hoạt động với Skills & MCP như thế nào",
"modalTitle": "Hooks + Skills + MCP",
"modalIntro": "Hooks tích hợp với hệ thống Skill và MCP (Model Context Protocol) để cho bạn kiểm soát chi tiết pipeline agent.",
"modalSkillTitle": "Hooks × Skills",
"modalSkillBody": "Khi skill được gọi, agent phát sự kiện pre_tool_use và post_tool_use. Hook trên pre_tool_use có thể kiểm tra tên skill và tham số, rồi cho phép, chặn hoặc viết lại trước khi thực thi. Hook post-tool quan sát kết quả để ghi log hoặc chuỗi xử lý.",
"modalMcpTitle": "Hooks × MCP Servers",
"modalMcpBody": "MCP tools từ server bên ngoài xuất hiện như tool thường trong pipeline agent. Hooks vẫn phát trên pre_tool_use / post_tool_use — không cần cấu hình thêm. Dùng matcher regex để nhắm MCP tool cụ thể (ví dụ: mcp__.*).",
"modalBuiltinTitle": "Hook dựng sẵn (Hệ thống)",
"modalBuiltinBody": "Hook dựng sẵn như PII Redactor chạy script sandbox với quyền cao: có thể viết lại đầu vào người dùng (rawInput) và tham số tool trước khi agent nhìn thấy. Script do người dùng tạo không được phép — bảo vệ ở tầng dispatcher.",
"modalClose": "Đã hiểu"
},
"actions": {
"create": "Tạo hook",
@@ -42,6 +52,8 @@
"form": {
"title_create": "Tạo Hook",
"title_edit": "Sửa Hook",
"name": "Tên",
"namePlaceholder": "VD: Chặn tool nhạy cảm",
"event": "Sự kiện",
"handlerType": "Loại xử lý",
"scope": "Phạm vi",
+13 -1
View File
@@ -13,7 +13,17 @@
"howItWorksBody2": "Script 在沙箱中运行 JavaScript。HTTP 调用你的 Webhook。Prompt 询问 LLM。内置钩子(系统徽章)由 GoClaw 提供 — 仅可切换启用状态。",
"howItWorksTitle3": "3. 启用前先测试",
"howItWorksBody3": "使用「运行钩子」配合示例事件预览决策、耗时及输入修改 — 启用前不会触及任何真实会话。",
"collapse": "收起"
"collapse": "收起",
"learnMore": "Hooks 如何与 Skills 和 MCP 配合",
"modalTitle": "Hooks + Skills + MCP",
"modalIntro": "Hooks 与 GoClaw 的 Skill 系统和 MCP(Model Context Protocol)服务器集成,为你提供对 Agent 管道的精细控制。",
"modalSkillTitle": "Hooks × Skills",
"modalSkillBody": "当 Skill 被调用时,Agent 触发 pre_tool_use 和 post_tool_use 事件。pre_tool_use 上的 Hook 可以检查 Skill 名称和参数,然后决定放行、阻止或改写。post-tool Hook 用于观察结果以记录日志或链式处理。",
"modalMcpTitle": "Hooks × MCP Servers",
"modalMcpBody": "来自外部 MCP 服务器的工具在 Agent 管道中作为普通工具出现。Hooks 同样在 pre_tool_use / post_tool_use 上触发 — 无需特殊配置。使用 matcher 正则来匹配特定 MCP 工具名(如 mcp__.*)。",
"modalBuiltinTitle": "内置 Hook(系统)",
"modalBuiltinBody": "内置 Hook(如 PII Redactor)以沙箱脚本运行并拥有提升的权限:可以在 Agent 看到之前改写用户输入(rawInput)和工具参数。用户创建的脚本无此权限 — 由调度器层面强制执行。",
"modalClose": "知道了"
},
"actions": {
"create": "创建钩子",
@@ -42,6 +52,8 @@
"form": {
"title_create": "创建钩子",
"title_edit": "编辑钩子",
"name": "名称",
"namePlaceholder": "例如:阻止敏感工具",
"event": "事件",
"handlerType": "处理器类型",
"scope": "范围",
@@ -1,7 +1,10 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, Sparkles } from "lucide-react";
import { ChevronDown, ChevronUp, Info, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
const LS_KEY = "goclaw:hooks:beta-card-collapsed";
@@ -21,12 +24,10 @@ function saveCollapsed(v: boolean) {
}
}
// 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 [modalOpen, setModalOpen] = useState(false);
const toggle = () => {
const next = !collapsed;
@@ -35,50 +36,96 @@ export function BetaInfoCard() {
};
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" />
<>
<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 items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => setModalOpen(true)}
className="h-7 gap-1 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"
>
<Info className="h-3.5 w-3.5" />
{t("beta.learnMore")}
</Button>
<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>
)}
</button>
</div>
{!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>
<HooksExplainerModal open={modalOpen} onOpenChange={setModalOpen} />
</>
);
}
<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>
function HooksExplainerModal({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const { t } = useTranslation("hooks");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto max-sm:inset-0 max-sm:rounded-none sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-base">{t("beta.modalTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm text-foreground/90">
<p>{t("beta.modalIntro")}</p>
<ExplainerSection title={t("beta.modalSkillTitle")} body={t("beta.modalSkillBody")} />
<ExplainerSection title={t("beta.modalMcpTitle")} body={t("beta.modalMcpBody")} />
<ExplainerSection title={t("beta.modalBuiltinTitle")} body={t("beta.modalBuiltinBody")} />
</div>
)}
<DialogFooter>
<Button variant="secondary" size="sm" onClick={() => onOpenChange(false)}>
{t("beta.modalClose")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ExplainerSection({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-md border bg-muted/40 p-3">
<p className="text-xs font-semibold">{title}</p>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{body}</p>
</div>
);
}
@@ -43,8 +43,9 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
} = useForm<HookFormData>({
resolver: zodResolver(hookFormSchema),
defaultValues: {
name: "",
event: "pre_tool_use",
handler_type: "http",
handler_type: "script",
scope: "tenant",
timeout_ms: 5000,
on_timeout: "block",
@@ -73,6 +74,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
const handlerType: HookFormData["handler_type"] =
initial.handler_type === "command" ? "http" : initial.handler_type;
reset({
name: initial.name ?? "",
event: initial.event as HookFormData["event"],
handler_type: handlerType,
scope: initial.scope,
@@ -116,6 +118,17 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
</div>
)}
{/* Name — optional user-facing label */}
<div className="space-y-1.5">
<Label>{t("form.name")}</Label>
<Input
{...register("name")}
placeholder={t("form.namePlaceholder")}
className="text-base md:text-sm"
disabled={isBuiltin}
/>
</div>
{/* Event + Scope side-by-side on wider viewports */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
@@ -204,7 +217,8 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
{/* Handler-specific sub-forms */}
{handlerType === "script" && (
<div className="space-y-3 rounded-lg border p-3">
<div className="rounded-lg border p-3">
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
<Controller
control={control}
name="script_source"
@@ -218,6 +232,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
)}
/>
</div>
</div>
)}
{handlerType === "http" && (
@@ -44,7 +44,10 @@ export function HookListRow({ hook, onClick, onToggle, onEdit, onDelete, onTest
{/* Main info */}
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
<div className="flex flex-wrap items-center gap-1.5">
{hook.name && (
<p className="truncate text-sm font-medium">{hook.name}</p>
)}
<div className={`flex flex-wrap items-center gap-1.5 ${hook.name ? "mt-0.5" : ""}`}>
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-2xs font-medium ${EVENT_COLORS[hook.event] ?? "bg-muted text-muted-foreground"}`}
>
+2
View File
@@ -120,6 +120,7 @@ export function HooksPage() {
return;
}
await createMutation.mutateAsync({
name: data.name ?? "",
event: data.event,
handler_type: data.handler_type,
scope: data.scope,
@@ -145,6 +146,7 @@ export function HooksPage() {
await updateMutation.mutateAsync({
hookId: editTarget.id,
updates: {
name: data.name ?? "",
event: data.event,
handler_type: data.handler_type,
scope: data.scope,
+1
View File
@@ -20,6 +20,7 @@ export const HookScopeEnum = z.enum(["global", "tenant", "agent"]);
export const hookFormSchema = z
.object({
name: z.string().max(255).optional(),
event: HookEventEnum,
handler_type: HookHandlerTypeEnum,
scope: HookScopeEnum,