- 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 | "dependencies": { | 12 | "dependencies": { |
| 13 | "@convex-dev/workos": "^0.0.1", | 13 | "@convex-dev/workos": "^0.0.1", |
| 14 | "@elysiajs/eden": "^1.4.6", | 14 | "@elysiajs/eden": "^1.4.6", |
| 15 | "@tanstack/react-query": "^5.80.7", | ||
| 16 | "@workos-inc/authkit-nextjs": "^2.13.0", | ||
| 17 | "@executor/contracts": "workspace:*", | 15 | "@executor/contracts": "workspace:*", |
| 18 | "@monaco-editor/react": "^4.7.0", | 16 | "@monaco-editor/react": "^4.7.0", |
| 17 | "@streamdown/code": "^1.0.1", | ||
| 18 | "@tanstack/react-query": "^5.80.7", | ||
| 19 | |||
| 20 | "@workos-inc/authkit-nextjs": "^2.13.0", | ||
| 19 | "class-variance-authority": "^0.7.1", | 21 | "class-variance-authority": "^0.7.1", |
| 20 | "clsx": "^2.1.1", | 22 | "clsx": "^2.1.1", |
| 21 | "convex": "^1.31.7", | 23 | "convex": "^1.31.7", |
| @@ -27,6 +29,7 @@ · 5 unmodified lines | |||
| 27 | "react": "19.2.3", | 29 | "react": "19.2.3", |
| 28 | "react-dom": "19.2.3", | 30 | "react-dom": "19.2.3", |
| 29 | "sonner": "^2.0.7", | 31 | "sonner": "^2.0.7", |
| 32 | "streamdown": "^2.1.0", | ||
| 30 | "tailwind-merge": "^3.4.0", | 33 | "tailwind-merge": "^3.4.0", |
| 31 | "tldts": "^7.0.22" | 34 | "tldts": "^7.0.22" |
| 32 | }, | 35 | }, |
| @@ -2,6 +2,8 @@ · 1 unmodified line | |||
| 2 | @import "tw-animate-css"; | 2 | @import "tw-animate-css"; |
| 3 | @import "shadcn/tailwind.css"; | 3 | @import "shadcn/tailwind.css"; |
| 4 | 4 | ||
| 5 | @source "../../../node_modules/streamdown/dist/*.js"; | ||
| 6 | |||
| 5 | @custom-variant dark (&:is(.dark *)); | 7 | @custom-variant dark (&:is(.dark *)); |
| 6 | 8 | ||
| 7 | @theme inline { | 9 | @theme inline { |
| @@ -45,15 +47,61 @@ · 37 unmodified lines | |||
| 45 | --radius-2xl: calc(var(--radius) + 8px); | 47 | --radius-2xl: calc(var(--radius) + 8px); |
| 46 | --radius-3xl: calc(var(--radius) + 12px); | 48 | --radius-3xl: calc(var(--radius) + 12px); |
| 47 | --radius-4xl: calc(var(--radius) + 16px); | 49 | --radius-4xl: calc(var(--radius) + 16px); |
| 48 | --color-terminal-green: oklch(0.75 0.18 155); | 50 | --color-terminal-green: var(--terminal-green); |
| 49 | --color-terminal-amber: oklch(0.78 0.16 75); | 51 | --color-terminal-amber: var(--terminal-amber); |
| 50 | --color-terminal-red: oklch(0.65 0.22 25); | 52 | --color-terminal-red: var(--terminal-red); |
| 51 | --color-terminal-cyan: oklch(0.75 0.12 200); | 53 | --color-terminal-cyan: var(--terminal-cyan); |
| 52 | --color-surface-raised: var(--card); | 54 | --color-surface-raised: var(--card); |
| 53 | } | 55 | } |
| 54 | 56 | ||
| 57 | /* ── Light theme (default) ─────────────────────────────────────────────────── */ | ||
| 55 | :root { | 58 | :root { |
| 56 | --radius: 0.5rem; | 59 | --radius: 0.5rem; |
| 60 | --background: oklch(0.985 0.002 260); | ||
| 61 | --foreground: oklch(0.18 0.01 260); | ||
| 62 | --card: oklch(1 0 0); | ||
| 63 | --card-foreground: oklch(0.18 0.01 260); | ||
| 64 | --popover: oklch(1 0 0); | ||
| 65 | --popover-foreground: oklch(0.18 0.01 260); | ||
| 66 | --primary: oklch(0.45 0.18 155); | ||
| 67 | --primary-foreground: oklch(0.98 0.005 155); | ||
| 68 | --secondary: oklch(0.95 0.005 260); | ||
| 69 | --secondary-foreground: oklch(0.30 0.01 260); | ||
| 70 | --muted: oklch(0.94 0.005 260); | ||
| 71 | --muted-foreground: oklch(0.45 0.01 260); | ||
| 72 | --accent: oklch(0.94 0.008 260); | ||
| 73 | --accent-foreground: oklch(0.18 0.01 260); | ||
| 74 | --destructive: oklch(0.55 0.22 25); | ||
| 75 | --border: oklch(0.88 0.008 260); | ||
| 76 | --input: oklch(0.88 0.008 260); | ||
| 77 | --ring: oklch(0.45 0.18 155); | ||
| 78 | --chart-1: oklch(0.45 0.18 155); | ||
| 79 | --chart-2: oklch(0.60 0.16 75); | ||
| 80 | --chart-3: oklch(0.50 0.12 200); | ||
| 81 | --chart-4: oklch(0.55 0.22 25); | ||
| 82 | --chart-5: oklch(0.55 0.15 300); | ||
| 83 | --sidebar: oklch(0.97 0.004 260); | ||
| 84 | --sidebar-foreground: oklch(0.30 0.01 260); | ||
| 85 | --sidebar-primary: oklch(0.45 0.18 155); | ||
| 86 | --sidebar-primary-foreground: oklch(0.98 0.005 155); | ||
| 87 | --sidebar-accent: oklch(0.94 0.006 260); | ||
| 88 | --sidebar-accent-foreground: oklch(0.18 0.01 260); | ||
| 89 | --sidebar-border: oklch(0.88 0.008 260); | ||
| 90 | --sidebar-ring: oklch(0.45 0.18 155); | ||
| 91 | --terminal-green: oklch(0.45 0.18 155); | ||
| 92 | --terminal-amber: oklch(0.55 0.16 75); | ||
| 93 | --terminal-red: oklch(0.50 0.22 25); | ||
| 94 | --terminal-cyan: oklch(0.45 0.12 200); | ||
| 95 | |||
| 96 | /* Shiki theme variables */ | ||
| 97 | --shiki-light: #24292e; | ||
| 98 | --shiki-light-bg: #ffffff; | ||
| 99 | --shiki-dark: #e1e4e8; | ||
| 100 | --shiki-dark-bg: #0d1117; | ||
| 101 | } | ||
| 102 | |||
| 103 | /* ── Dark theme ────────────────────────────────────────────────────────────── */ | ||
| 104 | .dark { | ||
| 57 | --background: oklch(0.11 0.005 260); | 105 | --background: oklch(0.11 0.005 260); |
| 58 | --foreground: oklch(0.88 0.01 250); | 106 | --foreground: oklch(0.88 0.01 250); |
| 59 | --card: oklch(0.15 0.005 260); | 107 | --card: oklch(0.15 0.005 260); |
| @@ -85,6 +133,10 @@ · 25 unmodified lines | |||
| 85 | --sidebar-accent-foreground: oklch(0.88 0.01 250); | 133 | --sidebar-accent-foreground: oklch(0.88 0.01 250); |
| 86 | --sidebar-border: oklch(0.22 0.008 260); | 134 | --sidebar-border: oklch(0.22 0.008 260); |
| 87 | --sidebar-ring: oklch(0.75 0.18 155); | 135 | --sidebar-ring: oklch(0.75 0.18 155); |
| 136 | --terminal-green: oklch(0.75 0.18 155); | ||
| 137 | --terminal-amber: oklch(0.78 0.16 75); | ||
| 138 | --terminal-red: oklch(0.65 0.22 25); | ||
| 139 | --terminal-cyan: oklch(0.75 0.12 200); | ||
| 88 | } | 140 | } |
| 89 | 141 | ||
| 90 | @layer base { | 142 | @layer base { |
| @@ -96,6 +148,31 @@ · 5 unmodified lines | |||
| 96 | } | 148 | } |
| 97 | } | 149 | } |
| 98 | 150 | ||
| 151 | /* ── Shiki / Streamdown code block theme overrides ─────────────────────────── */ | ||
| 152 | /* Light mode: use shiki-light tokens */ | ||
| 153 | [style*="--shiki-light"] { | ||
| 154 | color: var(--shiki-light) !important; | ||
| 155 | background-color: transparent !important; | ||
| 156 | } | ||
| 157 | |||
| 158 | /* Dark mode: swap to shiki-dark tokens */ | ||
| 159 | .dark [style*="--shiki-dark"] { | ||
| 160 | color: var(--shiki-dark) !important; | ||
| 161 | background-color: transparent !important; | ||
| 162 | } | ||
| 163 | |||
| 164 | .dark [style*="--shiki-light"] { | ||
| 165 | color: var(--shiki-dark) !important; | ||
| 166 | background-color: transparent !important; | ||
| 167 | } | ||
| 168 | |||
| 169 | /* Streamdown code block container — follows theme background */ | ||
| 170 | [data-streamdown="code-block-body"] { | ||
| 171 | background-color: var(--muted) !important; | ||
| 172 | border: 1px solid var(--border); | ||
| 173 | border-radius: 0.375rem; | ||
| 174 | } | ||
| 175 | |||
| 99 | /* Scrollbar styling */ | 176 | /* Scrollbar styling */ |
| 100 | ::-webkit-scrollbar { | 177 | ::-webkit-scrollbar { |
| 101 | width: 6px; | 178 | width: 6px; |
| @@ -105,17 +182,17 @@ · 3 unmodified lines | |||
| 105 | background: transparent; | 182 | background: transparent; |
| 106 | } | 183 | } |
| 107 | ::-webkit-scrollbar-thumb { | 184 | ::-webkit-scrollbar-thumb { |
| 108 | background: oklch(0.3 0.005 260); | 185 | background: var(--border); |
| 109 | border-radius: 3px; | 186 | border-radius: 3px; |
| 110 | } | 187 | } |
| 111 | ::-webkit-scrollbar-thumb:hover { | 188 | ::-webkit-scrollbar-thumb:hover { |
| 112 | background: oklch(0.4 0.005 260); | 189 | background: var(--muted-foreground); |
| 113 | } | 190 | } |
| 114 | 191 | ||
| 115 | /* Terminal code blocks */ | 192 | /* Terminal code blocks */ |
| 116 | .terminal-block { | 193 | .terminal-block { |
| 117 | background: oklch(0.09 0.005 260); | 194 | background: var(--muted); |
| 118 | border: 1px solid oklch(0.2 0.008 260); | 195 | border: 1px solid var(--border); |
| 119 | font-family: var(--font-geist-mono), ui-monospace, monospace; | 196 | font-family: var(--font-geist-mono), ui-monospace, monospace; |
| 120 | font-size: 0.8125rem; | 197 | font-size: 0.8125rem; |
| 121 | line-height: 1.6; | 198 | line-height: 1.6; |
| @@ -140,15 +217,15 @@ · 18 unmodified lines | |||
| 140 | ); | 217 | ); |
| 141 | } | 218 | } |
| 142 | 219 | ||
| 143 | /* Glow effects */ | 220 | /* Glow effects — use color-mix for alpha since relative oklch has limited support */ |
| 144 | .glow-green { | 221 | .glow-green { |
| 145 | box-shadow: 0 0 20px oklch(0.75 0.18 155 / 0.15), 0 0 60px oklch(0.75 0.18 155 / 0.05); | 222 | box-shadow: 0 0 20px color-mix(in oklch, var(--terminal-green) 15%, transparent), 0 0 60px color-mix(in oklch, var(--terminal-green) 5%, transparent); |
| 146 | } | 223 | } |
| 147 | .glow-amber { | 224 | .glow-amber { |
| 148 | box-shadow: 0 0 20px oklch(0.78 0.16 75 / 0.15), 0 0 60px oklch(0.78 0.16 75 / 0.05); | 225 | box-shadow: 0 0 20px color-mix(in oklch, var(--terminal-amber) 15%, transparent), 0 0 60px color-mix(in oklch, var(--terminal-amber) 5%, transparent); |
| 149 | } | 226 | } |
| 150 | .glow-red { | 227 | .glow-red { |
| 151 | box-shadow: 0 0 20px oklch(0.65 0.22 25 / 0.15), 0 0 60px oklch(0.65 0.22 25 / 0.05); | 228 | box-shadow: 0 0 20px color-mix(in oklch, var(--terminal-red) 15%, transparent), 0 0 60px color-mix(in oklch, var(--terminal-red) 5%, transparent); |
| 152 | } | 229 | } |
| 153 | 230 | ||
| 154 | /* Pulse animation for pending indicators */ | 231 | /* Pulse animation for pending indicators */ |
| @@ -163,7 +240,118 @@ · 8 unmodified lines | |||
| 163 | /* Subtle grid background */ | 240 | /* Subtle grid background */ |
| 164 | .grid-bg { | 241 | .grid-bg { |
| 165 | background-image: | 242 | background-image: |
| 166 | linear-gradient(oklch(0.2 0.005 260 / 0.5) 1px, transparent 1px), | 243 | linear-gradient(color-mix(in oklch, var(--border) 50%, transparent) 1px, transparent 1px), |
| 167 | linear-gradient(90deg, oklch(0.2 0.005 260 / 0.5) 1px, transparent 1px); | 244 | linear-gradient(90deg, color-mix(in oklch, var(--border) 50%, transparent) 1px, transparent 1px); |
| 168 | background-size: 24px 24px; | 245 | background-size: 24px 24px; |
| 169 | } | 246 | } |
| 247 | |||
| 248 | /* ── Tool description (Streamdown markdown rendering) ──────────────────────── */ | ||
| 249 | .tool-description { | ||
| 250 | font-size: 0.75rem; | ||
| 251 | line-height: 1.6; | ||
| 252 | color: var(--muted-foreground); | ||
| 253 | } | ||
| 254 | .tool-description p { | ||
| 255 | margin-bottom: 0.35em; | ||
| 256 | } | ||
| 257 | .tool-description p:last-child { | ||
| 258 | margin-bottom: 0; | ||
| 259 | } | ||
| 260 | .tool-description strong, | ||
| 261 | .tool-description b { | ||
| 262 | color: var(--foreground); | ||
| 263 | font-weight: 600; | ||
| 264 | } | ||
| 265 | .tool-description code { | ||
| 266 | font-family: var(--font-geist-mono), ui-monospace, monospace; | ||
| 267 | font-size: 0.6875rem; | ||
| 268 | background: var(--muted); | ||
| 269 | border: 1px solid var(--border); | ||
| 270 | border-radius: 3px; | ||
| 271 | padding: 0.1em 0.35em; | ||
| 272 | color: var(--terminal-cyan); | ||
| 273 | } | ||
| 274 | .tool-description pre { | ||
| 275 | background: var(--muted); | ||
| 276 | border: 1px solid var(--border); | ||
| 277 | border-radius: 0.375rem; | ||
| 278 | padding: 0.5rem 0.75rem; | ||
| 279 | overflow-x: auto; | ||
| 280 | margin: 0.4em 0; | ||
| 281 | font-size: 0.6875rem; | ||
| 282 | line-height: 1.5; | ||
| 283 | } | ||
| 284 | .tool-description pre code { | ||
| 285 | background: none; | ||
| 286 | border: none; | ||
| 287 | padding: 0; | ||
| 288 | color: inherit; | ||
| 289 | } | ||
| 290 | .tool-description a { | ||
| 291 | color: var(--primary); | ||
| 292 | text-decoration: underline; | ||
| 293 | text-underline-offset: 2px; | ||
| 294 | text-decoration-color: color-mix(in oklch, var(--primary) 30%, transparent); | ||
| 295 | } | ||
| 296 | .tool-description a:hover { | ||
| 297 | text-decoration-color: color-mix(in oklch, var(--primary) 80%, transparent); | ||
| 298 | } | ||
| 299 | .tool-description ul, | ||
| 300 | .tool-description ol { | ||
| 301 | padding-left: 1.25em; | ||
| 302 | margin: 0.3em 0; | ||
| 303 | } | ||
| 304 | .tool-description li { | ||
| 305 | margin-bottom: 0.15em; | ||
| 306 | } | ||
| 307 | .tool-description li::marker { | ||
| 308 | color: var(--muted-foreground); | ||
| 309 | } | ||
| 310 | .tool-description table { | ||
| 311 | width: 100%; | ||
| 312 | border-collapse: collapse; | ||
| 313 | font-size: 0.6875rem; | ||
| 314 | margin: 0.5em 0; | ||
| 315 | } | ||
| 316 | .tool-description th, | ||
| 317 | .tool-description td { | ||
| 318 | border: 1px solid var(--border); | ||
| 319 | padding: 0.3em 0.5em; | ||
| 320 | text-align: left; | ||
| 321 | } | ||
| 322 | .tool-description th { | ||
| 323 | background: var(--muted); | ||
| 324 | font-weight: 600; | ||
| 325 | color: var(--foreground); | ||
| 326 | } | ||
| 327 | .tool-description td { | ||
| 328 | background: var(--background); | ||
| 329 | } | ||
| 330 | .tool-description h1, | ||
| 331 | .tool-description h2, | ||
| 332 | .tool-description h3, | ||
| 333 | .tool-description h4 { | ||
| 334 | font-weight: 600; | ||
| 335 | color: var(--foreground); | ||
| 336 | margin-top: 0.5em; | ||
| 337 | margin-bottom: 0.25em; | ||
| 338 | } | ||
| 339 | .tool-description h1 { font-size: 0.875rem; } | ||
| 340 | .tool-description h2 { font-size: 0.8125rem; } | ||
| 341 | .tool-description h3 { font-size: 0.75rem; } | ||
| 342 | .tool-description h4 { font-size: 0.75rem; } | ||
| 343 | .tool-description blockquote { | ||
| 344 | border-left: 2px solid var(--border); | ||
| 345 | padding-left: 0.75em; | ||
| 346 | margin: 0.4em 0; | ||
| 347 | color: var(--muted-foreground); | ||
| 348 | } | ||
| 349 | .tool-description hr { | ||
| 350 | border: none; | ||
| 351 | border-top: 1px solid var(--border); | ||
| 352 | margin: 0.5em 0; | ||
| 353 | } | ||
| 354 | .tool-description img { | ||
| 355 | max-width: 100%; | ||
| 356 | border-radius: 0.25rem; | ||
| 357 | } | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import type { Metadata } from "next"; | 1 | import type { Metadata } from "next"; |
| 2 | import { JetBrains_Mono, Inter } from "next/font/google"; | 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 | import { AppShell } from "@/components/app-shell"; | 5 | import { AppShell } from "@/components/app-shell"; |
| 5 | import { AppConvexProvider } from "@/lib/convex-provider"; | 6 | import { AppConvexProvider } from "@/lib/convex-provider"; |
| 6 | import { QueryProvider } from "@/lib/query-provider"; | 7 | import { QueryProvider } from "@/lib/query-provider"; |
| @@ -28,28 +29,25 @@ export default function RootLayout({ · 21 unmodified lines | |||
| 28 | children: React.ReactNode; | 29 | children: React.ReactNode; |
| 29 | }>) { | 30 | }>) { |
| 30 | return ( | 31 | return ( |
| 31 | <html lang="en" className="dark"> | 32 | <html lang="en" suppressHydrationWarning> |
| 32 | <body | 33 | <body |
| 33 | className={`${inter.variable} ${jetbrainsMono.variable} antialiased`} | 34 | className={`${inter.variable} ${jetbrainsMono.variable} antialiased`} |
| 34 | > | 35 | > |
| 35 | <AppConvexProvider> | 36 | <ThemeProvider |
| 36 | <QueryProvider> | 37 | attribute="class" |
| 37 | <SessionProvider> | 38 | defaultTheme="dark" |
| 38 | <AppShell>{children}</AppShell> | 39 | enableSystem |
| 39 | </SessionProvider> | 40 | enableColorScheme |
| 40 | </QueryProvider> | 41 | > |
| 41 | </AppConvexProvider> | 42 | <AppConvexProvider> |
| 42 | <Toaster | 43 | <QueryProvider> |
| 43 | theme="dark" | 44 | <SessionProvider> |
| 44 | position="bottom-right" | 45 | <AppShell>{children}</AppShell> |
| 45 | toastOptions={{ | 46 | </SessionProvider> |
| 46 | style: { | 47 | </QueryProvider> |
| 47 | background: "oklch(0.15 0.005 260)", | 48 | </AppConvexProvider> |
| 48 | border: "1px solid oklch(0.24 0.008 260)", | 49 | <Toaster position="bottom-right" /> |
| 49 | color: "oklch(0.88 0.01 250)", | 50 | </ThemeProvider> |
| 50 | }, | ||
| 51 | }} | ||
| 52 | /> | ||
| 53 | </body> | 51 | </body> |
| 54 | </html> | 52 | </html> |
| 55 | ); | 53 | ); |
| @@ -1,5 +1,12 @@ | |||
| 1 | import { ToolsView } from "@/components/tools-view"; | 1 | import { ToolsView } from "@/components/tools-view"; |
| 2 | 2 | ||
| 3 | export default function ToolsPage() { | 3 | export default async function ToolsPage({ |
| 4 | return <ToolsView />; | 4 | searchParams, |
| 5 | }: { | ||
| 6 | searchParams: Promise<Record<string, string | string[] | undefined>>; | ||
| 7 | }) { | ||
| 8 | const params = await searchParams; | ||
| 9 | const source = typeof params.source === "string" ? params.source : null; | ||
| 10 | |||
| 11 | return <ToolsView initialSource={source} />; | ||
| 5 | } | 12 | } |
| @@ -39,6 +39,7 @@ import { Input } from "@/components/ui/input"; · 38 unmodified lines | |||
| 39 | import { useSession } from "@/lib/session-context"; | 39 | import { useSession } from "@/lib/session-context"; |
| 40 | import { convexApi } from "@/lib/convex-api"; | 40 | import { convexApi } from "@/lib/convex-api"; |
| 41 | import { workosEnabled } from "@/lib/auth-capabilities"; | 41 | import { workosEnabled } from "@/lib/auth-capabilities"; |
| 42 | import { ThemeSwitcher } from "@/components/theme-switcher"; | ||
| 42 | import { cn } from "@/lib/utils"; | 43 | import { cn } from "@/lib/utils"; |
| 43 | 44 | ||
| 44 | const NAV_ITEMS = [ | 45 | const NAV_ITEMS = [ |
| @@ -509,6 +510,9 @@ function Sidebar() { · 464 unmodified lines | |||
| 509 | <div className="flex-1 overflow-y-auto py-4 px-2"> | 510 | <div className="flex-1 overflow-y-auto py-4 px-2"> |
| 510 | <NavLinks /> | 511 | <NavLinks /> |
| 511 | </div> | 512 | </div> |
| 513 | <div className="px-3 py-2"> | ||
| 514 | <ThemeSwitcher /> | ||
| 515 | </div> | ||
| 512 | <div className="pb-4"> | 516 | <div className="pb-4"> |
| 513 | <Suspense> | 517 | <Suspense> |
| 514 | <SessionInfo /> | 518 | <SessionInfo /> |
| @@ -540,6 +544,9 @@ function MobileHeader() { · 25 unmodified lines | |||
| 540 | <div className="py-4 px-2"> | 544 | <div className="py-4 px-2"> |
| 541 | <NavLinks onClick={() => setOpen(false)} /> | 545 | <NavLinks onClick={() => setOpen(false)} /> |
| 542 | </div> | 546 | </div> |
| 547 | <div className="px-3 py-2"> | ||
| 548 | <ThemeSwitcher /> | ||
| 549 | </div> | ||
| 543 | <div className="mt-auto pb-4"> | 550 | <div className="mt-auto pb-4"> |
| 544 | <Suspense> | 551 | <Suspense> |
| 545 | <SessionInfo /> | 552 | <SessionInfo /> |
| @@ -181,7 +181,7 @@ export function ApprovalsView() { · 180 unmodified lines | |||
| 181 | <Clock className="h-3.5 w-3.5" /> | 181 | <Clock className="h-3.5 w-3.5" /> |
| 182 | {count} pending approval{count !== 1 ? "s" : ""} | 182 | {count} pending approval{count !== 1 ? "s" : ""} |
| 183 | </div> | 183 | </div> |
| 184 | {approvals!.map((a) => ( | 184 | {approvals!.map((a: PendingApprovalRecord) => ( |
| 185 | <ApprovalCard key={a.id} approval={a} /> | 185 | <ApprovalCard key={a.id} approval={a} /> |
| 186 | ))} | 186 | ))} |
| 187 | </div> | 187 | </div> |
| @@ -1,5 +1,6 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useMemo } from "react"; | ||
| 3 | import { useRouter } from "next/navigation"; | 4 | import { useRouter } from "next/navigation"; |
| 4 | import { | 5 | import { |
| 5 | Play, | 6 | Play, |
| @@ -9,6 +10,10 @@ import { · 3 unmodified lines | |||
| 9 | XCircle, | 10 | XCircle, |
| 10 | AlertTriangle, | 11 | AlertTriangle, |
| 11 | ArrowRight, | 12 | ArrowRight, |
| 13 | Wrench, | ||
| 14 | ChevronRight, | ||
| 15 | Server, | ||
| 16 | Globe, | ||
| 12 | } from "lucide-react"; | 17 | } from "lucide-react"; |
| 13 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | 18 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 14 | import { Button } from "@/components/ui/button"; | 19 | import { Button } from "@/components/ui/button"; |
| @@ -19,7 +24,7 @@ import { useSession } from "@/lib/session-context"; · 4 unmodified lines | |||
| 19 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 24 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 20 | import { useQuery } from "convex/react"; | 25 | import { useQuery } from "convex/react"; |
| 21 | import { convexApi } from "@/lib/convex-api"; | 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 | import { formatTime, formatTimeAgo } from "@/lib/format"; | 28 | import { formatTime, formatTimeAgo } from "@/lib/format"; |
| 24 | 29 | ||
| 25 | function StatCard({ | 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 | export function DashboardView() { | 232 | export function DashboardView() { |
| 106 | const { context, loading: sessionLoading } = useSession(); | 233 | const { context, loading: sessionLoading } = useSession(); |
| 107 | 234 | ||
| @@ -132,11 +259,11 @@ export function DashboardView() { · 24 unmodified lines | |||
| 132 | 259 | ||
| 133 | const pendingCount = approvals?.length ?? 0; | 260 | const pendingCount = approvals?.length ?? 0; |
| 134 | const runningCount = | 261 | const runningCount = |
| 135 | tasks?.filter((t) => t.status === "running").length ?? 0; | 262 | tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0; |
| 136 | const completedCount = | 263 | const completedCount = |
| 137 | tasks?.filter((t) => t.status === "completed").length ?? 0; | 264 | tasks?.filter((t: TaskRecord) => t.status === "completed").length ?? 0; |
| 138 | const failedCount = | 265 | const failedCount = |
| 139 | tasks?.filter((t) => ["failed", "timed_out", "denied"].includes(t.status)) | 266 | tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status)) |
| 140 | .length ?? 0; | 267 | .length ?? 0; |
| 141 | const recentTasks = (tasks ?? []).slice(0, 8); | 268 | const recentTasks = (tasks ?? []).slice(0, 8); |
| 142 | 269 | ||
| @@ -208,7 +335,7 @@ export function DashboardView() { · 65 unmodified lines | |||
| 208 | </div> | 335 | </div> |
| 209 | ) : ( | 336 | ) : ( |
| 210 | <div className="space-y-0.5"> | 337 | <div className="space-y-0.5"> |
| 211 | {(approvals ?? []).slice(0, 5).map((a) => ( | 338 | {(approvals ?? []).slice(0, 5).map((a: PendingApprovalRecord) => ( |
| 212 | <PendingApprovalRow key={a.id} approval={a} /> | 339 | <PendingApprovalRow key={a.id} approval={a} /> |
| 213 | ))} | 340 | ))} |
| 214 | </div> | 341 | </div> |
| @@ -241,7 +368,7 @@ export function DashboardView() { · 26 unmodified lines | |||
| 241 | </div> | 368 | </div> |
| 242 | ) : ( | 369 | ) : ( |
| 243 | <div className="space-y-0.5"> | 370 | <div className="space-y-0.5"> |
| 244 | {recentTasks.map((t) => ( | 371 | {recentTasks.map((t: TaskRecord) => ( |
| 245 | <RecentTaskRow key={t.id} task={t} /> | 372 | <RecentTaskRow key={t.id} task={t} /> |
| 246 | ))} | 373 | ))} |
| 247 | </div> | 374 | </div> |
| @@ -250,34 +377,8 @@ export function DashboardView() { · 2 unmodified lines | |||
| 250 | </Card> | 377 | </Card> |
| 251 | </div> | 378 | </div> |
| 252 | 379 | ||
| 253 | {/* Tools summary */} | 380 | {/* Tools summary — grouped by source */} |
| 254 | {tools.length > 0 && ( | 381 | {tools.length > 0 && <ToolsSummaryCard tools={tools} />} |
| 255 | <Card className="bg-card border-border"> | ||
| 256 | <CardHeader className="pb-3"> | ||
| 257 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | ||
| 258 | Available Tools | ||
| 259 | <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> | ||
| 260 | {tools.length} | ||
| 261 | </span> | ||
| 262 | </CardTitle> | ||
| 263 | </CardHeader> | ||
| 264 | <CardContent className="pt-0"> | ||
| 265 | <div className="flex flex-wrap gap-2"> | ||
| 266 | {tools.map((tool) => ( | ||
| 267 | <span | ||
| 268 | key={tool.path} | ||
| 269 | className="inline-flex items-center gap-1 text-[11px] font-mono px-2 py-1 rounded bg-muted text-muted-foreground" | ||
| 270 | > | ||
| 271 | {tool.approval === "required" && ( | ||
| 272 | <ShieldCheck className="h-3 w-3 text-terminal-amber" /> | ||
| 273 | )} | ||
| 274 | {tool.path} | ||
| 275 | </span> | ||
| 276 | ))} | ||
| 277 | </div> | ||
| 278 | </CardContent> | ||
| 279 | </Card> | ||
| 280 | )} | ||
| 281 | </div> | 382 | </div> |
| 282 | ); | 383 | ); |
| 283 | } | 384 | } |
| @@ -38,7 +38,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 37 unmodified lines | |||
| 38 | ); | 38 | ); |
| 39 | 39 | ||
| 40 | const memberItems = members?.items ?? []; | 40 | const memberItems = members?.items ?? []; |
| 41 | const actorMembership = memberItems.find((member) => | 41 | const actorMembership = memberItems.find((member: { accountId?: string; role?: string }) => |
| 42 | context?.accountId ? member.accountId === context.accountId : false, | 42 | context?.accountId ? member.accountId === context.accountId : false, |
| 43 | ); | 43 | ); |
| 44 | const actorRole = actorMembership?.role ?? null; | 44 | const actorRole = actorMembership?.role ?? null; |
| @@ -65,7 +65,8 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 20 unmodified lines | |||
| 65 | const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null); | 65 | const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null); |
| 66 | 66 | ||
| 67 | const inviteItems = listInvites?.items ?? []; | 67 | const inviteItems = listInvites?.items ?? []; |
| 68 | const pendingInviteItems = inviteItems.filter((invite) => invite.status === "pending" || invite.status === "failed"); | 68 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 69 | const pendingInviteItems = inviteItems.filter((invite: any) => invite.status === "pending" || invite.status === "failed"); | ||
| 69 | 70 | ||
| 70 | const submitInvite = async () => { | 71 | const submitInvite = async () => { |
| 71 | if (!typedOrganizationId) { | 72 | if (!typedOrganizationId) { |
| @@ -173,7 +174,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 101 unmodified lines | |||
| 173 | {memberItems.length === 0 ? ( | 174 | {memberItems.length === 0 ? ( |
| 174 | <p className="text-sm text-muted-foreground">No members found.</p> | 175 | <p className="text-sm text-muted-foreground">No members found.</p> |
| 175 | ) : ( | 176 | ) : ( |
| 176 | memberItems.map((member) => ( | 177 | memberItems.map((member: any) => ( |
| 177 | <div key={member.id} className="rounded-md border border-border p-3"> | 178 | <div key={member.id} className="rounded-md border border-border p-3"> |
| 178 | <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> | 179 | <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> |
| 179 | <div> | 180 | <div> |
| @@ -277,7 +278,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 97 unmodified lines | |||
| 277 | ) : pendingInviteItems.length === 0 ? ( | 278 | ) : pendingInviteItems.length === 0 ? ( |
| 278 | <p className="text-sm text-muted-foreground">No pending invites.</p> | 279 | <p className="text-sm text-muted-foreground">No pending invites.</p> |
| 279 | ) : ( | 280 | ) : ( |
| 280 | pendingInviteItems.map((invite) => ( | 281 | pendingInviteItems.map((invite: any) => ( |
| 281 | <div key={invite.id} className="rounded-md border border-border p-3 text-sm"> | 282 | <div key={invite.id} className="rounded-md border border-border p-3 text-sm"> |
| 282 | <div className="flex items-start justify-between gap-2"> | 283 | <div className="flex items-start justify-between gap-2"> |
| 283 | <div> | 284 | <div> |
| @@ -110,7 +110,7 @@ function TaskComposer() { · 109 unmodified lines | |||
| 110 | <SelectValue /> | 110 | <SelectValue /> |
| 111 | </SelectTrigger> | 111 | </SelectTrigger> |
| 112 | <SelectContent> | 112 | <SelectContent> |
| 113 | {(runtimes ?? []).map((r) => ( | 113 | {(runtimes ?? []).map((r: any) => ( |
| 114 | <SelectItem key={r.id} value={r.id} className="text-xs"> | 114 | <SelectItem key={r.id} value={r.id} className="text-xs"> |
| 115 | {r.label} | 115 | {r.label} |
| 116 | </SelectItem> | 116 | </SelectItem> |
| @@ -224,8 +224,8 @@ function TaskDetail({ · 107 unmodified lines | |||
| 224 | const liveTask = liveTaskData ?? task; | 224 | const liveTask = liveTaskData ?? task; |
| 225 | const liveStdout = useMemo(() => { | 225 | const liveStdout = useMemo(() => { |
| 226 | const stdoutLines = (taskEvents ?? []) | 226 | const stdoutLines = (taskEvents ?? []) |
| 227 | .filter((event) => event.type === "task.stdout") | 227 | .filter((event: any) => event.type === "task.stdout") |
| 228 | .map((event) => String((event.payload as Record<string, unknown>)?.line ?? "")); | 228 | .map((event: any) => String((event.payload as Record<string, unknown>)?.line ?? "")); |
| 229 | if (stdoutLines.length > 0) { | 229 | if (stdoutLines.length > 0) { |
| 230 | return stdoutLines.join("\n"); | 230 | return stdoutLines.join("\n"); |
| 231 | } | 231 | } |
| @@ -234,8 +234,8 @@ function TaskDetail({ · 2 unmodified lines | |||
| 234 | 234 | ||
| 235 | const liveStderr = useMemo(() => { | 235 | const liveStderr = useMemo(() => { |
| 236 | const stderrLines = (taskEvents ?? []) | 236 | const stderrLines = (taskEvents ?? []) |
| 237 | .filter((event) => event.type === "task.stderr") | 237 | .filter((event: any) => event.type === "task.stderr") |
| 238 | .map((event) => String((event.payload as Record<string, unknown>)?.line ?? "")); | 238 | .map((event: any) => String((event.payload as Record<string, unknown>)?.line ?? "")); |
| 239 | if (stderrLines.length > 0) { | 239 | if (stderrLines.length > 0) { |
| 240 | return stderrLines.join("\n"); | 240 | return stderrLines.join("\n"); |
| 241 | } | 241 | } |
| @@ -357,7 +357,7 @@ export function TasksView() { · 115 unmodified lines | |||
| 357 | ); | 357 | ); |
| 358 | const tasksLoading = !!context && tasks === undefined; | 358 | const tasksLoading = !!context && tasks === undefined; |
| 359 | 359 | ||
| 360 | const selectedTask = tasks?.find((t) => t.id === selectedId); | 360 | const selectedTask = tasks?.find((t: any) => t.id === selectedId); |
| 361 | 361 | ||
| 362 | const selectTask = useCallback( | 362 | const selectTask = useCallback( |
| 363 | (taskId: string | null) => { | 363 | (taskId: string | null) => { |
| @@ -415,7 +415,7 @@ export function TasksView() { · 51 unmodified lines | |||
| 415 | </div> | 415 | </div> |
| 416 | ) : ( | 416 | ) : ( |
| 417 | <div className="space-y-1 max-h-[500px] overflow-y-auto"> | 417 | <div className="space-y-1 max-h-[500px] overflow-y-auto"> |
| 418 | {tasks.map((task) => ( | 418 | {tasks.map((task: any) => ( |
| 419 | <TaskListItem | 419 | <TaskListItem |
| 420 | key={task.id} | 420 | key={task.id} |
| 421 | task={task} | 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 | ShieldCheck, | 8 | ShieldCheck, |
| 9 | Globe, | 9 | Globe, |
| 10 | Server, | 10 | Server, |
| 11 | Zap, | ||
| 12 | Search, | ||
| 13 | ChevronRight, | 11 | ChevronRight, |
| 14 | AlertTriangle, | 12 | AlertTriangle, |
| 15 | KeyRound, | 13 | KeyRound, |
| @@ -45,6 +43,7 @@ import { Skeleton } from "@/components/ui/skeleton"; · 29 unmodified lines | |||
| 45 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | 43 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 46 | import { PageHeader } from "@/components/page-header"; | 44 | import { PageHeader } from "@/components/page-header"; |
| 47 | import { McpSetupCard } from "@/components/mcp-setup-card"; | 45 | import { McpSetupCard } from "@/components/mcp-setup-card"; |
| 46 | import { ToolExplorer } from "@/components/tool-explorer"; | ||
| 48 | import { useSession } from "@/lib/session-context"; | 47 | import { useSession } from "@/lib/session-context"; |
| 49 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; | 48 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 50 | import { useAction, useMutation, useQuery } from "convex/react"; | 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 ── | 1333 | // ── Tool Inventory (legacy removed — replaced by ToolExplorer) ── |
| 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 | } | ||
| 1396 | 1334 | ||
| 1397 | // ── Tools View ── | 1335 | // ── Tools View ── |
| 1398 | 1336 | ||
| 1399 | export function ToolsView() { | 1337 | export function ToolsView({ initialSource }: { initialSource?: string | null }) { |
| 1400 | const { context, loading: sessionLoading } = useSession(); | 1338 | const { context, loading: sessionLoading } = useSession(); |
| 1401 | 1339 | ||
| 1402 | const sources = useQuery( | 1340 | const sources = useQuery( |
| @@ -1429,7 +1367,7 @@ export function ToolsView() { · 26 unmodified lines | |||
| 1429 | description="Manage sources, auth, credentials, and available tools" | 1367 | description="Manage sources, auth, credentials, and available tools" |
| 1430 | /> | 1368 | /> |
| 1431 | 1369 | ||
| 1432 | <Tabs defaultValue="sources" className="w-full"> | 1370 | <Tabs defaultValue={initialSource ? "inventory" : "sources"} className="w-full"> |
| 1433 | <TabsList className="bg-muted/50 h-9"> | 1371 | <TabsList className="bg-muted/50 h-9"> |
| 1434 | <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background"> | 1372 | <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background"> |
| 1435 | Sources | 1373 | Sources |
| @@ -1466,7 +1404,7 @@ export function ToolsView() { · 30 unmodified lines | |||
| 1466 | <Server className="h-4 w-4 text-muted-foreground" /> | 1404 | <Server className="h-4 w-4 text-muted-foreground" /> |
| 1467 | Tool Sources | 1405 | Tool Sources |
| 1468 | </CardTitle> | 1406 | </CardTitle> |
| 1469 | <AddSourceDialog existingSourceNames={new Set((sources ?? []).map(s => s.name))} /> | 1407 | <AddSourceDialog existingSourceNames={new Set((sources ?? []).map((s: any) => s.name))} /> |
| 1470 | </div> | 1408 | </div> |
| 1471 | </CardHeader> | 1409 | </CardHeader> |
| 1472 | <CardContent className="pt-0"> | 1410 | <CardContent className="pt-0"> |
| @@ -1497,7 +1435,7 @@ export function ToolsView() { · 24 unmodified lines | |||
| 1497 | Source load warnings ({warnings.length}) | 1435 | Source load warnings ({warnings.length}) |
| 1498 | </div> | 1436 | </div> |
| 1499 | <div className="mt-1.5 space-y-1"> | 1437 | <div className="mt-1.5 space-y-1"> |
| 1500 | {warnings.slice(0, 3).map((warning, i) => ( | 1438 | {warnings.slice(0, 3).map((warning: any, i: any) => ( |
| 1501 | <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90"> | 1439 | <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90"> |
| 1502 | {warning} | 1440 | {warning} |
| 1503 | </p> | 1441 | </p> |
| @@ -1510,7 +1448,7 @@ export function ToolsView() { · 6 unmodified lines | |||
| 1510 | </div> | 1448 | </div> |
| 1511 | </div> | 1449 | </div> |
| 1512 | )} | 1450 | )} |
| 1513 | {sources.map((s) => ( | 1451 | {sources.map((s: any) => ( |
| 1514 | <SourceCard key={s.id} source={s} /> | 1452 | <SourceCard key={s.id} source={s} /> |
| 1515 | ))} | 1453 | ))} |
| 1516 | </div> | 1454 | </div> |
| @@ -1528,36 +1466,13 @@ export function ToolsView() { · 11 unmodified lines | |||
| 1528 | </TabsContent> | 1466 | </TabsContent> |
| 1529 | 1467 | ||
| 1530 | <TabsContent value="inventory" className="mt-4"> | 1468 | <TabsContent value="inventory" className="mt-4"> |
| 1531 | <Card className="bg-card border-border"> | 1469 | <ToolExplorer |
| 1532 | <CardHeader className="pb-3"> | 1470 | tools={tools} |
| 1533 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | 1471 | sources={sources ?? []} |
| 1534 | <Zap className="h-4 w-4 text-primary" /> | 1472 | loading={toolsLoading} |
| 1535 | Available Tools | 1473 | warnings={warnings} |
| 1536 | <span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded"> | 1474 | initialSource={initialSource} |
| 1537 | {toolsLoading ? "…" : tools.length} | 1475 | /> |
| 1538 | </span> | ||
| 1539 | </CardTitle> | ||
| 1540 | </CardHeader> | ||
| 1541 | <CardContent className="pt-0"> | ||
| 1542 | {warnings.length > 0 && !toolsLoading && ( | ||
| 1543 | <div className="mb-3 rounded-md border border-terminal-amber/30 bg-terminal-amber/10 px-3 py-2.5"> | ||
| 1544 | <div className="flex items-center gap-1.5 text-[11px] font-medium text-terminal-amber"> | ||
| 1545 | <AlertTriangle className="h-3.5 w-3.5" /> | ||
| 1546 | Some sources failed to load; inventory may be incomplete | ||
| 1547 | </div> | ||
| 1548 | </div> | ||
| 1549 | )} | ||
| 1550 | {toolsLoading ? ( | ||
| 1551 | <div className="space-y-2"> | ||
| 1552 | {Array.from({ length: 5 }).map((_, i) => ( | ||
| 1553 | <Skeleton key={i} className="h-14" /> | ||
| 1554 | ))} | ||
| 1555 | </div> | ||
| 1556 | ) : ( | ||
| 1557 | <ToolInventory tools={tools} /> | ||
| 1558 | )} | ||
| 1559 | </CardContent> | ||
| 1560 | </Card> | ||
| 1561 | </TabsContent> | 1476 | </TabsContent> |
| 1562 | 1477 | ||
| 1563 | <TabsContent value="mcp" className="mt-4"> | 1478 | <TabsContent value="mcp" className="mt-4"> |
| @@ -158,13 +158,13 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 157 unmodified lines | |||
| 158 | return activeWorkspaceId; | 158 | return activeWorkspaceId; |
| 159 | } | 159 | } |
| 160 | 160 | ||
| 161 | if (activeWorkspaceId && workspaces.some((workspace) => workspace.id === activeWorkspaceId)) { | 161 | if (activeWorkspaceId && workspaces.some((workspace: any) => workspace.id === activeWorkspaceId)) { |
| 162 | return activeWorkspaceId; | 162 | return activeWorkspaceId; |
| 163 | } | 163 | } |
| 164 | 164 | ||
| 165 | const accountId = account?.provider === "workos" ? account._id : null; | 165 | const accountId = account?.provider === "workos" ? account._id : null; |
| 166 | const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null; | 166 | const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null; |
| 167 | if (accountStoredWorkspace && workspaces.some((workspace) => workspace.id === accountStoredWorkspace)) { | 167 | if (accountStoredWorkspace && workspaces.some((workspace: any) => workspace.id === accountStoredWorkspace)) { |
| 168 | return accountStoredWorkspace; | 168 | return accountStoredWorkspace; |
| 169 | } | 169 | } |
| 170 | 170 | ||
| @@ -265,7 +265,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 94 unmodified lines | |||
| 265 | } | 265 | } |
| 266 | 266 | ||
| 267 | const activeWorkspace = | 267 | const activeWorkspace = |
| 268 | workspaces.find((workspace) => workspace.id === resolvedActiveWorkspaceId) | 268 | workspaces.find((workspace: any) => workspace.id === resolvedActiveWorkspaceId) |
| 269 | ?? workspaces[0] | 269 | ?? workspaces[0] |
| 270 | ?? null; | 270 | ?? null; |
| 271 | if (!activeWorkspace) { | 271 | if (!activeWorkspace) { |
| @@ -318,7 +318,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 46 unmodified lines | |||
| 318 | ); | 318 | ); |
| 319 | const workspaceOptions = useMemo(() => { | 319 | const workspaceOptions = useMemo(() => { |
| 320 | if (mode === "workos" && workspaces) { | 320 | if (mode === "workos" && workspaces) { |
| 321 | return workspaces.map((workspace): SessionState["workspaces"][number] => ({ | 321 | return workspaces.map((workspace: any): SessionState["workspaces"][number] => ({ |
| 322 | id: workspace.id, | 322 | id: workspace.id, |
| 323 | docId: workspace.id, | 323 | docId: workspace.id, |
| 324 | name: workspace.name, | 324 | name: workspace.name, |
| @@ -5,15 +5,54 @@ · 4 unmodified lines | |||
| 5 | * THIS CODE IS AUTOMATICALLY GENERATED. | 5 | * THIS CODE IS AUTOMATICALLY GENERATED. |
| 6 | * | 6 | * |
| 7 | * To regenerate, run `npx convex dev`. | 7 | * To regenerate, run `npx convex dev`. |
| 8 | * | ||
| 9 | * NOTE: This file has been post-processed by scripts/postcodegen.ts | ||
| 10 | * to replace recursive ApiFromModules/FilterApi types with explicit | ||
| 11 | * declarations, avoiding TS2589 depth errors. | ||
| 12 | * | ||
| 13 | * @module | 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 | * A utility for referencing Convex functions in your app's public API. | 58 | * A utility for referencing Convex functions in your app's public API. |
| @@ -23,74 +62,10 @@ import type { FunctionReference } from "convex/server"; · 3 unmodified lines | |||
| 23 | * const myFunctionReference = api.myModule.myFunction; | 62 | * const myFunctionReference = api.myModule.myFunction; |
| 24 | * ``` | 63 | * ``` |
| 25 | */ | 64 | */ |
| 26 | export declare const api: { | 65 | export declare const api: FilterApi< |
| 27 | app: { | 66 | typeof fullApi, |
| 28 | getClientConfig: FunctionReference<"query", "public", any, any>; | 67 | FunctionReference<any, "public"> |
| 29 | getCurrentAccount: FunctionReference<"query", "public", any, any>; | 68 | >; |
| 30 | }; | ||
| 31 | auth: { | ||
| 32 | bootstrapCurrentWorkosAccount: FunctionReference<"mutation", "public", any, any>; | ||
| 33 | }; | ||
| 34 | billing: { | ||
| 35 | getSummary: FunctionReference<"query", "public", any, any>; | ||
| 36 | createSubscriptionCheckout: FunctionReference<"action", "public", any, any>; | ||
| 37 | createCustomerPortal: FunctionReference<"action", "public", any, any>; | ||
| 38 | retrySeatSync: FunctionReference<"mutation", "public", any, any>; | ||
| 39 | }; | ||
| 40 | credentialsNode: { | ||
| 41 | upsertCredential: FunctionReference<"action", "public", any, any>; | ||
| 42 | }; | ||
| 43 | executor: { | ||
| 44 | createTask: FunctionReference<"mutation", "public", any, any>; | ||
| 45 | resolveApproval: FunctionReference<"mutation", "public", any, any>; | ||
| 46 | }; | ||
| 47 | executorNode: { | ||
| 48 | listTools: FunctionReference<"action", "public", any, any>; | ||
| 49 | listToolsWithWarnings: FunctionReference<"action", "public", any, any>; | ||
| 50 | }; | ||
| 51 | invites: { | ||
| 52 | list: FunctionReference<"query", "public", any, any>; | ||
| 53 | create: FunctionReference<"mutation", "public", any, any>; | ||
| 54 | revoke: FunctionReference<"mutation", "public", any, any>; | ||
| 55 | }; | ||
| 56 | organizationMembers: { | ||
| 57 | list: FunctionReference<"query", "public", any, any>; | ||
| 58 | updateRole: FunctionReference<"mutation", "public", any, any>; | ||
| 59 | updateBillable: FunctionReference<"mutation", "public", any, any>; | ||
| 60 | remove: FunctionReference<"mutation", "public", any, any>; | ||
| 61 | }; | ||
| 62 | organizations: { | ||
| 63 | create: FunctionReference<"mutation", "public", any, any>; | ||
| 64 | listMine: FunctionReference<"query", "public", any, any>; | ||
| 65 | getNavigationState: FunctionReference<"query", "public", any, any>; | ||
| 66 | getOrganizationAccess: FunctionReference<"query", "public", any, any>; | ||
| 67 | resolveWorkosOrganizationId: FunctionReference<"query", "public", any, any>; | ||
| 68 | }; | ||
| 69 | workspace: { | ||
| 70 | bootstrapAnonymousSession: FunctionReference<"mutation", "public", any, any>; | ||
| 71 | listRuntimeTargets: FunctionReference<"query", "public", any, any>; | ||
| 72 | getTask: FunctionReference<"query", "public", any, any>; | ||
| 73 | getTaskInWorkspace: FunctionReference<"query", "public", any, any>; | ||
| 74 | listTasks: FunctionReference<"query", "public", any, any>; | ||
| 75 | listApprovals: FunctionReference<"query", "public", any, any>; | ||
| 76 | listPendingApprovals: FunctionReference<"query", "public", any, any>; | ||
| 77 | listTaskEvents: FunctionReference<"query", "public", any, any>; | ||
| 78 | upsertAccessPolicy: FunctionReference<"mutation", "public", any, any>; | ||
| 79 | listAccessPolicies: FunctionReference<"query", "public", any, any>; | ||
| 80 | upsertCredential: FunctionReference<"mutation", "public", any, any>; | ||
| 81 | listCredentials: FunctionReference<"query", "public", any, any>; | ||
| 82 | listCredentialProviders: FunctionReference<"query", "public", any, any>; | ||
| 83 | resolveCredential: FunctionReference<"query", "public", any, any>; | ||
| 84 | upsertToolSource: FunctionReference<"mutation", "public", any, any>; | ||
| 85 | listToolSources: FunctionReference<"query", "public", any, any>; | ||
| 86 | deleteToolSource: FunctionReference<"mutation", "public", any, any>; | ||
| 87 | }; | ||
| 88 | workspaces: { | ||
| 89 | create: FunctionReference<"mutation", "public", any, any>; | ||
| 90 | list: FunctionReference<"query", "public", any, any>; | ||
| 91 | generateWorkspaceIconUploadUrl: FunctionReference<"mutation", "public", any, any>; | ||
| 92 | }; | ||
| 93 | }; | ||
| 94 | 69 | ||
| 95 | /** | 70 | /** |
| 96 | * A utility for referencing Convex functions in your app's internal API. | 71 | * A utility for referencing Convex functions in your app's internal API. |
| @@ -100,86 +75,10 @@ export declare const api: { · 3 unmodified lines | |||
| 100 | * const myFunctionReference = internal.myModule.myFunction; | 75 | * const myFunctionReference = internal.myModule.myFunction; |
| 101 | * ``` | 76 | * ``` |
| 102 | */ | 77 | */ |
| 103 | export declare const internal: { | 78 | export declare const internal: FilterApi< |
| 104 | auth: { | 79 | typeof fullApi, |
| 105 | authKitEvent: FunctionReference<"mutation", "internal", any, any>; | 80 | FunctionReference<any, "internal"> |
| 106 | }; | 81 | >; |
| 107 | billingInternal: { | ||
| 108 | getBillingAccessForRequest: FunctionReference<"query", "internal", any, any>; | ||
| 109 | getSeatSyncSnapshot: FunctionReference<"query", "internal", any, any>; | ||
| 110 | upsertCustomerLink: FunctionReference<"mutation", "internal", any, any>; | ||
| 111 | bumpSeatSyncVersion: FunctionReference<"mutation", "internal", any, any>; | ||
| 112 | upsertSeatState: FunctionReference<"mutation", "internal", any, any>; | ||
| 113 | }; | ||
| 114 | billingSync: { | ||
| 115 | syncSeatQuantity: FunctionReference<"action", "internal", any, any>; | ||
| 116 | }; | ||
| 117 | database: { | ||
| 118 | createTask: FunctionReference<"mutation", "internal", any, any>; | ||
| 119 | getTask: FunctionReference<"query", "internal", any, any>; | ||
| 120 | listTasks: FunctionReference<"query", "internal", any, any>; | ||
| 121 | listQueuedTaskIds: FunctionReference<"query", "internal", any, any>; | ||
| 122 | listRuntimeTargets: FunctionReference<"query", "internal", any, any>; | ||
| 123 | getTaskInWorkspace: FunctionReference<"query", "internal", any, any>; | ||
| 124 | markTaskRunning: FunctionReference<"mutation", "internal", any, any>; | ||
| 125 | markTaskFinished: FunctionReference<"mutation", "internal", any, any>; | ||
| 126 | createApproval: FunctionReference<"mutation", "internal", any, any>; | ||
| 127 | getApproval: FunctionReference<"query", "internal", any, any>; | ||
| 128 | listApprovals: FunctionReference<"query", "internal", any, any>; | ||
| 129 | listPendingApprovals: FunctionReference<"query", "internal", any, any>; | ||
| 130 | resolveApproval: FunctionReference<"mutation", "internal", any, any>; | ||
| 131 | getApprovalInWorkspace: FunctionReference<"query", "internal", any, any>; | ||
| 132 | createAgentTask: FunctionReference<"mutation", "internal", any, any>; | ||
| 133 | getAgentTask: FunctionReference<"query", "internal", any, any>; | ||
| 134 | updateAgentTask: FunctionReference<"mutation", "internal", any, any>; | ||
| 135 | bootstrapAnonymousSession: FunctionReference<"mutation", "internal", any, any>; | ||
| 136 | upsertAccessPolicy: FunctionReference<"mutation", "internal", any, any>; | ||
| 137 | listAccessPolicies: FunctionReference<"query", "internal", any, any>; | ||
| 138 | upsertCredential: FunctionReference<"mutation", "internal", any, any>; | ||
| 139 | listCredentials: FunctionReference<"query", "internal", any, any>; | ||
| 140 | listCredentialProviders: FunctionReference<"query", "internal", any, any>; | ||
| 141 | resolveCredential: FunctionReference<"query", "internal", any, any>; | ||
| 142 | upsertToolSource: FunctionReference<"mutation", "internal", any, any>; | ||
| 143 | listToolSources: FunctionReference<"query", "internal", any, any>; | ||
| 144 | deleteToolSource: FunctionReference<"mutation", "internal", any, any>; | ||
| 145 | createTaskEvent: FunctionReference<"mutation", "internal", any, any>; | ||
| 146 | listTaskEvents: FunctionReference<"query", "internal", any, any>; | ||
| 147 | }; | ||
| 148 | executor: { | ||
| 149 | createTaskInternal: FunctionReference<"mutation", "internal", any, any>; | ||
| 150 | resolveApprovalInternal: FunctionReference<"mutation", "internal", any, any>; | ||
| 151 | appendRuntimeOutput: FunctionReference<"mutation", "internal", any, any>; | ||
| 152 | }; | ||
| 153 | executorNode: { | ||
| 154 | listToolsInternal: FunctionReference<"action", "internal", any, any>; | ||
| 155 | listToolsWithWarningsInternal: FunctionReference<"action", "internal", any, any>; | ||
| 156 | handleExternalToolCall: FunctionReference<"action", "internal", any, any>; | ||
| 157 | runTask: FunctionReference<"action", "internal", any, any>; | ||
| 158 | }; | ||
| 159 | invites: { | ||
| 160 | deliverWorkosInvite: FunctionReference<"action", "internal", any, any>; | ||
| 161 | revokeWorkosInvite: FunctionReference<"action", "internal", any, any>; | ||
| 162 | getInviteDeliveryContext: FunctionReference<"query", "internal", any, any>; | ||
| 163 | linkOrganizationToWorkos: FunctionReference<"mutation", "internal", any, any>; | ||
| 164 | getInviteById: FunctionReference<"query", "internal", any, any>; | ||
| 165 | markInviteDelivered: FunctionReference<"mutation", "internal", any, any>; | ||
| 166 | markInviteDeliveryFailed: FunctionReference<"mutation", "internal", any, any>; | ||
| 167 | }; | ||
| 168 | openApiSpecCache: { | ||
| 169 | getEntry: FunctionReference<"query", "internal", any, any>; | ||
| 170 | putEntry: FunctionReference<"mutation", "internal", any, any>; | ||
| 171 | pruneExpired: FunctionReference<"mutation", "internal", any, any>; | ||
| 172 | }; | ||
| 173 | workspaceAuthInternal: { | ||
| 174 | getWorkspaceAccessForRequest: FunctionReference<"query", "internal", any, any>; | ||
| 175 | getWorkspaceAccessForWorkosSubject: FunctionReference<"query", "internal", any, any>; | ||
| 176 | }; | ||
| 177 | workspaceToolCache: { | ||
| 178 | getEntry: FunctionReference<"query", "internal", any, any>; | ||
| 179 | putEntry: FunctionReference<"mutation", "internal", any, any>; | ||
| 180 | getDtsStorageIds: FunctionReference<"query", "internal", any, any>; | ||
| 181 | }; | ||
| 182 | }; | ||
| 183 | 82 | ||
| 184 | export declare const components: { | 83 | export declare const components: { |
| 185 | workOSAuthKit: { | 84 | workOSAuthKit: { |
| @@ -571,4 +470,3 @@ export declare const components: { · 385 unmodified lines | |||
| 571 | }; | 470 | }; |
| 572 | }; | 471 | }; |
| 573 | }; | 472 | }; |
| 574 | |||
| @@ -128,40 +128,10 @@ interface DtsStorageEntry { · 127 unmodified lines | |||
| 128 | storageId: string; | 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 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 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 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ | 133 | /** Cache version — bump when PreparedOpenApiSpec shape changes. */ |
| 147 | const OPENAPI_CACHE_VERSION = "v3"; | 134 | const OPENAPI_CACHE_VERSION = "v4"; |
| 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 | } | ||
| 165 | 135 | ||
| 166 | async function publish( | 136 | async function publish( |
| 167 | ctx: any, | 137 | ctx: any, |
| @@ -277,173 +247,127 @@ async function loadSourceTools( · 109 unmodified lines | |||
| 277 | 247 | ||
| 278 | interface WorkspaceToolsResult { | 248 | interface WorkspaceToolsResult { |
| 279 | tools: Map<string, ToolDefinition>; | 249 | tools: Map<string, ToolDefinition>; |
| 250 | warnings: string[]; | ||
| 280 | dtsStorageIds: DtsStorageEntry[]; | 251 | dtsStorageIds: DtsStorageEntry[]; |
| 281 | } | 252 | } |
| 282 | 253 | ||
| 283 | async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<WorkspaceToolsResult> { | 254 | async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<WorkspaceToolsResult> { |
| 284 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) | 255 | const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) |
| 285 | .filter((source: { enabled: boolean }) => source.enabled); | 256 | .filter((source: { enabled: boolean }) => source.enabled); |
| 286 | const now = Date.now(); | ||
| 287 | const signature = sourceSignature(workspaceId, sources); | 257 | const signature = sourceSignature(workspaceId, sources); |
| 288 | 258 | ||
| 289 | // ── Layer 1: In-memory cache (same warm worker) ─────────────────────── | 259 | // ── Layer 1: Persistent workspace cache (Convex file storage) ────── |
| 290 | const cached = workspaceToolCache.get(workspaceId); | 260 | try { |
| 291 | if (cached && isWorkspaceToolCacheFresh(cached, signature, now)) { | 261 | const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, { |
| 292 | return { tools: cached.tools, dtsStorageIds: cached.dtsStorageIds }; | 262 | workspaceId, |
| 293 | } | 263 | signature, |
| 264 | }); | ||
| 294 | 265 | ||
| 295 | // ── Layer 2: In-flight dedup (concurrent requests on same worker) ───── | 266 | if (cacheEntry) { |
| 296 | const inFlight = workspaceToolLoadsInFlight.get(workspaceId); | 267 | const blob = await ctx.storage.get(cacheEntry.storageId); |
| 297 | if (inFlight && inFlight.signature === signature) { | 268 | if (blob) { |
| 298 | const loaded = await inFlight.promise; | 269 | const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; |
| 299 | return { tools: loaded.tools, dtsStorageIds: loaded.dtsStorageIds }; | 270 | const rehydrated = rehydrateTools(snapshot.tools, baseTools); |
| 300 | } | ||
| 301 | 271 | ||
| 302 | const loadPromise = (async () => { | 272 | const merged = new Map<string, ToolDefinition>(); |
| 303 | // ── Layer 3: Persistent workspace cache (Convex file storage) ────── | 273 | for (const tool of rehydrated) { |
| 304 | // Single blob with all tool descriptors + run specs. Avoids per-source | 274 | merged.set(tool.path, tool); |
| 305 | // storage reads, JSON parses, and TS compiler runs on cold start. | 275 | } |
| 306 | try { | 276 | // Recreate discover tool (it captures the full tool list in a closure) |
| 307 | const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, { | 277 | const discover = createDiscoverTool([...merged.values()]); |
| 308 | workspaceId, | 278 | merged.set(discover.path, discover); |
| 309 | signature, | ||
| 310 | }); | ||
| 311 | 279 | ||
| 312 | if (cacheEntry) { | 280 | const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[]; |
| 313 | const blob = await ctx.storage.get(cacheEntry.storageId); | ||
| 314 | if (blob) { | ||
| 315 | const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; | ||
| 316 | const rehydrated = rehydrateTools(snapshot.tools, baseTools); | ||
| 317 | |||
| 318 | const merged = new Map<string, ToolDefinition>(); | ||
| 319 | for (const tool of rehydrated) { | ||
| 320 | merged.set(tool.path, tool); | ||
| 321 | } | ||
| 322 | // Recreate discover tool (it captures the full tool list in a closure) | ||
| 323 | const discover = createDiscoverTool([...merged.values()]); | ||
| 324 | merged.set(discover.path, discover); | ||
| 325 | |||
| 326 | const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[]; | ||
| 327 | |||
| 328 | workspaceToolCache.set(workspaceId, { | ||
| 329 | signature, | ||
| 330 | loadedAt: Date.now(), | ||
| 331 | tools: merged, | ||
| 332 | warnings: snapshot.warnings, | ||
| 333 | dtsStorageIds, | ||
| 334 | }); | ||
| 335 | 281 | ||
| 336 | return { tools: merged, warnings: snapshot.warnings, dtsStorageIds }; | 282 | return { tools: merged, warnings: snapshot.warnings, dtsStorageIds }; |
| 337 | } | ||
| 338 | } | 283 | } |
| 339 | } catch (error) { | ||
| 340 | const msg = error instanceof Error ? error.message : String(error); | ||
| 341 | console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`); | ||
| 342 | } | 284 | } |
| 285 | } catch (error) { | ||
| 286 | const msg = error instanceof Error ? error.message : String(error); | ||
| 287 | console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`); | ||
| 288 | } | ||
| 343 | 289 | ||
| 344 | // ── Layer 4: Full rebuild from sources ───────────────────────────── | 290 | // ── Layer 2: Full rebuild from sources ───────────────────────────── |
| 345 | const configs: ExternalToolSourceConfig[] = []; | 291 | const configs: ExternalToolSourceConfig[] = []; |
| 346 | const warnings: string[] = []; | 292 | const warnings: string[] = []; |
| 347 | for (const source of sources) { | 293 | for (const source of sources) { |
| 348 | try { | 294 | try { |
| 349 | configs.push(normalizeExternalToolSource(source)); | 295 | configs.push(normalizeExternalToolSource(source)); |
| 350 | } catch (error) { | 296 | } catch (error) { |
| 351 | const message = error instanceof Error ? error.message : String(error); | 297 | const message = error instanceof Error ? error.message : String(error); |
| 352 | warnings.push(`Source '${source.name}': ${message}`); | 298 | warnings.push(`Source '${source.name}': ${message}`); |
| 353 | } | ||
| 354 | } | 299 | } |
| 300 | } | ||
| 355 | 301 | ||
| 356 | const loadedSources = await Promise.all(configs.map((config) => loadSourceTools(ctx, config))); | 302 | const loadedSources = await Promise.all(configs.map((config) => loadSourceTools(ctx, config))); |
| 357 | const externalTools = loadedSources.flatMap((loaded) => loaded.tools); | 303 | const externalTools = loadedSources.flatMap((loaded) => loaded.tools); |
| 358 | warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings)); | 304 | warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings)); |
| 359 | 305 | ||
| 360 | const merged = new Map<string, ToolDefinition>(); | 306 | const merged = new Map<string, ToolDefinition>(); |
| 361 | for (const tool of baseTools.values()) { | 307 | for (const tool of baseTools.values()) { |
| 362 | if (tool.path === "discover") continue; | 308 | if (tool.path === "discover") continue; |
| 363 | merged.set(tool.path, tool); | 309 | merged.set(tool.path, tool); |
| 364 | } | 310 | } |
| 365 | for (const tool of externalTools) { | 311 | for (const tool of externalTools) { |
| 366 | merged.set(tool.path, tool); | 312 | merged.set(tool.path, tool); |
| 367 | } | 313 | } |
| 368 | 314 | ||
| 369 | const discover = createDiscoverTool([...merged.values()]); | 315 | const discover = createDiscoverTool([...merged.values()]); |
| 370 | merged.set(discover.path, discover); | 316 | merged.set(discover.path, discover); |
| 371 | 317 | ||
| 372 | // ── Write to persistent workspace cache (best-effort) ───────────── | 318 | // ── Write to persistent workspace cache (best-effort) ───────────── |
| 373 | let dtsStorageIds: DtsStorageEntry[] = []; | 319 | let dtsStorageIds: DtsStorageEntry[] = []; |
| 374 | try { | 320 | try { |
| 375 | const allTools = [...merged.values()]; | 321 | const allTools = [...merged.values()]; |
| 376 | 322 | ||
| 377 | // Extract and store .d.ts blobs per source (too large for action responses) | 323 | // Extract and store .d.ts blobs per source (too large for action responses) |
| 378 | const seenDtsSources = new Set<string>(); | 324 | const seenDtsSources = new Set<string>(); |
| 379 | const dtsEntries: { sourceKey: string; content: string }[] = []; | 325 | const dtsEntries: { sourceKey: string; content: string }[] = []; |
| 380 | for (const tool of allTools) { | 326 | for (const tool of allTools) { |
| 381 | if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) { | 327 | if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) { |
| 382 | seenDtsSources.add(tool.source); | 328 | seenDtsSources.add(tool.source); |
| 383 | dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts }); | 329 | dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts }); |
| 384 | } | ||
| 385 | } | 330 | } |
| 331 | } | ||
| 386 | 332 | ||
| 387 | // Store .d.ts blobs in parallel | 333 | // Store .d.ts blobs in parallel |
| 388 | const storedDts = await Promise.all( | 334 | const storedDts = await Promise.all( |
| 389 | dtsEntries.map(async (entry) => { | 335 | dtsEntries.map(async (entry) => { |
| 390 | const dtsBlob = new Blob([entry.content], { type: "text/plain" }); | 336 | const dtsBlob = new Blob([entry.content], { type: "text/plain" }); |
| 391 | const sid = await ctx.storage.store(dtsBlob); | 337 | const sid = await ctx.storage.store(dtsBlob); |
| 392 | return { sourceKey: entry.sourceKey, storageId: String(sid) }; | 338 | return { sourceKey: entry.sourceKey, storageId: String(sid) }; |
| 393 | }), | 339 | }), |
| 394 | ); | 340 | ); |
| 395 | dtsStorageIds = storedDts; | 341 | dtsStorageIds = storedDts; |
| 396 | 342 | ||
| 397 | // Strip sourceDts from serialized tools (it's stored separately) | 343 | // Strip sourceDts from serialized tools (it's stored separately) |
| 398 | const snapshot: WorkspaceToolSnapshot = { | 344 | const snapshot: WorkspaceToolSnapshot = { |
| 399 | tools: serializeTools(allTools), | 345 | tools: serializeTools(allTools), |
| 400 | warnings, | 346 | warnings, |
| 401 | }; | 347 | }; |
| 402 | // Remove sourceDts from the serialized snapshot to keep it small | 348 | for (const st of snapshot.tools) { |
| 403 | for (const st of snapshot.tools) { | 349 | if (st.metadata?.sourceDts) { |
| 404 | if (st.metadata?.sourceDts) { | 350 | delete (st.metadata as Record<string, unknown>).sourceDts; |
| 405 | delete (st.metadata as Record<string, unknown>).sourceDts; | ||
| 406 | } | ||
| 407 | } | 351 | } |
| 408 | |||
| 409 | const json = JSON.stringify(snapshot); | ||
| 410 | const blob = new Blob([json], { type: "application/json" }); | ||
| 411 | const storageId = await ctx.storage.store(blob); | ||
| 412 | await ctx.runMutation(internal.workspaceToolCache.putEntry, { | ||
| 413 | workspaceId, | ||
| 414 | signature, | ||
| 415 | storageId, | ||
| 416 | dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId as any })), | ||
| 417 | toolCount: allTools.length, | ||
| 418 | sizeBytes: json.length, | ||
| 419 | }); | ||
| 420 | } catch (error) { | ||
| 421 | const msg = error instanceof Error ? error.message : String(error); | ||
| 422 | console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); | ||
| 423 | } | 352 | } |
| 424 | 353 | ||
| 425 | workspaceToolCache.set(workspaceId, { | 354 | const json = JSON.stringify(snapshot); |
| 355 | const blob = new Blob([json], { type: "application/json" }); | ||
| 356 | const storageId = await ctx.storage.store(blob); | ||
| 357 | await ctx.runMutation(internal.workspaceToolCache.putEntry, { | ||
| 358 | workspaceId, | ||
| 426 | signature, | 359 | signature, |
| 427 | loadedAt: Date.now(), | 360 | storageId, |
| 428 | tools: merged, | 361 | dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId as any })), |
| 429 | warnings, | 362 | toolCount: allTools.length, |
| 430 | dtsStorageIds, | 363 | sizeBytes: json.length, |
| 431 | }); | 364 | }); |
| 432 | 365 | } catch (error) { | |
| 433 | return { tools: merged, warnings, dtsStorageIds }; | 366 | const msg = error instanceof Error ? error.message : String(error); |
| 434 | })(); | 367 | console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); |
| 435 | |||
| 436 | workspaceToolLoadsInFlight.set(workspaceId, { signature, promise: loadPromise }); | ||
| 437 | |||
| 438 | try { | ||
| 439 | const loaded = await loadPromise; | ||
| 440 | return { tools: loaded.tools, dtsStorageIds: loaded.dtsStorageIds }; | ||
| 441 | } finally { | ||
| 442 | const current = workspaceToolLoadsInFlight.get(workspaceId); | ||
| 443 | if (current?.promise === loadPromise) { | ||
| 444 | workspaceToolLoadsInFlight.delete(workspaceId); | ||
| 445 | } | ||
| 446 | } | 368 | } |
| 369 | |||
| 370 | return { tools: merged, warnings, dtsStorageIds }; | ||
| 447 | } | 371 | } |
| 448 | 372 | ||
| 449 | function getDecisionForContext( | 373 | function getDecisionForContext( |
| @@ -536,7 +460,6 @@ async function listToolsWithWarningsForContext( · 86 unmodified lines | |||
| 536 | ]); | 460 | ]); |
| 537 | const typedPolicies = policies as AccessPolicyRecord[]; | 461 | const typedPolicies = policies as AccessPolicyRecord[]; |
| 538 | const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies); | 462 | const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies); |
| 539 | const cachedEntry = workspaceToolCache.get(context.workspaceId); | ||
| 540 | 463 | ||
| 541 | // Generate download URLs for .d.ts blobs | 464 | // Generate download URLs for .d.ts blobs |
| 542 | const dtsUrls: Record<string, string> = {}; | 465 | const dtsUrls: Record<string, string> = {}; |
| @@ -551,7 +474,7 @@ async function listToolsWithWarningsForContext( · 8 unmodified lines | |||
| 551 | 474 | ||
| 552 | return { | 475 | return { |
| 553 | tools, | 476 | tools, |
| 554 | warnings: cachedEntry?.warnings ?? [], | 477 | warnings: result.warnings, |
| 555 | dtsUrls, | 478 | dtsUrls, |
| 556 | }; | 479 | }; |
| 557 | } | 480 | } |
| @@ -518,38 +518,73 @@ function compactOpenApiPaths( · 517 unmodified lines | |||
| 518 | compactOperation.parameters = operationParameters; | 518 | compactOperation.parameters = operationParameters; |
| 519 | } | 519 | } |
| 520 | 520 | ||
| 521 | // Only keep request/response schemas when we need schema-hint fallback. | 521 | // Extract request/response schemas for type hints. |
| 522 | if (!hasGeneratedTypes) { | 522 | // When .d.ts types exist, pre-compute lightweight hints and skip storing full schemas. |
| 523 | { | ||
| 523 | const requestBody = asRecord(operation.requestBody); | 524 | const requestBody = asRecord(operation.requestBody); |
| 524 | const requestBodyContent = asRecord(requestBody.content); | 525 | const requestBodyContent = asRecord(requestBody.content); |
| 525 | const requestBodySchema = getPreferredContentSchema(requestBodyContent); | 526 | const requestBodySchema = getPreferredContentSchema(requestBodyContent); |
| 526 | if (Object.keys(requestBodySchema).length > 0) { | ||
| 527 | compactOperation.requestBody = { | ||
| 528 | content: { | ||
| 529 | "application/json": { | ||
| 530 | schema: requestBodySchema, | ||
| 531 | }, | ||
| 532 | }, | ||
| 533 | }; | ||
| 534 | } | ||
| 535 | 527 | ||
| 536 | const responses = asRecord(operation.responses); | 528 | const responses = asRecord(operation.responses); |
| 529 | let responseSchema: Record<string, unknown> = {}; | ||
| 530 | let responseStatus = ""; | ||
| 537 | for (const [status, responseValue] of Object.entries(responses)) { | 531 | for (const [status, responseValue] of Object.entries(responses)) { |
| 538 | if (!status.startsWith("2")) continue; | 532 | if (!status.startsWith("2")) continue; |
| 539 | const responseContent = asRecord(asRecord(responseValue).content); | 533 | const responseContent = asRecord(asRecord(responseValue).content); |
| 540 | const responseSchema = getPreferredContentSchema(responseContent); | 534 | responseSchema = getPreferredContentSchema(responseContent); |
| 541 | compactOperation.responses = { | 535 | responseStatus = status; |
| 542 | [status]: Object.keys(responseSchema).length > 0 | 536 | if (Object.keys(responseSchema).length > 0) break; |
| 543 | ? { | 537 | } |
| 544 | content: { | 538 | |
| 545 | "application/json": { | 539 | if (hasGeneratedTypes) { |
| 546 | schema: responseSchema, | 540 | // Pre-compute lightweight type hint strings (the full schemas are in the .d.ts) |
| 547 | }, | 541 | const combinedSchema: JsonSchema = { |
| 548 | }, | 542 | type: "object", |
| 549 | } | 543 | properties: { |
| 550 | : {}, | 544 | ...Object.fromEntries( |
| 545 | normalizeParameters(operation.parameters) | ||
| 546 | .concat(sharedParameters) | ||
| 547 | .map((param) => [param.name, param.schema]), | ||
| 548 | ), | ||
| 549 | ...asRecord(requestBodySchema.properties), | ||
| 550 | }, | ||
| 551 | required: [ | ||
| 552 | ...normalizeParameters(operation.parameters) | ||
| 553 | .concat(sharedParameters) | ||
| 554 | .filter((param) => param.required) | ||
| 555 | .map((param) => param.name as string), | ||
| 556 | ...((Array.isArray(requestBodySchema.required) | ||
| 557 | ? requestBodySchema.required.filter((item): item is string => typeof item === "string") | ||
| 558 | : []) as string[]), | ||
| 559 | ], | ||
| 551 | }; | 560 | }; |
| 552 | break; | 561 | compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(combinedSchema); |
| 562 | compactOperation._returnsTypeHint = jsonSchemaTypeHintFallback(responseSchema); | ||
| 563 | } else { | ||
| 564 | // Keep full schemas for the fallback path | ||
| 565 | if (Object.keys(requestBodySchema).length > 0) { | ||
| 566 | compactOperation.requestBody = { | ||
| 567 | content: { | ||
| 568 | "application/json": { | ||
| 569 | schema: requestBodySchema, | ||
| 570 | }, | ||
| 571 | }, | ||
| 572 | }; | ||
| 573 | } | ||
| 574 | |||
| 575 | if (responseStatus) { | ||
| 576 | compactOperation.responses = { | ||
| 577 | [responseStatus]: Object.keys(responseSchema).length > 0 | ||
| 578 | ? { | ||
| 579 | content: { | ||
| 580 | "application/json": { | ||
| 581 | schema: responseSchema, | ||
| 582 | }, | ||
| 583 | }, | ||
| 584 | } | ||
| 585 | : {}, | ||
| 586 | }; | ||
| 587 | } | ||
| 553 | } | 588 | } |
| 554 | } | 589 | } |
| 555 | 590 | ||
| @@ -687,36 +722,44 @@ export function buildOpenApiToolsFromPrepared( · 131 unmodified lines | |||
| 687 | schema: asRecord(entry.schema), | 722 | schema: asRecord(entry.schema), |
| 688 | })); | 723 | })); |
| 689 | 724 | ||
| 690 | // Lightweight type hints for LLM prompt / discover tool (from JSON Schema fallback) | 725 | // Lightweight type hints for LLM prompt / discover tool. |
| 691 | const requestBody = asRecord(operation.requestBody); | 726 | // Pre-computed during compaction when .d.ts types exist (schemas stripped to save space). |
| 692 | const requestBodyContent = asRecord(requestBody.content); | 727 | let argsType: string; |
| 693 | const requestBodySchema = getPreferredContentSchema(requestBodyContent); | 728 | let returnsType: string; |
| 694 | 729 | if (typeof operation._argsTypeHint === "string" && typeof operation._returnsTypeHint === "string") { | |
| 695 | const responses = asRecord(operation.responses); | 730 | argsType = operation._argsTypeHint as string; |
| 696 | let responseSchema: Record<string, unknown> = {}; | 731 | returnsType = operation._returnsTypeHint as string; |
| 697 | for (const [status, responseValue] of Object.entries(responses)) { | 732 | } else { |
| 698 | if (!status.startsWith("2")) continue; | 733 | const requestBody = asRecord(operation.requestBody); |
| 699 | const responseContent = asRecord(asRecord(responseValue).content); | 734 | const requestBodyContent = asRecord(requestBody.content); |
| 700 | responseSchema = getPreferredContentSchema(responseContent); | 735 | const requestBodySchema = getPreferredContentSchema(requestBodyContent); |
| 701 | if (Object.keys(responseSchema).length > 0) break; | ||
| 702 | } | ||
| 703 | 736 | ||
| 704 | const combinedSchema: JsonSchema = { | 737 | const responses = asRecord(operation.responses); |
| 705 | type: "object", | 738 | let responseSchema: Record<string, unknown> = {}; |
| 706 | properties: { | 739 | for (const [status, responseValue] of Object.entries(responses)) { |
| 707 | ...Object.fromEntries(parameters.map((param) => [param.name, param.schema])), | 740 | if (!status.startsWith("2")) continue; |
| 708 | ...asRecord(requestBodySchema.properties), | 741 | const responseContent = asRecord(asRecord(responseValue).content); |
| 709 | }, | 742 | responseSchema = getPreferredContentSchema(responseContent); |
| 710 | required: [ | 743 | if (Object.keys(responseSchema).length > 0) break; |
| 711 | ...parameters.filter((param) => param.required).map((param) => param.name), | 744 | } |
| 712 | ...((Array.isArray(requestBodySchema.required) | ||
| 713 | ? requestBodySchema.required.filter((item): item is string => typeof item === "string") | ||
| 714 | : []) as string[]), | ||
| 715 | ], | ||
| 716 | }; | ||
| 717 | 745 | ||
| 718 | const argsType = jsonSchemaTypeHintFallback(combinedSchema); | 746 | const combinedSchema: JsonSchema = { |
| 719 | const returnsType = jsonSchemaTypeHintFallback(responseSchema); | 747 | type: "object", |
| 748 | properties: { | ||
| 749 | ...Object.fromEntries(parameters.map((param) => [param.name, param.schema])), | ||
| 750 | ...asRecord(requestBodySchema.properties), | ||
| 751 | }, | ||
| 752 | required: [ | ||
| 753 | ...parameters.filter((param) => param.required).map((param) => param.name), | ||
| 754 | ...((Array.isArray(requestBodySchema.required) | ||
| 755 | ? requestBodySchema.required.filter((item): item is string => typeof item === "string") | ||
| 756 | : []) as string[]), | ||
| 757 | ], | ||
| 758 | }; | ||
| 759 | |||
| 760 | argsType = jsonSchemaTypeHintFallback(combinedSchema); | ||
| 761 | returnsType = jsonSchemaTypeHintFallback(responseSchema); | ||
| 762 | } | ||
| 720 | 763 | ||
| 721 | const approval = config.overrides?.[operationIdRaw]?.approval | 764 | const approval = config.overrides?.[operationIdRaw]?.approval |
| 722 | ?? (readMethods.has(method) | 765 | ?? (readMethods.has(method) |