"use client"; /* Tools list (card grid) + Tool Builder (tabbed config + Live response token-meter signature). */ import * as jmespath from "jmespath"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../icons"; import { Field, Modal, Segmented, StatusPill, Tabs, Tile, TokenMeter, Toggle } from "../primitives"; import { VersionHistory } from "../version-history"; import { ImportExport } from "../import-export"; import { api, AuthProviderT, Tool, ToolSet, ToolTestResult } from "@/lib/api"; import { KIND_ICON, KIND_LABEL } from "@/lib/data"; const estTokens = (o: any) => (o == null ? 0 : Math.max(1, JSON.stringify(o).length >> 2)); const DEFAULT_SAMPLE = { data: { totals: { subtotal: 4210.0, tax: 421.0, grand_total: 4631.0 }, customer: { name: "Ada Lovelace", email: "ada@example.com", tier: "gold" }, line_items: [{ sku: "WIDGET-1", qty: 2, price: 1200 }, { sku: "GADGET-9", qty: 1, price: 1810 }], status: "open", }, meta: { request_id: "req_8f21c0", ts: 1780000000, page: 1, per_page: 50 }, }; /* ============ TOOLS LIST ============ */ /* last_tested → [dot colour, label]. Untested reads amber (needs attention) per the design. */ const STATUS = (s?: string | null): [string, string] => s === "pass" ? ["var(--ok)", "Passing"] : s === "fail" ? ["var(--err)", "Failing"] : ["var(--warn)", "Untested"]; export function ToolsScreen({ project, onOpen }: { project: any; onOpen: (t: Tool) => void }) { const [tools, setTools] = useState([]); const [err, setErr] = useState(null); const [view, setView] = useState<"grid" | "list">("grid"); const [open, setOpen] = useState(false); const [toolSets, setToolSets] = useState([]); const [filterSet, setFilterSet] = useState("all"); // "all" | "ungrouped" | const [drawerOpen, setDrawerOpen] = useState(false); const [drawerSel, setDrawerSel] = useState(null); // null = create a new set const [selected, setSelected] = useState>(new Set()); const [query, setQuery] = useState(""); const reload = useCallback(() => { if (!project?.id) return; api.listTools(project.id).then(setTools).catch((e) => setErr(String(e.message || e))); api.listToolSets(project.id).then(setToolSets).catch(() => {}); }, [project?.id]); useEffect(() => { reload(); }, [reload]); async function del(e: React.MouseEvent, t: Tool) { e.stopPropagation(); if (t.kind === "builtin") return; // built-ins are platform capabilities — not deletable if (!window.confirm(`Delete tool "${t.name}"? Workflows/agents referencing it will skip it. This cannot be undone.`)) return; setTools((prev) => prev.filter((x) => x.id !== t.id)); // optimistic try { await api.deleteTool(project.id, t.id); } catch { reload(); } } async function duplicate(e: React.MouseEvent, t: Tool) { e.stopPropagation(); try { const cfg = { ...((t.config as any) || {}) }; delete cfg._last_test; // start the copy untested await api.createTool(project.id, { name: `${t.name}_copy`, kind: t.kind, config: cfg, auth_provider_id: t.auth_provider_id || undefined }); reload(); } catch (e2: any) { setErr(String(e2?.message || e2)); } } async function toggleEnabled(t: Tool) { setTools((prev) => prev.map((x) => (x.id === t.id ? { ...x, enabled: !x.enabled } : x))); // optimistic try { await api.updateTool(project.id, t.id, { enabled: !t.enabled }); } catch { reload(); } } async function toggleToolInSet(setId: string, toolId: string, isMember: boolean) { try { if (isMember) await api.removeToolFromSet(project.id, setId, toolId); else await api.addToolToSet(project.id, setId, toolId); } finally { reload(); } } const memberOf = useMemo(() => { const m = new Set(); toolSets.forEach((s) => s.tool_ids.forEach((id) => m.add(id))); return m; }, [toolSets]); const ungroupedCount = useMemo(() => tools.filter((t) => !memberOf.has(t.id)).length, [tools, memberOf]); const countFor = useCallback((s: ToolSet) => { const ids = new Set(s.tool_ids); return tools.filter((t) => ids.has(t.id)).length; }, [tools]); const shown = useMemo(() => { let list = tools; if (filterSet === "ungrouped") list = tools.filter((t) => !memberOf.has(t.id)); else if (filterSet !== "all") { const ids = new Set(toolSets.find((x) => x.id === filterSet)?.tool_ids || []); list = tools.filter((t) => ids.has(t.id)); } const q = query.trim().toLowerCase(); if (q) list = list.filter((t) => t.name.toLowerCase().includes(q) || String((t.config as any)?.description || "").toLowerCase().includes(q)); // Built-ins (platform capabilities) always float to the top; stable within each group. return [...list].sort((a, b) => (a.kind === "builtin" ? 0 : 1) - (b.kind === "builtin" ? 0 : 1)); }, [tools, toolSets, filterSet, memberOf, query]); const showCheckbox = filterSet === "all" || filterSet === "ungrouped"; const headingLabel = filterSet === "all" ? "Tools" : filterSet === "ungrouped" ? "Ungrouped tools" : (toolSets.find((s) => s.id === filterSet)?.name || "Tools"); function selectFilter(key: string) { setFilterSet(key); setSelected(new Set()); } function toggleSelect(id: string) { setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); } function openManage() { setDrawerSel(filterSet !== "all" && filterSet !== "ungrouped" ? filterSet : (toolSets[0]?.id ?? null)); setDrawerOpen(true); } function openNewSet() { setDrawerSel(null); setDrawerOpen(true); } // Bulk-assign the current selection to a set (skipping tools already in it), then clear. async function addSelectedToSet(setId: string) { const already = new Set(toolSets.find((s) => s.id === setId)?.tool_ids || []); const toAdd = Array.from(selected).filter((id) => !already.has(id)); try { await Promise.all(toAdd.map((id) => api.addToolToSet(project.id, setId, id))); } finally { setSelected(new Set()); reload(); } } return (
{headingLabel}
External capabilities - REST, GraphQL, code, SQL, or builtins - with response projection.
setQuery(e.target.value)} /> t.kind !== "builtin").map((t) => ({ id: t.id, name: t.name, sub: KIND_LABEL[t.kind] || t.kind }))} />
setView(v as any)} />
{err &&
{err}
} {tools.length === 0 && !err ? (
No tools yet
Create one, or ask the Forge Assistant to build it.
) : shown.length === 0 ? (
{filterSet === "ungrouped" ? "All tools are assigned to at least one toolset." : query ? "No tools match your search." : "No tools in this set yet."}
) : view === "grid" ? (
{shown.map((t) => toggleSelect(t.id)} memberSetIds={new Set(toolSets.filter((s) => s.tool_ids.includes(t.id)).map((s) => s.id))} onToggleSet={(sid, isMember) => toggleToolInSet(sid, t.id, isMember)} onOpen={() => onOpen(t)} onDelete={(e) => del(e, t)} onDuplicate={(e) => duplicate(e, t)} onToggle={() => toggleEnabled(t)} />)}
) : (
{showCheckbox && } {shown.map((t) => { const isBuiltin = t.kind === "builtin"; return ( onOpen(t)}> {showCheckbox && } ); })}
ToolKindStatus
{ e.stopPropagation(); toggleSelect(t.id); }}>
{t.name}
{isBuiltin ? Built-in : {KIND_LABEL[t.kind] || t.kind}}
toggleEnabled(t)} /> {isBuiltin ? : }
)}
{selected.size > 0 && setSelected(new Set())} />} setOpen(false)} onOpenTool={onOpen} onReload={reload} /> setDrawerOpen(false)} onChanged={reload} />
); } /* ============ TOOLS SIDEBAR (All / Ungrouped / colour-coded toolsets) ============ */ function Checkbox({ checked }: { checked: boolean }) { return (
{checked && }
); } function SideItem({ label, count, active, onClick, alert }: { label: string; count: number; active: boolean; onClick: () => void; alert?: boolean }) { return (
{label} {alert && } {count}
); } function ToolsSidebar({ tools, toolSets, countFor, ungroupedCount, filterSet, onFilter, onNewSet, onManage }: { tools: Tool[]; toolSets: ToolSet[]; countFor: (s: ToolSet) => number; ungroupedCount: number; filterSet: string; onFilter: (k: string) => void; onNewSet: () => void; onManage: () => void }) { return (
MCP Tools
onFilter("all")} /> onFilter("ungrouped")} alert={ungroupedCount > 0} />
Toolsets
{toolSets.map((s) => ( onFilter(s.id)} /> ))} {toolSets.length === 0 &&
No toolsets yet.
}
A tool can belong to any number of toolsets - assign it from its card, bulk-select, or Manage toolsets.
); } /* Bulk-action bar (fixed, bottom-centre) shown while tools are multi-selected. */ function SelectionBar({ count, toolSets, onAddTo, onClear }: { count: number; toolSets: ToolSet[]; onAddTo: (setId: string) => void; onClear: () => void }) { const [menu, setMenu] = useState(false); const ref = useRef(null); useEffect(() => { if (!menu) return; const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setMenu(false); }; window.addEventListener("mousedown", h); return () => window.removeEventListener("mousedown", h); }, [menu]); return (
{count} selected
{menu && (
{toolSets.length === 0 &&
No toolsets yet.
} {toolSets.map((s) => ( ))}
)}
); } /* ============ MANAGE TOOLSETS (right drawer: set list + editor) ============ */ function ManageToolsetsDrawer({ project, tools, toolSets, open, initialSel, onClose, onChanged }: { project: any; tools: Tool[]; toolSets: ToolSet[]; open: boolean; initialSel: string | null; onClose: () => void; onChanged: () => void }) { const [sel, setSel] = useState(initialSel); // null = creating a new set const [name, setName] = useState(""); const [desc, setDesc] = useState(""); const [memberIds, setMemberIds] = useState([]); const [busy, setBusy] = useState(false); const [addOpen, setAddOpen] = useState(false); const addRef = useRef(null); useEffect(() => { if (open) setSel(initialSel); }, [open, initialSel]); useEffect(() => { if (sel == null) { setName(""); setDesc(""); setMemberIds([]); return; } const s = toolSets.find((x) => x.id === sel); if (s) { setName(s.name); setDesc(s.description || ""); setMemberIds(s.tool_ids); } }, [sel, toolSets]); useEffect(() => { if (!addOpen) return; const h = (e: MouseEvent) => { if (addRef.current && !addRef.current.contains(e.target as Node)) setAddOpen(false); }; window.addEventListener("mousedown", h); return () => window.removeEventListener("mousedown", h); }, [addOpen]); const saved = toolSets.find((x) => x.id === sel)?.tool_ids || []; // The list shows only tools that belong to the set - plus any member you just unchecked, // kept visible (unchecked) until you Save, at which point it drops off. Unrelated tools // are never listed here; add them from the "Add tools" picker. const visibleIds = useMemo(() => new Set([...saved, ...memberIds]), [saved, memberIds]); const memberList = tools.filter((t) => visibleIds.has(t.id)); const addable = tools.filter((t) => !visibleIds.has(t.id)); const addMember = (tid: string) => setMemberIds((m) => (m.includes(tid) ? m : [...m, tid])); const toggleMember = (tid: string) => setMemberIds((m) => (m.includes(tid) ? m.filter((x) => x !== tid) : [...m, tid])); async function save() { setBusy(true); try { if (sel == null) { const s = await api.createToolSet(project.id, { name: name.trim() || "new_toolset", description: desc, tool_ids: memberIds }); setSel(s.id); } else await api.updateToolSet(project.id, sel, { name, description: desc, tool_ids: memberIds }); onChanged(); } finally { setBusy(false); } } async function del() { if (sel == null) return; if (!window.confirm("Delete this tool set? The tools themselves are not deleted.")) return; await api.deleteToolSet(project.id, sel); setSel(null); onChanged(); } return (
Toolsets
{toolSets.map((s) => { const active = sel === s.id; return (
setSel(s.id)} className="sidenav-item" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "9px 10px", borderRadius: 8, cursor: "pointer", background: active ? "var(--accent-glow)" : undefined }}> {s.name} {s.tool_ids.length}
); })}
{/* Right editor pane: header + fields + list header stay put; only the member list scrolls, and the action row is pinned to the bottom. */}
{sel == null ? "New toolset" : name || "Toolset"}
setName(e.target.value)} placeholder="crm_tools" /> setDesc(e.target.value)} />
Tools in this set - {memberIds.length}
{addOpen && addable.length > 0 && (
{addable.map((t) => ( ))}
)}
{memberList.map((t, i) => (
toggleMember(t.id)} className="row gap2" style={{ padding: "10px 12px", borderBottom: i < memberList.length - 1 ? "1px solid var(--line)" : "none", cursor: "pointer" }}> {t.name} {KIND_LABEL[t.kind] || t.kind}
))} {memberList.length === 0 &&
No tools in this set yet — use “Add tools”.
}
{sel != null ? : }
); } function ToolCard({ t, sets, selectable, selected, onToggleSelect, memberSetIds, onToggleSet, onOpen, onDelete, onDuplicate, onToggle }: { t: Tool; sets: ToolSet[]; selectable: boolean; selected: boolean; onToggleSelect: () => void; memberSetIds: Set; onToggleSet: (setId: string, isMember: boolean) => void; onOpen: () => void; onDelete: (e: React.MouseEvent) => void; onDuplicate: (e: React.MouseEvent) => void; onToggle: () => void }) { const [statusColor, statusLabel] = STATUS(t.last_tested); const [menuOpen, setMenuOpen] = useState(false); const isBuiltin = t.kind === "builtin"; return ( // While its ••• menu is open the card is lifted above its grid siblings, so the dropdown // (which overflows the card bounds) isn't painted under the neighbouring cards.
{selectable &&
{ e.stopPropagation(); onToggleSelect(); }}>
} {isBuiltin ? Built-in : {KIND_LABEL[t.kind] || t.kind}}
{statusLabel}
{t.name}
{/* Clamp to 2 lines with a fixed height so cards stay uniform regardless of how long a tool's description is (grid rows otherwise stretch to the tallest card). */}
{(t.config as any)?.description || "No description."}
); } /* Overflow menu on each tool card - enable/disable toggle, per-set membership toggles (colour-coded to match the sidebar), duplicate, delete. Drops DOWN from the trigger, right-aligned so it never runs off-screen. */ function ToolCardMenu({ enabled, canDelete = true, sets, memberSetIds, onToggleSet, onToggle, onDuplicate, onDelete, onOpenChange }: { enabled: boolean; canDelete?: boolean; sets: ToolSet[]; memberSetIds: Set; onToggleSet: (setId: string, isMember: boolean) => void; onToggle: () => void; onDuplicate: (e: React.MouseEvent) => void; onDelete: (e: React.MouseEvent) => void; onOpenChange?: (open: boolean) => void }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]); useEffect(() => { if (!open) return; const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; window.addEventListener("mousedown", h); return () => window.removeEventListener("mousedown", h); }, [open]); const item: React.CSSProperties = { display: "flex", alignItems: "center", gap: 9, width: "100%", textAlign: "left", padding: "7px 9px", border: "none", background: "none", cursor: "pointer", borderRadius: 6, fontSize: 12.5, fontFamily: "var(--font-ui)" }; return (
e.stopPropagation()}> {open && (
{enabled ? "Enabled" : "Disabled"}
Tool sets
{/* Show only the sets this tool actually belongs to (click a row to remove it). The full catalogue would be noise on a card; add-to-set lives in the Manage toolsets drawer. */} {(() => { const memberSets = sets.filter((s) => memberSetIds.has(s.id)); return memberSets.length > 0 ? memberSets.map((s) => ( )) :
Not in any toolset
; })()}
{canDelete ? :
Built-in · protected
}
)}
); } /* ============ NEW TOOL ============ */ function NewToolModal({ project, open, onClose, onOpenTool, onReload }: { project: any; open: boolean; onClose: () => void; onOpenTool: (t: Tool) => void; onReload: () => void }) { const [kind, setKind] = useState("rest_api"); const [name, setName] = useState(""); const [displayName, setDisplayName] = useState(""); const [description, setDescription] = useState(""); const [builtin, setBuiltin] = useState("current_time"); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); useEffect(() => { if (!open) return; setKind("rest_api"); setName(""); setDisplayName(""); setDescription(""); setBuiltin("current_time"); setErr(null); }, [open]); async function create() { setBusy(true); setErr(null); try { const nm = (name || "untitled_tool").trim().replace(/\s+/g, "_"); const dn = displayName.trim(); const config: Record = kind === "rest_api" ? { description, request: { method: "GET", url_template: "https://api.example.com/resource", fields: [], headers: [{ name: "Accept", value: "application/json" }] }, response: {} } : kind === "graphql" ? { description, endpoint: "https://api.example.com/graphql", query: "query { __typename }", variables: [] } : kind === "code" ? { description, language: "python", source: "def main(text):\n return text.upper()\n", args_schema: { properties: { text: { type: "string", description: "input text" } }, required: ["text"] }, timeout_seconds: 5 } : kind === "sql" ? { description, connection_ref: "secret://proj/db_url", query: "SELECT id, name FROM customers WHERE id = :id", args_schema: { properties: { id: { type: "integer" } }, required: ["id"] }, read_only: true, max_rows: 100 } : { description, builtin }; if (dn) config.display_name = dn; const tool = await api.createTool(project.id, { name: nm, kind, config }); onClose(); onReload(); onOpenTool(tool); } catch (e: any) { setErr(String(e?.message || e)); } finally { setBusy(false); } } return ( }> { setKind(v); setErr(null); }} /> setName(e.target.value)} placeholder="get_order" /> setDisplayName(e.target.value)} placeholder="Get order" />