feat(ui): hook test panel tool combobox with JSON auto-fill

Replace plain text input with single-select combobox listing builtin
tools, auto-fill JSON skeleton on selection, expand dialog sizing.
This commit is contained in:
viettranx committed 2026-04-16 14:17:48 +07:00
1 parent b097a29a61
commit f8a6f9363f
7 files changed
+238 -9

No files matched your search

@@ -0,0 +1,181 @@
import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useBuiltinTools } from "@/pages/builtin-tools/hooks/use-builtin-tools";
interface ToolSingleComboboxProps {
value: string;
onChange: (value: string) => void;
/** Fires only when selecting from dropdown (not on typing) */
onToolSelect?: (toolName: string) => void;
placeholder?: string;
className?: string;
}
interface ToolOption {
name: string;
displayName: string;
}
export function ToolSingleCombobox({
value,
onChange,
onToolSelect,
placeholder,
className,
}: ToolSingleComboboxProps) {
const { t } = useTranslation("common");
const { tools: builtinTools } = useBuiltinTools();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
// When value changes externally, sync the search field
const displayValue = open ? search : value;
const allTools = useMemo<ToolOption[]>(() => {
return builtinTools.map((t) => ({
name: t.name,
displayName: t.display_name || t.name,
}));
}, [builtinTools]);
const filtered = useMemo(() => {
const q = (open ? search : "").toLowerCase();
return allTools.filter(
(t) => !q || t.name.toLowerCase().includes(q) || t.displayName.toLowerCase().includes(q),
);
}, [allTools, search, open]);
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const selectTool = (name: string) => {
onChange(name);
onToolSelect?.(name);
setSearch("");
setOpen(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
const trimmed = search.trim();
if (trimmed) {
onChange(trimmed);
// Check if it matches a known tool for template fill
const match = allTools.find((t) => t.name === trimmed);
if (match) onToolSelect?.(trimmed);
}
setSearch("");
setOpen(false);
}
};
const handleFocus = () => {
setSearch(value);
setOpen(true);
};
const handleBlur = () => {
// Commit typed value on blur if changed
if (search.trim() && search.trim() !== value) {
onChange(search.trim());
}
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<div
className={cn(
"border-input dark:bg-input/30 flex min-h-9 items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-2",
)}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef}
value={displayValue}
onChange={(e) => {
setSearch(e.target.value);
if (!open) setOpen(true);
}}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("selectOrTypeTools")}
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-base md:text-sm font-mono outline-none"
/>
<ChevronDownIcon
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (open) {
setOpen(false);
} else {
setSearch(value);
setOpen(true);
inputRef.current?.focus();
}
}}
/>
</div>
{open && filtered.length > 0 && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="bg-popover text-popover-foreground pointer-events-auto max-h-60 overflow-y-auto rounded-md border p-1 shadow-md"
>
<div className="text-muted-foreground px-2 py-1 text-2xs font-semibold uppercase tracking-wider">
{t("builtinTools")}
</div>
{filtered.map((tool) => (
<button
key={tool.name}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectTool(tool.name)}
className={cn(
"hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
tool.name === value && "bg-accent/50",
)}
>
<span className="truncate">{tool.displayName}</span>
<code className="text-muted-foreground text-2xs">{tool.name}</code>
</button>
))}
</div>,
document.body,
)}
</div>
);
}
+3 -1
View File
@@ -111,7 +111,9 @@
"stdout": "Stdout",
"stderr": "Stderr",
"statusCode": "Status code",
"updatedInput": "Updated input (diff)"
"updatedInput": "Updated input (diff)",
"toolNamePickerPlaceholder": "Select or type tool name...",
"overwriteConfirm": "Current input will be replaced with template. Continue?"
},
"history": {
"title": "Execution history",
+3 -1
View File
@@ -111,7 +111,9 @@
"stdout": "Stdout",
"stderr": "Stderr",
"statusCode": "Mã trạng thái",
"updatedInput": "Đầu vào đã cập nhật (diff)"
"updatedInput": "Đầu vào đã cập nhật (diff)",
"toolNamePickerPlaceholder": "Chọn hoặc nhập tên tool...",
"overwriteConfirm": "Nội dung hiện tại sẽ bị thay thế bằng mẫu. Tiếp tục?"
},
"history": {
"title": "Lịch sử thực thi",
+3 -1
View File
@@ -111,7 +111,9 @@
"stdout": "标准输出",
"stderr": "标准错误",
"statusCode": "状态码",
"updatedInput": "已更新输入(差异)"
"updatedInput": "已更新输入(差异)",
"toolNamePickerPlaceholder": "选择或输入工具名称...",
"overwriteConfirm": "当前内容将被模板替换。继续?"
},
"history": {
"title": "执行历史",
@@ -4,6 +4,8 @@ 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";
import { ToolSingleCombobox } from "@/components/shared/tool-single-combobox";
import { TOOL_INPUT_TEMPLATES } from "./tool-input-templates";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { useTestHook, type HookConfig, type HookTestResult } from "@/hooks/use-hooks";
@@ -54,6 +56,16 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
const [result, setResult] = useState<HookTestResult | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const handleToolSelect = (name: string) => {
const template = TOOL_INPUT_TEMPLATES[name];
if (!template) return;
const json = JSON.stringify(template, null, 2);
if (toolInputRaw.trim() && toolInputRaw.trim() !== "{}") {
if (!window.confirm(t("test.overwriteConfirm"))) return;
}
setToolInputRaw(json);
};
const handleFire = async () => {
setParseError(null);
let toolInput: Record<string, unknown>;
@@ -86,11 +98,11 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolName")}</Label>
<Input
<ToolSingleCombobox
value={toolName}
onChange={(e) => setToolName(e.target.value)}
placeholder="bash"
className="text-base md:text-sm font-mono"
onChange={setToolName}
onToolSelect={handleToolSelect}
placeholder={t("test.toolNamePickerPlaceholder")}
/>
<p className="text-2xs text-muted-foreground">{t("test.toolNameHint")}</p>
</div>
@@ -100,7 +112,7 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
<Textarea
value={toolInputRaw}
onChange={(e) => setToolInputRaw(e.target.value)}
rows={6}
rows={10}
placeholder='{"command": "ls -la"}'
className="text-base md:text-sm font-mono"
/>
@@ -0,0 +1,30 @@
/**
* Static map of tool_name → JSON skeleton for auto-filling
* the hook test panel textarea when a tool is selected.
* Only includes commonly-used properties per tool.
*/
export const TOOL_INPUT_TEMPLATES: Record<string, Record<string, unknown>> = {
exec: { command: "" },
web_search: { query: "" },
web_fetch: { url: "" },
memory_search: { query: "" },
read_file: { path: "" },
write_file: { path: "", content: "" },
edit: { path: "", old_string: "", new_string: "" },
list_files: { path: "." },
delegate: { agent_key: "", task: "" },
message: { action: "send", message: "" },
create_image: { prompt: "" },
tts: { text: "" },
read_image: { prompt: "", path: "" },
read_audio: { prompt: "" },
read_document: { prompt: "" },
skill_search: { query: "" },
use_skill: { name: "" },
heartbeat: { action: "status" },
cron: { action: "list" },
vault_search: { query: "" },
spawn: { task: "" },
sessions_list: {},
datetime: {},
};
+1 -1
View File
@@ -358,7 +358,7 @@ export function HooksPage() {
{/* Test dialog — centered modal with 2-col input/result layout. */}
{testTarget && (
<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">
<DialogContent className="max-h-[90vh] flex flex-col max-sm:inset-0 max-sm:rounded-none sm:max-w-5xl lg:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
{t("test.title")}