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
12 "dependencies": {12 "dependencies": {
13 "@convex-dev/workos": "^0.0.1",13 "@convex-dev/workos": "^0.0.1",
14 "@elysiajs/eden": "^1.4.6",14 "@elysiajs/eden": "^1.4.6",
15 "@tanstack/react-query": "^5.80.7",
16 "@workos-inc/authkit-nextjs": "^2.13.0",
17 "@executor/contracts": "workspace:*",15 "@executor/contracts": "workspace:*",
18 "@monaco-editor/react": "^4.7.0",16 "@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",
19 "class-variance-authority": "^0.7.1",21 "class-variance-authority": "^0.7.1",
20 "clsx": "^2.1.1",22 "clsx": "^2.1.1",
21 "convex": "^1.31.7",23 "convex": "^1.31.7",
@@ -27,6 +29,7 @@ · 5 unmodified lines
27 "react": "19.2.3",29 "react": "19.2.3",
28 "react-dom": "19.2.3",30 "react-dom": "19.2.3",
29 "sonner": "^2.0.7",31 "sonner": "^2.0.7",
32 "streamdown": "^2.1.0",
30 "tailwind-merge": "^3.4.0",33 "tailwind-merge": "^3.4.0",
31 "tldts": "^7.0.22"34 "tldts": "^7.0.22"
32 },35 },
executor/apps/web/src/app/globals.css+202−14
@@ -2,6 +2,8 @@ · 1 unmodified line
2@import "tw-animate-css";2@import "tw-animate-css";
3@import "shadcn/tailwind.css";3@import "shadcn/tailwind.css";
4 4
5@source "../../../node_modules/streamdown/dist/*.js";
6
5@custom-variant dark (&:is(.dark *));7@custom-variant dark (&:is(.dark *));
6 8
7@theme inline {9@theme inline {
@@ -45,15 +47,61 @@ · 37 unmodified lines
45 --radius-2xl: calc(var(--radius) + 8px);47 --radius-2xl: calc(var(--radius) + 8px);
46 --radius-3xl: calc(var(--radius) + 12px);48 --radius-3xl: calc(var(--radius) + 12px);
47 --radius-4xl: calc(var(--radius) + 16px);49 --radius-4xl: calc(var(--radius) + 16px);
48 --color-terminal-green: oklch(0.75 0.18 155);50 --color-terminal-green: var(--terminal-green);
49 --color-terminal-amber: oklch(0.78 0.16 75);51 --color-terminal-amber: var(--terminal-amber);
50 --color-terminal-red: oklch(0.65 0.22 25);52 --color-terminal-red: var(--terminal-red);
51 --color-terminal-cyan: oklch(0.75 0.12 200);53 --color-terminal-cyan: var(--terminal-cyan);
52 --color-surface-raised: var(--card);54 --color-surface-raised: var(--card);
53}55}
54 56
57/* ── Light theme (default) ─────────────────────────────────────────────────── */
55:root {58:root {
56 --radius: 0.5rem;59 --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 {
57 --background: oklch(0.11 0.005 260);105 --background: oklch(0.11 0.005 260);
58 --foreground: oklch(0.88 0.01 250);106 --foreground: oklch(0.88 0.01 250);
59 --card: oklch(0.15 0.005 260);107 --card: oklch(0.15 0.005 260);
@@ -85,6 +133,10 @@ · 25 unmodified lines
85 --sidebar-accent-foreground: oklch(0.88 0.01 250);133 --sidebar-accent-foreground: oklch(0.88 0.01 250);
86 --sidebar-border: oklch(0.22 0.008 260);134 --sidebar-border: oklch(0.22 0.008 260);
87 --sidebar-ring: oklch(0.75 0.18 155);135 --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);
88}140}
89 141
90@layer base {142@layer base {
@@ -96,6 +148,31 @@ · 5 unmodified lines
96 }148 }
97}149}
98 150
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
99/* Scrollbar styling */176/* Scrollbar styling */
100::-webkit-scrollbar {177::-webkit-scrollbar {
101 width: 6px;178 width: 6px;
@@ -105,17 +182,17 @@ · 3 unmodified lines
105 background: transparent;182 background: transparent;
106}183}
107::-webkit-scrollbar-thumb {184::-webkit-scrollbar-thumb {
108 background: oklch(0.3 0.005 260);185 background: var(--border);
109 border-radius: 3px;186 border-radius: 3px;
110}187}
111::-webkit-scrollbar-thumb:hover {188::-webkit-scrollbar-thumb:hover {
112 background: oklch(0.4 0.005 260);189 background: var(--muted-foreground);
113}190}
114 191
115/* Terminal code blocks */192/* Terminal code blocks */
116.terminal-block {193.terminal-block {
117 background: oklch(0.09 0.005 260);194 background: var(--muted);
118 border: 1px solid oklch(0.2 0.008 260);195 border: 1px solid var(--border);
119 font-family: var(--font-geist-mono), ui-monospace, monospace;196 font-family: var(--font-geist-mono), ui-monospace, monospace;
120 font-size: 0.8125rem;197 font-size: 0.8125rem;
121 line-height: 1.6;198 line-height: 1.6;
@@ -140,15 +217,15 @@ · 18 unmodified lines
140 );217 );
141}218}
142 219
143/* Glow effects */220/* Glow effects — use color-mix for alpha since relative oklch has limited support */
144.glow-green {221.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);
146}223}
147.glow-amber {224.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);
149}226}
150.glow-red {227.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);
152}229}
153 230
154/* Pulse animation for pending indicators */231/* Pulse animation for pending indicators */
@@ -163,7 +240,118 @@ · 8 unmodified lines
163/* Subtle grid background */240/* Subtle grid background */
164.grid-bg {241.grid-bg {
165 background-image:242 background-image:
166 linear-gradient(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),
167 linear-gradient(90deg, oklch(0.2 0.005 260 / 0.5) 1px, transparent 1px);244 linear-gradient(90deg, color-mix(in oklch, var(--border) 50%, transparent) 1px, transparent 1px);
168 background-size: 24px 24px;245 background-size: 24px 24px;
169}246}
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 @@
1import type { Metadata } from "next";1import type { Metadata } from "next";
2import { JetBrains_Mono, Inter } from "next/font/google";2import { JetBrains_Mono, Inter } from "next/font/google";
3import { Toaster } from "sonner";3import { ThemeProvider } from "next-themes";
4import { Toaster } from "@/components/ui/sonner";
4import { AppShell } from "@/components/app-shell";5import { AppShell } from "@/components/app-shell";
5import { AppConvexProvider } from "@/lib/convex-provider";6import { AppConvexProvider } from "@/lib/convex-provider";
6import { QueryProvider } from "@/lib/query-provider";7import { QueryProvider } from "@/lib/query-provider";
@@ -28,28 +29,25 @@ export default function RootLayout({ · 21 unmodified lines
28 children: React.ReactNode;29 children: React.ReactNode;
29}>) {30}>) {
30 return (31 return (
31 <html lang="en" className="dark">32 <html lang="en" suppressHydrationWarning>
32 <body33 <body
33 className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}34 className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
34 >35 >
35 <AppConvexProvider>36 <ThemeProvider
36 <QueryProvider>37 attribute="class"
37 <SessionProvider>38 defaultTheme="dark"
38 <AppShell>{children}</AppShell>39 enableSystem
39 </SessionProvider>40 enableColorScheme
40 </QueryProvider>41 >
41 </AppConvexProvider>42 <AppConvexProvider>
42 <Toaster43 <QueryProvider>
43 theme="dark"44 <SessionProvider>
44 position="bottom-right"45 <AppShell>{children}</AppShell>
45 toastOptions={{46 </SessionProvider>
46 style: {47 </QueryProvider>
47 background: "oklch(0.15 0.005 260)",48 </AppConvexProvider>
48 border: "1px solid oklch(0.24 0.008 260)",49 <Toaster position="bottom-right" />
49 color: "oklch(0.88 0.01 250)",50 </ThemeProvider>
50 },
51 }}
52 />
53 </body>51 </body>
54 </html>52 </html>
55 );53 );
executor/apps/web/src/app/tools/page.tsx+9−2
@@ -1,5 +1,12 @@
1import { ToolsView } from "@/components/tools-view";1import { ToolsView } from "@/components/tools-view";
2 2
3export default function ToolsPage() {3export default async function ToolsPage({
4 return <ToolsView />;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} />;
5}12}
executor/apps/web/src/components/app-shell.tsx+7−0
@@ -39,6 +39,7 @@ import { Input } from "@/components/ui/input"; · 38 unmodified lines
39import { useSession } from "@/lib/session-context";39import { useSession } from "@/lib/session-context";
40import { convexApi } from "@/lib/convex-api";40import { convexApi } from "@/lib/convex-api";
41import { workosEnabled } from "@/lib/auth-capabilities";41import { workosEnabled } from "@/lib/auth-capabilities";
42import { ThemeSwitcher } from "@/components/theme-switcher";
42import { cn } from "@/lib/utils";43import { cn } from "@/lib/utils";
43 44
44const NAV_ITEMS = [45const NAV_ITEMS = [
@@ -509,6 +510,9 @@ function Sidebar() { · 464 unmodified lines
509 <div className="flex-1 overflow-y-auto py-4 px-2">510 <div className="flex-1 overflow-y-auto py-4 px-2">
510 <NavLinks />511 <NavLinks />
511 </div>512 </div>
513 <div className="px-3 py-2">
514 <ThemeSwitcher />
515 </div>
512 <div className="pb-4">516 <div className="pb-4">
513 <Suspense>517 <Suspense>
514 <SessionInfo />518 <SessionInfo />
@@ -540,6 +544,9 @@ function MobileHeader() { · 25 unmodified lines
540 <div className="py-4 px-2">544 <div className="py-4 px-2">
541 <NavLinks onClick={() => setOpen(false)} />545 <NavLinks onClick={() => setOpen(false)} />
542 </div>546 </div>
547 <div className="px-3 py-2">
548 <ThemeSwitcher />
549 </div>
543 <div className="mt-auto pb-4">550 <div className="mt-auto pb-4">
544 <Suspense>551 <Suspense>
545 <SessionInfo />552 <SessionInfo />
executor/apps/web/src/components/approvals-view.tsx+1−1
@@ -181,7 +181,7 @@ export function ApprovalsView() { · 180 unmodified lines
181 <Clock className="h-3.5 w-3.5" />181 <Clock className="h-3.5 w-3.5" />
182 {count} pending approval{count !== 1 ? "s" : ""}182 {count} pending approval{count !== 1 ? "s" : ""}
183 </div>183 </div>
184 {approvals!.map((a) => (184 {approvals!.map((a: PendingApprovalRecord) => (
185 <ApprovalCard key={a.id} approval={a} />185 <ApprovalCard key={a.id} approval={a} />
186 ))}186 ))}
187 </div>187 </div>
executor/apps/web/src/components/dashboard-view.tsx+135−34
@@ -1,5 +1,6 @@
1"use client";1"use client";
2 2
3import { useMemo } from "react";
3import { useRouter } from "next/navigation";4import { useRouter } from "next/navigation";
4import {5import {
5 Play,6 Play,
@@ -9,6 +10,10 @@ import { · 3 unmodified lines
9 XCircle,10 XCircle,
10 AlertTriangle,11 AlertTriangle,
11 ArrowRight,12 ArrowRight,
13 Wrench,
14 ChevronRight,
15 Server,
16 Globe,
12} from "lucide-react";17} from "lucide-react";
13import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";18import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
14import { Button } from "@/components/ui/button";19import { Button } from "@/components/ui/button";
@@ -19,7 +24,7 @@ import { useSession } from "@/lib/session-context"; · 4 unmodified lines
19import { useWorkspaceTools } from "@/hooks/use-workspace-tools";24import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
20import { useQuery } from "convex/react";25import { useQuery } from "convex/react";
21import { convexApi } from "@/lib/convex-api";26import { convexApi } from "@/lib/convex-api";
22import type { TaskRecord, PendingApprovalRecord } from "@/lib/types";27import type { TaskRecord, PendingApprovalRecord, ToolDescriptor } from "@/lib/types";
23import { formatTime, formatTimeAgo } from "@/lib/format";28import { formatTime, formatTimeAgo } from "@/lib/format";
24 29
25function StatCard({30function StatCard({
@@ -102,6 +107,128 @@ function RecentTaskRow({ task }: { task: TaskRecord }) { · 76 unmodified lines
102 );107 );
103}108}
104 109
110/** Derive the "source name" from a tool's source field (e.g. "openapi:github" → "github"). */
111function 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"). */
118function 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
124function 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
105export function DashboardView() {232export function DashboardView() {
106 const { context, loading: sessionLoading } = useSession();233 const { context, loading: sessionLoading } = useSession();
107 234
@@ -132,11 +259,11 @@ export function DashboardView() { · 24 unmodified lines
132 259
133 const pendingCount = approvals?.length ?? 0;260 const pendingCount = approvals?.length ?? 0;
134 const runningCount =261 const runningCount =
135 tasks?.filter((t) => t.status === "running").length ?? 0;262 tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;
136 const completedCount =263 const completedCount =
137 tasks?.filter((t) => t.status === "completed").length ?? 0;264 tasks?.filter((t: TaskRecord) => t.status === "completed").length ?? 0;
138 const failedCount =265 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))
140 .length ?? 0;267 .length ?? 0;
141 const recentTasks = (tasks ?? []).slice(0, 8);268 const recentTasks = (tasks ?? []).slice(0, 8);
142 269
@@ -208,7 +335,7 @@ export function DashboardView() { · 65 unmodified lines
208 </div>335 </div>
209 ) : (336 ) : (
210 <div className="space-y-0.5">337 <div className="space-y-0.5">
211 {(approvals ?? []).slice(0, 5).map((a) => (338 {(approvals ?? []).slice(0, 5).map((a: PendingApprovalRecord) => (
212 <PendingApprovalRow key={a.id} approval={a} />339 <PendingApprovalRow key={a.id} approval={a} />
213 ))}340 ))}
214 </div>341 </div>
@@ -241,7 +368,7 @@ export function DashboardView() { · 26 unmodified lines
241 </div>368 </div>
242 ) : (369 ) : (
243 <div className="space-y-0.5">370 <div className="space-y-0.5">
244 {recentTasks.map((t) => (371 {recentTasks.map((t: TaskRecord) => (
245 <RecentTaskRow key={t.id} task={t} />372 <RecentTaskRow key={t.id} task={t} />
246 ))}373 ))}
247 </div>374 </div>
@@ -250,34 +377,8 @@ export function DashboardView() { · 2 unmodified lines
250 </Card>377 </Card>
251 </div>378 </div>
252 379
253 {/* Tools summary */}380 {/* Tools summary — grouped by source */}
254 {tools.length > 0 && (381 {tools.length > 0 && <ToolsSummaryCard tools={tools} />}
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 )}
281 </div>382 </div>
282 );383 );
283}384}
executor/apps/web/src/components/members-view.tsx+5−4
@@ -38,7 +38,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 37 unmodified lines
38 );38 );
39 39
40 const memberItems = members?.items ?? [];40 const memberItems = members?.items ?? [];
41 const actorMembership = memberItems.find((member) =>41 const actorMembership = memberItems.find((member: { accountId?: string; role?: string }) =>
42 context?.accountId ? member.accountId === context.accountId : false,42 context?.accountId ? member.accountId === context.accountId : false,
43 );43 );
44 const actorRole = actorMembership?.role ?? null;44 const actorRole = actorMembership?.role ?? null;
@@ -65,7 +65,8 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 20 unmodified lines
65 const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null);65 const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null);
66 66
67 const inviteItems = listInvites?.items ?? [];67 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");
69 70
70 const submitInvite = async () => {71 const submitInvite = async () => {
71 if (!typedOrganizationId) {72 if (!typedOrganizationId) {
@@ -173,7 +174,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 101 unmodified lines
173 {memberItems.length === 0 ? (174 {memberItems.length === 0 ? (
174 <p className="text-sm text-muted-foreground">No members found.</p>175 <p className="text-sm text-muted-foreground">No members found.</p>
175 ) : (176 ) : (
176 memberItems.map((member) => (177 memberItems.map((member: any) => (
177 <div key={member.id} className="rounded-md border border-border p-3">178 <div key={member.id} className="rounded-md border border-border p-3">
178 <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">179 <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
179 <div>180 <div>
@@ -277,7 +278,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 97 unmodified lines
277 ) : pendingInviteItems.length === 0 ? (278 ) : pendingInviteItems.length === 0 ? (
278 <p className="text-sm text-muted-foreground">No pending invites.</p>279 <p className="text-sm text-muted-foreground">No pending invites.</p>
279 ) : (280 ) : (
280 pendingInviteItems.map((invite) => (281 pendingInviteItems.map((invite: any) => (
281 <div key={invite.id} className="rounded-md border border-border p-3 text-sm">282 <div key={invite.id} className="rounded-md border border-border p-3 text-sm">
282 <div className="flex items-start justify-between gap-2">283 <div className="flex items-start justify-between gap-2">
283 <div>284 <div>
executor/apps/web/src/components/tasks-view.tsx+7−7
@@ -110,7 +110,7 @@ function TaskComposer() { · 109 unmodified lines
110 <SelectValue />110 <SelectValue />
111 </SelectTrigger>111 </SelectTrigger>
112 <SelectContent>112 <SelectContent>
113 {(runtimes ?? []).map((r) => (113 {(runtimes ?? []).map((r: any) => (
114 <SelectItem key={r.id} value={r.id} className="text-xs">114 <SelectItem key={r.id} value={r.id} className="text-xs">
115 {r.label}115 {r.label}
116 </SelectItem>116 </SelectItem>
@@ -224,8 +224,8 @@ function TaskDetail({ · 107 unmodified lines
224 const liveTask = liveTaskData ?? task;224 const liveTask = liveTaskData ?? task;
225 const liveStdout = useMemo(() => {225 const liveStdout = useMemo(() => {
226 const stdoutLines = (taskEvents ?? [])226 const stdoutLines = (taskEvents ?? [])
227 .filter((event) => event.type === "task.stdout")227 .filter((event: any) => event.type === "task.stdout")
228 .map((event) => String((event.payload as Record<string, unknown>)?.line ?? ""));228 .map((event: any) => String((event.payload as Record<string, unknown>)?.line ?? ""));
229 if (stdoutLines.length > 0) {229 if (stdoutLines.length > 0) {
230 return stdoutLines.join("\n");230 return stdoutLines.join("\n");
231 }231 }
@@ -234,8 +234,8 @@ function TaskDetail({ · 2 unmodified lines
234 234
235 const liveStderr = useMemo(() => {235 const liveStderr = useMemo(() => {
236 const stderrLines = (taskEvents ?? [])236 const stderrLines = (taskEvents ?? [])
237 .filter((event) => event.type === "task.stderr")237 .filter((event: any) => event.type === "task.stderr")
238 .map((event) => String((event.payload as Record<string, unknown>)?.line ?? ""));238 .map((event: any) => String((event.payload as Record<string, unknown>)?.line ?? ""));
239 if (stderrLines.length > 0) {239 if (stderrLines.length > 0) {
240 return stderrLines.join("\n");240 return stderrLines.join("\n");
241 }241 }
@@ -357,7 +357,7 @@ export function TasksView() { · 115 unmodified lines
357 );357 );
358 const tasksLoading = !!context && tasks === undefined;358 const tasksLoading = !!context && tasks === undefined;
359 359
360 const selectedTask = tasks?.find((t) => t.id === selectedId);360 const selectedTask = tasks?.find((t: any) => t.id === selectedId);
361 361
362 const selectTask = useCallback(362 const selectTask = useCallback(
363 (taskId: string | null) => {363 (taskId: string | null) => {
@@ -415,7 +415,7 @@ export function TasksView() { · 51 unmodified lines
415 </div>415 </div>
416 ) : (416 ) : (
417 <div className="space-y-1 max-h-[500px] overflow-y-auto">417 <div className="space-y-1 max-h-[500px] overflow-y-auto">
418 {tasks.map((task) => (418 {tasks.map((task: any) => (
419 <TaskListItem419 <TaskListItem
420 key={task.id}420 key={task.id}
421 task={task}421 task={task}
executor/apps/web/src/components/theme-switcher.tsx+42−0
@@ -0,0 +1,42 @@
1"use client";
2
3import { Monitor, Moon, Sun } from "lucide-react";
4import { useTheme } from "next-themes";
5import { useEffect, useState } from "react";
6import { cn } from "@/lib/utils";
7
8export 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
3import {
4 useState,
5 useMemo,
6 useCallback,
7 useEffect,
8 memo,
9} from "react";
10import {
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";
27import { Input } from "@/components/ui/input";
28import { Button } from "@/components/ui/button";
29import {
30 DropdownMenu,
31 DropdownMenuContent,
32 DropdownMenuCheckboxItem,
33 DropdownMenuLabel,
34 DropdownMenuSeparator,
35 DropdownMenuTrigger,
36} from "@/components/ui/dropdown-menu";
37import {
38 Tooltip,
39 TooltipContent,
40 TooltipProvider,
41 TooltipTrigger,
42} from "@/components/ui/tooltip";
43import {
44 Collapsible,
45 CollapsibleContent,
46 CollapsibleTrigger,
47} from "@/components/ui/collapsible";
48import { ScrollArea } from "@/components/ui/scroll-area";
49import { Skeleton } from "@/components/ui/skeleton";
50import { Streamdown } from "streamdown";
51import { createCodePlugin } from "@streamdown/code";
52import { cn } from "@/lib/utils";
53import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types";
54
55// ── Streamdown code plugin (light + dark dual-theme) ──
56const codePlugin = createCodePlugin({
57 themes: ["github-light", "github-dark"],
58});
59
60// ── Helpers ──
61
62function 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
68function 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
74function 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
80function toolOperation(path: string): string {
81 const parts = path.split(".");
82 return parts[parts.length - 1];
83}
84
85// ── Types for grouped tree data ──
86
87interface 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
97type GroupBy = "source" | "namespace" | "approval";
98type ViewMode = "tree" | "flat";
99
100// ── Build grouped data structures ──
101
102function 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
157function 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
181function 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
212function 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
259function 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
318const 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
401function 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 */
538const 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
576function 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
658interface ToolExplorerProps {
659 tools: ToolDescriptor[];
660 sources: ToolSourceRecord[];
661 loading?: boolean;
662 warnings?: string[];
663 initialSource?: string | null;
664}
665
666export 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
1245function 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
8 ShieldCheck,8 ShieldCheck,
9 Globe,9 Globe,
10 Server,10 Server,
11 Zap,
12 Search,
13 ChevronRight,11 ChevronRight,
14 AlertTriangle,12 AlertTriangle,
15 KeyRound,13 KeyRound,
@@ -45,6 +43,7 @@ import { Skeleton } from "@/components/ui/skeleton"; · 29 unmodified lines
45import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";43import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
46import { PageHeader } from "@/components/page-header";44import { PageHeader } from "@/components/page-header";
47import { McpSetupCard } from "@/components/mcp-setup-card";45import { McpSetupCard } from "@/components/mcp-setup-card";
46import { ToolExplorer } from "@/components/tool-explorer";
48import { useSession } from "@/lib/session-context";47import { useSession } from "@/lib/session-context";
49import { useWorkspaceTools } from "@/hooks/use-workspace-tools";48import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
50import { useAction, useMutation, useQuery } from "convex/react";49import { useAction, useMutation, useQuery } from "convex/react";
@@ -1331,72 +1330,11 @@ function CredentialsPanel({ · 1280 unmodified lines
1331 );1330 );
1332}1331}
1333 1332
1334// ── Tool Inventory ──1333// ── Tool Inventory (legacy removed — replaced by ToolExplorer) ──
1335
1336function 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}
1396 1334
1397// ── Tools View ──1335// ── Tools View ──
1398 1336
1399export function ToolsView() {1337export function ToolsView({ initialSource }: { initialSource?: string | null }) {
1400 const { context, loading: sessionLoading } = useSession();1338 const { context, loading: sessionLoading } = useSession();
1401 1339
1402 const sources = useQuery(1340 const sources = useQuery(
@@ -1429,7 +1367,7 @@ export function ToolsView() { · 26 unmodified lines
1429 description="Manage sources, auth, credentials, and available tools"1367 description="Manage sources, auth, credentials, and available tools"
1430 />1368 />
1431 1369
1432 <Tabs defaultValue="sources" className="w-full">1370 <Tabs defaultValue={initialSource ? "inventory" : "sources"} className="w-full">
1433 <TabsList className="bg-muted/50 h-9">1371 <TabsList className="bg-muted/50 h-9">
1434 <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background">1372 <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background">
1435 Sources1373 Sources
@@ -1466,7 +1404,7 @@ export function ToolsView() { · 30 unmodified lines
1466 <Server className="h-4 w-4 text-muted-foreground" />1404 <Server className="h-4 w-4 text-muted-foreground" />
1467 Tool Sources1405 Tool Sources
1468 </CardTitle>1406 </CardTitle>
1469 <AddSourceDialog existingSourceNames={new Set((sources ?? []).map(s => s.name))} />1407 <AddSourceDialog existingSourceNames={new Set((sources ?? []).map((s: any) => s.name))} />
1470 </div>1408 </div>
1471 </CardHeader>1409 </CardHeader>
1472 <CardContent className="pt-0">1410 <CardContent className="pt-0">
@@ -1497,7 +1435,7 @@ export function ToolsView() { · 24 unmodified lines
1497 Source load warnings ({warnings.length})1435 Source load warnings ({warnings.length})
1498 </div>1436 </div>
1499 <div className="mt-1.5 space-y-1">1437 <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) => (
1501 <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90">1439 <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90">
1502 {warning}1440 {warning}
1503 </p>1441 </p>
@@ -1510,7 +1448,7 @@ export function ToolsView() { · 6 unmodified lines
1510 </div>1448 </div>
1511 </div>1449 </div>
1512 )}1450 )}
1513 {sources.map((s) => (1451 {sources.map((s: any) => (
1514 <SourceCard key={s.id} source={s} />1452 <SourceCard key={s.id} source={s} />
1515 ))}1453 ))}
1516 </div>1454 </div>
@@ -1528,36 +1466,13 @@ export function ToolsView() { · 11 unmodified lines
1528 </TabsContent>1466 </TabsContent>
1529 1467
1530 <TabsContent value="inventory" className="mt-4">1468 <TabsContent value="inventory" className="mt-4">
1531 <Card className="bg-card border-border">1469 <ToolExplorer
1532 <CardHeader className="pb-3">1470 tools={tools}
1533 <CardTitle className="text-sm font-medium flex items-center gap-2">1471 sources={sources ?? []}
1534 <Zap className="h-4 w-4 text-primary" />1472 loading={toolsLoading}
1535 Available Tools1473 warnings={warnings}
1536 <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">1474 initialSource={initialSource}
1537 {toolsLoading ? "…" : tools.length}1475 />
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>
1561 </TabsContent>1476 </TabsContent>
1562 1477
1563 <TabsContent value="mcp" className="mt-4">1478 <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
158 return activeWorkspaceId;158 return activeWorkspaceId;
159 }159 }
160 160
161 if (activeWorkspaceId && workspaces.some((workspace) => workspace.id === activeWorkspaceId)) {161 if (activeWorkspaceId && workspaces.some((workspace: any) => workspace.id === activeWorkspaceId)) {
162 return activeWorkspaceId;162 return activeWorkspaceId;
163 }163 }
164 164
165 const accountId = account?.provider === "workos" ? account._id : null;165 const accountId = account?.provider === "workos" ? account._id : null;
166 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;166 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)) {
168 return accountStoredWorkspace;168 return accountStoredWorkspace;
169 }169 }
170 170
@@ -265,7 +265,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 94 unmodified lines
265 }265 }
266 266
267 const activeWorkspace =267 const activeWorkspace =
268 workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId)268 workspaces.find((workspace: any) => workspace.id === resolvedActiveWorkspaceId)
269 ?? workspaces[0]269 ?? workspaces[0]
270 ?? null;270 ?? null;
271 if (!activeWorkspace) {271 if (!activeWorkspace) {
@@ -318,7 +318,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 46 unmodified lines
318 );318 );
319 const workspaceOptions = useMemo(() => {319 const workspaceOptions = useMemo(() => {
320 if (mode === "workos" && workspaces) {320 if (mode === "workos" && workspaces) {
321 return workspaces.map((workspace): SessionState["workspaces"][number] => ({321 return workspaces.map((workspace: any): SessionState["workspaces"][number] => ({
322 id: workspace.id,322 id: workspace.id,
323 docId: workspace.id,323 docId: workspace.id,
324 name: workspace.name,324 name: workspace.name,
executor/convex/_generated/api.d.ts+53−155
@@ -5,15 +5,54 @@ · 4 unmodified lines
5 * THIS CODE IS AUTOMATICALLY GENERATED.5 * THIS CODE IS AUTOMATICALLY GENERATED.
6 *6 *
7 * To regenerate, run `npx convex dev`.7 * 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 *
13 * @module8 * @module
14 */9 */
15 10
16import type { FunctionReference } from "convex/server";11import type * as app from "../app.js";
12import type * as auth from "../auth.js";
13import type * as billing from "../billing.js";
14import type * as billingInternal from "../billingInternal.js";
15import type * as billingSync from "../billingSync.js";
16import type * as credentialsNode from "../credentialsNode.js";
17import type * as database from "../database.js";
18import type * as executor from "../executor.js";
19import type * as executorNode from "../executorNode.js";
20import type * as http from "../http.js";
21import type * as invites from "../invites.js";
22import type * as openApiSpecCache from "../openApiSpecCache.js";
23import type * as organizationMembers from "../organizationMembers.js";
24import type * as organizations from "../organizations.js";
25import type * as workspace from "../workspace.js";
26import type * as workspaceAuthInternal from "../workspaceAuthInternal.js";
27import type * as workspaceToolCache from "../workspaceToolCache.js";
28import type * as workspaces from "../workspaces.js";
29
30import type {
31 ApiFromModules,
32 FilterApi,
33 FunctionReference,
34} from "convex/server";
35
36declare 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}>;
17 56
18/**57/**
19 * A utility for referencing Convex functions in your app's public API.58 * 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
23 * const myFunctionReference = api.myModule.myFunction;62 * const myFunctionReference = api.myModule.myFunction;
24 * ```63 * ```
25 */64 */
26export declare const api: {65export declare const api: FilterApi<
27 app: {66 typeof fullApi,
28 getClientConfig: FunctionReference<"query", "public", any, any>;67 FunctionReference<any, "public">
29 getCurrentAccount: FunctionReference<"query", "public", any, any>;68>;
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};
94 69
95/**70/**
96 * A utility for referencing Convex functions in your app's internal API.71 * A utility for referencing Convex functions in your app's internal API.
@@ -100,86 +75,10 @@ export declare const api: { · 3 unmodified lines
100 * const myFunctionReference = internal.myModule.myFunction;75 * const myFunctionReference = internal.myModule.myFunction;
101 * ```76 * ```
102 */77 */
103export declare const internal: {78export declare const internal: FilterApi<
104 auth: {79 typeof fullApi,
105 authKitEvent: FunctionReference<"mutation", "internal", any, any>;80 FunctionReference<any, "internal">
106 };81>;
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};
183 82
184export declare const components: {83export declare const components: {
185 workOSAuthKit: {84 workOSAuthKit: {
@@ -571,4 +470,3 @@ export declare const components: { · 385 unmodified lines
571 };470 };
572 };471 };
573};472};
574
executor/convex/executorNode.ts+94−171
@@ -128,40 +128,10 @@ interface DtsStorageEntry { · 127 unmodified lines
128 storageId: string;128 storageId: string;
129}129}
130 130
131const workspaceToolCache = new Map<
132 string,
133 { signature: string; loadedAt: number; tools: Map<string, ToolDefinition>; warnings: string[]; dtsStorageIds: DtsStorageEntry[] }
134>();
135const workspaceToolLoadsInFlight = new Map<
136 string,
137 {
138 signature: string;
139 promise: Promise<{ tools: Map<string, ToolDefinition>; warnings: string[]; dtsStorageIds: DtsStorageEntry[] }>;
140 }
141>();
142const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;131const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
143const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000;
144const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000;
145 132
146/** Cache version — bump when PreparedOpenApiSpec shape changes. */133/** Cache version — bump when PreparedOpenApiSpec shape changes. */
147const OPENAPI_CACHE_VERSION = "v3";134const OPENAPI_CACHE_VERSION = "v4";
148
149function 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}
165 135
166async function publish(136async function publish(
167 ctx: any,137 ctx: any,
@@ -277,173 +247,127 @@ async function loadSourceTools( · 109 unmodified lines
277 247
278interface WorkspaceToolsResult {248interface WorkspaceToolsResult {
279 tools: Map<string, ToolDefinition>;249 tools: Map<string, ToolDefinition>;
250 warnings: string[];
280 dtsStorageIds: DtsStorageEntry[];251 dtsStorageIds: DtsStorageEntry[];
281}252}
282 253
283async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<WorkspaceToolsResult> {254async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<WorkspaceToolsResult> {
284 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))255 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
285 .filter((source: { enabled: boolean }) => source.enabled);256 .filter((source: { enabled: boolean }) => source.enabled);
286 const now = Date.now();
287 const signature = sourceSignature(workspaceId, sources);257 const signature = sourceSignature(workspaceId, sources);
288 258
289 // ── Layer 1: In-memory cache (same warm worker) ───────────────────────259 // ── Layer 1: Persistent workspace cache (Convex file storage) ──────
290 const cached = workspaceToolCache.get(workspaceId);260 try {
291 if (cached && isWorkspaceToolCacheFresh(cached, signature, now)) {261 const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
292 return { tools: cached.tools, dtsStorageIds: cached.dtsStorageIds };262 workspaceId,
293 }263 signature,
264 });
294 265
295 // ── Layer 2: In-flight dedup (concurrent requests on same worker) ─────266 if (cacheEntry) {
296 const inFlight = workspaceToolLoadsInFlight.get(workspaceId);267 const blob = await ctx.storage.get(cacheEntry.storageId);
297 if (inFlight && inFlight.signature === signature) {268 if (blob) {
298 const loaded = await inFlight.promise;269 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
299 return { tools: loaded.tools, dtsStorageIds: loaded.dtsStorageIds };270 const rehydrated = rehydrateTools(snapshot.tools, baseTools);
300 }
301 271
302 const loadPromise = (async () => {272 const merged = new Map<string, ToolDefinition>();
303 // ── Layer 3: Persistent workspace cache (Convex file storage) ──────273 for (const tool of rehydrated) {
304 // Single blob with all tool descriptors + run specs. Avoids per-source274 merged.set(tool.path, tool);
305 // storage reads, JSON parses, and TS compiler runs on cold start.275 }
306 try {276 // Recreate discover tool (it captures the full tool list in a closure)
307 const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {277 const discover = createDiscoverTool([...merged.values()]);
308 workspaceId,278 merged.set(discover.path, discover);
309 signature,
310 });
311 279
312 if (cacheEntry) {280 const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
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 });
335 281
336 return { tools: merged, warnings: snapshot.warnings, dtsStorageIds };282 return { tools: merged, warnings: snapshot.warnings, dtsStorageIds };
337 }
338 }283 }
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}`);
342 }284 }
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 }
343 289
344 // ── Layer 4: Full rebuild from sources ─────────────────────────────290 // ── Layer 2: Full rebuild from sources ─────────────────────────────
345 const configs: ExternalToolSourceConfig[] = [];291 const configs: ExternalToolSourceConfig[] = [];
346 const warnings: string[] = [];292 const warnings: string[] = [];
347 for (const source of sources) {293 for (const source of sources) {
348 try {294 try {
349 configs.push(normalizeExternalToolSource(source));295 configs.push(normalizeExternalToolSource(source));
350 } catch (error) {296 } catch (error) {
351 const message = error instanceof Error ? error.message : String(error);297 const message = error instanceof Error ? error.message : String(error);
352 warnings.push(`Source '${source.name}': ${message}`);298 warnings.push(`Source '${source.name}': ${message}`);
353 }
354 }299 }
300 }
355 301
356 const loadedSources = await Promise.all(configs.map((config) => loadSourceTools(ctx, config)));302 const loadedSources = await Promise.all(configs.map((config) => loadSourceTools(ctx, config)));
357 const externalTools = loadedSources.flatMap((loaded) => loaded.tools);303 const externalTools = loadedSources.flatMap((loaded) => loaded.tools);
358 warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));304 warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
359 305
360 const merged = new Map<string, ToolDefinition>();306 const merged = new Map<string, ToolDefinition>();
361 for (const tool of baseTools.values()) {307 for (const tool of baseTools.values()) {
362 if (tool.path === "discover") continue;308 if (tool.path === "discover") continue;
363 merged.set(tool.path, tool);309 merged.set(tool.path, tool);
364 }310 }
365 for (const tool of externalTools) {311 for (const tool of externalTools) {
366 merged.set(tool.path, tool);312 merged.set(tool.path, tool);
367 }313 }
368 314
369 const discover = createDiscoverTool([...merged.values()]);315 const discover = createDiscoverTool([...merged.values()]);
370 merged.set(discover.path, discover);316 merged.set(discover.path, discover);
371 317
372 // ── Write to persistent workspace cache (best-effort) ─────────────318 // ── Write to persistent workspace cache (best-effort) ─────────────
373 let dtsStorageIds: DtsStorageEntry[] = [];319 let dtsStorageIds: DtsStorageEntry[] = [];
374 try {320 try {
375 const allTools = [...merged.values()];321 const allTools = [...merged.values()];
376 322
377 // Extract and store .d.ts blobs per source (too large for action responses)323 // Extract and store .d.ts blobs per source (too large for action responses)
378 const seenDtsSources = new Set<string>();324 const seenDtsSources = new Set<string>();
379 const dtsEntries: { sourceKey: string; content: string }[] = [];325 const dtsEntries: { sourceKey: string; content: string }[] = [];
380 for (const tool of allTools) {326 for (const tool of allTools) {
381 if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {327 if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {
382 seenDtsSources.add(tool.source);328 seenDtsSources.add(tool.source);
383 dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });329 dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });
384 }
385 }330 }
331 }
386 332
387 // Store .d.ts blobs in parallel333 // Store .d.ts blobs in parallel
388 const storedDts = await Promise.all(334 const storedDts = await Promise.all(
389 dtsEntries.map(async (entry) => {335 dtsEntries.map(async (entry) => {
390 const dtsBlob = new Blob([entry.content], { type: "text/plain" });336 const dtsBlob = new Blob([entry.content], { type: "text/plain" });
391 const sid = await ctx.storage.store(dtsBlob);337 const sid = await ctx.storage.store(dtsBlob);
392 return { sourceKey: entry.sourceKey, storageId: String(sid) };338 return { sourceKey: entry.sourceKey, storageId: String(sid) };
393 }),339 }),
394 );340 );
395 dtsStorageIds = storedDts;341 dtsStorageIds = storedDts;
396 342
397 // Strip sourceDts from serialized tools (it's stored separately)343 // Strip sourceDts from serialized tools (it's stored separately)
398 const snapshot: WorkspaceToolSnapshot = {344 const snapshot: WorkspaceToolSnapshot = {
399 tools: serializeTools(allTools),345 tools: serializeTools(allTools),
400 warnings,346 warnings,
401 };347 };
402 // Remove sourceDts from the serialized snapshot to keep it small348 for (const st of snapshot.tools) {
403 for (const st of snapshot.tools) {349 if (st.metadata?.sourceDts) {
404 if (st.metadata?.sourceDts) {350 delete (st.metadata as Record<string, unknown>).sourceDts;
405 delete (st.metadata as Record<string, unknown>).sourceDts;
406 }
407 }351 }
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}`);
423 }352 }
424 353
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,
426 signature,359 signature,
427 loadedAt: Date.now(),360 storageId,
428 tools: merged,361 dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId as any })),
429 warnings,362 toolCount: allTools.length,
430 dtsStorageIds,363 sizeBytes: json.length,
431 });364 });
432 365 } catch (error) {
433 return { tools: merged, warnings, dtsStorageIds };366 const msg = error instanceof Error ? error.message : String(error);
434 })();367 console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
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 }
446 }368 }
369
370 return { tools: merged, warnings, dtsStorageIds };
447}371}
448 372
449function getDecisionForContext(373function getDecisionForContext(
@@ -536,7 +460,6 @@ async function listToolsWithWarningsForContext( · 86 unmodified lines
536 ]);460 ]);
537 const typedPolicies = policies as AccessPolicyRecord[];461 const typedPolicies = policies as AccessPolicyRecord[];
538 const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies);462 const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies);
539 const cachedEntry = workspaceToolCache.get(context.workspaceId);
540 463
541 // Generate download URLs for .d.ts blobs464 // Generate download URLs for .d.ts blobs
542 const dtsUrls: Record<string, string> = {};465 const dtsUrls: Record<string, string> = {};
@@ -551,7 +474,7 @@ async function listToolsWithWarningsForContext( · 8 unmodified lines
551 474
552 return {475 return {
553 tools,476 tools,
554 warnings: cachedEntry?.warnings ?? [],477 warnings: result.warnings,
555 dtsUrls,478 dtsUrls,
556 };479 };
557}480}
executor/lib/tool_sources.ts+94−51
@@ -518,38 +518,73 @@ function compactOpenApiPaths( · 517 unmodified lines
518 compactOperation.parameters = operationParameters;518 compactOperation.parameters = operationParameters;
519 }519 }
520 520
521 // Only keep request/response schemas when we need schema-hint fallback.521 // Extract request/response schemas for type hints.
522 if (!hasGeneratedTypes) {522 // When .d.ts types exist, pre-compute lightweight hints and skip storing full schemas.
523 {
523 const requestBody = asRecord(operation.requestBody);524 const requestBody = asRecord(operation.requestBody);
524 const requestBodyContent = asRecord(requestBody.content);525 const requestBodyContent = asRecord(requestBody.content);
525 const requestBodySchema = getPreferredContentSchema(requestBodyContent);526 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 }
535 527
536 const responses = asRecord(operation.responses);528 const responses = asRecord(operation.responses);
529 let responseSchema: Record<string, unknown> = {};
530 let responseStatus = "";
537 for (const [status, responseValue] of Object.entries(responses)) {531 for (const [status, responseValue] of Object.entries(responses)) {
538 if (!status.startsWith("2")) continue;532 if (!status.startsWith("2")) continue;
539 const responseContent = asRecord(asRecord(responseValue).content);533 const responseContent = asRecord(asRecord(responseValue).content);
540 const responseSchema = getPreferredContentSchema(responseContent);534 responseSchema = getPreferredContentSchema(responseContent);
541 compactOperation.responses = {535 responseStatus = status;
542 [status]: Object.keys(responseSchema).length > 0536 if (Object.keys(responseSchema).length > 0) break;
543 ? {537 }
544 content: {538
545 "application/json": {539 if (hasGeneratedTypes) {
546 schema: responseSchema,540 // Pre-compute lightweight type hint strings (the full schemas are in the .d.ts)
547 },541 const combinedSchema: JsonSchema = {
548 },542 type: "object",
549 }543 properties: {
550 : {},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 ],
551 };560 };
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 }
553 }588 }
554 }589 }
555 590
@@ -687,36 +722,44 @@ export function buildOpenApiToolsFromPrepared( · 131 unmodified lines
687 schema: asRecord(entry.schema),722 schema: asRecord(entry.schema),
688 }));723 }));
689 724
690 // Lightweight type hints for LLM prompt / discover tool (from JSON Schema fallback)725 // Lightweight type hints for LLM prompt / discover tool.
691 const requestBody = asRecord(operation.requestBody);726 // Pre-computed during compaction when .d.ts types exist (schemas stripped to save space).
692 const requestBodyContent = asRecord(requestBody.content);727 let argsType: string;
693 const requestBodySchema = getPreferredContentSchema(requestBodyContent);728 let returnsType: string;
694 729 if (typeof operation._argsTypeHint === "string" && typeof operation._returnsTypeHint === "string") {
695 const responses = asRecord(operation.responses);730 argsType = operation._argsTypeHint as string;
696 let responseSchema: Record<string, unknown> = {};731 returnsType = operation._returnsTypeHint as string;
697 for (const [status, responseValue] of Object.entries(responses)) {732 } else {
698 if (!status.startsWith("2")) continue;733 const requestBody = asRecord(operation.requestBody);
699 const responseContent = asRecord(asRecord(responseValue).content);734 const requestBodyContent = asRecord(requestBody.content);
700 responseSchema = getPreferredContentSchema(responseContent);735 const requestBodySchema = getPreferredContentSchema(requestBodyContent);
701 if (Object.keys(responseSchema).length > 0) break;
702 }
703 736
704 const combinedSchema: JsonSchema = {737 const responses = asRecord(operation.responses);
705 type: "object",738 let responseSchema: Record<string, unknown> = {};
706 properties: {739 for (const [status, responseValue] of Object.entries(responses)) {
707 ...Object.fromEntries(parameters.map((param) => [param.name, param.schema])),740 if (!status.startsWith("2")) continue;
708 ...asRecord(requestBodySchema.properties),741 const responseContent = asRecord(asRecord(responseValue).content);
709 },742 responseSchema = getPreferredContentSchema(responseContent);
710 required: [743 if (Object.keys(responseSchema).length > 0) break;
711 ...parameters.filter((param) => param.required).map((param) => param.name),744 }
712 ...((Array.isArray(requestBodySchema.required)
713 ? requestBodySchema.required.filter((item): item is string => typeof item === "string")
714 : []) as string[]),
715 ],
716 };
717 745
718 const argsType = jsonSchemaTypeHintFallback(combinedSchema);746 const combinedSchema: JsonSchema = {
719 const returnsType = jsonSchemaTypeHintFallback(responseSchema);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 }
720 763
721 const approval = config.overrides?.[operationIdRaw]?.approval764 const approval = config.overrides?.[operationIdRaw]?.approval
722 ?? (readMethods.has(method)765 ?? (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