mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user