History

Enhance web application with theme support and tool management improvements

- Added ThemeProvider to manage dark/light themes across the application. - Integrated ThemeSwitcher component into the app shell for user theme selection. - Updated ToolsPage to handle search parameters for tool sources. - Refactored ToolsView to replace legacy tool inventory with ToolExplorer for better tool management. - Improved type safety by specifying types for various components and functions. - Added new CSS variables for terminal colors and improved styling for code blocks and tool descriptions.

Rhys Sullivan committed 9 months ago

Commit f305b211 parent a1cea9616 files · +1946 −564Browse files

Unified · Split

16 files
  1. executor/apps/web/package.json +5 −2
  2. executor/apps/web/src/app/globals.css +202 −14
  3. executor/apps/web/src/app/layout.tsx +18 −20
  4. executor/apps/web/src/app/tools/page.tsx +9 −2
  5. executor/apps/web/src/components/app-shell.tsx +7 −0
  6. executor/apps/web/src/components/approvals-view.tsx +1 −1
  7. executor/apps/web/src/components/dashboard-view.tsx +135 −34
  8. executor/apps/web/src/components/members-view.tsx +5 −4
  9. executor/apps/web/src/components/tasks-view.tsx +7 −7
  10. executor/apps/web/src/components/theme-switcher.tsx +42 −0
  11. executor/apps/web/src/components/tool-explorer.tsx +1256 −0
  12. executor/apps/web/src/components/tools-view.tsx +14 −99
  13. executor/apps/web/src/lib/session-context.tsx +4 −4
  14. executor/convex/_generated/api.d.ts +53 −155
  15. executor/convex/executorNode.ts +94 −171
  16. executor/lib/tool_sources.ts +94 −51
executor/apps/web/package.json+5−2
@@ -12,10 +12,12 @@ · 11 unmodified lines
1212 "dependencies": {
1313 "@convex-dev/workos": "^0.0.1",
1414 "@elysiajs/eden": "^1.4.6",
15− "@tanstack/react-query": "^5.80.7",
16− "@workos-inc/authkit-nextjs": "^2.13.0",
1715 "@executor/contracts": "workspace:*",
1816 "@monaco-editor/react": "^4.7.0",
17+ "@streamdown/code": "^1.0.1",
18+ "@tanstack/react-query": "^5.80.7",
19+
20+ "@workos-inc/authkit-nextjs": "^2.13.0",
1921 "class-variance-authority": "^0.7.1",
2022 "clsx": "^2.1.1",
2123 "convex": "^1.31.7",
@@ -27,6 +29,7 @@ · 5 unmodified lines
2729 "react": "19.2.3",
2830 "react-dom": "19.2.3",
2931 "sonner": "^2.0.7",
32+ "streamdown": "^2.1.0",
3033 "tailwind-merge": "^3.4.0",
3134 "tldts": "^7.0.22"
3235 },
executor/apps/web/src/app/globals.css+202−14
@@ -2,6 +2,8 @@ · 1 unmodified line
22 @import "tw-animate-css";
33 @import "shadcn/tailwind.css";
44
5+@source "../../../node_modules/streamdown/dist/*.js";
6+
57 @custom-variant dark (&:is(.dark *));
68
79 @theme inline {
@@ -45,15 +47,61 @@ · 37 unmodified lines
4547 --radius-2xl: calc(var(--radius) + 8px);
4648 --radius-3xl: calc(var(--radius) + 12px);
4749 --radius-4xl: calc(var(--radius) + 16px);
48− --color-terminal-green: oklch(0.75 0.18 155);
49− --color-terminal-amber: oklch(0.78 0.16 75);
50− --color-terminal-red: oklch(0.65 0.22 25);
51− --color-terminal-cyan: oklch(0.75 0.12 200);
50+ --color-terminal-green: var(--terminal-green);
51+ --color-terminal-amber: var(--terminal-amber);
52+ --color-terminal-red: var(--terminal-red);
53+ --color-terminal-cyan: var(--terminal-cyan);
5254 --color-surface-raised: var(--card);
5355 }
5456
57+/* ── Light theme (default) ─────────────────────────────────────────────────── */
5558 :root {
5659 --radius: 0.5rem;
60+ --background: oklch(0.985 0.002 260);
61+ --foreground: oklch(0.18 0.01 260);
62+ --card: oklch(1 0 0);
63+ --card-foreground: oklch(0.18 0.01 260);
64+ --popover: oklch(1 0 0);
65+ --popover-foreground: oklch(0.18 0.01 260);
66+ --primary: oklch(0.45 0.18 155);
67+ --primary-foreground: oklch(0.98 0.005 155);
68+ --secondary: oklch(0.95 0.005 260);
69+ --secondary-foreground: oklch(0.30 0.01 260);
70+ --muted: oklch(0.94 0.005 260);
71+ --muted-foreground: oklch(0.45 0.01 260);
72+ --accent: oklch(0.94 0.008 260);
73+ --accent-foreground: oklch(0.18 0.01 260);
74+ --destructive: oklch(0.55 0.22 25);
75+ --border: oklch(0.88 0.008 260);
76+ --input: oklch(0.88 0.008 260);
77+ --ring: oklch(0.45 0.18 155);
78+ --chart-1: oklch(0.45 0.18 155);
79+ --chart-2: oklch(0.60 0.16 75);
80+ --chart-3: oklch(0.50 0.12 200);
81+ --chart-4: oklch(0.55 0.22 25);
82+ --chart-5: oklch(0.55 0.15 300);
83+ --sidebar: oklch(0.97 0.004 260);
84+ --sidebar-foreground: oklch(0.30 0.01 260);
85+ --sidebar-primary: oklch(0.45 0.18 155);
86+ --sidebar-primary-foreground: oklch(0.98 0.005 155);
87+ --sidebar-accent: oklch(0.94 0.006 260);
88+ --sidebar-accent-foreground: oklch(0.18 0.01 260);
89+ --sidebar-border: oklch(0.88 0.008 260);
90+ --sidebar-ring: oklch(0.45 0.18 155);
91+ --terminal-green: oklch(0.45 0.18 155);
92+ --terminal-amber: oklch(0.55 0.16 75);
93+ --terminal-red: oklch(0.50 0.22 25);
94+ --terminal-cyan: oklch(0.45 0.12 200);
95+
96+ /* Shiki theme variables */
97+ --shiki-light: #24292e;
98+ --shiki-light-bg: #ffffff;
99+ --shiki-dark: #e1e4e8;
100+ --shiki-dark-bg: #0d1117;
101+}
102+
103+/* ── Dark theme ────────────────────────────────────────────────────────────── */
104+.dark {
57105 --background: oklch(0.11 0.005 260);
58106 --foreground: oklch(0.88 0.01 250);
59107 --card: oklch(0.15 0.005 260);
@@ -85,6 +133,10 @@ · 25 unmodified lines
85133 --sidebar-accent-foreground: oklch(0.88 0.01 250);
86134 --sidebar-border: oklch(0.22 0.008 260);
87135 --sidebar-ring: oklch(0.75 0.18 155);
136+ --terminal-green: oklch(0.75 0.18 155);
137+ --terminal-amber: oklch(0.78 0.16 75);
138+ --terminal-red: oklch(0.65 0.22 25);
139+ --terminal-cyan: oklch(0.75 0.12 200);
88140 }
89141
90142 @layer base {
@@ -96,6 +148,31 @@ · 5 unmodified lines
96148 }
97149 }
98150
151+/* ── Shiki / Streamdown code block theme overrides ─────────────────────────── */
152+/* Light mode: use shiki-light tokens */
153+[style*="--shiki-light"] {
154+ color: var(--shiki-light) !important;
155+ background-color: transparent !important;
156+}
157+
158+/* Dark mode: swap to shiki-dark tokens */
159+.dark [style*="--shiki-dark"] {
160+ color: var(--shiki-dark) !important;
161+ background-color: transparent !important;
162+}
163+
164+.dark [style*="--shiki-light"] {
165+ color: var(--shiki-dark) !important;
166+ background-color: transparent !important;
167+}
168+
169+/* Streamdown code block container — follows theme background */
170+[data-streamdown="code-block-body"] {
171+ background-color: var(--muted) !important;
172+ border: 1px solid var(--border);
173+ border-radius: 0.375rem;
174+}
175+
99176 /* Scrollbar styling */
100177 ::-webkit-scrollbar {
101178 width: 6px;
@@ -105,17 +182,17 @@ · 3 unmodified lines
105182 background: transparent;
106183 }
107184 ::-webkit-scrollbar-thumb {
108− background: oklch(0.3 0.005 260);
185+ background: var(--border);
109186 border-radius: 3px;
110187 }
111188 ::-webkit-scrollbar-thumb:hover {
112− background: oklch(0.4 0.005 260);
189+ background: var(--muted-foreground);
113190 }
114191
115192 /* Terminal code blocks */
116193 .terminal-block {
117− background: oklch(0.09 0.005 260);
118− border: 1px solid oklch(0.2 0.008 260);
194+ background: var(--muted);
195+ border: 1px solid var(--border);
119196 font-family: var(--font-geist-mono), ui-monospace, monospace;
120197 font-size: 0.8125rem;
121198 line-height: 1.6;
@@ -140,15 +217,15 @@ · 18 unmodified lines
140217 );
141218 }
142219
143−/* Glow effects */
220+/* Glow effects — use color-mix for alpha since relative oklch has limited support */
144221 .glow-green {
145− box-shadow: 0 0 20px oklch(0.75 0.18 155 / 0.15), 0 0 60px oklch(0.75 0.18 155 / 0.05);
222+ box-shadow: 0 0 20px color-mix(in oklch, var(--terminal-green) 15%, transparent), 0 0 60px color-mix(in oklch, var(--terminal-green) 5%, transparent);
146223 }
147224 .glow-amber {
148− box-shadow: 0 0 20px oklch(0.78 0.16 75 / 0.15), 0 0 60px oklch(0.78 0.16 75 / 0.05);
225+ box-shadow: 0 0 20px color-mix(in oklch, var(--terminal-amber) 15%, transparent), 0 0 60px color-mix(in oklch, var(--terminal-amber) 5%, transparent);
149226 }
150227 .glow-red {
151− box-shadow: 0 0 20px oklch(0.65 0.22 25 / 0.15), 0 0 60px oklch(0.65 0.22 25 / 0.05);
228+ box-shadow: 0 0 20px color-mix(in oklch, var(--terminal-red) 15%, transparent), 0 0 60px color-mix(in oklch, var(--terminal-red) 5%, transparent);
152229 }
153230
154231 /* Pulse animation for pending indicators */
@@ -163,7 +240,118 @@ · 8 unmodified lines
163240 /* Subtle grid background */
164241 .grid-bg {
165242 background-image:
166− linear-gradient(oklch(0.2 0.005 260 / 0.5) 1px, transparent 1px),
167− linear-gradient(90deg, oklch(0.2 0.005 260 / 0.5) 1px, transparent 1px);
243+ linear-gradient(color-mix(in oklch, var(--border) 50%, transparent) 1px, transparent 1px),
244+ linear-gradient(90deg, color-mix(in oklch, var(--border) 50%, transparent) 1px, transparent 1px);
168245 background-size: 24px 24px;
169246 }
247+
248+/* ── Tool description (Streamdown markdown rendering) ──────────────────────── */
249+.tool-description {
250+ font-size: 0.75rem;
251+ line-height: 1.6;
252+ color: var(--muted-foreground);
253+}
254+.tool-description p {
255+ margin-bottom: 0.35em;
256+}
257+.tool-description p:last-child {
258+ margin-bottom: 0;
259+}
260+.tool-description strong,
261+.tool-description b {
262+ color: var(--foreground);
263+ font-weight: 600;
264+}
265+.tool-description code {
266+ font-family: var(--font-geist-mono), ui-monospace, monospace;
267+ font-size: 0.6875rem;
268+ background: var(--muted);
269+ border: 1px solid var(--border);
270+ border-radius: 3px;
271+ padding: 0.1em 0.35em;
272+ color: var(--terminal-cyan);
273+}
274+.tool-description pre {
275+ background: var(--muted);
276+ border: 1px solid var(--border);
277+ border-radius: 0.375rem;
278+ padding: 0.5rem 0.75rem;
279+ overflow-x: auto;
280+ margin: 0.4em 0;
281+ font-size: 0.6875rem;
282+ line-height: 1.5;
283+}
284+.tool-description pre code {
285+ background: none;
286+ border: none;
287+ padding: 0;
288+ color: inherit;
289+}
290+.tool-description a {
291+ color: var(--primary);
292+ text-decoration: underline;
293+ text-underline-offset: 2px;
294+ text-decoration-color: color-mix(in oklch, var(--primary) 30%, transparent);
295+}
296+.tool-description a:hover {
297+ text-decoration-color: color-mix(in oklch, var(--primary) 80%, transparent);
298+}
299+.tool-description ul,
300+.tool-description ol {
301+ padding-left: 1.25em;
302+ margin: 0.3em 0;
303+}
304+.tool-description li {
305+ margin-bottom: 0.15em;
306+}
307+.tool-description li::marker {
308+ color: var(--muted-foreground);
309+}
310+.tool-description table {
311+ width: 100%;
312+ border-collapse: collapse;
313+ font-size: 0.6875rem;
314+ margin: 0.5em 0;
315+}
316+.tool-description th,
317+.tool-description td {
318+ border: 1px solid var(--border);
319+ padding: 0.3em 0.5em;
320+ text-align: left;
321+}
322+.tool-description th {
323+ background: var(--muted);
324+ font-weight: 600;
325+ color: var(--foreground);
326+}
327+.tool-description td {
328+ background: var(--background);
329+}
330+.tool-description h1,
331+.tool-description h2,
332+.tool-description h3,
333+.tool-description h4 {
334+ font-weight: 600;
335+ color: var(--foreground);
336+ margin-top: 0.5em;
337+ margin-bottom: 0.25em;
338+}
339+.tool-description h1 { font-size: 0.875rem; }
340+.tool-description h2 { font-size: 0.8125rem; }
341+.tool-description h3 { font-size: 0.75rem; }
342+.tool-description h4 { font-size: 0.75rem; }
343+.tool-description blockquote {
344+ border-left: 2px solid var(--border);
345+ padding-left: 0.75em;
346+ margin: 0.4em 0;
347+ color: var(--muted-foreground);
348+}
349+.tool-description hr {
350+ border: none;
351+ border-top: 1px solid var(--border);
352+ margin: 0.5em 0;
353+}
354+.tool-description img {
355+ max-width: 100%;
356+ border-radius: 0.25rem;
357+}
executor/apps/web/src/app/layout.tsx+18−20
@@ -1,6 +1,7 @@
11 import type { Metadata } from "next";
22 import { JetBrains_Mono, Inter } from "next/font/google";
3−import { Toaster } from "sonner";
3+import { ThemeProvider } from "next-themes";
4+import { Toaster } from "@/components/ui/sonner";
45 import { AppShell } from "@/components/app-shell";
56 import { AppConvexProvider } from "@/lib/convex-provider";
67 import { QueryProvider } from "@/lib/query-provider";
@@ -28,28 +29,25 @@ export default function RootLayout({ · 21 unmodified lines
2829 children: React.ReactNode;
2930 }>) {
3031 return (
31− <html lang="en" className="dark">
32+ <html lang="en" suppressHydrationWarning>
3233 <body
3334 className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
3435 >
35− <AppConvexProvider>
36− <QueryProvider>
37− <SessionProvider>
38− <AppShell>{children}</AppShell>
39− </SessionProvider>
40− </QueryProvider>
41− </AppConvexProvider>
42− <Toaster
43− theme="dark"
44− position="bottom-right"
45− toastOptions={{
46− style: {
47− background: "oklch(0.15 0.005 260)",
48− border: "1px solid oklch(0.24 0.008 260)",
49− color: "oklch(0.88 0.01 250)",
50− },
51− }}
52− />
36+ <ThemeProvider
37+ attribute="class"
38+ defaultTheme="dark"
39+ enableSystem
40+ enableColorScheme
41+ >
42+ <AppConvexProvider>
43+ <QueryProvider>
44+ <SessionProvider>
45+ <AppShell>{children}</AppShell>
46+ </SessionProvider>
47+ </QueryProvider>
48+ </AppConvexProvider>
49+ <Toaster position="bottom-right" />
50+ </ThemeProvider>
5351 </body>
5452 </html>
5553 );
executor/apps/web/src/app/tools/page.tsx+9−2
@@ -1,5 +1,12 @@
11 import { ToolsView } from "@/components/tools-view";
22
3−export default function ToolsPage() {
4− return <ToolsView />;
3+export default async function ToolsPage({
4+ searchParams,
5+}: {
6+ searchParams: Promise<Record<string, string | string[] | undefined>>;
7+}) {
8+ const params = await searchParams;
9+ const source = typeof params.source === "string" ? params.source : null;
10+
11+ return <ToolsView initialSource={source} />;
512 }
executor/apps/web/src/components/app-shell.tsx+7−0
@@ -39,6 +39,7 @@ import { Input } from "@/components/ui/input"; · 38 unmodified lines
3939 import { useSession } from "@/lib/session-context";
4040 import { convexApi } from "@/lib/convex-api";
4141 import { workosEnabled } from "@/lib/auth-capabilities";
42+import { ThemeSwitcher } from "@/components/theme-switcher";
4243 import { cn } from "@/lib/utils";
4344
4445 const NAV_ITEMS = [
@@ -509,6 +510,9 @@ function Sidebar() { · 464 unmodified lines
509510 <div className="flex-1 overflow-y-auto py-4 px-2">
510511 <NavLinks />
511512 </div>
513+ <div className="px-3 py-2">
514+ <ThemeSwitcher />
515+ </div>
512516 <div className="pb-4">
513517 <Suspense>
514518 <SessionInfo />
@@ -540,6 +544,9 @@ function MobileHeader() { · 25 unmodified lines
540544 <div className="py-4 px-2">
541545 <NavLinks onClick={() => setOpen(false)} />
542546 </div>
547+ <div className="px-3 py-2">
548+ <ThemeSwitcher />
549+ </div>
543550 <div className="mt-auto pb-4">
544551 <Suspense>
545552 <SessionInfo />
executor/apps/web/src/components/approvals-view.tsx+1−1
@@ -181,7 +181,7 @@ export function ApprovalsView() { · 180 unmodified lines
181181 <Clock className="h-3.5 w-3.5" />
182182 {count} pending approval{count !== 1 ? "s" : ""}
183183 </div>
184− {approvals!.map((a) => (
184+ {approvals!.map((a: PendingApprovalRecord) => (
185185 <ApprovalCard key={a.id} approval={a} />
186186 ))}
187187 </div>
executor/apps/web/src/components/dashboard-view.tsx+135−34
@@ -1,5 +1,6 @@
11 "use client";
22
3+import { useMemo } from "react";
34 import { useRouter } from "next/navigation";
45 import {
56 Play,
@@ -9,6 +10,10 @@ import { · 3 unmodified lines
910 XCircle,
1011 AlertTriangle,
1112 ArrowRight,
13+ Wrench,
14+ ChevronRight,
15+ Server,
16+ Globe,
1217 } from "lucide-react";
1318 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1419 import { Button } from "@/components/ui/button";
@@ -19,7 +24,7 @@ import { useSession } from "@/lib/session-context"; · 4 unmodified lines
1924 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
2025 import { useQuery } from "convex/react";
2126 import { convexApi } from "@/lib/convex-api";
22−import type { TaskRecord, PendingApprovalRecord } from "@/lib/types";
27+import type { TaskRecord, PendingApprovalRecord, ToolDescriptor } from "@/lib/types";
2328 import { formatTime, formatTimeAgo } from "@/lib/format";
2429
2530 function StatCard({
@@ -102,6 +107,128 @@ function RecentTaskRow({ task }: { task: TaskRecord }) { · 76 unmodified lines
102107 );
103108 }
104109
110+/** Derive the "source name" from a tool's source field (e.g. "openapi:github" → "github"). */
111+function sourceLabel(source?: string): string {
112+ if (!source) return "built-in";
113+ const colonIdx = source.indexOf(":");
114+ return colonIdx >= 0 ? source.slice(colonIdx + 1) : source;
115+}
116+
117+/** Derive the source type prefix (e.g. "openapi", "mcp", "graphql"). */
118+function sourceType(source?: string): string {
119+ if (!source) return "local";
120+ const colonIdx = source.indexOf(":");
121+ return colonIdx >= 0 ? source.slice(0, colonIdx) : "local";
122+}
123+
124+function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) {
125+ const router = useRouter();
126+
127+ const groups = useMemo(() => {
128+ const map = new Map<
129+ string,
130+ {
131+ name: string;
132+ type: string;
133+ tools: ToolDescriptor[];
134+ namespaces: Set<string>;
135+ approvalCount: number;
136+ }
137+ >();
138+
139+ for (const tool of tools) {
140+ const name = sourceLabel(tool.source);
141+ const type = sourceType(tool.source);
142+ let group = map.get(name);
143+ if (!group) {
144+ group = { name, type, tools: [], namespaces: new Set(), approvalCount: 0 };
145+ map.set(name, group);
146+ }
147+ group.tools.push(tool);
148+ // Extract namespace: first two segments of the path (e.g. "github.repos" from "github.repos.list")
149+ const parts = tool.path.split(".");
150+ if (parts.length >= 2) {
151+ group.namespaces.add(`${parts[0]}.${parts[1]}`);
152+ }
153+ if (tool.approval === "required") {
154+ group.approvalCount++;
155+ }
156+ }
157+
158+ return Array.from(map.values()).sort((a, b) => b.tools.length - a.tools.length);
159+ }, [tools]);
160+
161+ const totalApprovals = tools.filter((t) => t.approval === "required").length;
162+
163+ return (
164+ <Card className="bg-card border-border">
165+ <CardHeader className="pb-3">
166+ <div className="flex items-center justify-between">
167+ <CardTitle className="text-sm font-medium flex items-center gap-2">
168+ <Wrench className="h-4 w-4 text-muted-foreground" />
169+ Tool Sources
170+ <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
171+ {tools.length} tools
172+ </span>
173+ {totalApprovals > 0 && (
174+ <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
175+ {totalApprovals} gated
176+ </span>
177+ )}
178+ </CardTitle>
179+ <Button
180+ variant="ghost"
181+ size="sm"
182+ className="text-xs h-7"
183+ onClick={() => router.push("/tools")}
184+ >
185+ Manage
186+ <ArrowRight className="h-3 w-3 ml-1" />
187+ </Button>
188+ </div>
189+ </CardHeader>
190+ <CardContent className="pt-0">
191+ <div className="grid gap-1">
192+ {groups.map((group) => {
193+ const SourceIcon = group.type === "mcp" ? Server : Globe;
194+ return (
195+ <button
196+ key={group.name}
197+ onClick={() => router.push(`/tools?source=${encodeURIComponent(group.name)}`)}
198+ className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full"
199+ >
200+ <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0">
201+ <SourceIcon className="h-3.5 w-3.5 text-muted-foreground" />
202+ </div>
203+ <div className="flex-1 min-w-0">
204+ <div className="flex items-center gap-2">
205+ <span className="text-sm font-mono font-medium text-foreground">
206+ {group.name}
207+ </span>
208+ <span className="text-[10px] font-mono text-muted-foreground/70 uppercase tracking-wider">
209+ {group.type}
210+ </span>
211+ </div>
212+ <span className="text-[11px] text-muted-foreground">
213+ {group.tools.length} tool{group.tools.length !== 1 ? "s" : ""}
214+ {group.namespaces.size > 0 && (
215+ <> · {group.namespaces.size} namespace{group.namespaces.size !== 1 ? "s" : ""}</>
216+ )}
217+ {group.approvalCount > 0 && (
218+ <> · <span className="text-terminal-amber">{group.approvalCount} gated</span></>
219+ )}
220+ </span>
221+ </div>
222+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 opacity-0 group-hover/row:opacity-100 transition-opacity shrink-0" />
223+ </button>
224+ );
225+ })}
226+ </div>
227+ </CardContent>
228+ </Card>
229+ );
230+}
231+
105232 export function DashboardView() {
106233 const { context, loading: sessionLoading } = useSession();
107234
@@ -132,11 +259,11 @@ export function DashboardView() { · 24 unmodified lines
132259
133260 const pendingCount = approvals?.length ?? 0;
134261 const runningCount =
135− tasks?.filter((t) => t.status === "running").length ?? 0;
262+ tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;
136263 const completedCount =
137− tasks?.filter((t) => t.status === "completed").length ?? 0;
264+ tasks?.filter((t: TaskRecord) => t.status === "completed").length ?? 0;
138265 const failedCount =
139− tasks?.filter((t) => ["failed", "timed_out", "denied"].includes(t.status))
266+ tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status))
140267 .length ?? 0;
141268 const recentTasks = (tasks ?? []).slice(0, 8);
142269
@@ -208,7 +335,7 @@ export function DashboardView() { · 65 unmodified lines
208335 </div>
209336 ) : (
210337 <div className="space-y-0.5">
211− {(approvals ?? []).slice(0, 5).map((a) => (
338+ {(approvals ?? []).slice(0, 5).map((a: PendingApprovalRecord) => (
212339 <PendingApprovalRow key={a.id} approval={a} />
213340 ))}
214341 </div>
@@ -241,7 +368,7 @@ export function DashboardView() { · 26 unmodified lines
241368 </div>
242369 ) : (
243370 <div className="space-y-0.5">
244− {recentTasks.map((t) => (
371+ {recentTasks.map((t: TaskRecord) => (
245372 <RecentTaskRow key={t.id} task={t} />
246373 ))}
247374 </div>
@@ -250,34 +377,8 @@ export function DashboardView() { · 2 unmodified lines
250377 </Card>
251378 </div>
252379
253− {/* Tools summary */}
254− {tools.length > 0 && (
255− <Card className="bg-card border-border">
256− <CardHeader className="pb-3">
257− <CardTitle className="text-sm font-medium flex items-center gap-2">
258− Available Tools
259− <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
260− {tools.length}
261− </span>
262− </CardTitle>
263− </CardHeader>
264− <CardContent className="pt-0">
265− <div className="flex flex-wrap gap-2">
266− {tools.map((tool) => (
267− <span
268− key={tool.path}
269− className="inline-flex items-center gap-1 text-[11px] font-mono px-2 py-1 rounded bg-muted text-muted-foreground"
270− >
271− {tool.approval === "required" && (
272− <ShieldCheck className="h-3 w-3 text-terminal-amber" />
273− )}
274− {tool.path}
275− </span>
276− ))}
277− </div>
278− </CardContent>
279− </Card>
280− )}
380+ {/* Tools summary — grouped by source */}
381+ {tools.length > 0 && <ToolsSummaryCard tools={tools} />}
281382 </div>
282383 );
283384 }
executor/apps/web/src/components/members-view.tsx+5−4
@@ -38,7 +38,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 37 unmodified lines
3838 );
3939
4040 const memberItems = members?.items ?? [];
41− const actorMembership = memberItems.find((member) =>
41+ const actorMembership = memberItems.find((member: { accountId?: string; role?: string }) =>
4242 context?.accountId ? member.accountId === context.accountId : false,
4343 );
4444 const actorRole = actorMembership?.role ?? null;
@@ -65,7 +65,8 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 20 unmodified lines
6565 const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null);
6666
6767 const inviteItems = listInvites?.items ?? [];
68− const pendingInviteItems = inviteItems.filter((invite) => invite.status === "pending" || invite.status === "failed");
68+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69+ const pendingInviteItems = inviteItems.filter((invite: any) => invite.status === "pending" || invite.status === "failed");
6970
7071 const submitInvite = async () => {
7172 if (!typedOrganizationId) {
@@ -173,7 +174,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 101 unmodified lines
173174 {memberItems.length === 0 ? (
174175 <p className="text-sm text-muted-foreground">No members found.</p>
175176 ) : (
176− memberItems.map((member) => (
177+ memberItems.map((member: any) => (
177178 <div key={member.id} className="rounded-md border border-border p-3">
178179 <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
179180 <div>
@@ -277,7 +278,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 97 unmodified lines
277278 ) : pendingInviteItems.length === 0 ? (
278279 <p className="text-sm text-muted-foreground">No pending invites.</p>
279280 ) : (
280− pendingInviteItems.map((invite) => (
281+ pendingInviteItems.map((invite: any) => (
281282 <div key={invite.id} className="rounded-md border border-border p-3 text-sm">
282283 <div className="flex items-start justify-between gap-2">
283284 <div>
executor/apps/web/src/components/tasks-view.tsx+7−7
@@ -110,7 +110,7 @@ function TaskComposer() { · 109 unmodified lines
110110 <SelectValue />
111111 </SelectTrigger>
112112 <SelectContent>
113− {(runtimes ?? []).map((r) => (
113+ {(runtimes ?? []).map((r: any) => (
114114 <SelectItem key={r.id} value={r.id} className="text-xs">
115115 {r.label}
116116 </SelectItem>
@@ -224,8 +224,8 @@ function TaskDetail({ · 107 unmodified lines
224224 const liveTask = liveTaskData ?? task;
225225 const liveStdout = useMemo(() => {
226226 const stdoutLines = (taskEvents ?? [])
227− .filter((event) => event.type === "task.stdout")
228− .map((event) => String((event.payload as Record<string, unknown>)?.line ?? ""));
227+ .filter((event: any) => event.type === "task.stdout")
228+ .map((event: any) => String((event.payload as Record<string, unknown>)?.line ?? ""));
229229 if (stdoutLines.length > 0) {
230230 return stdoutLines.join("\n");
231231 }
@@ -234,8 +234,8 @@ function TaskDetail({ · 2 unmodified lines
234234
235235 const liveStderr = useMemo(() => {
236236 const stderrLines = (taskEvents ?? [])
237− .filter((event) => event.type === "task.stderr")
238− .map((event) => String((event.payload as Record<string, unknown>)?.line ?? ""));
237+ .filter((event: any) => event.type === "task.stderr")
238+ .map((event: any) => String((event.payload as Record<string, unknown>)?.line ?? ""));
239239 if (stderrLines.length > 0) {
240240 return stderrLines.join("\n");
241241 }
@@ -357,7 +357,7 @@ export function TasksView() { · 115 unmodified lines
357357 );
358358 const tasksLoading = !!context && tasks === undefined;
359359
360− const selectedTask = tasks?.find((t) => t.id === selectedId);
360+ const selectedTask = tasks?.find((t: any) => t.id === selectedId);
361361
362362 const selectTask = useCallback(
363363 (taskId: string | null) => {
@@ -415,7 +415,7 @@ export function TasksView() { · 51 unmodified lines
415415 </div>
416416 ) : (
417417 <div className="space-y-1 max-h-[500px] overflow-y-auto">
418− {tasks.map((task) => (
418+ {tasks.map((task: any) => (
419419 <TaskListItem
420420 key={task.id}
421421 task={task}
executor/apps/web/src/components/theme-switcher.tsx+42−0
@@ -0,0 +1,42 @@
1+"use client";
2+
3+import { Monitor, Moon, Sun } from "lucide-react";
4+import { useTheme } from "next-themes";
5+import { useEffect, useState } from "react";
6+import { cn } from "@/lib/utils";
7+
8+export function ThemeSwitcher() {
9+ const { theme, setTheme } = useTheme();
10+ const [mounted, setMounted] = useState(false);
11+
12+ useEffect(() => {
13+ setMounted(true);
14+ }, []);
15+
16+ const options = [
17+ { value: "system", icon: Monitor, label: "System" },
18+ { value: "light", icon: Sun, label: "Light" },
19+ { value: "dark", icon: Moon, label: "Dark" },
20+ ] as const;
21+
22+ return (
23+ <div className="flex items-center gap-0.5 rounded-full border border-border bg-muted/50 p-0.5">
24+ {options.map((option) => (
25+ <button
26+ key={option.value}
27+ onClick={() => setTheme(option.value)}
28+ className={cn(
29+ "rounded-full p-1.5 transition-colors",
30+ mounted && theme === option.value
31+ ? "bg-background text-foreground shadow-sm"
32+ : "text-muted-foreground hover:text-foreground",
33+ )}
34+ title={option.label}
35+ >
36+ <option.icon className="h-3.5 w-3.5" />
37+ <span className="sr-only">{option.label}</span>
38+ </button>
39+ ))}
40+ </div>
41+ );
42+}
executor/apps/web/src/components/tool-explorer.tsx+1256−0
@@ -0,0 +1,1256 @@
1+"use client";
2+
3+import {
4+ useState,
5+ useMemo,
6+ useCallback,
7+ useEffect,
8+ memo,
9+} from "react";
10+import {
11+ Search,
12+ ChevronRight,
13+ ChevronDown,
14+ ShieldCheck,
15+ Zap,
16+ Server,
17+ Globe,
18+ Filter,
19+ X,
20+ FolderTree,
21+ List,
22+ Check,
23+ Copy,
24+ Layers,
25+ Loader2,
26+} from "lucide-react";
27+import { Input } from "@/components/ui/input";
28+import { Button } from "@/components/ui/button";
29+import {
30+ DropdownMenu,
31+ DropdownMenuContent,
32+ DropdownMenuCheckboxItem,
33+ DropdownMenuLabel,
34+ DropdownMenuSeparator,
35+ DropdownMenuTrigger,
36+} from "@/components/ui/dropdown-menu";
37+import {
38+ Tooltip,
39+ TooltipContent,
40+ TooltipProvider,
41+ TooltipTrigger,
42+} from "@/components/ui/tooltip";
43+import {
44+ Collapsible,
45+ CollapsibleContent,
46+ CollapsibleTrigger,
47+} from "@/components/ui/collapsible";
48+import { ScrollArea } from "@/components/ui/scroll-area";
49+import { Skeleton } from "@/components/ui/skeleton";
50+import { Streamdown } from "streamdown";
51+import { createCodePlugin } from "@streamdown/code";
52+import { cn } from "@/lib/utils";
53+import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types";
54+
55+// ── Streamdown code plugin (light + dark dual-theme) ──
56+const codePlugin = createCodePlugin({
57+ themes: ["github-light", "github-dark"],
58+});
59+
60+// ── Helpers ──
61+
62+function sourceLabel(source?: string): string {
63+ if (!source) return "built-in";
64+ const idx = source.indexOf(":");
65+ return idx >= 0 ? source.slice(idx + 1) : source;
66+}
67+
68+function sourceType(source?: string): string {
69+ if (!source) return "local";
70+ const idx = source.indexOf(":");
71+ return idx >= 0 ? source.slice(0, idx) : "local";
72+}
73+
74+function toolNamespace(path: string): string {
75+ const parts = path.split(".");
76+ if (parts.length >= 2) return parts.slice(0, -1).join(".");
77+ return parts[0];
78+}
79+
80+function toolOperation(path: string): string {
81+ const parts = path.split(".");
82+ return parts[parts.length - 1];
83+}
84+
85+// ── Types for grouped tree data ──
86+
87+interface ToolGroup {
88+ key: string;
89+ label: string;
90+ type: "source" | "namespace";
91+ sourceType?: string;
92+ childCount: number;
93+ approvalCount: number;
94+ children: ToolGroup[] | ToolDescriptor[];
95+}
96+
97+type GroupBy = "source" | "namespace" | "approval";
98+type ViewMode = "tree" | "flat";
99+
100+// ── Build grouped data structures ──
101+
102+function buildSourceTree(tools: ToolDescriptor[]): ToolGroup[] {
103+ const bySource = new Map<string, ToolDescriptor[]>();
104+ for (const tool of tools) {
105+ const src = sourceLabel(tool.source);
106+ let list = bySource.get(src);
107+ if (!list) {
108+ list = [];
109+ bySource.set(src, list);
110+ }
111+ list.push(tool);
112+ }
113+
114+ return Array.from(bySource.entries())
115+ .sort((a, b) => b[1].length - a[1].length)
116+ .map(([src, srcTools]) => {
117+ const sType = srcTools[0] ? sourceType(srcTools[0].source) : "local";
118+
119+ const byNs = new Map<string, ToolDescriptor[]>();
120+ for (const tool of srcTools) {
121+ const ns = toolNamespace(tool.path);
122+ let list = byNs.get(ns);
123+ if (!list) {
124+ list = [];
125+ byNs.set(ns, list);
126+ }
127+ list.push(tool);
128+ }
129+
130+ const nsGroups: ToolGroup[] = Array.from(byNs.entries())
131+ .sort((a, b) => a[0].localeCompare(b[0]))
132+ .map(([ns, nsTools]) => ({
133+ key: `source:${src}:ns:${ns}`,
134+ label: ns,
135+ type: "namespace" as const,
136+ childCount: nsTools.length,
137+ approvalCount: nsTools.filter((t) => t.approval === "required")
138+ .length,
139+ children: [...nsTools].sort((a, b) =>
140+ a.path.localeCompare(b.path),
141+ ),
142+ }));
143+
144+ return {
145+ key: `source:${src}`,
146+ label: src,
147+ type: "source" as const,
148+ sourceType: sType,
149+ childCount: srcTools.length,
150+ approvalCount: srcTools.filter((t) => t.approval === "required")
151+ .length,
152+ children: nsGroups,
153+ };
154+ });
155+}
156+
157+function buildNamespaceTree(tools: ToolDescriptor[]): ToolGroup[] {
158+ const byNs = new Map<string, ToolDescriptor[]>();
159+ for (const tool of tools) {
160+ const ns = toolNamespace(tool.path);
161+ let list = byNs.get(ns);
162+ if (!list) {
163+ list = [];
164+ byNs.set(ns, list);
165+ }
166+ list.push(tool);
167+ }
168+
169+ return Array.from(byNs.entries())
170+ .sort((a, b) => a[0].localeCompare(b[0]))
171+ .map(([ns, nsTools]) => ({
172+ key: `ns:${ns}`,
173+ label: ns,
174+ type: "namespace" as const,
175+ childCount: nsTools.length,
176+ approvalCount: nsTools.filter((t) => t.approval === "required").length,
177+ children: [...nsTools].sort((a, b) => a.path.localeCompare(b.path)),
178+ }));
179+}
180+
181+function buildApprovalTree(tools: ToolDescriptor[]): ToolGroup[] {
182+ const gated = tools.filter((t) => t.approval === "required");
183+ const auto = tools.filter((t) => t.approval !== "required");
184+ const groups: ToolGroup[] = [];
185+
186+ if (gated.length > 0) {
187+ groups.push({
188+ key: "approval:required",
189+ label: "Approval Required",
190+ type: "namespace",
191+ childCount: gated.length,
192+ approvalCount: gated.length,
193+ children: [...gated].sort((a, b) => a.path.localeCompare(b.path)),
194+ });
195+ }
196+ if (auto.length > 0) {
197+ groups.push({
198+ key: "approval:auto",
199+ label: "Auto-approved",
200+ type: "namespace",
201+ childCount: auto.length,
202+ approvalCount: 0,
203+ children: [...auto].sort((a, b) => a.path.localeCompare(b.path)),
204+ });
205+ }
206+
207+ return groups;
208+}
209+
210+// ── Copy button ──
211+
212+function CopyButton({
213+ text,
214+ className,
215+}: {
216+ text: string;
217+ className?: string;
218+}) {
219+ const [copied, setCopied] = useState(false);
220+
221+ const handleCopy = useCallback(
222+ (e: React.MouseEvent) => {
223+ e.stopPropagation();
224+ navigator.clipboard.writeText(text);
225+ setCopied(true);
226+ setTimeout(() => setCopied(false), 1500);
227+ },
228+ [text],
229+ );
230+
231+ return (
232+ <TooltipProvider delayDuration={200}>
233+ <Tooltip>
234+ <TooltipTrigger asChild>
235+ <button
236+ onClick={handleCopy}
237+ className={cn(
238+ "h-5 w-5 rounded flex items-center justify-center text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted transition-colors shrink-0",
239+ className,
240+ )}
241+ >
242+ {copied ? (
243+ <Check className="h-3 w-3 text-terminal-green" />
244+ ) : (
245+ <Copy className="h-3 w-3" />
246+ )}
247+ </button>
248+ </TooltipTrigger>
249+ <TooltipContent side="left" className="text-[11px]">
250+ Copy path
251+ </TooltipContent>
252+ </Tooltip>
253+ </TooltipProvider>
254+ );
255+}
256+
257+// ── Inline tool detail (shown when tool row is expanded) ──
258+
259+function ToolDetail({ tool, depth }: { tool: ToolDescriptor; depth: number }) {
260+ // Align with the tool name text: depth indent + row paddingLeft(8) + checkbox(16) + gap(8) + chevron(16) + gap(8) + zap(12) + gap(8) = 76
261+ const insetLeft = depth * 20 + 8 + 76;
262+ return (
263+ <div className="pr-2 pb-3 pt-1 space-y-2.5" style={{ paddingLeft: insetLeft }}>
264+ {/* Description rendered as markdown/HTML via Streamdown */}
265+ {tool.description && (
266+ <div className="tool-description text-[12px] text-muted-foreground leading-relaxed">
267+ <Streamdown plugins={{ code: codePlugin }}>
268+ {tool.description}
269+ </Streamdown>
270+ </div>
271+ )}
272+
273+ {/* Metadata pills */}
274+ <div className="flex flex-wrap gap-1.5">
275+ <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
276+ {tool.path}
277+ </span>
278+ {tool.source && (
279+ <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
280+ source: {tool.source}
281+ </span>
282+ )}
283+ {tool.operationId && (
284+ <span className="text-[10px] font-mono text-muted-foreground/70 bg-muted/50 px-1.5 py-0.5 rounded">
285+ op: {tool.operationId}
286+ </span>
287+ )}
288+ </div>
289+
290+ {/* Args / Returns rendered as highlighted TypeScript via Streamdown */}
291+ {tool.argsType && (
292+ <div className="tool-description">
293+ <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1">
294+ Arguments
295+ </p>
296+ <Streamdown plugins={{ code: codePlugin }}>
297+ {"```ts\n" + tool.argsType + "\n```"}
298+ </Streamdown>
299+ </div>
300+ )}
301+ {tool.returnsType && (
302+ <div className="tool-description">
303+ <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50 mb-1">
304+ Returns
305+ </p>
306+ <Streamdown plugins={{ code: codePlugin }}>
307+ {"```ts\n" + tool.returnsType + "\n```"}
308+ </Streamdown>
309+ </div>
310+ )}
311+ </div>
312+ );
313+}
314+
315+// ── Tool row — self-contained expand state, memoized ──
316+// Expand/collapse is local state so toggling one row never re-renders siblings.
317+
318+const ToolRow = memo(function ToolRow({
319+ tool,
320+ label,
321+ depth,
322+ selected,
323+ onSelect,
324+}: {
325+ tool: ToolDescriptor;
326+ label: string;
327+ depth: number;
328+ selected: boolean;
329+ onSelect: (e: React.MouseEvent) => void;
330+}) {
331+ const [expanded, setExpanded] = useState(false);
332+
333+ return (
334+ <Collapsible open={expanded} onOpenChange={setExpanded}>
335+ <CollapsibleTrigger asChild>
336+ <div
337+ className={cn(
338+ "flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors cursor-pointer group/tool select-none",
339+ expanded
340+ ? "bg-accent/30"
341+ : selected
342+ ? "bg-primary/5 ring-1 ring-primary/10"
343+ : "hover:bg-accent/20",
344+ )}
345+ style={{ paddingLeft: `${depth * 20 + 8}px` }}
346+ >
347+ {/* Selection checkbox */}
348+ <button
349+ onClick={onSelect}
350+ className={cn(
351+ "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors",
352+ selected
353+ ? "bg-primary border-primary text-primary-foreground"
354+ : "border-border hover:border-muted-foreground/50",
355+ )}
356+ >
357+ {selected && <Check className="h-2.5 w-2.5" />}
358+ </button>
359+
360+ {/* Expand chevron */}
361+ <div className="h-4 w-4 flex items-center justify-center shrink-0">
362+ {expanded ? (
363+ <ChevronDown className="h-3 w-3 text-muted-foreground/50" />
364+ ) : (
365+ <ChevronRight className="h-3 w-3 text-muted-foreground/30" />
366+ )}
367+ </div>
368+
369+ {/* Tool icon */}
370+ <Zap className="h-3 w-3 text-primary/60 shrink-0" />
371+
372+ {/* Tool name */}
373+ <span className="text-[13px] font-mono text-foreground/90 truncate">
374+ {label}
375+ </span>
376+
377+ {/* Badges */}
378+ {tool.approval === "required" && (
379+ <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0">
380+ <ShieldCheck className="h-2.5 w-2.5" />
381+ gated
382+ </span>
383+ )}
384+
385+ {/* Right side actions */}
386+ <div className="ml-auto flex items-center gap-1 shrink-0 opacity-0 group-hover/tool:opacity-100 transition-opacity">
387+ <CopyButton text={tool.path} />
388+ </div>
389+ </div>
390+ </CollapsibleTrigger>
391+
392+ <CollapsibleContent>
393+ <ToolDetail tool={tool} depth={depth} />
394+ </CollapsibleContent>
395+ </Collapsible>
396+ );
397+});
398+
399+// ── Group row with Collapsible children ──
400+
401+function GroupNode({
402+ group,
403+ depth,
404+ expandedKeys,
405+ onToggle,
406+ selectedKeys,
407+ onSelectGroup,
408+ onSelectTool,
409+ search,
410+}: {
411+ group: ToolGroup;
412+ depth: number;
413+ expandedKeys: Set<string>;
414+ onToggle: (key: string) => void;
415+ selectedKeys: Set<string>;
416+ onSelectGroup: (key: string, e: React.MouseEvent) => void;
417+ onSelectTool: (path: string, e: React.MouseEvent) => void;
418+ search: string;
419+}) {
420+ const isExpanded = expandedKeys.has(group.key);
421+ const isSource = group.type === "source";
422+ const isGroupSelected = selectedKeys.has(group.key);
423+ const SourceIcon =
424+ group.sourceType === "mcp"
425+ ? Server
426+ : group.sourceType === "graphql"
427+ ? Layers
428+ : Globe;
429+
430+ const hasNestedGroups =
431+ group.children.length > 0 && "key" in group.children[0];
432+
433+ return (
434+ <Collapsible open={isExpanded} onOpenChange={() => onToggle(group.key)}>
435+ <CollapsibleTrigger asChild>
436+ <div
437+ className={cn(
438+ "flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors cursor-pointer group/row select-none",
439+ isGroupSelected
440+ ? "bg-primary/10 ring-1 ring-primary/20"
441+ : "hover:bg-accent/30",
442+ )}
443+ style={{ paddingLeft: `${depth * 20 + 8}px` }}
444+ >
445+ {/* Selection checkbox */}
446+ <button
447+ onClick={(e) => onSelectGroup(group.key, e)}
448+ className={cn(
449+ "h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors",
450+ isGroupSelected
451+ ? "bg-primary border-primary text-primary-foreground"
452+ : "border-border hover:border-muted-foreground/50",
453+ )}
454+ >
455+ {isGroupSelected && <Check className="h-2.5 w-2.5" />}
456+ </button>
457+
458+ {/* Expand/collapse chevron */}
459+ <div className="h-4 w-4 flex items-center justify-center shrink-0">
460+ {isExpanded ? (
461+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
462+ ) : (
463+ <ChevronRight className="h-3 w-3 text-muted-foreground" />
464+ )}
465+ </div>
466+
467+ {/* Icon */}
468+ {isSource && (
469+ <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0">
470+ <SourceIcon className="h-3 w-3 text-muted-foreground" />
471+ </div>
472+ )}
473+
474+ {/* Label */}
475+ <span
476+ className={cn(
477+ "font-mono text-[13px] truncate",
478+ isSource
479+ ? "font-semibold text-foreground"
480+ : "font-medium text-foreground/90",
481+ )}
482+ >
483+ {group.label}
484+ </span>
485+
486+ {/* Metadata */}
487+ <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0">
488+ {isSource && group.sourceType && (
489+ <span className="uppercase tracking-wider opacity-70">
490+ {group.sourceType}
491+ </span>
492+ )}
493+ {group.approvalCount > 0 && (
494+ <span className="inline-flex items-center gap-0.5 text-terminal-amber">
495+ <ShieldCheck className="h-2.5 w-2.5" />
496+ {group.approvalCount}
497+ </span>
498+ )}
499+ <span className="tabular-nums">{group.childCount}</span>
500+ </span>
501+ </div>
502+ </CollapsibleTrigger>
503+
504+ <CollapsibleContent>
505+ {hasNestedGroups
506+ ? (group.children as ToolGroup[]).map((child) => (
507+ <GroupNode
508+ key={child.key}
509+ group={child}
510+ depth={depth + 1}
511+ expandedKeys={expandedKeys}
512+ onToggle={onToggle}
513+ selectedKeys={selectedKeys}
514+ onSelectGroup={onSelectGroup}
515+ onSelectTool={onSelectTool}
516+ search={search}
517+ />
518+ ))
519+ : (group.children as ToolDescriptor[]).map((tool) => (
520+ <SelectableToolRow
521+ key={tool.path}
522+ tool={tool}
523+ label={search ? tool.path : toolOperation(tool.path)}
524+ depth={depth + 1}
525+ selectedKeys={selectedKeys}
526+ onSelectTool={onSelectTool}
527+ />
528+ ))}
529+ </CollapsibleContent>
530+ </Collapsible>
531+ );
532+}
533+
534+/**
535+ * Thin adapter: extracts a boolean from the Set so ToolRow's memo sees
536+ * a primitive `selected` and skips re-render when a *different* tool changes.
537+ */
538+const SelectableToolRow = memo(function SelectableToolRow({
539+ tool,
540+ label,
541+ depth,
542+ selectedKeys,
543+ onSelectTool,
544+}: {
545+ tool: ToolDescriptor;
546+ label: string;
547+ depth: number;
548+ selectedKeys: Set<string>;
549+ onSelectTool: (path: string, e: React.MouseEvent) => void;
550+}) {
551+ const selected = selectedKeys.has(tool.path);
552+ const handleSelect = useCallback(
553+ (e: React.MouseEvent) => onSelectTool(tool.path, e),
554+ [onSelectTool, tool.path],
555+ );
556+
557+ return (
558+ <ToolRow
559+ tool={tool}
560+ label={label}
561+ depth={depth}
562+ selected={selected}
563+ onSelect={handleSelect}
564+ />
565+ );
566+},
567+(prev, next) =>
568+ prev.tool === next.tool &&
569+ prev.label === next.label &&
570+ prev.depth === next.depth &&
571+ prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path),
572+);
573+
574+// ── Sidebar (Source list) ──
575+
576+function SourceSidebar({
577+ tools,
578+ activeSource,
579+ onSelectSource,
580+}: {
581+ tools: ToolDescriptor[];
582+ activeSource: string | null;
583+ onSelectSource: (source: string | null) => void;
584+}) {
585+ const groups = useMemo(() => {
586+ const map = new Map<
587+ string,
588+ { name: string; type: string; count: number; approvalCount: number }
589+ >();
590+
591+ for (const tool of tools) {
592+ const name = sourceLabel(tool.source);
593+ const type = sourceType(tool.source);
594+ let group = map.get(name);
595+ if (!group) {
596+ group = { name, type, count: 0, approvalCount: 0 };
597+ map.set(name, group);
598+ }
599+ group.count++;
600+ if (tool.approval === "required") group.approvalCount++;
601+ }
602+
603+ return Array.from(map.values()).sort((a, b) => b.count - a.count);
604+ }, [tools]);
605+
606+ return (
607+ <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">
608+ <div className="px-3 pb-2 pt-1">
609+ <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50">
610+ Sources
611+ </p>
612+ </div>
613+ <div className="space-y-0.5 px-1">
614+ <button
615+ onClick={() => onSelectSource(null)}
616+ className={cn(
617+ "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]",
618+ activeSource === null
619+ ? "bg-accent/40 text-foreground"
620+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
621+ )}
622+ >
623+ <Layers className="h-3 w-3 shrink-0" />
624+ <span className="font-medium truncate">All sources</span>
625+ <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60">
626+ {tools.length}
627+ </span>
628+ </button>
629+
630+ {groups.map((g) => {
631+ const Icon = g.type === "mcp" ? Server : Globe;
632+ return (
633+ <button
634+ key={g.name}
635+ onClick={() => onSelectSource(g.name)}
636+ className={cn(
637+ "flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-left transition-colors text-[12px]",
638+ activeSource === g.name
639+ ? "bg-accent/40 text-foreground"
640+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
641+ )}
642+ >
643+ <Icon className="h-3 w-3 shrink-0" />
644+ <span className="font-mono font-medium truncate">{g.name}</span>
645+ <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60">
646+ {g.count}
647+ </span>
648+ </button>
649+ );
650+ })}
651+ </div>
652+ </div>
653+ );
654+}
655+
656+// ── Main Explorer ──
657+
658+interface ToolExplorerProps {
659+ tools: ToolDescriptor[];
660+ sources: ToolSourceRecord[];
661+ loading?: boolean;
662+ warnings?: string[];
663+ initialSource?: string | null;
664+}
665+
666+export function ToolExplorer({
667+ tools,
668+ sources,
669+ loading = false,
670+ warnings = [],
671+ initialSource = null,
672+}: ToolExplorerProps) {
673+ const [search, setSearch] = useState("");
674+ const [viewMode, setViewMode] = useState<ViewMode>("tree");
675+ const [groupBy, setGroupBy] = useState<GroupBy>("source");
676+ const [activeSource, setActiveSource] = useState<string | null>(
677+ initialSource,
678+ );
679+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
680+ const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
681+ const [filterApproval, setFilterApproval] = useState<
682+ "all" | "required" | "auto"
683+ >("all");
684+
685+ // When initialSource changes (e.g. URL param), update the active source
686+ useEffect(() => {
687+ if (initialSource) {
688+ setActiveSource(initialSource);
689+ }
690+ }, [initialSource]);
691+
692+ // Auto-expand source when navigating from dashboard with a source filter
693+ useEffect(() => {
694+ if (initialSource && tools.length > 0) {
695+ setExpandedKeys((prev) => {
696+ const next = new Set(prev);
697+ next.add(`source:${initialSource}`);
698+ return next;
699+ });
700+ }
701+ }, [initialSource, tools.length]);
702+
703+ // Filter tools by active source and approval filter
704+ const filteredTools = useMemo(() => {
705+ let result = tools;
706+
707+ if (activeSource) {
708+ result = result.filter(
709+ (t) => sourceLabel(t.source) === activeSource,
710+ );
711+ }
712+
713+ if (filterApproval === "required") {
714+ result = result.filter((t) => t.approval === "required");
715+ } else if (filterApproval === "auto") {
716+ result = result.filter((t) => t.approval !== "required");
717+ }
718+
719+ return result;
720+ }, [tools, activeSource, filterApproval]);
721+
722+ // Apply search filter
723+ const searchedTools = useMemo(() => {
724+ if (!search) return filteredTools;
725+ const lowerSearch = search.toLowerCase();
726+ return filteredTools.filter(
727+ (t) =>
728+ t.path.toLowerCase().includes(lowerSearch) ||
729+ t.description.toLowerCase().includes(lowerSearch),
730+ );
731+ }, [filteredTools, search]);
732+
733+ // Build grouped tree
734+ const treeGroups = useMemo(() => {
735+ if (viewMode === "flat") return [];
736+ if (groupBy === "source") return buildSourceTree(searchedTools);
737+ if (groupBy === "namespace") return buildNamespaceTree(searchedTools);
738+ return buildApprovalTree(searchedTools);
739+ }, [searchedTools, viewMode, groupBy]);
740+
741+ // Flat list
742+ const flatTools = useMemo(() => {
743+ if (viewMode !== "flat") return [];
744+ return [...searchedTools].sort((a, b) => a.path.localeCompare(b.path));
745+ }, [searchedTools, viewMode]);
746+
747+ // Auto-expand everything when searching
748+ useEffect(() => {
749+ if (search.length >= 2 && viewMode === "tree") {
750+ const allGroupKeys = new Set<string>();
751+ const lowerSearch = search.toLowerCase();
752+ const matching = filteredTools.filter(
753+ (t) =>
754+ t.path.toLowerCase().includes(lowerSearch) ||
755+ t.description.toLowerCase().includes(lowerSearch),
756+ );
757+
758+ for (const tool of matching) {
759+ const src = sourceLabel(tool.source);
760+ const ns = toolNamespace(tool.path);
761+ allGroupKeys.add(`source:${src}`);
762+ allGroupKeys.add(`source:${src}:ns:${ns}`);
763+ allGroupKeys.add(`ns:${ns}`);
764+ }
765+
766+ setExpandedKeys(allGroupKeys);
767+ }
768+ }, [search, filteredTools, viewMode]);
769+
770+ const toggleExpand = useCallback((key: string) => {
771+ setExpandedKeys((prev) => {
772+ const next = new Set(prev);
773+ if (next.has(key)) {
774+ next.delete(key);
775+ } else {
776+ next.add(key);
777+ }
778+ return next;
779+ });
780+ }, []);
781+
782+ const toggleSelectTool = useCallback(
783+ (path: string, e: React.MouseEvent) => {
784+ e.stopPropagation();
785+ e.preventDefault();
786+ setSelectedKeys((prev) => {
787+ const next = new Set(prev);
788+ if (next.has(path)) {
789+ next.delete(path);
790+ } else {
791+ next.add(path);
792+ }
793+ return next;
794+ });
795+ },
796+ [],
797+ );
798+
799+ const toggleSelectGroup = useCallback(
800+ (key: string, e: React.MouseEvent) => {
801+ e.stopPropagation();
802+ e.preventDefault();
803+ setSelectedKeys((prev) => {
804+ const next = new Set(prev);
805+
806+ const findToolsInGroup = (
807+ groups: ToolGroup[],
808+ ): ToolDescriptor[] => {
809+ const result: ToolDescriptor[] = [];
810+ for (const g of groups) {
811+ if (g.key === key) {
812+ const collectTools = (group: ToolGroup): void => {
813+ if (
814+ group.children.length > 0 &&
815+ "key" in group.children[0]
816+ ) {
817+ for (const child of group.children as ToolGroup[]) {
818+ collectTools(child);
819+ }
820+ } else {
821+ result.push(
822+ ...(group.children as ToolDescriptor[]),
823+ );
824+ }
825+ };
826+ collectTools(g);
827+ return result;
828+ }
829+ if (
830+ g.children.length > 0 &&
831+ "key" in g.children[0]
832+ ) {
833+ const found = findToolsInGroup(
834+ g.children as ToolGroup[],
835+ );
836+ if (found.length > 0) return found;
837+ }
838+ }
839+ return result;
840+ };
841+
842+ const childTools = findToolsInGroup(treeGroups);
843+ const allSelected =
844+ childTools.length > 0 &&
845+ childTools.every((t) => prev.has(t.path));
846+
847+ if (allSelected) {
848+ for (const t of childTools) next.delete(t.path);
849+ next.delete(key);
850+ } else {
851+ for (const t of childTools) next.add(t.path);
852+ next.add(key);
853+ }
854+ return next;
855+ });
856+ },
857+ [treeGroups],
858+ );
859+
860+ const clearSelection = useCallback(() => {
861+ setSelectedKeys(new Set());
862+ }, []);
863+
864+ const selectAll = useCallback(() => {
865+ setSelectedKeys(new Set(filteredTools.map((t) => t.path)));
866+ }, [filteredTools]);
867+
868+ const selectedToolCount = useMemo(() => {
869+ return Array.from(selectedKeys).filter((k) =>
870+ filteredTools.some((t) => t.path === k),
871+ ).length;
872+ }, [selectedKeys, filteredTools]);
873+
874+ const collectAllGroupKeys = useCallback(
875+ (groups: ToolGroup[]): Set<string> => {
876+ const keys = new Set<string>();
877+ for (const g of groups) {
878+ keys.add(g.key);
879+ if (g.children.length > 0 && "key" in g.children[0]) {
880+ for (const k of collectAllGroupKeys(
881+ g.children as ToolGroup[],
882+ )) {
883+ keys.add(k);
884+ }
885+ }
886+ }
887+ return keys;
888+ },
889+ [],
890+ );
891+
892+ if (loading) {
893+ const enabledSources = sources.filter((s) => s.enabled);
894+ return (
895+ <div className="space-y-3">
896+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
897+ <Loader2 className="h-4 w-4 animate-spin" />
898+ Loading {enabledSources.length > 0 ? `${enabledSources.length} tool source${enabledSources.length === 1 ? "" : "s"}` : "tools"}…
899+ </div>
900+ {enabledSources.length > 0 && (
901+ <div className="space-y-1">
902+ {enabledSources.map((s) => (
903+ <div
904+ key={s.id}
905+ className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
906+ >
907+ <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
908+ <span className="font-medium">{s.name}</span>
909+ <span className="text-muted-foreground">{s.type}</span>
910+ </div>
911+ ))}
912+ </div>
913+ )}
914+ </div>
915+ );
916+ }
917+
918+ return (
919+ <div className="flex h-[calc(100vh-220px)] min-h-[400px]">
920+ {/* Source sidebar */}
921+ <SourceSidebar
922+ tools={tools}
923+ activeSource={activeSource}
924+ onSelectSource={setActiveSource}
925+ />
926+
927+ {/* Main content */}
928+ <div className="flex-1 min-w-0 flex flex-col pl-0 lg:pl-4">
929+ {/* Toolbar */}
930+ <div className="flex items-center gap-2 pb-3 flex-wrap">
931+ {/* Search */}
932+ <div className="relative flex-1 min-w-[200px]">
933+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
934+ <Input
935+ value={search}
936+ onChange={(e) => setSearch(e.target.value)}
937+ placeholder={`Search ${filteredTools.length} tools...`}
938+ className="h-8 text-xs pl-8 bg-background/50 border-border/50 focus:border-primary/30"
939+ />
940+ {search && (
941+ <button
942+ onClick={() => setSearch("")}
943+ className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded flex items-center justify-center text-muted-foreground hover:text-foreground"
944+ >
945+ <X className="h-3 w-3" />
946+ </button>
947+ )}
948+ </div>
949+
950+ {/* View mode toggle */}
951+ <div className="flex h-8 rounded-md border border-border/50 overflow-hidden">
952+ <button
953+ onClick={() => setViewMode("tree")}
954+ className={cn(
955+ "px-2 flex items-center gap-1 text-[11px] transition-colors",
956+ viewMode === "tree"
957+ ? "bg-accent/40 text-foreground"
958+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
959+ )}
960+ >
961+ <FolderTree className="h-3 w-3" />
962+ <span className="hidden sm:inline">Tree</span>
963+ </button>
964+ <button
965+ onClick={() => setViewMode("flat")}
966+ className={cn(
967+ "px-2 flex items-center gap-1 text-[11px] transition-colors border-l border-border/50",
968+ viewMode === "flat"
969+ ? "bg-accent/40 text-foreground"
970+ : "text-muted-foreground hover:text-foreground hover:bg-accent/20",
971+ )}
972+ >
973+ <List className="h-3 w-3" />
974+ <span className="hidden sm:inline">Flat</span>
975+ </button>
976+ </div>
977+
978+ {/* Group by (only in tree mode) */}
979+ {viewMode === "tree" && (
980+ <DropdownMenu>
981+ <DropdownMenuTrigger asChild>
982+ <Button
983+ variant="outline"
984+ size="sm"
985+ className="h-8 text-[11px] border-border/50"
986+ >
987+ <FolderTree className="h-3 w-3 mr-1" />
988+ {groupBy === "source"
989+ ? "By source"
990+ : groupBy === "namespace"
991+ ? "By namespace"
992+ : "By approval"}
993+ </Button>
994+ </DropdownMenuTrigger>
995+ <DropdownMenuContent align="end">
996+ <DropdownMenuLabel className="text-[11px]">
997+ Group by
998+ </DropdownMenuLabel>
999+ <DropdownMenuSeparator />
1000+ <DropdownMenuCheckboxItem
1001+ checked={groupBy === "source"}
1002+ onCheckedChange={() => setGroupBy("source")}
1003+ className="text-xs"
1004+ >
1005+ Source → Namespace
1006+ </DropdownMenuCheckboxItem>
1007+ <DropdownMenuCheckboxItem
1008+ checked={groupBy === "namespace"}
1009+ onCheckedChange={() => setGroupBy("namespace")}
1010+ className="text-xs"
1011+ >
1012+ Namespace
1013+ </DropdownMenuCheckboxItem>
1014+ <DropdownMenuCheckboxItem
1015+ checked={groupBy === "approval"}
1016+ onCheckedChange={() => setGroupBy("approval")}
1017+ className="text-xs"
1018+ >
1019+ Approval mode
1020+ </DropdownMenuCheckboxItem>
1021+ </DropdownMenuContent>
1022+ </DropdownMenu>
1023+ )}
1024+
1025+ {/* Filter */}
1026+ <DropdownMenu>
1027+ <DropdownMenuTrigger asChild>
1028+ <Button
1029+ variant="outline"
1030+ size="sm"
1031+ className={cn(
1032+ "h-8 text-[11px] border-border/50",
1033+ filterApproval !== "all" &&
1034+ "border-primary/30 text-primary",
1035+ )}
1036+ >
1037+ <Filter className="h-3 w-3 mr-1" />
1038+ {filterApproval === "all"
1039+ ? "Filter"
1040+ : filterApproval === "required"
1041+ ? "Gated only"
1042+ : "Auto only"}
1043+ </Button>
1044+ </DropdownMenuTrigger>
1045+ <DropdownMenuContent align="end">
1046+ <DropdownMenuLabel className="text-[11px]">
1047+ Approval filter
1048+ </DropdownMenuLabel>
1049+ <DropdownMenuSeparator />
1050+ <DropdownMenuCheckboxItem
1051+ checked={filterApproval === "all"}
1052+ onCheckedChange={() => setFilterApproval("all")}
1053+ className="text-xs"
1054+ >
1055+ All tools
1056+ </DropdownMenuCheckboxItem>
1057+ <DropdownMenuCheckboxItem
1058+ checked={filterApproval === "required"}
1059+ onCheckedChange={() => setFilterApproval("required")}
1060+ className="text-xs"
1061+ >
1062+ Approval required
1063+ </DropdownMenuCheckboxItem>
1064+ <DropdownMenuCheckboxItem
1065+ checked={filterApproval === "auto"}
1066+ onCheckedChange={() => setFilterApproval("auto")}
1067+ className="text-xs"
1068+ >
1069+ Auto-approved
1070+ </DropdownMenuCheckboxItem>
1071+ </DropdownMenuContent>
1072+ </DropdownMenu>
1073+
1074+ {/* Mobile source selector */}
1075+ <DropdownMenu>
1076+ <DropdownMenuTrigger asChild>
1077+ <Button
1078+ variant="outline"
1079+ size="sm"
1080+ className={cn(
1081+ "h-8 text-[11px] border-border/50 lg:hidden",
1082+ activeSource && "border-primary/30 text-primary",
1083+ )}
1084+ >
1085+ <Server className="h-3 w-3 mr-1" />
1086+ {activeSource ?? "All"}
1087+ </Button>
1088+ </DropdownMenuTrigger>
1089+ <DropdownMenuContent align="end">
1090+ <DropdownMenuLabel className="text-[11px]">
1091+ Source
1092+ </DropdownMenuLabel>
1093+ <DropdownMenuSeparator />
1094+ <DropdownMenuCheckboxItem
1095+ checked={activeSource === null}
1096+ onCheckedChange={() => setActiveSource(null)}
1097+ className="text-xs"
1098+ >
1099+ All sources
1100+ </DropdownMenuCheckboxItem>
1101+ {Array.from(
1102+ new Set(tools.map((t) => sourceLabel(t.source))),
1103+ ).map((src) => (
1104+ <DropdownMenuCheckboxItem
1105+ key={src}
1106+ checked={activeSource === src}
1107+ onCheckedChange={() =>
1108+ setActiveSource(
1109+ activeSource === src ? null : src,
1110+ )
1111+ }
1112+ className="text-xs font-mono"
1113+ >
1114+ {src}
1115+ </DropdownMenuCheckboxItem>
1116+ ))}
1117+ </DropdownMenuContent>
1118+ </DropdownMenu>
1119+ </div>
1120+
1121+ {/* Selection bar */}
1122+ {selectedToolCount > 0 && (
1123+ <div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-md bg-primary/5 border border-primary/10">
1124+ <span className="text-[12px] font-mono text-primary">
1125+ {selectedToolCount} tool
1126+ {selectedToolCount !== 1 ? "s" : ""} selected
1127+ </span>
1128+ <div className="flex-1" />
1129+ <Button
1130+ variant="ghost"
1131+ size="sm"
1132+ className="h-6 text-[11px] text-muted-foreground"
1133+ onClick={selectAll}
1134+ >
1135+ Select all ({filteredTools.length})
1136+ </Button>
1137+ <Button
1138+ variant="ghost"
1139+ size="sm"
1140+ className="h-6 text-[11px] text-muted-foreground"
1141+ onClick={clearSelection}
1142+ >
1143+ Clear
1144+ </Button>
1145+ <div className="h-4 w-px bg-border/50" />
1146+ <Button
1147+ variant="outline"
1148+ size="sm"
1149+ className="h-6 text-[11px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10"
1150+ >
1151+ <ShieldCheck className="h-3 w-3 mr-1" />
1152+ Set approval
1153+ </Button>
1154+ </div>
1155+ )}
1156+
1157+ {/* Warnings */}
1158+ {warnings.length > 0 && (
1159+ <div className="mb-2 rounded-md border border-terminal-amber/20 bg-terminal-amber/5 px-3 py-2">
1160+ <p className="text-[11px] text-terminal-amber/80">
1161+ {warnings.length} source warning
1162+ {warnings.length !== 1 ? "s" : ""} — inventory may be
1163+ incomplete
1164+ </p>
1165+ </div>
1166+ )}
1167+
1168+ {/* Results info */}
1169+ <div className="flex items-center justify-between pb-1.5">
1170+ <span className="text-[10px] font-mono text-muted-foreground/50">
1171+ {search
1172+ ? `${searchedTools.length} results`
1173+ : `${filteredTools.length} tools`}
1174+ {activeSource && ` in ${activeSource}`}
1175+ </span>
1176+ {viewMode === "tree" && (
1177+ <div className="flex gap-1">
1178+ <button
1179+ onClick={() => {
1180+ setExpandedKeys(collectAllGroupKeys(treeGroups));
1181+ }}
1182+ className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
1183+ >
1184+ Expand all
1185+ </button>
1186+ <span className="text-[10px] text-muted-foreground/30">
1187+ ·
1188+ </span>
1189+ <button
1190+ onClick={() => setExpandedKeys(new Set())}
1191+ className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
1192+ >
1193+ Collapse all
1194+ </button>
1195+ </div>
1196+ )}
1197+ </div>
1198+
1199+ {/* Scrollable list */}
1200+ <ScrollArea className="flex-1 rounded-md border border-border/30 bg-background/30">
1201+ {viewMode === "flat" ? (
1202+ flatTools.length === 0 ? (
1203+ <EmptyState hasSearch={!!search} />
1204+ ) : (
1205+ <div className="p-1">
1206+ {flatTools.map((tool) => (
1207+ <SelectableToolRow
1208+ key={tool.path}
1209+ tool={tool}
1210+ label={tool.path}
1211+ depth={0}
1212+ selectedKeys={selectedKeys}
1213+ onSelectTool={toggleSelectTool}
1214+ />
1215+ ))}
1216+ </div>
1217+ )
1218+ ) : treeGroups.length === 0 ? (
1219+ <EmptyState hasSearch={!!search} />
1220+ ) : (
1221+ <div className="p-1">
1222+ {treeGroups.map((group) => (
1223+ <GroupNode
1224+ key={group.key}
1225+ group={group}
1226+ depth={0}
1227+ expandedKeys={expandedKeys}
1228+ onToggle={toggleExpand}
1229+ selectedKeys={selectedKeys}
1230+ onSelectGroup={toggleSelectGroup}
1231+ onSelectTool={toggleSelectTool}
1232+ search={search}
1233+ />
1234+ ))}
1235+ </div>
1236+ )}
1237+ </ScrollArea>
1238+ </div>
1239+ </div>
1240+ );
1241+}
1242+
1243+// ── Empty state ──
1244+
1245+function EmptyState({ hasSearch }: { hasSearch: boolean }) {
1246+ return (
1247+ <div className="flex flex-col items-center justify-center h-full py-12 gap-2">
1248+ <div className="h-10 w-10 rounded-full bg-muted/50 flex items-center justify-center">
1249+ <Search className="h-5 w-5 text-muted-foreground/30" />
1250+ </div>
1251+ <p className="text-sm text-muted-foreground/60">
1252+ {hasSearch ? "No tools match your search" : "No tools available"}
1253+ </p>
1254+ </div>
1255+ );
1256+}
executor/apps/web/src/components/tools-view.tsx+14−99
@@ -8,8 +8,6 @@ import { · 7 unmodified lines
88 ShieldCheck,
99 Globe,
1010 Server,
11− Zap,
12− Search,
1311 ChevronRight,
1412 AlertTriangle,
1513 KeyRound,
@@ -45,6 +43,7 @@ import { Skeleton } from "@/components/ui/skeleton"; · 29 unmodified lines
4543 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4644 import { PageHeader } from "@/components/page-header";
4745 import { McpSetupCard } from "@/components/mcp-setup-card";
46+import { ToolExplorer } from "@/components/tool-explorer";
4847 import { useSession } from "@/lib/session-context";
4948 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
5049 import { useAction, useMutation, useQuery } from "convex/react";
@@ -1331,72 +1330,11 @@ function CredentialsPanel({ · 1280 unmodified lines
13311330 );
13321331 }
13331332
1334−// ── Tool Inventory ──
1335−
1336−function ToolInventory({ tools }: { tools: ToolDescriptor[] }) {
1337− const [search, setSearch] = useState("");
1338−
1339− const filtered = tools.filter(
1340− (t) =>
1341− t.path.toLowerCase().includes(search.toLowerCase()) ||
1342− t.description.toLowerCase().includes(search.toLowerCase()),
1343− );
1344−
1345− return (
1346− <div className="space-y-3">
1347− <div className="relative">
1348− <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
1349− <Input
1350− value={search}
1351− onChange={(e) => setSearch(e.target.value)}
1352− placeholder="Search tools..."
1353− className="h-8 text-xs pl-8 bg-background"
1354− />
1355− </div>
1356− <div className="space-y-1 max-h-[500px] overflow-y-auto">
1357− {filtered.length === 0 ? (
1358− <div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
1359− {search ? "No tools match your search" : "No tools available"}
1360− </div>
1361− ) : (
1362− filtered.map((tool) => (
1363− <div
1364− key={tool.path}
1365− className="flex items-start gap-3 px-3 py-2.5 rounded-md hover:bg-accent/30 transition-colors"
1366− >
1367− <Zap className="h-3.5 w-3.5 mt-0.5 text-primary shrink-0" />
1368− <div className="flex-1 min-w-0">
1369− <div className="flex items-center gap-2 flex-wrap">
1370− <span className="text-sm font-mono font-medium">
1371− {tool.path}
1372− </span>
1373− {tool.approval === "required" && (
1374− <span className="inline-flex items-center gap-1 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/10 px-1.5 py-0.5 rounded border border-terminal-amber/20">
1375− <ShieldCheck className="h-2.5 w-2.5" />
1376− approval
1377− </span>
1378− )}
1379− {tool.source && (
1380− <span className="text-[9px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1381− {tool.source}
1382− </span>
1383− )}
1384− </div>
1385− <p className="text-[11px] text-muted-foreground mt-0.5 leading-relaxed">
1386− {tool.description}
1387− </p>
1388− </div>
1389− </div>
1390− ))
1391− )}
1392− </div>
1393− </div>
1394− );
1395−}
1333+// ── Tool Inventory (legacy removed — replaced by ToolExplorer) ──
13961334
13971335 // ── Tools View ──
13981336
1399−export function ToolsView() {
1337+export function ToolsView({ initialSource }: { initialSource?: string | null }) {
14001338 const { context, loading: sessionLoading } = useSession();
14011339
14021340 const sources = useQuery(
@@ -1429,7 +1367,7 @@ export function ToolsView() { · 26 unmodified lines
14291367 description="Manage sources, auth, credentials, and available tools"
14301368 />
14311369
1432− <Tabs defaultValue="sources" className="w-full">
1370+ <Tabs defaultValue={initialSource ? "inventory" : "sources"} className="w-full">
14331371 <TabsList className="bg-muted/50 h-9">
14341372 <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background">
14351373 Sources
@@ -1466,7 +1404,7 @@ export function ToolsView() { · 30 unmodified lines
14661404 <Server className="h-4 w-4 text-muted-foreground" />
14671405 Tool Sources
14681406 </CardTitle>
1469− <AddSourceDialog existingSourceNames={new Set((sources ?? []).map(s => s.name))} />
1407+ <AddSourceDialog existingSourceNames={new Set((sources ?? []).map((s: any) => s.name))} />
14701408 </div>
14711409 </CardHeader>
14721410 <CardContent className="pt-0">
@@ -1497,7 +1435,7 @@ export function ToolsView() { · 24 unmodified lines
14971435 Source load warnings ({warnings.length})
14981436 </div>
14991437 <div className="mt-1.5 space-y-1">
1500− {warnings.slice(0, 3).map((warning, i) => (
1438+ {warnings.slice(0, 3).map((warning: any, i: any) => (
15011439 <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90">
15021440 {warning}
15031441 </p>
@@ -1510,7 +1448,7 @@ export function ToolsView() { · 6 unmodified lines
15101448 </div>
15111449 </div>
15121450 )}
1513− {sources.map((s) => (
1451+ {sources.map((s: any) => (
15141452 <SourceCard key={s.id} source={s} />
15151453 ))}
15161454 </div>
@@ -1528,36 +1466,13 @@ export function ToolsView() { · 11 unmodified lines
15281466 </TabsContent>
15291467
15301468 <TabsContent value="inventory" className="mt-4">
1531− <Card className="bg-card border-border">
1532− <CardHeader className="pb-3">
1533− <CardTitle className="text-sm font-medium flex items-center gap-2">
1534− <Zap className="h-4 w-4 text-primary" />
1535− Available Tools
1536− <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
1537− {toolsLoading ? "…" : tools.length}
1538− </span>
1539− </CardTitle>
1540− </CardHeader>
1541− <CardContent className="pt-0">
1542− {warnings.length > 0 && !toolsLoading && (
1543− <div className="mb-3 rounded-md border border-terminal-amber/30 bg-terminal-amber/10 px-3 py-2.5">
1544− <div className="flex items-center gap-1.5 text-[11px] font-medium text-terminal-amber">
1545− <AlertTriangle className="h-3.5 w-3.5" />
1546− Some sources failed to load; inventory may be incomplete
1547− </div>
1548− </div>
1549− )}
1550− {toolsLoading ? (
1551− <div className="space-y-2">
1552− {Array.from({ length: 5 }).map((_, i) => (
1553− <Skeleton key={i} className="h-14" />
1554− ))}
1555− </div>
1556− ) : (
1557− <ToolInventory tools={tools} />
1558− )}
1559− </CardContent>
1560− </Card>
1469+ <ToolExplorer
1470+ tools={tools}
1471+ sources={sources ?? []}
1472+ loading={toolsLoading}
1473+ warnings={warnings}
1474+ initialSource={initialSource}
1475+ />
15611476 </TabsContent>
15621477
15631478 <TabsContent value="mcp" className="mt-4">
executor/apps/web/src/lib/session-context.tsx+4−4
@@ -158,13 +158,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 157 unmodified lines
158158 return activeWorkspaceId;
159159 }
160160
161− if (activeWorkspaceId && workspaces.some((workspace) => workspace.id === activeWorkspaceId)) {
161+ if (activeWorkspaceId && workspaces.some((workspace: any) => workspace.id === activeWorkspaceId)) {
162162 return activeWorkspaceId;
163163 }
164164
165165 const accountId = account?.provider === "workos" ? account._id : null;
166166 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;
167− if (accountStoredWorkspace && workspaces.some((workspace) => workspace.id === accountStoredWorkspace)) {
167+ if (accountStoredWorkspace && workspaces.some((workspace: any) => workspace.id === accountStoredWorkspace)) {
168168 return accountStoredWorkspace;
169169 }
170170
@@ -265,7 +265,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 94 unmodified lines
265265 }
266266
267267 const activeWorkspace =
268− workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId)
268+ workspaces.find((workspace: any) => workspace.id === resolvedActiveWorkspaceId)
269269 ?? workspaces[0]
270270 ?? null;
271271 if (!activeWorkspace) {
@@ -318,7 +318,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 46 unmodified lines
318318 );
319319 const workspaceOptions = useMemo(() => {
320320 if (mode === "workos" && workspaces) {
321− return workspaces.map((workspace): SessionState["workspaces"][number] => ({
321+ return workspaces.map((workspace: any): SessionState["workspaces"][number] => ({
322322 id: workspace.id,
323323 docId: workspace.id,
324324 name: workspace.name,
executor/convex/_generated/api.d.ts+53−155
@@ -5,15 +5,54 @@ · 4 unmodified lines
55 * THIS CODE IS AUTOMATICALLY GENERATED.
66 *
77 * To regenerate, run `npx convex dev`.
8− *
9− * NOTE: This file has been post-processed by scripts/postcodegen.ts
10− * to replace recursive ApiFromModules/FilterApi types with explicit
11− * declarations, avoiding TS2589 depth errors.
12− *
138 * @module
149 */
1510
16−import type { FunctionReference } from "convex/server";
11+import type * as app from "../app.js";
12+import type * as auth from "../auth.js";
13+import type * as billing from "../billing.js";
14+import type * as billingInternal from "../billingInternal.js";
15+import type * as billingSync from "../billingSync.js";
16+import type * as credentialsNode from "../credentialsNode.js";
17+import type * as database from "../database.js";
18+import type * as executor from "../executor.js";
19+import type * as executorNode from "../executorNode.js";
20+import type * as http from "../http.js";
21+import type * as invites from "../invites.js";
22+import type * as openApiSpecCache from "../openApiSpecCache.js";
23+import type * as organizationMembers from "../organizationMembers.js";
24+import type * as organizations from "../organizations.js";
25+import type * as workspace from "../workspace.js";
26+import type * as workspaceAuthInternal from "../workspaceAuthInternal.js";
27+import type * as workspaceToolCache from "../workspaceToolCache.js";
28+import type * as workspaces from "../workspaces.js";
29+
30+import type {
31+ ApiFromModules,
32+ FilterApi,
33+ FunctionReference,
34+} from "convex/server";
35+
36+declare const fullApi: ApiFromModules<{
37+ app: typeof app;
38+ auth: typeof auth;
39+ billing: typeof billing;
40+ billingInternal: typeof billingInternal;
41+ billingSync: typeof billingSync;
42+ credentialsNode: typeof credentialsNode;
43+ database: typeof database;
44+ executor: typeof executor;
45+ executorNode: typeof executorNode;
46+ http: typeof http;
47+ invites: typeof invites;
48+ openApiSpecCache: typeof openApiSpecCache;
49+ organizationMembers: typeof organizationMembers;
50+ organizations: typeof organizations;
51+ workspace: typeof workspace;
52+ workspaceAuthInternal: typeof workspaceAuthInternal;
53+ workspaceToolCache: typeof workspaceToolCache;
54+ workspaces: typeof workspaces;
55+}>;
1756
1857 /**
1958 * A utility for referencing Convex functions in your app's public API.
@@ -23,74 +62,10 @@ import type { FunctionReference } from "convex/server"; · 3 unmodified lines
2362 * const myFunctionReference = api.myModule.myFunction;
2463 * ```
2564 */
26−export declare const api: {
27− app: {
28− getClientConfig: FunctionReference<"query", "public", any, any>;
29− getCurrentAccount: FunctionReference<"query", "public", any, any>;
30− };
31− auth: {
32− bootstrapCurrentWorkosAccount: FunctionReference<"mutation", "public", any, any>;
33− };
34− billing: {
35− getSummary: FunctionReference<"query", "public", any, any>;
36− createSubscriptionCheckout: FunctionReference<"action", "public", any, any>;
37− createCustomerPortal: FunctionReference<"action", "public", any, any>;
38− retrySeatSync: FunctionReference<"mutation", "public", any, any>;
39− };
40− credentialsNode: {
41− upsertCredential: FunctionReference<"action", "public", any, any>;
42− };
43− executor: {
44− createTask: FunctionReference<"mutation", "public", any, any>;
45− resolveApproval: FunctionReference<"mutation", "public", any, any>;
46− };
47− executorNode: {
48− listTools: FunctionReference<"action", "public", any, any>;
49− listToolsWithWarnings: FunctionReference<"action", "public", any, any>;
50− };
51− invites: {
52− list: FunctionReference<"query", "public", any, any>;
53− create: FunctionReference<"mutation", "public", any, any>;
54− revoke: FunctionReference<"mutation", "public", any, any>;
55− };
56− organizationMembers: {
57− list: FunctionReference<"query", "public", any, any>;
58− updateRole: FunctionReference<"mutation", "public", any, any>;
59− updateBillable: FunctionReference<"mutation", "public", any, any>;
60− remove: FunctionReference<"mutation", "public", any, any>;
61− };
62− organizations: {
63− create: FunctionReference<"mutation", "public", any, any>;
64− listMine: FunctionReference<"query", "public", any, any>;
65− getNavigationState: FunctionReference<"query", "public", any, any>;
66− getOrganizationAccess: FunctionReference<"query", "public", any, any>;
67− resolveWorkosOrganizationId: FunctionReference<"query", "public", any, any>;
68− };
69− workspace: {
70− bootstrapAnonymousSession: FunctionReference<"mutation", "public", any, any>;
71− listRuntimeTargets: FunctionReference<"query", "public", any, any>;
72− getTask: FunctionReference<"query", "public", any, any>;
73− getTaskInWorkspace: FunctionReference<"query", "public", any, any>;
74− listTasks: FunctionReference<"query", "public", any, any>;
75− listApprovals: FunctionReference<"query", "public", any, any>;
76− listPendingApprovals: FunctionReference<"query", "public", any, any>;
77− listTaskEvents: FunctionReference<"query", "public", any, any>;
78− upsertAccessPolicy: FunctionReference<"mutation", "public", any, any>;
79− listAccessPolicies: FunctionReference<"query", "public", any, any>;
80− upsertCredential: FunctionReference<"mutation", "public", any, any>;
81− listCredentials: FunctionReference<"query", "public", any, any>;
82− listCredentialProviders: FunctionReference<"query", "public", any, any>;
83− resolveCredential: FunctionReference<"query", "public", any, any>;
84− upsertToolSource: FunctionReference<"mutation", "public", any, any>;
85− listToolSources: FunctionReference<"query", "public", any, any>;
86− deleteToolSource: FunctionReference<"mutation", "public", any, any>;
87− };
88− workspaces: {
89− create: FunctionReference<"mutation", "public", any, any>;
90− list: FunctionReference<"query", "public", any, any>;
91− generateWorkspaceIconUploadUrl: FunctionReference<"mutation", "public", any, any>;
92− };
93−};
65+export declare const api: FilterApi<
66+ typeof fullApi,
67+ FunctionReference<any, "public">
68+>;
9469
9570 /**
9671 * A utility for referencing Convex functions in your app's internal API.
@@ -100,86 +75,10 @@ export declare const api: { · 3 unmodified lines
10075 * const myFunctionReference = internal.myModule.myFunction;
10176 * ```
10277 */
103−export declare const internal: {
104− auth: {
105− authKitEvent: FunctionReference<"mutation", "internal", any, any>;
106− };
107− billingInternal: {
108− getBillingAccessForRequest: FunctionReference<"query", "internal", any, any>;
109− getSeatSyncSnapshot: FunctionReference<"query", "internal", any, any>;
110− upsertCustomerLink: FunctionReference<"mutation", "internal", any, any>;
111− bumpSeatSyncVersion: FunctionReference<"mutation", "internal", any, any>;
112− upsertSeatState: FunctionReference<"mutation", "internal", any, any>;
113− };
114− billingSync: {
115− syncSeatQuantity: FunctionReference<"action", "internal", any, any>;
116− };
117− database: {
118− createTask: FunctionReference<"mutation", "internal", any, any>;
119− getTask: FunctionReference<"query", "internal", any, any>;
120− listTasks: FunctionReference<"query", "internal", any, any>;
121− listQueuedTaskIds: FunctionReference<"query", "internal", any, any>;
122− listRuntimeTargets: FunctionReference<"query", "internal", any, any>;
123− getTaskInWorkspace: FunctionReference<"query", "internal", any, any>;
124− markTaskRunning: FunctionReference<"mutation", "internal", any, any>;
125− markTaskFinished: FunctionReference<"mutation", "internal", any, any>;
126− createApproval: FunctionReference<"mutation", "internal", any, any>;
127− getApproval: FunctionReference<"query", "internal", any, any>;
128− listApprovals: FunctionReference<"query", "internal", any, any>;
129− listPendingApprovals: FunctionReference<"query", "internal", any, any>;
130− resolveApproval: FunctionReference<"mutation", "internal", any, any>;
131− getApprovalInWorkspace: FunctionReference<"query", "internal", any, any>;
132− createAgentTask: FunctionReference<"mutation", "internal", any, any>;
133− getAgentTask: FunctionReference<"query", "internal", any, any>;
134− updateAgentTask: FunctionReference<"mutation", "internal", any, any>;
135− bootstrapAnonymousSession: FunctionReference<"mutation", "internal", any, any>;
136− upsertAccessPolicy: FunctionReference<"mutation", "internal", any, any>;
137− listAccessPolicies: FunctionReference<"query", "internal", any, any>;
138− upsertCredential: FunctionReference<"mutation", "internal", any, any>;
139− listCredentials: FunctionReference<"query", "internal", any, any>;
140− listCredentialProviders: FunctionReference<"query", "internal", any, any>;
141− resolveCredential: FunctionReference<"query", "internal", any, any>;
142− upsertToolSource: FunctionReference<"mutation", "internal", any, any>;
143− listToolSources: FunctionReference<"query", "internal", any, any>;
144− deleteToolSource: FunctionReference<"mutation", "internal", any, any>;
145− createTaskEvent: FunctionReference<"mutation", "internal", any, any>;
146− listTaskEvents: FunctionReference<"query", "internal", any, any>;
147− };
148− executor: {
149− createTaskInternal: FunctionReference<"mutation", "internal", any, any>;
150− resolveApprovalInternal: FunctionReference<"mutation", "internal", any, any>;
151− appendRuntimeOutput: FunctionReference<"mutation", "internal", any, any>;
152− };
153− executorNode: {
154− listToolsInternal: FunctionReference<"action", "internal", any, any>;
155− listToolsWithWarningsInternal: FunctionReference<"action", "internal", any, any>;
156− handleExternalToolCall: FunctionReference<"action", "internal", any, any>;
157− runTask: FunctionReference<"action", "internal", any, any>;
158− };
159− invites: {
160− deliverWorkosInvite: FunctionReference<"action", "internal", any, any>;
161− revokeWorkosInvite: FunctionReference<"action", "internal", any, any>;
162− getInviteDeliveryContext: FunctionReference<"query", "internal", any, any>;
163− linkOrganizationToWorkos: FunctionReference<"mutation", "internal", any, any>;
164− getInviteById: FunctionReference<"query", "internal", any, any>;
165− markInviteDelivered: FunctionReference<"mutation", "internal", any, any>;
166− markInviteDeliveryFailed: FunctionReference<"mutation", "internal", any, any>;
167− };
168− openApiSpecCache: {
169− getEntry: FunctionReference<"query", "internal", any, any>;
170− putEntry: FunctionReference<"mutation", "internal", any, any>;
171− pruneExpired: FunctionReference<"mutation", "internal", any, any>;
172− };
173− workspaceAuthInternal: {
174− getWorkspaceAccessForRequest: FunctionReference<"query", "internal", any, any>;
175− getWorkspaceAccessForWorkosSubject: FunctionReference<"query", "internal", any, any>;
176− };
177− workspaceToolCache: {
178− getEntry: FunctionReference<"query", "internal", any, any>;
179− putEntry: FunctionReference<"mutation", "internal", any, any>;
180− getDtsStorageIds: FunctionReference<"query", "internal", any, any>;
181− };
182−};
78+export declare const internal: FilterApi<
79+ typeof fullApi,
80+ FunctionReference<any, "internal">
81+>;
18382
18483 export declare const components: {
18584 workOSAuthKit: {
@@ -571,4 +470,3 @@ export declare const components: { · 385 unmodified lines
571470 };
572471 };
573472 };
574−
executor/convex/executorNode.ts+94−171
@@ -128,40 +128,10 @@ interface DtsStorageEntry { · 127 unmodified lines
128128 storageId: string;
129129 }
130130
131−const workspaceToolCache = new Map<
132− string,
133− { signature: string; loadedAt: number; tools: Map<string, ToolDefinition>; warnings: string[]; dtsStorageIds: DtsStorageEntry[] }
134−>();
135−const workspaceToolLoadsInFlight = new Map<
136− string,
137− {
138− signature: string;
139− promise: Promise<{ tools: Map<string, ToolDefinition>; warnings: string[]; dtsStorageIds: DtsStorageEntry[] }>;
140− }
141−>();
142131 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
143−const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000;
144−const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000;
145132
146133 /** Cache version — bump when PreparedOpenApiSpec shape changes. */
147−const OPENAPI_CACHE_VERSION = "v3";
148−
149−function isWorkspaceToolCacheFresh(
150− cached: { signature: string; loadedAt: number; warnings: string[] },
151− signature: string,
152− now: number,
153−): boolean {
154− if (cached.signature !== signature) {
155− return false;
156− }
157−
158− const ageMs = now - cached.loadedAt;
159− const ttlMs = cached.warnings.length > 0
160− ? WORKSPACE_TOOL_CACHE_RETRY_TTL_MS
161− : WORKSPACE_TOOL_CACHE_TTL_MS;
162−
163− return ageMs < ttlMs;
164−}
134+const OPENAPI_CACHE_VERSION = "v4";
165135
166136 async function publish(
167137 ctx: any,
@@ -277,173 +247,127 @@ async function loadSourceTools( · 109 unmodified lines
277247
278248 interface WorkspaceToolsResult {
279249 tools: Map<string, ToolDefinition>;
250+ warnings: string[];
280251 dtsStorageIds: DtsStorageEntry[];
281252 }
282253
283254 async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<WorkspaceToolsResult> {
284255 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
285256 .filter((source: { enabled: boolean }) => source.enabled);
286− const now = Date.now();
287257 const signature = sourceSignature(workspaceId, sources);
288258
289− // ── Layer 1: In-memory cache (same warm worker) ───────────────────────
290− const cached = workspaceToolCache.get(workspaceId);
291− if (cached && isWorkspaceToolCacheFresh(cached, signature, now)) {
292− return { tools: cached.tools, dtsStorageIds: cached.dtsStorageIds };
293− }
259+ // ── Layer 1: Persistent workspace cache (Convex file storage) ──────
260+ try {
261+ const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
262+ workspaceId,
263+ signature,
264+ });
294265
295− // ── Layer 2: In-flight dedup (concurrent requests on same worker) ─────
296− const inFlight = workspaceToolLoadsInFlight.get(workspaceId);
297− if (inFlight && inFlight.signature === signature) {
298− const loaded = await inFlight.promise;
299− return { tools: loaded.tools, dtsStorageIds: loaded.dtsStorageIds };
300− }
266+ if (cacheEntry) {
267+ const blob = await ctx.storage.get(cacheEntry.storageId);
268+ if (blob) {
269+ const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
270+ const rehydrated = rehydrateTools(snapshot.tools, baseTools);
301271
302− const loadPromise = (async () => {
303− // ── Layer 3: Persistent workspace cache (Convex file storage) ──────
304− // Single blob with all tool descriptors + run specs. Avoids per-source
305− // storage reads, JSON parses, and TS compiler runs on cold start.
306− try {
307− const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
308− workspaceId,
309− signature,
310− });
272+ const merged = new Map<string, ToolDefinition>();
273+ for (const tool of rehydrated) {
274+ merged.set(tool.path, tool);
275+ }
276+ // Recreate discover tool (it captures the full tool list in a closure)
277+ const discover = createDiscoverTool([...merged.values()]);
278+ merged.set(discover.path, discover);
311279
312− if (cacheEntry) {
313− const blob = await ctx.storage.get(cacheEntry.storageId);
314− if (blob) {
315− const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
316− const rehydrated = rehydrateTools(snapshot.tools, baseTools);
317−
318− const merged = new Map<string, ToolDefinition>();
319− for (const tool of rehydrated) {
320− merged.set(tool.path, tool);
321− }
322− // Recreate discover tool (it captures the full tool list in a closure)
323− const discover = createDiscoverTool([...merged.values()]);
324− merged.set(discover.path, discover);
325−
326− const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
327−
328− workspaceToolCache.set(workspaceId, {
329− signature,
330− loadedAt: Date.now(),
331− tools: merged,
332− warnings: snapshot.warnings,
333− dtsStorageIds,
334− });
280+ const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
335281
336− return { tools: merged, warnings: snapshot.warnings, dtsStorageIds };
337− }
282+ return { tools: merged, warnings: snapshot.warnings, dtsStorageIds };
338283 }
339− } catch (error) {
340− const msg = error instanceof Error ? error.message : String(error);
341− console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`);
342284 }
285+ } catch (error) {
286+ const msg = error instanceof Error ? error.message : String(error);
287+ console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`);
288+ }
343289
344− // ── Layer 4: Full rebuild from sources ─────────────────────────────
345− const configs: ExternalToolSourceConfig[] = [];
346− const warnings: string[] = [];
347− for (const source of sources) {
348− try {
349− configs.push(normalizeExternalToolSource(source));
350− } catch (error) {
351− const message = error instanceof Error ? error.message : String(error);
352− warnings.push(`Source '${source.name}': ${message}`);
353− }
290+ // ── Layer 2: Full rebuild from sources ─────────────────────────────
291+ const configs: ExternalToolSourceConfig[] = [];
292+ const warnings: string[] = [];
293+ for (const source of sources) {
294+ try {
295+ configs.push(normalizeExternalToolSource(source));
296+ } catch (error) {
297+ const message = error instanceof Error ? error.message : String(error);
298+ warnings.push(`Source '${source.name}': ${message}`);
354299 }
300+ }
355301
356− const loadedSources = await Promise.all(configs.map((config) => loadSourceTools(ctx, config)));
357− const externalTools = loadedSources.flatMap((loaded) => loaded.tools);
358− warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
302+ const loadedSources = await Promise.all(configs.map((config) => loadSourceTools(ctx, config)));
303+ const externalTools = loadedSources.flatMap((loaded) => loaded.tools);
304+ warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
359305
360− const merged = new Map<string, ToolDefinition>();
361− for (const tool of baseTools.values()) {
362− if (tool.path === "discover") continue;
363− merged.set(tool.path, tool);
364− }
365− for (const tool of externalTools) {
366− merged.set(tool.path, tool);
367− }
306+ const merged = new Map<string, ToolDefinition>();
307+ for (const tool of baseTools.values()) {
308+ if (tool.path === "discover") continue;
309+ merged.set(tool.path, tool);
310+ }
311+ for (const tool of externalTools) {
312+ merged.set(tool.path, tool);
313+ }
368314
369− const discover = createDiscoverTool([...merged.values()]);
370− merged.set(discover.path, discover);
315+ const discover = createDiscoverTool([...merged.values()]);
316+ merged.set(discover.path, discover);
371317
372− // ── Write to persistent workspace cache (best-effort) ─────────────
373− let dtsStorageIds: DtsStorageEntry[] = [];
374− try {
375− const allTools = [...merged.values()];
376−
377− // Extract and store .d.ts blobs per source (too large for action responses)
378− const seenDtsSources = new Set<string>();
379− const dtsEntries: { sourceKey: string; content: string }[] = [];
380− for (const tool of allTools) {
381− if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {
382− seenDtsSources.add(tool.source);
383− dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });
384− }
318+ // ── Write to persistent workspace cache (best-effort) ─────────────
319+ let dtsStorageIds: DtsStorageEntry[] = [];
320+ try {
321+ const allTools = [...merged.values()];
322+
323+ // Extract and store .d.ts blobs per source (too large for action responses)
324+ const seenDtsSources = new Set<string>();
325+ const dtsEntries: { sourceKey: string; content: string }[] = [];
326+ for (const tool of allTools) {
327+ if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {
328+ seenDtsSources.add(tool.source);
329+ dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });
385330 }
331+ }
386332
387− // Store .d.ts blobs in parallel
388− const storedDts = await Promise.all(
389− dtsEntries.map(async (entry) => {
390− const dtsBlob = new Blob([entry.content], { type: "text/plain" });
391− const sid = await ctx.storage.store(dtsBlob);
392− return { sourceKey: entry.sourceKey, storageId: String(sid) };
393− }),
394− );
395− dtsStorageIds = storedDts;
333+ // Store .d.ts blobs in parallel
334+ const storedDts = await Promise.all(
335+ dtsEntries.map(async (entry) => {
336+ const dtsBlob = new Blob([entry.content], { type: "text/plain" });
337+ const sid = await ctx.storage.store(dtsBlob);
338+ return { sourceKey: entry.sourceKey, storageId: String(sid) };
339+ }),
340+ );
341+ dtsStorageIds = storedDts;
396342
397− // Strip sourceDts from serialized tools (it's stored separately)
398− const snapshot: WorkspaceToolSnapshot = {
399− tools: serializeTools(allTools),
400− warnings,
401− };
402− // Remove sourceDts from the serialized snapshot to keep it small
403− for (const st of snapshot.tools) {
404− if (st.metadata?.sourceDts) {
405− delete (st.metadata as Record<string, unknown>).sourceDts;
406− }
343+ // Strip sourceDts from serialized tools (it's stored separately)
344+ const snapshot: WorkspaceToolSnapshot = {
345+ tools: serializeTools(allTools),
346+ warnings,
347+ };
348+ for (const st of snapshot.tools) {
349+ if (st.metadata?.sourceDts) {
350+ delete (st.metadata as Record<string, unknown>).sourceDts;
407351 }
408−
409− const json = JSON.stringify(snapshot);
410− const blob = new Blob([json], { type: "application/json" });
411− const storageId = await ctx.storage.store(blob);
412− await ctx.runMutation(internal.workspaceToolCache.putEntry, {
413− workspaceId,
414− signature,
415− storageId,
416− dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId as any })),
417− toolCount: allTools.length,
418− sizeBytes: json.length,
419− });
420− } catch (error) {
421− const msg = error instanceof Error ? error.message : String(error);
422− console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
423352 }
424353
425− workspaceToolCache.set(workspaceId, {
354+ const json = JSON.stringify(snapshot);
355+ const blob = new Blob([json], { type: "application/json" });
356+ const storageId = await ctx.storage.store(blob);
357+ await ctx.runMutation(internal.workspaceToolCache.putEntry, {
358+ workspaceId,
426359 signature,
427− loadedAt: Date.now(),
428− tools: merged,
429− warnings,
430− dtsStorageIds,
360+ storageId,
361+ dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId as any })),
362+ toolCount: allTools.length,
363+ sizeBytes: json.length,
431364 });
432−
433− return { tools: merged, warnings, dtsStorageIds };
434− })();
435−
436− workspaceToolLoadsInFlight.set(workspaceId, { signature, promise: loadPromise });
437−
438− try {
439− const loaded = await loadPromise;
440− return { tools: loaded.tools, dtsStorageIds: loaded.dtsStorageIds };
441− } finally {
442− const current = workspaceToolLoadsInFlight.get(workspaceId);
443− if (current?.promise === loadPromise) {
444− workspaceToolLoadsInFlight.delete(workspaceId);
445− }
365+ } catch (error) {
366+ const msg = error instanceof Error ? error.message : String(error);
367+ console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
446368 }
369+
370+ return { tools: merged, warnings, dtsStorageIds };
447371 }
448372
449373 function getDecisionForContext(
@@ -536,7 +460,6 @@ async function listToolsWithWarningsForContext( · 86 unmodified lines
536460 ]);
537461 const typedPolicies = policies as AccessPolicyRecord[];
538462 const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies);
539− const cachedEntry = workspaceToolCache.get(context.workspaceId);
540463
541464 // Generate download URLs for .d.ts blobs
542465 const dtsUrls: Record<string, string> = {};
@@ -551,7 +474,7 @@ async function listToolsWithWarningsForContext( · 8 unmodified lines
551474
552475 return {
553476 tools,
554− warnings: cachedEntry?.warnings ?? [],
477+ warnings: result.warnings,
555478 dtsUrls,
556479 };
557480 }
executor/lib/tool_sources.ts+94−51
@@ -518,38 +518,73 @@ function compactOpenApiPaths( · 517 unmodified lines
518518 compactOperation.parameters = operationParameters;
519519 }
520520
521− // Only keep request/response schemas when we need schema-hint fallback.
522− if (!hasGeneratedTypes) {
521+ // Extract request/response schemas for type hints.
522+ // When .d.ts types exist, pre-compute lightweight hints and skip storing full schemas.
523+ {
523524 const requestBody = asRecord(operation.requestBody);
524525 const requestBodyContent = asRecord(requestBody.content);
525526 const requestBodySchema = getPreferredContentSchema(requestBodyContent);
526− if (Object.keys(requestBodySchema).length > 0) {
527− compactOperation.requestBody = {
528− content: {
529− "application/json": {
530− schema: requestBodySchema,
531− },
532− },
533− };
534− }
535527
536528 const responses = asRecord(operation.responses);
529+ let responseSchema: Record<string, unknown> = {};
530+ let responseStatus = "";
537531 for (const [status, responseValue] of Object.entries(responses)) {
538532 if (!status.startsWith("2")) continue;
539533 const responseContent = asRecord(asRecord(responseValue).content);
540− const responseSchema = getPreferredContentSchema(responseContent);
541− compactOperation.responses = {
542− [status]: Object.keys(responseSchema).length > 0
543− ? {
544− content: {
545− "application/json": {
546− schema: responseSchema,
547− },
548− },
549− }
550− : {},
534+ responseSchema = getPreferredContentSchema(responseContent);
535+ responseStatus = status;
536+ if (Object.keys(responseSchema).length > 0) break;
537+ }
538+
539+ if (hasGeneratedTypes) {
540+ // Pre-compute lightweight type hint strings (the full schemas are in the .d.ts)
541+ const combinedSchema: JsonSchema = {
542+ type: "object",
543+ properties: {
544+ ...Object.fromEntries(
545+ normalizeParameters(operation.parameters)
546+ .concat(sharedParameters)
547+ .map((param) => [param.name, param.schema]),
548+ ),
549+ ...asRecord(requestBodySchema.properties),
550+ },
551+ required: [
552+ ...normalizeParameters(operation.parameters)
553+ .concat(sharedParameters)
554+ .filter((param) => param.required)
555+ .map((param) => param.name as string),
556+ ...((Array.isArray(requestBodySchema.required)
557+ ? requestBodySchema.required.filter((item): item is string => typeof item === "string")
558+ : []) as string[]),
559+ ],
551560 };
552− break;
561+ compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema);
562+ compactOperation._returnsTypeHint = jsonSchemaTypeHintFallback(responseSchema);
563+ } else {
564+ // Keep full schemas for the fallback path
565+ if (Object.keys(requestBodySchema).length > 0) {
566+ compactOperation.requestBody = {
567+ content: {
568+ "application/json": {
569+ schema: requestBodySchema,
570+ },
571+ },
572+ };
573+ }
574+
575+ if (responseStatus) {
576+ compactOperation.responses = {
577+ [responseStatus]: Object.keys(responseSchema).length > 0
578+ ? {
579+ content: {
580+ "application/json": {
581+ schema: responseSchema,
582+ },
583+ },
584+ }
585+ : {},
586+ };
587+ }
553588 }
554589 }
555590
@@ -687,36 +722,44 @@ export function buildOpenApiToolsFromPrepared( · 131 unmodified lines
687722 schema: asRecord(entry.schema),
688723 }));
689724
690− // Lightweight type hints for LLM prompt / discover tool (from JSON Schema fallback)
691− const requestBody = asRecord(operation.requestBody);
692− const requestBodyContent = asRecord(requestBody.content);
693− const requestBodySchema = getPreferredContentSchema(requestBodyContent);
694−
695− const responses = asRecord(operation.responses);
696− let responseSchema: Record<string, unknown> = {};
697− for (const [status, responseValue] of Object.entries(responses)) {
698− if (!status.startsWith("2")) continue;
699− const responseContent = asRecord(asRecord(responseValue).content);
700− responseSchema = getPreferredContentSchema(responseContent);
701− if (Object.keys(responseSchema).length > 0) break;
702− }
725+ // Lightweight type hints for LLM prompt / discover tool.
726+ // Pre-computed during compaction when .d.ts types exist (schemas stripped to save space).
727+ let argsType: string;
728+ let returnsType: string;
729+ if (typeof operation._argsTypeHint === "string" && typeof operation._returnsTypeHint === "string") {
730+ argsType = operation._argsTypeHint as string;
731+ returnsType = operation._returnsTypeHint as string;
732+ } else {
733+ const requestBody = asRecord(operation.requestBody);
734+ const requestBodyContent = asRecord(requestBody.content);
735+ const requestBodySchema = getPreferredContentSchema(requestBodyContent);
703736
704− const combinedSchema: JsonSchema = {
705− type: "object",
706− properties: {
707− ...Object.fromEntries(parameters.map((param) => [param.name, param.schema])),
708− ...asRecord(requestBodySchema.properties),
709− },
710− required: [
711− ...parameters.filter((param) => param.required).map((param) => param.name),
712− ...((Array.isArray(requestBodySchema.required)
713− ? requestBodySchema.required.filter((item): item is string => typeof item === "string")
714− : []) as string[]),
715− ],
716− };
737+ const responses = asRecord(operation.responses);
738+ let responseSchema: Record<string, unknown> = {};
739+ for (const [status, responseValue] of Object.entries(responses)) {
740+ if (!status.startsWith("2")) continue;
741+ const responseContent = asRecord(asRecord(responseValue).content);
742+ responseSchema = getPreferredContentSchema(responseContent);
743+ if (Object.keys(responseSchema).length > 0) break;
744+ }
717745
718− const argsType = jsonSchemaTypeHintFallback(combinedSchema);
719− const returnsType = jsonSchemaTypeHintFallback(responseSchema);
746+ const combinedSchema: JsonSchema = {
747+ type: "object",
748+ properties: {
749+ ...Object.fromEntries(parameters.map((param) => [param.name, param.schema])),
750+ ...asRecord(requestBodySchema.properties),
751+ },
752+ required: [
753+ ...parameters.filter((param) => param.required).map((param) => param.name),
754+ ...((Array.isArray(requestBodySchema.required)
755+ ? requestBodySchema.required.filter((item): item is string => typeof item === "string")
756+ : []) as string[]),
757+ ],
758+ };
759+
760+ argsType = jsonSchemaTypeHintFallback(combinedSchema);
761+ returnsType = jsonSchemaTypeHintFallback(responseSchema);
762+ }
720763
721764 const approval = config.overrides?.[operationIdRaw]?.approval
722765 ?? (readMethods.has(method)

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t