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