import { useEffect, useMemo, useRef, useState } from "react";import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide-react";import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared";import { ownerLabel, useOwnerDisplay } from "../api/owner-display";import { trackEvent } from "../api/analytics";import { toPolicyPattern } from "../lib/policy-pattern";import { Badge } from "./badge";import { Button } from "./button";import { Input } from "./input";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "./dropdown-menu";import { cn } from "../lib/utils";import { POLICY_ACTION_LABEL, POLICY_ACTIONS_IN_ORDER, POLICY_INDICATOR_COLOR, POLICY_STATE_LABEL,} from "../lib/policy-display"; // ---------------------------------------------------------------------------// Types// --------------------------------------------------------------------------- export interface ToolSummary { readonly id: string; readonly name: string; readonly description?: string; /** Resolved policy for this tool — combines user-authored rules and * plugin defaults into one answer. Always present. UI distinguishes * user vs default purely via `policy.source`. */ readonly policy: EffectivePolicy; /** Owner of the connection that produced this tool. Present only in the * account-grouped view (`groupByConnection`); ignored in the flat tree. */ readonly owner?: Owner; /** Name of the connection (account) that produced this tool. Present only in * the account-grouped view; ignored in the flat tree. */ readonly connection?: string;} // ---------------------------------------------------------------------------// Account grouping (v2) — top-level sections keyed by (owner, connection).//// The global owner toggle is retired: the Tools tab merges BOTH owners and// groups tools by the account (connection) that produced them, badging each// section with its owner (Personal / Workspace). The SAME tool name can appear// under two connections — it is NOT deduped across accounts.// --------------------------------------------------------------------------- export type AccountGroup = { /** Stable key `${owner}:${connection}`. */ readonly key: string; readonly owner: Owner; readonly connection: string; /** Section header, e.g. "Personal · axiom-mcp". */ readonly label: string; readonly tools: readonly ToolSummary[];}; /** * Partition tools into per-account sections, sorted by owner (Workspace before * Personal) then connection name. Tools missing owner/connection (the flat-tree * case) land in a single empty-keyed group so callers can still render them. * Pure — unit-testable without React. */export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => { const byKey = new Map<string, { owner: Owner; connection: string; tools: ToolSummary[] }>(); for (const tool of tools) { const owner: Owner = tool.owner ?? "org"; const connection = tool.connection ?? ""; const key = `${owner}:${connection}`; let group = byKey.get(key); if (!group) { group = { owner, connection, tools: [] }; byKey.set(key, group); } group.tools.push(tool); } // Workspace (org) sorts before Personal (user); then by connection name. const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1); return [...byKey.values()] .sort( (a, b) => ownerRank(a.owner) - ownerRank(b.owner) || a.connection.localeCompare(b.connection), ) .map((group) => ({ key: `${group.owner}:${group.connection}`, owner: group.owner, connection: group.connection, label: group.connection ? `${ownerLabel(group.owner)} · ${group.connection}` : ownerLabel(group.owner), tools: group.tools, }));}; // What the dot looks like for a given effective policy. Always-run as// a plugin default is silent (the safe state — no point cluttering every// row); everything else gets a dot. User policies are filled, plugin// defaults are hollow rings.const indicatorFor = (policy: EffectivePolicy) => { if (policy.source === "plugin-default" && policy.action === "approve") { return null; } const colors = POLICY_INDICATOR_COLOR[policy.action]; const stateLabel = POLICY_STATE_LABEL[policy.action]; const filled = policy.source === "user"; const label = policy.source === "user" ? `${stateLabel} (matched ${policy.pattern})` : `Plugin default: ${stateLabel}`; return { label, className: filled ? colors.dot : cn("bg-transparent ring-1", colors.ring), };}; type TreeNode = { segment: string; path: string; tool?: ToolSummary; children: Map<string, TreeNode>;}; type Row = | { kind: "leaf"; depth: number; path: string; tool: ToolSummary } | { kind: "group"; depth: number; path: string; segment: string; count: number; open: boolean; }; // ---------------------------------------------------------------------------// Tree builder// --------------------------------------------------------------------------- const buildTree = (tools: readonly ToolSummary[]): TreeNode => { const root: TreeNode = { segment: "", path: "", children: new Map() }; for (const tool of tools) { const parts = tool.name.split("."); let node = root; let path = ""; for (const part of parts) { path = path ? `${path}.${part}` : part; let child = node.children.get(part); if (!child) { child = { segment: part, path, children: new Map() }; node.children.set(part, child); } node = child; } node.tool = tool; } return root;}; const countLeaves = (node: TreeNode): number => { let count = node.tool ? 1 : 0; for (const child of node.children.values()) { count += countLeaves(child); } return count;}; const collectGroupPaths = (node: TreeNode, acc: Set<string>): void => { for (const child of node.children.values()) { if (child.children.size > 0) { acc.add(child.path); collectGroupPaths(child, acc); } }}; const flattenTree = ( node: TreeNode, depth: number, openSet: ReadonlySet<string>, acc: Row[],): void => { const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); for (const child of sorted) { const hasChildren = child.children.size > 0; const isLeaf = !!child.tool && !hasChildren; if (isLeaf) { acc.push({ kind: "leaf", depth, path: child.path, tool: child.tool! }); continue; } const open = openSet.has(child.path); acc.push({ kind: "group", depth, path: child.path, segment: child.segment, count: countLeaves(child), open, }); if (open) { flattenTree(child, depth + 1, openSet, acc); } }}; // ---------------------------------------------------------------------------// Highlight// --------------------------------------------------------------------------- const highlightMatch = (text: string, search: string) => { if (!search.trim()) return text; const terms = search.trim().toLowerCase().split(/\s+/); const lower = text.toLowerCase(); const ranges: [number, number][] = []; for (const term of terms) { let idx = 0; while (idx < lower.length) { const found = lower.indexOf(term, idx); if (found === -1) break; ranges.push([found, found + term.length]); idx = found + 1; } } if (ranges.length === 0) return text; ranges.sort((a, b) => a[0] - b[0]); const merged: [number, number][] = [ranges[0]!]; for (let i = 1; i < ranges.length; i++) { const last = merged[merged.length - 1]!; const cur = ranges[i]!; if (cur[0] <= last[1]) { last[1] = Math.max(last[1], cur[1]); } else { merged.push(cur); } } const parts: { text: string; hl: boolean }[] = []; let cursor = 0; for (const [start, end] of merged) { if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false }); parts.push({ text: text.slice(start, end), hl: true }); cursor = end; } if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false }); return ( <> {parts.map((p, i) => p.hl ? ( <mark key={i} className="rounded-sm bg-primary/25 px-px text-foreground"> {p.text} </mark> ) : ( <span key={i}>{p.text}</span> ), )} </> );}; // ---------------------------------------------------------------------------// ToolTree — main export// --------------------------------------------------------------------------- export function ToolTree(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; /** When provided, each row gets a hover-revealed action menu that * applies (or clears) a user policy for that exact node. Leaf rows * emit the tool's full dotted id; group rows emit `prefix.*`. */ onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; /** Maps the displayed row path into the persisted policy pattern. */ patternForDisplay?: (displayPattern: string) => string; /** Sorted user-authored policies (most-precedent first). Used to * decide whether a node has its own exact-pattern user rule today * (so the menu can show a "Clear" option). Optional — when absent, * the menu hides "Clear". */ policies?: ReadonlyArray<{ readonly pattern: string; readonly action: ToolPolicyAction }>; /** When true, render top-level sections per (owner, connection) account, each * badged with its owner (Personal / Workspace). The dotted-name tree renders * beneath each section. The same tool name can appear under two accounts (NOT * deduped). When false/unset, render one flat tree (unchanged). */ groupByConnection?: boolean;}) { const { tools, selectedToolId, onSelect, onSetPolicy, onClearPolicy, patternForDisplay = toPolicyPattern, policies, groupByConnection, } = props; const exactPatterns = useMemo(() => { if (!policies) return new Map<string, ToolPolicyAction>(); const m = new Map<string, ToolPolicyAction>(); for (const p of policies) m.set(p.pattern, p.action); return m; }, [policies]); const [search, setSearch] = useState(""); const searchRef = useRef<HTMLInputElement>(null); const selectedRowRef = useRef<HTMLButtonElement>(null); const ownerDisplay = useOwnerDisplay(); const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); const filteredTools = useMemo(() => { if (terms.length === 0) return tools; return tools.filter((t) => { const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); return terms.every((term) => corpus.includes(term)); }); }, [tools, terms]); // Account sections (only when grouping). Each carries its already-filtered // tools so empty sections drop out under an active search. const accountGroups = useMemo( () => (groupByConnection ? buildAccountGroups(filteredTools) : []), [groupByConnection, filteredTools], ); // Keyboard shortcuts — `/` focuses search, Escape clears useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { e.preventDefault(); searchRef.current?.focus(); } if (e.key === "Escape") { searchRef.current?.blur(); if (search.length > 0) setSearch(""); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [search]); // Scroll the selected row into view when it changes useEffect(() => { if (!selectedToolId) return; selectedRowRef.current?.scrollIntoView({ block: "nearest" }); }, [selectedToolId]); const bodyProps = { selectedToolId, onSelect, onSetPolicy, onClearPolicy, patternForDisplay, exactPatterns, search, terms, selectedRowRef, }; return ( <div className="flex h-full min-h-0 flex-col bg-muted/20"> <div className="flex shrink-0 items-center gap-2 px-3 py-2"> <SearchIcon aria-hidden className="size-3 shrink-0 text-muted-foreground" /> <Input ref={searchRef} value={search} onChange={(e) => setSearch(e.target.value)} placeholder={`Filter ${tools.length} tools…`} aria-label="Filter tools" className="h-auto min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-xs shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0" /> {search.length > 0 && ( <Button variant="ghost" size="icon-xs" onClick={() => setSearch("")} aria-label="Clear search" className="size-4 shrink-0 text-muted-foreground hover:text-foreground" > <XIcon className="size-3" /> </Button> )} </div> <div className="mx-2 border-t border-border/30" /> <div className="min-h-0 flex-1 overflow-y-auto"> {filteredTools.length === 0 ? ( <div className="p-4 text-center text-xs text-muted-foreground"> {terms.length > 0 ? "No tools match your filter" : "No tools available"} </div> ) : groupByConnection ? ( accountGroups.map((group) => ( <section key={group.key}> <header className="sticky top-0 z-10 flex items-center gap-2 border-b border-border/30 bg-muted/40 px-3 py-1.5 backdrop-blur-sm"> {ownerDisplay.showOwnerLabels ? ( <Badge variant="outline" className="shrink-0 text-[10px]"> {ownerLabel(group.owner)} </Badge> ) : null} <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"> {group.connection || ownerDisplay.label(group.owner)} </span> <span className="shrink-0 tabular-nums text-xs text-muted-foreground"> {group.tools.length} </span> </header> <ToolTreeBody key={group.key} tools={group.tools} {...bodyProps} /> </section> )) ) : ( <ToolTreeBody tools={filteredTools} {...bodyProps} /> )} </div> </div> );} // ---------------------------------------------------------------------------// ToolTreeBody — one dotted-name tree for a tools subset. Owns its own// expand/collapse state so account sections (grouped mode) expand independently// and never share path keys. Already-filtered tools are passed in.// --------------------------------------------------------------------------- function ToolTreeBody(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; patternForDisplay: (displayPattern: string) => string; exactPatterns: ReadonlyMap<string, ToolPolicyAction>; search: string; terms: readonly string[]; selectedRowRef: React.Ref<HTMLButtonElement>;}) { const { tools, selectedToolId, onSelect, onSetPolicy, onClearPolicy, patternForDisplay, exactPatterns, search, terms, selectedRowRef, } = props; const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set()); const tree = useMemo(() => buildTree(tools), [tools]); // When searching, expand everything so matches are visible. // Also auto-expand groups that contain the selected tool. const openSet = useMemo(() => { if (terms.length > 0) { const all = new Set<string>(); collectGroupPaths(tree, all); return all; } const set = new Set(manualOpen); if (selectedToolId) { const parts = selectedToolId.split("."); // Progressively add ancestor paths (best-effort, based on dotted name). let acc = ""; for (let i = 0; i < parts.length - 1; i++) { acc = acc ? `${acc}.${parts[i]}` : parts[i]!; set.add(acc); } } return set; }, [tree, manualOpen, selectedToolId, terms.length]); const rows = useMemo(() => { const acc: Row[] = []; flattenTree(tree, 0, openSet, acc); return acc; }, [tree, openSet]); const toggleGroup = (path: string) => { setManualOpen((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; return ( <> {rows.map((row) =>Lines 1–500 of 742. Load more
import { useEffect, useMemo, useRef, useState } from "react"; import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide-react"; import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; import { toPolicyPattern } from "../lib/policy-pattern"; import { Badge } from "./badge"; import { Button } from "./button"; import { Input } from "./input"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu"; import { cn } from "../lib/utils"; import { POLICY_ACTION_LABEL, POLICY_ACTIONS_IN_ORDER, POLICY_INDICATOR_COLOR, POLICY_STATE_LABEL, } from "../lib/policy-display"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ToolSummary { readonly id: string; readonly name: string; readonly description?: string; /** Resolved policy for this tool — combines user-authored rules and * plugin defaults into one answer. Always present. UI distinguishes * user vs default purely via `policy.source`. */ readonly policy: EffectivePolicy; /** Owner of the connection that produced this tool. Present only in the * account-grouped view (`groupByConnection`); ignored in the flat tree. */ readonly owner?: Owner; /** Name of the connection (account) that produced this tool. Present only in * the account-grouped view; ignored in the flat tree. */ readonly connection?: string; } // --------------------------------------------------------------------------- // Account grouping (v2) — top-level sections keyed by (owner, connection). // // The global owner toggle is retired: the Tools tab merges BOTH owners and // groups tools by the account (connection) that produced them, badging each // section with its owner (Personal / Workspace). The SAME tool name can appear // under two connections — it is NOT deduped across accounts. // --------------------------------------------------------------------------- export type AccountGroup = { /** Stable key `${owner}:${connection}`. */ readonly key: string; readonly owner: Owner; readonly connection: string; /** Section header, e.g. "Personal · axiom-mcp". */ readonly label: string; readonly tools: readonly ToolSummary[]; }; /** * Partition tools into per-account sections, sorted by owner (Workspace before * Personal) then connection name. Tools missing owner/connection (the flat-tree * case) land in a single empty-keyed group so callers can still render them. * Pure — unit-testable without React. */ export const buildAccountGroups = (tools: readonly ToolSummary[]): readonly AccountGroup[] => { const byKey = new Map<string, { owner: Owner; connection: string; tools: ToolSummary[] }>(); for (const tool of tools) { const owner: Owner = tool.owner ?? "org"; const connection = tool.connection ?? ""; const key = `${owner}:${connection}`; let group = byKey.get(key); if (!group) { group = { owner, connection, tools: [] }; byKey.set(key, group); } group.tools.push(tool); } // Workspace (org) sorts before Personal (user); then by connection name. const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1); return [...byKey.values()] .sort( (a, b) => ownerRank(a.owner) - ownerRank(b.owner) || a.connection.localeCompare(b.connection), ) .map((group) => ({ key: `${group.owner}:${group.connection}`, owner: group.owner, connection: group.connection, label: group.connection ? `${ownerLabel(group.owner)} · ${group.connection}` : ownerLabel(group.owner), tools: group.tools, })); }; // What the dot looks like for a given effective policy. Always-run as // a plugin default is silent (the safe state — no point cluttering every // row); everything else gets a dot. User policies are filled, plugin // defaults are hollow rings. const indicatorFor = (policy: EffectivePolicy) => { if (policy.source === "plugin-default" && policy.action === "approve") { return null; } const colors = POLICY_INDICATOR_COLOR[policy.action]; const stateLabel = POLICY_STATE_LABEL[policy.action]; const filled = policy.source === "user"; const label = policy.source === "user" ? `${stateLabel} (matched ${policy.pattern})` : `Plugin default: ${stateLabel}`; return { label, className: filled ? colors.dot : cn("bg-transparent ring-1", colors.ring), }; }; type TreeNode = { segment: string; path: string; tool?: ToolSummary; children: Map<string, TreeNode>; }; type Row = | { kind: "leaf"; depth: number; path: string; tool: ToolSummary } | { kind: "group"; depth: number; path: string; segment: string; count: number; open: boolean; }; // --------------------------------------------------------------------------- // Tree builder // --------------------------------------------------------------------------- const buildTree = (tools: readonly ToolSummary[]): TreeNode => { const root: TreeNode = { segment: "", path: "", children: new Map() }; for (const tool of tools) { const parts = tool.name.split("."); let node = root; let path = ""; for (const part of parts) { path = path ? `${path}.${part}` : part; let child = node.children.get(part); if (!child) { child = { segment: part, path, children: new Map() }; node.children.set(part, child); } node = child; } node.tool = tool; } return root; }; const countLeaves = (node: TreeNode): number => { let count = node.tool ? 1 : 0; for (const child of node.children.values()) { count += countLeaves(child); } return count; }; const collectGroupPaths = (node: TreeNode, acc: Set<string>): void => { for (const child of node.children.values()) { if (child.children.size > 0) { acc.add(child.path); collectGroupPaths(child, acc); } } }; const flattenTree = ( node: TreeNode, depth: number, openSet: ReadonlySet<string>, acc: Row[], ): void => { const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); for (const child of sorted) { const hasChildren = child.children.size > 0; const isLeaf = !!child.tool && !hasChildren; if (isLeaf) { acc.push({ kind: "leaf", depth, path: child.path, tool: child.tool! }); continue; } const open = openSet.has(child.path); acc.push({ kind: "group", depth, path: child.path, segment: child.segment, count: countLeaves(child), open, }); if (open) { flattenTree(child, depth + 1, openSet, acc); } } }; // --------------------------------------------------------------------------- // Highlight // --------------------------------------------------------------------------- const highlightMatch = (text: string, search: string) => { if (!search.trim()) return text; const terms = search.trim().toLowerCase().split(/\s+/); const lower = text.toLowerCase(); const ranges: [number, number][] = []; for (const term of terms) { let idx = 0; while (idx < lower.length) { const found = lower.indexOf(term, idx); if (found === -1) break; ranges.push([found, found + term.length]); idx = found + 1; } } if (ranges.length === 0) return text; ranges.sort((a, b) => a[0] - b[0]); const merged: [number, number][] = [ranges[0]!]; for (let i = 1; i < ranges.length; i++) { const last = merged[merged.length - 1]!; const cur = ranges[i]!; if (cur[0] <= last[1]) { last[1] = Math.max(last[1], cur[1]); } else { merged.push(cur); } } const parts: { text: string; hl: boolean }[] = []; let cursor = 0; for (const [start, end] of merged) { if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false }); parts.push({ text: text.slice(start, end), hl: true }); cursor = end; } if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false }); return ( <> {parts.map((p, i) => p.hl ? ( <mark key={i} className="rounded-sm bg-primary/25 px-px text-foreground"> {p.text} </mark> ) : ( <span key={i}>{p.text}</span> ), )} </> ); }; // --------------------------------------------------------------------------- // ToolTree — main export // --------------------------------------------------------------------------- export function ToolTree(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; /** When provided, each row gets a hover-revealed action menu that * applies (or clears) a user policy for that exact node. Leaf rows * emit the tool's full dotted id; group rows emit `prefix.*`. */ onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; /** Maps the displayed row path into the persisted policy pattern. */ patternForDisplay?: (displayPattern: string) => string; /** Sorted user-authored policies (most-precedent first). Used to * decide whether a node has its own exact-pattern user rule today * (so the menu can show a "Clear" option). Optional — when absent, * the menu hides "Clear". */ policies?: ReadonlyArray<{ readonly pattern: string; readonly action: ToolPolicyAction }>; /** When true, render top-level sections per (owner, connection) account, each * badged with its owner (Personal / Workspace). The dotted-name tree renders * beneath each section. The same tool name can appear under two accounts (NOT * deduped). When false/unset, render one flat tree (unchanged). */ groupByConnection?: boolean; }) { const { tools, selectedToolId, onSelect, onSetPolicy, onClearPolicy, patternForDisplay = toPolicyPattern, policies, groupByConnection, } = props; const exactPatterns = useMemo(() => { if (!policies) return new Map<string, ToolPolicyAction>(); const m = new Map<string, ToolPolicyAction>(); for (const p of policies) m.set(p.pattern, p.action); return m; }, [policies]); const [search, setSearch] = useState(""); const searchRef = useRef<HTMLInputElement>(null); const selectedRowRef = useRef<HTMLButtonElement>(null); const ownerDisplay = useOwnerDisplay(); const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); const filteredTools = useMemo(() => { if (terms.length === 0) return tools; return tools.filter((t) => { const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); return terms.every((term) => corpus.includes(term)); }); }, [tools, terms]); // Account sections (only when grouping). Each carries its already-filtered // tools so empty sections drop out under an active search. const accountGroups = useMemo( () => (groupByConnection ? buildAccountGroups(filteredTools) : []), [groupByConnection, filteredTools], ); // Keyboard shortcuts — `/` focuses search, Escape clears useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { e.preventDefault(); searchRef.current?.focus(); } if (e.key === "Escape") { searchRef.current?.blur(); if (search.length > 0) setSearch(""); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [search]); // Scroll the selected row into view when it changes useEffect(() => { if (!selectedToolId) return; selectedRowRef.current?.scrollIntoView({ block: "nearest" }); }, [selectedToolId]); const bodyProps = { selectedToolId, onSelect, onSetPolicy, onClearPolicy, patternForDisplay, exactPatterns, search, terms, selectedRowRef, }; return ( <div className="flex h-full min-h-0 flex-col bg-muted/20"> <div className="flex shrink-0 items-center gap-2 px-3 py-2"> <SearchIcon aria-hidden className="size-3 shrink-0 text-muted-foreground" /> <Input ref={searchRef} value={search} onChange={(e) => setSearch(e.target.value)} placeholder={`Filter ${tools.length} tools…`} aria-label="Filter tools" className="h-auto min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-xs shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0" /> {search.length > 0 && ( <Button variant="ghost" size="icon-xs" onClick={() => setSearch("")} aria-label="Clear search" className="size-4 shrink-0 text-muted-foreground hover:text-foreground" > <XIcon className="size-3" /> </Button> )} </div> <div className="mx-2 border-t border-border/30" /> <div className="min-h-0 flex-1 overflow-y-auto"> {filteredTools.length === 0 ? ( <div className="p-4 text-center text-xs text-muted-foreground"> {terms.length > 0 ? "No tools match your filter" : "No tools available"} </div> ) : groupByConnection ? ( accountGroups.map((group) => ( <section key={group.key}> <header className="sticky top-0 z-10 flex items-center gap-2 border-b border-border/30 bg-muted/40 px-3 py-1.5 backdrop-blur-sm"> {ownerDisplay.showOwnerLabels ? ( <Badge variant="outline" className="shrink-0 text-[10px]"> {ownerLabel(group.owner)} </Badge> ) : null} <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"> {group.connection || ownerDisplay.label(group.owner)} </span> <span className="shrink-0 tabular-nums text-xs text-muted-foreground"> {group.tools.length} </span> </header> <ToolTreeBody key={group.key} tools={group.tools} {...bodyProps} /> </section> )) ) : ( <ToolTreeBody tools={filteredTools} {...bodyProps} /> )} </div> </div> ); } // --------------------------------------------------------------------------- // ToolTreeBody — one dotted-name tree for a tools subset. Owns its own // expand/collapse state so account sections (grouped mode) expand independently // and never share path keys. Already-filtered tools are passed in. // --------------------------------------------------------------------------- function ToolTreeBody(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; patternForDisplay: (displayPattern: string) => string; exactPatterns: ReadonlyMap<string, ToolPolicyAction>; search: string; terms: readonly string[]; selectedRowRef: React.Ref<HTMLButtonElement>; }) { const { tools, selectedToolId, onSelect, onSetPolicy, onClearPolicy, patternForDisplay, exactPatterns, search, terms, selectedRowRef, } = props; const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set()); const tree = useMemo(() => buildTree(tools), [tools]); // When searching, expand everything so matches are visible. // Also auto-expand groups that contain the selected tool. const openSet = useMemo(() => { if (terms.length > 0) { const all = new Set<string>(); collectGroupPaths(tree, all); return all; } const set = new Set(manualOpen); if (selectedToolId) { const parts = selectedToolId.split("."); // Progressively add ancestor paths (best-effort, based on dotted name). let acc = ""; for (let i = 0; i < parts.length - 1; i++) { acc = acc ? `${acc}.${parts[i]}` : parts[i]!; set.add(acc); } } return set; }, [tree, manualOpen, selectedToolId, terms.length]); const rows = useMemo(() => { const acc: Row[] = []; flattenTree(tree, 0, openSet, acc); return acc; }, [tree, openSet]); const toggleGroup = (path: string) => { setManualOpen((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; return ( <> {rows.map((row) =>