History

Rework web task and tools views around activity-first workflows

Shift the Tasks page to an activity-first layout with inline approval actions and explicit result output, while keeping the code runner as an advanced path. Refresh tool loading UX and source/auth presentation so tool readiness and configuration are clearer during setup.

Rhys Sullivan committed 9 months ago

Commit 55a88e91 parent 2c6905b9 files · +1423 −760Browse files

Unified · Split

9 files
  1. executor/apps/web/src/components/app-shell.tsx +2 −2
  2. executor/apps/web/src/components/approvals-view.tsx +13 −1
  3. executor/apps/web/src/components/code-editor.tsx +42 −28
  4. executor/apps/web/src/components/dashboard-view.tsx +83 −4
  5. executor/apps/web/src/components/tasks-view.tsx +326 −114
  6. executor/apps/web/src/components/tool-explorer.tsx +66 −26
  7. executor/apps/web/src/components/tools-view.tsx +828 −572
  8. executor/apps/web/src/hooks/use-workspace-tools.ts +49 −10
  9. executor/apps/web/src/lib/types.ts +14 −3
executor/apps/web/src/components/app-shell.tsx+2−2
@@ -43,8 +43,8 @@ import { ApprovalNotifier } from "@/components/approval-notifier"; · 42 unmodified lines
43import { cn } from "@/lib/utils";43import { cn } from "@/lib/utils";
44 44
45const NAV_ITEMS = [45const NAV_ITEMS = [
46 { href: "/", label: "Dashboard", icon: LayoutDashboard },46 { href: "/", label: "Home", icon: LayoutDashboard },
47 { href: "/tasks", label: "Tasks", icon: Play },47 { href: "/tasks", label: "Task Activity", icon: Play },
48 { href: "/approvals", label: "Approvals", icon: ShieldCheck },48 { href: "/approvals", label: "Approvals", icon: ShieldCheck },
49 { href: "/tools", label: "Tools", icon: Wrench },49 { href: "/tools", label: "Tools", icon: Wrench },
50];50];
executor/apps/web/src/components/approvals-view.tsx+13−1
@@ -1,6 +1,7 @@
1"use client";1"use client";
2 2
3import { useMemo, useState } from "react";3import { useMemo, useState } from "react";
4import { useNavigate } from "react-router";
4import {5import {
5 ShieldCheck,6 ShieldCheck,
6 ShieldX,7 ShieldX,
@@ -82,6 +83,7 @@ function ApprovalCard({ · 75 unmodified lines
82}: {83}: {
83 approval: PendingApprovalRecord;84 approval: PendingApprovalRecord;
84}) {85}) {
86 const navigate = useNavigate();
85 const { context } = useSession();87 const { context } = useSession();
86 const resolveApproval = useMutation(convexApi.executor.resolveApproval);88 const resolveApproval = useMutation(convexApi.executor.resolveApproval);
87 const [resolving, setResolving] = useState<"approved" | "denied" | null>(89 const [resolving, setResolving] = useState<"approved" | "denied" | null>(
@@ -139,7 +141,17 @@ function ApprovalCard({ · 51 unmodified lines
139 </span>141 </span>
140 </div>142 </div>
141 </div>143 </div>
142 <TaskStatusBadge status={approval.task.status} />144 <div className="flex items-center gap-2 shrink-0">
145 <TaskStatusBadge status={approval.task.status} />
146 <Button
147 variant="ghost"
148 size="sm"
149 className="h-7 text-[11px]"
150 onClick={() => navigate(`/tasks?selected=${approval.taskId}`)}
151 >
152 Open task
153 </Button>
154 </div>
143 </div>155 </div>
144 156
145 {/* Input */}157 {/* Input */}
executor/apps/web/src/components/code-editor.tsx+42−28
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { useRef, useEffect } from "react";3import { useRef, useEffect, useState } from "react";
4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
5import { Loader2 } from "lucide-react";5import { Loader2 } from "lucide-react";
6import { useTheme } from "next-themes";6import { useTheme } from "next-themes";
@@ -222,11 +222,11 @@ function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): str · 215 unmodified lines
222 222
223const BASE_ENVIRONMENT_DTS = `223const BASE_ENVIRONMENT_DTS = `
224interface Console {224interface Console {
225 /** Log output to stdout (visible in task detail). */225 /** Console output is discarded; use explicit return values for results. */
226 log(...args: any[]): void;226 log(...args: any[]): void;
227 /** Log output to stderr (visible in task detail). */227 /** Console output is discarded; use explicit return values for results. */
228 error(...args: any[]): void;228 error(...args: any[]): void;
229 /** Log a warning to stderr. */229 /** Console output is discarded; use explicit return values for results. */
230 warn(...args: any[]): void;230 warn(...args: any[]): void;
231 info(...args: any[]): void;231 info(...args: any[]): void;
232 debug(...args: any[]): void;232 debug(...args: any[]): void;
@@ -277,6 +277,7 @@ export function CodeEditor({ · 44 unmodified lines
277 height = "400px",277 height = "400px",
278}: CodeEditorProps) {278}: CodeEditorProps) {
279 const { resolvedTheme } = useTheme();279 const { resolvedTheme } = useTheme();
280 const [dtsHydrating, setDtsHydrating] = useState(false);
280 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);281 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
281 const monacoRef = useRef<Monaco | null>(null);282 const monacoRef = useRef<Monaco | null>(null);
282 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);283 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);
@@ -285,10 +286,14 @@ export function CodeEditor({ · 2 unmodified lines
285 const toolsLibVersion = useRef(0);286 const toolsLibVersion = useRef(0);
286 const fetchedDtsUrls = useRef<string>("");287 const fetchedDtsUrls = useRef<string>("");
287 const dtsSources = new Set(Object.keys(dtsUrls ?? {}));288 const dtsSources = new Set(Object.keys(dtsUrls ?? {}));
289 const toolTypesLoading = typesLoading || dtsHydrating;
288 290
289 // Fetch and register .d.ts blobs from OpenAPI sources291 // Fetch and register .d.ts blobs from OpenAPI sources
290 useEffect(() => {292 useEffect(() => {
291 if (!dtsUrls || Object.keys(dtsUrls).length === 0) return;293 if (!dtsUrls || Object.keys(dtsUrls).length === 0) {
294 setDtsHydrating(false);
295 return;
296 }
292 const m = monacoRef.current;297 const m = monacoRef.current;
293 if (!m) return;298 if (!m) return;
294 299
@@ -305,6 +310,7 @@ export function CodeEditor({ · 10 unmodified lines
305 dtsLibDisposables.current = [];310 dtsLibDisposables.current = [];
306 311
307 let cancelled = false;312 let cancelled = false;
313 setDtsHydrating(true);
308 314
309 // Fetch each .d.ts blob and register with Monaco315 // Fetch each .d.ts blob and register with Monaco
310 const entries = Object.entries(dtsUrls);316 const entries = Object.entries(dtsUrls);
@@ -319,25 +325,31 @@ export function CodeEditor({ · 8 unmodified lines
319 return null;325 return null;
320 }326 }
321 }),327 }),
322 ).then((results) => {328 )
323 if (cancelled) return;329 .then((results) => {
324 330 if (cancelled) return;
325 // Build the helper types + .d.ts declarations331
326 let helperDts = OPENAPI_HELPER_TYPES + "\n";332 // Build the helper types + .d.ts declarations
327 for (const result of results) {333 let helperDts = OPENAPI_HELPER_TYPES + "\n";
328 if (!result) continue;334 for (const result of results) {
329 // Strip 'export' keywords so types are ambient in Monaco335 if (!result) continue;
330 const ambient = result.content.replace(/^export /gm, "");336 // Strip 'export' keywords so types are ambient in Monaco
331 helperDts += ambient + "\n";337 const ambient = result.content.replace(/^export /gm, "");
332 }338 helperDts += ambient + "\n";
339 }
333 340
334 const version = ++toolsLibVersion.current;341 const version = ++toolsLibVersion.current;
335 const disposable = jsDefaults.addExtraLib(342 const disposable = jsDefaults.addExtraLib(
336 helperDts,343 helperDts,
337 `file:///node_modules/@types/executor-openapi/v${version}.d.ts`,344 `file:///node_modules/@types/executor-openapi/v${version}.d.ts`,
338 );345 );
339 dtsLibDisposables.current.push(disposable);346 dtsLibDisposables.current.push(disposable);
340 });347 })
348 .finally(() => {
349 if (!cancelled) {
350 setDtsHydrating(false);
351 }
352 });
341 353
342 return () => {354 return () => {
343 cancelled = true;355 cancelled = true;
@@ -370,8 +382,8 @@ export function CodeEditor({ · 26 unmodified lines
370 useEffect(() => {382 useEffect(() => {
371 const m = monacoRef.current;383 const m = monacoRef.current;
372 if (!m) return;384 if (!m) return;
373 setDiagnosticsOptions(m, typesLoading);385 setDiagnosticsOptions(m, toolTypesLoading);
374 }, [typesLoading]);386 }, [toolTypesLoading]);
375 387
376 // Clean up on unmount388 // Clean up on unmount
377 useEffect(() => {389 useEffect(() => {
@@ -391,7 +403,7 @@ export function CodeEditor({ · 13 unmodified lines
391 // Configure JavaScript/TypeScript defaults for our execution environment403 // Configure JavaScript/TypeScript defaults for our execution environment
392 // Code runs inside an AsyncFunction body so top-level await is valid404 // Code runs inside an AsyncFunction body so top-level await is valid
393 // and there are no imports/exports.405 // and there are no imports/exports.
394 setDiagnosticsOptions(monaco, typesLoading);406 setDiagnosticsOptions(monaco, toolTypesLoading);
395 407
396 ts.javascriptDefaults.setCompilerOptions({408 ts.javascriptDefaults.setCompilerOptions({
397 target: ts.ScriptTarget.ESNext,409 target: ts.ScriptTarget.ESNext,
@@ -537,10 +549,12 @@ export function CodeEditor({ · 139 unmodified lines
537 549
538 return (550 return (
539 <div className={cn("relative", className)}>551 <div className={cn("relative", className)}>
540 {typesLoading ? (552 {toolTypesLoading ? (
541 <div className="pointer-events-none absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-md border border-border/80 bg-background/85 px-2 py-1 text-[10px] font-mono text-muted-foreground backdrop-blur-sm">553 <div className="pointer-events-none absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-md border border-border/80 bg-background/85 px-2 py-1 text-[10px] font-mono text-muted-foreground backdrop-blur-sm">
542 <Loader2 className="h-3 w-3 animate-spin" />554 <Loader2 className="h-3 w-3 animate-spin" />
543 Loading tool types...555 {tools.length > 0
556 ? `Loaded ${tools.length} tools, loading type definitions...`
557 : "Loading tool metadata..."}
544 </div>558 </div>
545 ) : null}559 ) : null}
546 <Editor560 <Editor
executor/apps/web/src/components/dashboard-view.tsx+83−4
@@ -70,7 +70,7 @@ function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) { · 69 unmodified lines
70 const navigate = useNavigate();70 const navigate = useNavigate();
71 return (71 return (
72 <button72 <button
73 onClick={() => navigate("/approvals")}73 onClick={() => navigate(`/tasks?selected=${approval.taskId}`)}
74 className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"74 className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
75 >75 >
76 <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" />76 <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" />
@@ -242,6 +242,11 @@ export function DashboardView() { · 165 unmodified lines
242 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",242 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
243 );243 );
244 244
245 const sources = useQuery(
246 convexApi.workspace.listToolSources,
247 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
248 );
249
245 const { tools } = useWorkspaceTools(context ?? null);250 const { tools } = useWorkspaceTools(context ?? null);
246 251
247 if (sessionLoading) {252 if (sessionLoading) {
@@ -258,6 +263,8 @@ export function DashboardView() { · 10 unmodified lines
258 }263 }
259 264
260 const pendingCount = approvals?.length ?? 0;265 const pendingCount = approvals?.length ?? 0;
266 const sourceCount = sources?.length ?? 0;
267 const taskItems = tasks ?? [];
261 const runningCount =268 const runningCount =
262 tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;269 tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;
263 const completedCount =270 const completedCount =
@@ -265,15 +272,87 @@ export function DashboardView() { · 1 unmodified line
265 const failedCount =272 const failedCount =
266 tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status))273 tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status))
267 .length ?? 0;274 .length ?? 0;
268 const recentTasks = (tasks ?? []).slice(0, 8);275 const recentTasks = taskItems.slice(0, 8);
276
277 const setupSteps = [
278 {
279 label: "Connect a tool source",
280 done: sourceCount > 0,
281 href: "/tools",
282 pendingText: "Add MCP, OpenAPI, or GraphQL",
283 doneText: `${sourceCount} source${sourceCount === 1 ? "" : "s"} connected`,
284 },
285 {
286 label: "Run a first task",
287 done: taskItems.length > 0,
288 href: "/tasks",
289 pendingText: "Use the advanced runner to execute code",
290 doneText: `${taskItems.length} task${taskItems.length === 1 ? "" : "s"} recorded`,
291 },
292 {
293 label: "Review gated calls",
294 done: pendingCount === 0,
295 href: "/approvals",
296 pendingText: `${pendingCount} approval${pendingCount === 1 ? "" : "s"} waiting`,
297 doneText: "Approval queue is clear",
298 },
299 ];
269 300
270 return (301 return (
271 <div className="space-y-6">302 <div className="space-y-6">
272 <PageHeader303 <PageHeader
273 title="Dashboard"304 title="Workspace Home"
274 description="Overview of your executor workspace"305 description="Start from the current status, then jump straight to execution"
275 />306 />
276 307
308 <Card className="border-border bg-gradient-to-br from-card to-muted/25">
309 <CardContent className="p-5 md:p-6 space-y-5">
310 <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
311 <div className="space-y-1.5 max-w-2xl">
312 <p className="text-[11px] uppercase tracking-widest text-muted-foreground">Landing View</p>
313 <h2 className="text-lg font-semibold tracking-tight">
314 {taskItems.length === 0
315 ? "Connect tools and run your first task"
316 : "Your task activity and approval queue are live"}
317 </h2>
318 <p className="text-sm text-muted-foreground">
319 Tasks is the primary workflow. Use the advanced runner only when you want direct code execution.
320 </p>
321 </div>
322 <div className="flex flex-wrap items-center gap-2">
323 <Button asChild size="sm" className="h-8 text-xs">
324 <a href="/tasks">Open task activity</a>
325 </Button>
326 <Button asChild variant="outline" size="sm" className="h-8 text-xs">
327 <a href="/tools">Manage tools</a>
328 </Button>
329 </div>
330 </div>
331
332 <div className="grid gap-2 md:grid-cols-3">
333 {setupSteps.map((step) => (
334 <a
335 key={step.label}
336 href={step.href}
337 className="rounded-md border border-border/70 bg-background/70 px-3 py-2.5 hover:bg-accent/25 transition-colors"
338 >
339 <div className="flex items-center justify-between gap-2">
340 <span className="text-xs font-medium text-foreground">{step.label}</span>
341 {step.done ? (
342 <CheckCircle2 className="h-3.5 w-3.5 text-terminal-green" />
343 ) : (
344 <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
345 )}
346 </div>
347 <p className="mt-1 text-[11px] text-muted-foreground">
348 {step.done ? step.doneText : step.pendingText}
349 </p>
350 </a>
351 ))}
352 </div>
353 </CardContent>
354 </Card>
355
277 {/* Stats row */}356 {/* Stats row */}
278 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">357 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
279 <StatCard358 <StatCard
executor/apps/web/src/components/tasks-view.tsx+326−114
@@ -1,12 +1,15 @@
1"use client";1"use client";
2 2
3import { useState, useCallback, useMemo } from "react";3import { useState, useCallback } from "react";
4import { useNavigate, useSearchParams } from "react-router";4import { useNavigate, useSearchParams } from "react-router";
5import {5import {
6 Play,6 Play,
7 ChevronRight,7 ChevronRight,
8 X,8 X,
9 Send,9 Send,
10 ShieldCheck,
11 ShieldX,
12 CheckCircle2,
10} from "lucide-react";13} from "lucide-react";
11import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";14import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12import { Button } from "@/components/ui/button";15import { Button } from "@/components/ui/button";
@@ -23,6 +26,7 @@ import { · 10 unmodified lines
23import { Skeleton } from "@/components/ui/skeleton";26import { Skeleton } from "@/components/ui/skeleton";
24import { Separator } from "@/components/ui/separator";27import { Separator } from "@/components/ui/separator";
25import { PageHeader } from "@/components/page-header";28import { PageHeader } from "@/components/page-header";
29import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
26import { TaskStatusBadge } from "@/components/status-badge";30import { TaskStatusBadge } from "@/components/status-badge";
27import { FormattedCodeBlock } from "@/components/formatted-code-block";31import { FormattedCodeBlock } from "@/components/formatted-code-block";
28import { useSession } from "@/lib/session-context";32import { useSession } from "@/lib/session-context";
@@ -31,25 +35,27 @@ import { useMutation, useQuery } from "convex/react"; · 2 unmodified lines
31import { convexApi } from "@/lib/convex-api";35import { convexApi } from "@/lib/convex-api";
32import type {36import type {
33 RuntimeTargetDescriptor,37 RuntimeTargetDescriptor,
34 TaskEventRecord,
35 TaskRecord,38 TaskRecord,
39 PendingApprovalRecord,
36} from "@/lib/types";40} from "@/lib/types";
37import type { Id } from "@executor/convex/_generated/dataModel";41import type { Id } from "@executor/convex/_generated/dataModel";
38import { toast } from "sonner";42import { toast } from "sonner";
39import { cn } from "@/lib/utils";43import { cn } from "@/lib/utils";
40 44
41const DEFAULT_CODE = `// Example: call some tools45const DEFAULT_CODE = `// Example: call some tools and return a compact result
42const time = await tools.utils.get_time();46const time = await tools.utils.get_time();
43console.log("Current time:", time.iso);47const sum = await tools.math.add({ a: 7, b: 35 });
44
45const result = await tools.math.add({ a: 7, b: 35 });
46console.log("7 + 35 =", result.result);
47 48
48// This will require approval:49// This will require approval:
49await tools.admin.send_announcement({50await tools.admin.send_announcement({
50 channel: "general",51 channel: "general",
51 message: "Hello from executor!"52 message: "Hello from executor!"
52});`;53});
54
55return {
56 isoTime: time.iso,
57 total: sum.result,
58};`;
53const DEFAULT_TIMEOUT_MS = 300_000;59const DEFAULT_TIMEOUT_MS = 300_000;
54 60
55function formatDate(ts: number) {61function formatDate(ts: number) {
@@ -61,6 +67,65 @@ function formatDate(ts: number) { · 5 unmodified lines
61 });67 });
62}68}
63 69
70function formatResult(result: unknown): string {
71 if (result === undefined) {
72 return "";
73 }
74
75 try {
76 return JSON.stringify(result, null, 2);
77 } catch {
78 return String(result);
79 }
80}
81
82function formatApprovalInput(
83 input: unknown,
84): {
85 content: string;
86 language: "json" | "text";
87} | null {
88 if (input === null || input === undefined) {
89 return null;
90 }
91
92 if (typeof input === "string") {
93 const trimmed = input.trim();
94 if (!trimmed) {
95 return null;
96 }
97
98 try {
99 return {
100 content: JSON.stringify(JSON.parse(trimmed), null, 2),
101 language: "json",
102 };
103 } catch {
104 return {
105 content: trimmed,
106 language: "text",
107 };
108 }
109 }
110
111 try {
112 return {
113 content: JSON.stringify(input, null, 2),
114 language: "json",
115 };
116 } catch {
117 const fallback = String(input).trim();
118 if (!fallback) {
119 return null;
120 }
121
122 return {
123 content: fallback,
124 language: "text",
125 };
126 }
127}
128
64// ── Task Composer ──129// ── Task Composer ──
65 130
66function TaskComposer() {131function TaskComposer() {
@@ -72,7 +137,7 @@ function TaskComposer() { · 5 unmodified lines
72 137
73 const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});138 const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});
74 const createTask = useMutation(convexApi.executor.createTask);139 const createTask = useMutation(convexApi.executor.createTask);
75 const { tools, dtsUrls, loading: toolsLoading } = useWorkspaceTools(context ?? null);140 const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null);
76 141
77 const handleSubmit = async () => {142 const handleSubmit = async () => {
78 if (!context || !code.trim()) return;143 if (!context || !code.trim()) return;
@@ -134,14 +199,40 @@ function TaskComposer() { · 55 unmodified lines
134 </div>199 </div>
135 </div>200 </div>
136 <div className="space-y-1.5">201 <div className="space-y-1.5">
137 <Label className="text-xs text-muted-foreground">Code</Label>202 <div className="flex items-center justify-between gap-2">
203 <Label className="text-xs text-muted-foreground">Code</Label>
204 <span className="text-[10px] font-mono text-muted-foreground">
205 {loadingTools
206 ? "Loading tool inventory..."
207 : loadingTypes
208 ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...`
209 : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`}
210 </span>
211 </div>
212 {!loadingTools && loadingTypes && tools.length > 0 && (
213 <div className="flex flex-wrap gap-1.5 rounded-md border border-border/50 bg-muted/20 px-2 py-1.5">
214 {tools.slice(0, 8).map((tool) => (
215 <span
216 key={tool.path}
217 className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"
218 >
219 {tool.path}
220 </span>
221 ))}
222 {tools.length > 8 && (
223 <span className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
224 +{tools.length - 8} more
225 </span>
226 )}
227 </div>
228 )}
138 <div className="rounded-md border border-border">229 <div className="rounded-md border border-border">
139 <CodeEditor230 <CodeEditor
140 value={code}231 value={code}
141 onChange={setCode}232 onChange={setCode}
142 tools={tools}233 tools={tools}
143 dtsUrls={dtsUrls}234 dtsUrls={dtsUrls}
144 typesLoading={toolsLoading}235 typesLoading={loadingTypes}
145 height="400px"236 height="400px"
146 />237 />
147 </div>238 </div>
@@ -208,43 +299,24 @@ function TaskDetail({ · 60 unmodified lines
208 task,299 task,
209 workspaceId,300 workspaceId,
210 sessionId,301 sessionId,
302 pendingApprovals,
211 onClose,303 onClose,
212}: {304}: {
213 task: TaskRecord;305 task: TaskRecord;
214 workspaceId: Id<"workspaces">;306 workspaceId: Id<"workspaces">;
215 sessionId?: string;307 sessionId?: string;
308 pendingApprovals: PendingApprovalRecord[];
216 onClose: () => void;309 onClose: () => void;
217}) {310}) {
311 const resolveApproval = useMutation(convexApi.executor.resolveApproval);
312 const [resolvingApprovalId, setResolvingApprovalId] = useState<string | null>(null);
218 const liveTaskData = useQuery(313 const liveTaskData = useQuery(
219 convexApi.workspace.getTaskInWorkspace,314 convexApi.workspace.getTaskInWorkspace,
220 workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",315 workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",
221 );316 );
222 const taskEvents = useQuery(
223 convexApi.workspace.listTaskEvents,
224 workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",
225 );
226 317
227 const liveTask = liveTaskData ?? task;318 const liveTask = liveTaskData ?? task;
228 const liveTaskEvents = taskEvents ?? [];319 const liveResult = formatResult(liveTask.result);
229 const liveStdout = useMemo(() => {
230 const stdoutLines = liveTaskEvents
231 .filter((event: TaskEventRecord) => event.type === "task.stdout")
232 .map((event: TaskEventRecord) => String((event.payload as Record<string, unknown>)?.line ?? ""));
233 if (stdoutLines.length > 0) {
234 return stdoutLines.join("\n");
235 }
236 return liveTask.stdout ?? "";
237 }, [liveTaskEvents, liveTask.stdout]);
238
239 const liveStderr = useMemo(() => {
240 const stderrLines = liveTaskEvents
241 .filter((event: TaskEventRecord) => event.type === "task.stderr")
242 .map((event: TaskEventRecord) => String((event.payload as Record<string, unknown>)?.line ?? ""));
243 if (stderrLines.length > 0) {
244 return stderrLines.join("\n");
245 }
246 return liveTask.stderr ?? "";
247 }, [liveTaskEvents, liveTask.stderr]);
248 320
249 const duration =321 const duration =
250 liveTask.completedAt && liveTask.startedAt322 liveTask.completedAt && liveTask.startedAt
@@ -253,6 +325,31 @@ function TaskDetail({ · 2 unmodified lines
253 ? "running..."325 ? "running..."
254 : "—";326 : "—";
255 327
328 const handleResolveApproval = async (
329 approvalId: string,
330 decision: "approved" | "denied",
331 toolPath: string,
332 ) => {
333 setResolvingApprovalId(approvalId);
334 try {
335 await resolveApproval({
336 workspaceId,
337 sessionId,
338 approvalId,
339 decision,
340 });
341 toast.success(
342 decision === "approved"
343 ? `Approved: ${toolPath}`
344 : `Denied: ${toolPath}`,
345 );
346 } catch (error) {
347 toast.error(error instanceof Error ? error.message : "Failed to resolve approval");
348 } finally {
349 setResolvingApprovalId(null);
350 }
351 };
352
256 return (353 return (
257 <Card className="bg-card border-border">354 <Card className="bg-card border-border">
258 <CardHeader className="pb-3">355 <CardHeader className="pb-3">
@@ -295,6 +392,84 @@ function TaskDetail({ · 36 unmodified lines
295 ))}392 ))}
296 </div>393 </div>
297 394
395 {pendingApprovals.length > 0 ? (
396 <>
397 <Separator />
398 <div className="space-y-2.5">
399 <div className="flex items-center justify-between">
400 <span className="text-[10px] uppercase tracking-widest text-terminal-amber">
401 Pending approvals
402 </span>
403 <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
404 {pendingApprovals.length}
405 </span>
406 </div>
407 {pendingApprovals.map((approval) => {
408 const input = formatApprovalInput(approval.input);
409 const resolving = resolvingApprovalId === approval.id;
410 return (
411 <div
412 key={approval.id}
413 className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2"
414 >
415 <div className="flex items-center justify-between gap-3">
416 <div className="min-w-0">
417 <p className="text-xs font-mono text-foreground truncate">
418 {approval.toolPath}
419 </p>
420 <p className="text-[10px] text-muted-foreground">
421 Requested {formatDate(approval.createdAt)}
422 </p>
423 </div>
424 <div className="flex items-center gap-1.5 shrink-0">
425 <Button
426 variant="outline"
427 size="sm"
428 className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10"
429 disabled={resolvingApprovalId !== null}
430 onClick={() =>
431 void handleResolveApproval(
432 approval.id,
433 "approved",
434 approval.toolPath,
435 )
436 }
437 >
438 <CheckCircle2 className="h-3 w-3 mr-1" />
439 {resolving ? "Approving..." : "Approve"}
440 </Button>
441 <Button
442 variant="outline"
443 size="sm"
444 className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10"
445 disabled={resolvingApprovalId !== null}
446 onClick={() =>
447 void handleResolveApproval(
448 approval.id,
449 "denied",
450 approval.toolPath,
451 )
452 }
453 >
454 <ShieldX className="h-3 w-3 mr-1" />
455 {resolving ? "Denying..." : "Deny"}
456 </Button>
457 </div>
458 </div>
459 {input ? (
460 <FormattedCodeBlock
461 content={input.content}
462 language={input.language}
463 className="max-h-40 overflow-y-auto"
464 />
465 ) : null}
466 </div>
467 );
468 })}
469 </div>
470 </>
471 ) : null}
472
298 <Separator />473 <Separator />
299 474
300 {/* Code */}475 {/* Code */}
@@ -309,31 +484,15 @@ function TaskDetail({ · 8 unmodified lines
309 />484 />
310 </div>485 </div>
311 486
312 {/* Stdout */}487 {/* Result */}
313 {liveStdout && (488 {liveResult && (
314 <div>489 <div>
315 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">490 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
316 Stdout491 Result
317 </span>
318 <FormattedCodeBlock
319 content={liveStdout}
320 language="text"
321 tone="green"
322 className="max-h-48 overflow-y-auto"
323 />
324 </div>
325 )}
326
327 {/* Stderr */}
328 {liveStderr && (
329 <div>
330 <span className="text-[10px] uppercase tracking-widest text-terminal-amber block mb-2">
331 Stderr
332 </span>492 </span>
333 <FormattedCodeBlock493 <FormattedCodeBlock
334 content={liveStderr}494 content={liveResult}
335 language="text"495 language="json"
336 tone="amber"
337 className="max-h-48 overflow-y-auto"496 className="max-h-48 overflow-y-auto"
338 />497 />
339 </div>498 </div>
@@ -364,6 +523,7 @@ export function TasksView() { · 24 unmodified lines
364 const { context, loading: sessionLoading } = useSession();523 const { context, loading: sessionLoading } = useSession();
365 const navigate = useNavigate();524 const navigate = useNavigate();
366 const [searchParams] = useSearchParams();525 const [searchParams] = useSearchParams();
526 const [activeTab, setActiveTab] = useState<"activity" | "runner">("activity");
367 const selectedId = searchParams.get("selected");527 const selectedId = searchParams.get("selected");
368 528
369 const tasks = useQuery(529 const tasks = useQuery(
@@ -373,7 +533,16 @@ export function TasksView() { · 3 unmodified lines
373 const tasksLoading = !!context && tasks === undefined;533 const tasksLoading = !!context && tasks === undefined;
374 const taskItems = tasks ?? [];534 const taskItems = tasks ?? [];
375 535
536 const approvals = useQuery(
537 convexApi.workspace.listPendingApprovals,
538 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
539 );
540 const pendingApprovals = approvals ?? [];
541
376 const selectedTask = taskItems.find((t: TaskRecord) => t.id === selectedId);542 const selectedTask = taskItems.find((t: TaskRecord) => t.id === selectedId);
543 const selectedTaskApprovals = selectedTask
544 ? pendingApprovals.filter((approval: PendingApprovalRecord) => approval.taskId === selectedTask.id)
545 : [];
377 546
378 const selectTask = useCallback(547 const selectTask = useCallback(
379 (taskId: string | null) => {548 (taskId: string | null) => {
@@ -399,72 +568,115 @@ export function TasksView() { · 19 unmodified lines
399 <div className="space-y-6">568 <div className="space-y-6">
400 <PageHeader569 <PageHeader
401 title="Tasks"570 title="Tasks"
402 description="Execute code and manage task history"571 description="Task activity first, with an advanced editor when you need it"
403 />572 >
404 573 <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => setActiveTab("runner")}>
405 <div className="grid lg:grid-cols-2 gap-6">574 Advanced runner
406 {/* Left: composer + list */}575 </Button>
407 <div className="space-y-6">576 <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => navigate("/approvals")}>
408 <TaskComposer />577 <ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
409 578 {pendingApprovals.length} pending
410 <Card className="bg-card border-border">579 </Button>
411 <CardHeader className="pb-3">580 </PageHeader>
412 <CardTitle className="text-sm font-medium flex items-center gap-2">581
413 Task History582 <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "activity" | "runner")}>
414 {tasks && (583 <TabsList className="bg-muted/50 h-9">
415 <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">584 <TabsTrigger value="activity" className="text-xs data-[state=active]:bg-background">
416 {taskItems.length}585 Activity
417 </span>586 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">{taskItems.length}</span>
587 </TabsTrigger>
588 <TabsTrigger value="runner" className="text-xs data-[state=active]:bg-background">
589 Runner (Advanced)
590 </TabsTrigger>
591 </TabsList>
592
593 <TabsContent value="activity" className="mt-4">
594 <div className="grid lg:grid-cols-2 gap-6">
595 <Card className="bg-card border-border">
596 <CardHeader className="pb-3">
597 <CardTitle className="text-sm font-medium flex items-center gap-2">
598 Task History
599 {tasks && (
600 <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
601 {taskItems.length}
602 </span>
603 )}
604 </CardTitle>
605 </CardHeader>
606 <CardContent className="pt-0">
607 {tasksLoading ? (
608 <div className="space-y-2">
609 {Array.from({ length: 4 }).map((_, i) => (
610 <Skeleton key={i} className="h-14" />
611 ))}
612 </div>
613 ) : taskItems.length === 0 ? (
614 <div className="flex flex-col items-center justify-center py-12 text-sm text-muted-foreground gap-2">
615 <p>No tasks yet.</p>
616 <Button size="sm" className="h-8 text-xs" onClick={() => setActiveTab("runner")}>Run your first task</Button>
617 </div>
618 ) : (
619 <div className="space-y-1 max-h-[620px] overflow-y-auto">
620 {taskItems.map((task: TaskRecord) => (
621 <TaskListItem
622 key={task.id}
623 task={task}
624 selected={task.id === selectedId}
625 onClick={() => selectTask(task.id)}
626 />
627 ))}
628 </div>
418 )}629 )}
419 </CardTitle>630 </CardContent>
420 </CardHeader>631 </Card>
421 <CardContent className="pt-0">632
422 {tasksLoading ? (633 <div>
423 <div className="space-y-2">634 {selectedTask ? (
424 {Array.from({ length: 4 }).map((_, i) => (635 <TaskDetail
425 <Skeleton key={i} className="h-14" />636 task={selectedTask}
426 ))}637 workspaceId={context!.workspaceId}
427 </div>638 sessionId={context?.sessionId}
428 ) : taskItems.length === 0 ? (639 pendingApprovals={selectedTaskApprovals}
429 <div className="flex items-center justify-center py-12 text-sm text-muted-foreground">640 onClose={() => selectTask(null)}
430 No tasks yet. Create one above.641 />
431 </div>
432 ) : (642 ) : (
433 <div className="space-y-1 max-h-[500px] overflow-y-auto">643 <Card className="bg-card border-border">
434 {taskItems.map((task: TaskRecord) => (644 <CardContent className="flex items-center justify-center py-24">
435 <TaskListItem645 <p className="text-sm text-muted-foreground">
436 key={task.id}646 Select a task to view logs, output, and approval actions
437 task={task}647 </p>
438 selected={task.id === selectedId}648 </CardContent>
439 onClick={() => selectTask(task.id)}649 </Card>
440 />
441 ))}
442 </div>
443 )}650 )}
444 </CardContent>651 </div>
445 </Card>652 </div>
446 </div>653 </TabsContent>
447 654
448 {/* Right: detail panel */}655 <TabsContent value="runner" className="mt-4">
449 <div>656 <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
450 {selectedTask ? (657 <TaskComposer />
451 <TaskDetail
452 task={selectedTask}
453 workspaceId={context!.workspaceId}
454 sessionId={context?.sessionId}
455 onClose={() => selectTask(null)}
456 />
457 ) : (
458 <Card className="bg-card border-border">658 <Card className="bg-card border-border">
459 <CardContent className="flex items-center justify-center py-24">659 <CardHeader className="pb-3">
460 <p className="text-sm text-muted-foreground">660 <CardTitle className="text-sm font-medium flex items-center gap-2">
461 Select a task to view details661 <Play className="h-4 w-4 text-terminal-green" />
662 Before you run
663 </CardTitle>
664 </CardHeader>
665 <CardContent className="pt-0 space-y-3 text-xs text-muted-foreground">
666 <p>
667 This editor is the advanced path for direct code execution. Most day-to-day work happens in Activity.
668 </p>
669 <p>
670 New runs appear in Task History, and any gated tool calls can be approved inline from the selected task.
462 </p>671 </p>
672 <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => setActiveTab("activity")}>
673 Back to activity view
674 </Button>
463 </CardContent>675 </CardContent>
464 </Card>676 </Card>
465 )}677 </div>
466 </div>678 </TabsContent>
467 </div>679 </Tabs>
468 </div>680 </div>
469 );681 );
470}682}
executor/apps/web/src/components/tool-explorer.tsx+66−26
@@ -46,7 +46,6 @@ import { · 45 unmodified lines
46 CollapsibleContent,46 CollapsibleContent,
47 CollapsibleTrigger,47 CollapsibleTrigger,
48} from "@/components/ui/collapsible";48} from "@/components/ui/collapsible";
49import { Skeleton } from "@/components/ui/skeleton";
50import { Streamdown } from "streamdown";49import { Streamdown } from "streamdown";
51import { createCodePlugin } from "@streamdown/code";50import { createCodePlugin } from "@streamdown/code";
52import { cn } from "@/lib/utils";51import { cn } from "@/lib/utils";
@@ -90,6 +89,32 @@ function trimLeadingNamespace(path: string, prefix: string): string { · 37 unmodified lines
90 return path;89 return path;
91}90}
92 91
92function displaySourceName(name: string): string {
93 const parts = name.split(/[-_.]+/).filter(Boolean);
94 if (parts.length === 0) return name;
95
96 const deduped = parts.filter((part, index, all) => {
97 if (index === 0) return true;
98 return part.toLowerCase() !== all[index - 1]?.toLowerCase();
99 });
100
101 const tokenMap: Record<string, string> = {
102 api: "API",
103 oauth: "OAuth",
104 graphql: "GraphQL",
105 mcp: "MCP",
106 github: "GitHub",
107 };
108
109 return deduped
110 .map((token) => {
111 const lower = token.toLowerCase();
112 if (tokenMap[lower]) return tokenMap[lower];
113 return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`;
114 })
115 .join(" ");
116}
117
93// ── Types for grouped tree data ──118// ── Types for grouped tree data ──
94 119
95interface ToolGroup {120interface ToolGroup {
@@ -768,6 +793,9 @@ interface ToolExplorerProps { · 672 unmodified lines
768 loading?: boolean;793 loading?: boolean;
769 warnings?: string[];794 warnings?: string[];
770 initialSource?: string | null;795 initialSource?: string | null;
796 activeSource?: string | null;
797 onActiveSourceChange?: (source: string | null) => void;
798 showSourceSidebar?: boolean;
771}799}
772 800
773export function ToolExplorer({801export function ToolExplorer({
@@ -776,11 +804,14 @@ export function ToolExplorer({ · 2 unmodified lines
776 loading = false,804 loading = false,
777 warnings = [],805 warnings = [],
778 initialSource = null,806 initialSource = null,
807 activeSource,
808 onActiveSourceChange,
809 showSourceSidebar = true,
779}: ToolExplorerProps) {810}: ToolExplorerProps) {
780 const [search, setSearch] = useState("");811 const [search, setSearch] = useState("");
781 const [viewMode, setViewMode] = useState<ViewMode>("tree");812 const [viewMode, setViewMode] = useState<ViewMode>("tree");
782 const [groupBy, setGroupBy] = useState<GroupBy>("source");813 const [groupBy, setGroupBy] = useState<GroupBy>("source");
783 const [activeSource, setActiveSource] = useState<string | null>(814 const [internalActiveSource, setInternalActiveSource] = useState<string | null>(
784 initialSource,815 initialSource,
785 );816 );
786 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(817 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
@@ -792,19 +823,24 @@ export function ToolExplorer({ · 5 unmodified lines
792 >("all");823 >("all");
793 const treeListRef = useRef<HTMLDivElement>(null);824 const treeListRef = useRef<HTMLDivElement>(null);
794 const flatListRef = useRef<HTMLDivElement>(null);825 const flatListRef = useRef<HTMLDivElement>(null);
826 const resolvedActiveSource =
827 activeSource === undefined ? internalActiveSource : activeSource;
795 828
796 const handleSourceSelect = useCallback((source: string | null) => {829 const handleSourceSelect = useCallback((source: string | null) => {
797 setActiveSource(source);830 if (activeSource === undefined) {
831 setInternalActiveSource(source);
832 }
833 onActiveSourceChange?.(source);
798 setExpandedKeys(source ? new Set([`source:${source}`]) : new Set());834 setExpandedKeys(source ? new Set([`source:${source}`]) : new Set());
799 }, []);835 }, [activeSource, onActiveSourceChange]);
800 836
801 // Filter tools by active source and approval filter837 // Filter tools by active source and approval filter
802 const filteredTools = useMemo(() => {838 const filteredTools = useMemo(() => {
803 let result = tools;839 let result = tools;
804 840
805 if (activeSource) {841 if (resolvedActiveSource) {
806 result = result.filter(842 result = result.filter(
807 (t) => sourceLabel(t.source) === activeSource,843 (t) => sourceLabel(t.source) === resolvedActiveSource,
808 );844 );
809 }845 }
810 846
@@ -815,7 +851,7 @@ export function ToolExplorer({ · 4 unmodified lines
815 }851 }
816 852
817 return result;853 return result;
818 }, [tools, activeSource, filterApproval]);854 }, [tools, resolvedActiveSource, filterApproval]);
819 855
820 // Apply search filter856 // Apply search filter
821 const searchedTools = useMemo(() => {857 const searchedTools = useMemo(() => {
@@ -995,21 +1031,23 @@ export function ToolExplorer({ · 173 unmodified lines
995 if (loading) {1031 if (loading) {
996 const enabledSources = sources.filter((s) => s.enabled);1032 const enabledSources = sources.filter((s) => s.enabled);
997 return (1033 return (
998 <div className="space-y-3">1034 <div className="space-y-3 rounded-md border border-border/40 bg-muted/20 p-3">
999 <div className="flex items-center gap-2 text-sm text-muted-foreground">1035 <div className="flex items-center gap-2 text-sm text-muted-foreground">
1000 <Loader2 className="h-4 w-4 animate-spin" />1036 <Loader2 className="h-4 w-4 animate-spin" />
1001 Loading {enabledSources.length > 0 ? `${enabledSources.length} tool source${enabledSources.length === 1 ? "" : "s"}` : "tools"}…1037 Loading tool inventory...
1002 </div>1038 </div>
1003 {enabledSources.length > 0 && (1039 {enabledSources.length > 0 && (
1004 <div className="space-y-1">1040 <div className="space-y-1">
1005 {enabledSources.map((s) => (1041 {enabledSources.map((s) => (
1006 <div1042 <div
1007 key={s.id}1043 key={s.id}
1008 className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"1044 className="flex items-center gap-2 rounded-md border border-border bg-background/70 px-3 py-2 text-xs"
1009 >1045 >
1010 <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />1046 <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
1011 <span className="font-medium">{s.name}</span>1047 <span className="font-medium truncate">{displaySourceName(s.name)}</span>
1012 <span className="text-muted-foreground">{s.type}</span>1048 <span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
1049 {s.type}
1050 </span>
1013 </div>1051 </div>
1014 ))}1052 ))}
1015 </div>1053 </div>
@@ -1020,15 +1058,16 @@ export function ToolExplorer({ · 4 unmodified lines
1020 1058
1021 return (1059 return (
1022 <div className="flex" onWheelCapture={handleExplorerWheel}>1060 <div className="flex" onWheelCapture={handleExplorerWheel}>
1023 {/* Source sidebar */}1061 {showSourceSidebar ? (
1024 <SourceSidebar1062 <SourceSidebar
1025 tools={tools}1063 tools={tools}
1026 activeSource={activeSource}1064 activeSource={resolvedActiveSource}
1027 onSelectSource={handleSourceSelect}1065 onSelectSource={handleSourceSelect}
1028 />1066 />
1067 ) : null}
1029 1068
1030 {/* Main content */}1069 {/* Main content */}
1031 <div className="flex-1 min-w-0 flex flex-col pl-0 lg:pl-4">1070 <div className={cn("flex-1 min-w-0 flex flex-col", showSourceSidebar ? "pl-0 lg:pl-4" : "pl-0")}>
1032 <div className="shrink-0">1071 <div className="shrink-0">
1033 {/* Toolbar */}1072 {/* Toolbar */}
1034 <div className="flex items-center gap-2 pb-3 flex-wrap">1073 <div className="flex items-center gap-2 pb-3 flex-wrap">
@@ -1182,12 +1221,13 @@ export function ToolExplorer({ · 147 unmodified lines
1182 variant="outline"1221 variant="outline"
1183 size="sm"1222 size="sm"
1184 className={cn(1223 className={cn(
1185 "h-8 text-[11px] border-border/50 lg:hidden",1224 "h-8 text-[11px] border-border/50",
1186 activeSource && "border-primary/30 text-primary",1225 showSourceSidebar && "lg:hidden",
1226 resolvedActiveSource && "border-primary/30 text-primary",
1187 )}1227 )}
1188 >1228 >
1189 <Server className="h-3 w-3 mr-1" />1229 <Server className="h-3 w-3 mr-1" />
1190 {activeSource ?? "All"}1230 {resolvedActiveSource ?? "All"}
1191 </Button>1231 </Button>
1192 </DropdownMenuTrigger>1232 </DropdownMenuTrigger>
1193 <DropdownMenuContent align="end">1233 <DropdownMenuContent align="end">
@@ -1196,7 +1236,7 @@ export function ToolExplorer({ · 2 unmodified lines
1196 </DropdownMenuLabel>1236 </DropdownMenuLabel>
1197 <DropdownMenuSeparator />1237 <DropdownMenuSeparator />
1198 <DropdownMenuCheckboxItem1238 <DropdownMenuCheckboxItem
1199 checked={activeSource === null}1239 checked={resolvedActiveSource === null}
1200 onCheckedChange={() => handleSourceSelect(null)}1240 onCheckedChange={() => handleSourceSelect(null)}
1201 className="text-xs"1241 className="text-xs"
1202 >1242 >
@@ -1207,10 +1247,10 @@ export function ToolExplorer({ · 4 unmodified lines
1207 ).map((src) => (1247 ).map((src) => (
1208 <DropdownMenuCheckboxItem1248 <DropdownMenuCheckboxItem
1209 key={src}1249 key={src}
1210 checked={activeSource === src}1250 checked={resolvedActiveSource === src}
1211 onCheckedChange={() =>1251 onCheckedChange={() =>
1212 handleSourceSelect(1252 handleSourceSelect(
1213 activeSource === src ? null : src,1253 resolvedActiveSource === src ? null : src,
1214 )1254 )
1215 }1255 }
1216 className="text-xs font-mono"1256 className="text-xs font-mono"
@@ -1275,7 +1315,7 @@ export function ToolExplorer({ · 58 unmodified lines
1275 {search1315 {search
1276 ? `${searchedTools.length} results`1316 ? `${searchedTools.length} results`
1277 : `${filteredTools.length} tools`}1317 : `${filteredTools.length} tools`}
1278 {activeSource && ` in ${activeSource}`}1318 {resolvedActiveSource && ` in ${resolvedActiveSource}`}
1279 </span>1319 </span>
1280 {viewMode === "tree" && (1320 {viewMode === "tree" && (
1281 <div className="flex gap-1">1321 <div className="flex gap-1">
executor/apps/web/src/components/tools-view.tsx+828−572
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { useRef, useState } from "react";3import { useEffect, useMemo, useState } from "react";
4import {4import {
5 Wrench,5 Wrench,
6 Plus,6 Plus,
@@ -11,7 +11,6 @@ import { · 4 unmodified lines
11 AlertTriangle,11 AlertTriangle,
12 KeyRound,12 KeyRound,
13 Pencil,13 Pencil,
14 Loader2,
15} from "lucide-react";14} from "lucide-react";
16import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";15import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
17import { Button } from "@/components/ui/button";16import { Button } from "@/components/ui/button";
@@ -52,6 +51,7 @@ import type { · 34 unmodified lines
52 ToolSourceRecord,51 ToolSourceRecord,
53 CredentialRecord,52 CredentialRecord,
54 CredentialScope,53 CredentialScope,
54 SourceAuthProfile,
55 OpenApiSourceQuality,55 OpenApiSourceQuality,
56} from "@/lib/types";56} from "@/lib/types";
57import { parse as parseDomain } from "tldts";57import { parse as parseDomain } from "tldts";
@@ -69,15 +69,108 @@ interface CatalogCollectionItem { · 11 unmodified lines
69 logoUrl?: string;69 logoUrl?: string;
70 categories?: string;70 categories?: string;
71 version?: string;71 version?: string;
72 rank: number;
73 addedAt: string;
72}74}
73 75
74interface CatalogCollectionsResponse {76const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [
75 items?: CatalogCollectionItem[];77 {
76 totalCount?: number;78 id: "github-rest",
77 hasMore?: boolean;79 name: "GitHub REST API",
78 error?: string;80 summary: "Manage repositories, pull requests, issues, and org settings.",
79 detail?: string;81 specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
80}82 originUrl: "https://docs.github.com/en/rest",
83 providerName: "GitHub",
84 categories: "developer-tools",
85 version: "latest",
86 rank: 1,
87 addedAt: "2026-01-10",
88 },
89 {
90 id: "stripe-api",
91 name: "Stripe API",
92 summary: "Create payments, manage customers, and handle billing workflows.",
93 specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
94 originUrl: "https://docs.stripe.com/api",
95 providerName: "Stripe",
96 categories: "payments",
97 version: "2026-01",
98 rank: 2,
99 addedAt: "2026-01-08",
100 },
101 {
102 id: "openai-api",
103 name: "OpenAI API",
104 summary: "Generate text, run reasoning models, and process multimodal inputs.",
105 specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
106 originUrl: "https://platform.openai.com/docs/api-reference",
107 providerName: "OpenAI",
108 categories: "ai",
109 version: "latest",
110 rank: 3,
111 addedAt: "2026-01-06",
112 },
113 {
114 id: "cloudflare-api",
115 name: "Cloudflare API",
116 summary: "Control zones, DNS records, workers, and edge configuration.",
117 specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml",
118 originUrl: "https://api.cloudflare.com/",
119 providerName: "Cloudflare",
120 categories: "infrastructure",
121 version: "latest",
122 rank: 4,
123 addedAt: "2026-01-04",
124 },
125 {
126 id: "vercel-api",
127 name: "Vercel API",
128 summary: "Manage deployments, projects, domains, and team resources.",
129 specUrl: "https://openapi.vercel.sh",
130 originUrl: "https://vercel.com/docs/rest-api",
131 providerName: "Vercel",
132 categories: "developer-tools",
133 version: "latest",
134 rank: 5,
135 addedAt: "2025-12-18",
136 },
137 {
138 id: "slack-api",
139 name: "Slack API",
140 summary: "Work with channels, messages, users, and workspace automation.",
141 specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json",
142 originUrl: "https://api.slack.com/web",
143 providerName: "Slack",
144 categories: "communications",
145 version: "v2",
146 rank: 6,
147 addedAt: "2025-12-10",
148 },
149 {
150 id: "sentry-api",
151 name: "Sentry API",
152 summary: "Query issues, releases, projects, and alerting configuration.",
153 specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json",
154 originUrl: "https://docs.sentry.io/api/",
155 providerName: "Sentry",
156 categories: "observability",
157 version: "latest",
158 rank: 7,
159 addedAt: "2025-11-30",
160 },
161 {
162 id: "jira-cloud-api",
163 name: "Jira Cloud Platform",
164 summary: "Manage projects, issues, workflows, and Jira metadata.",
165 specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json",
166 originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/",
167 providerName: "Atlassian",
168 categories: "project-management",
169 version: "v3",
170 rank: 8,
171 addedAt: "2025-11-15",
172 },
173];
81 174
82/** Derive a favicon URL from any URL string via Google's favicon service. */175/** Derive a favicon URL from any URL string via Google's favicon service. */
83function faviconForUrl(url: string | undefined | null): string | null {176function faviconForUrl(url: string | undefined | null): string | null {
@@ -141,45 +234,94 @@ function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): · 57 unmodified lines
141 return sources.find((source) => source.id === sourceId) ?? null;234 return sources.find((source) => source.id === sourceId) ?? null;
142}235}
143 236
144type SourceAuthType = "none" | "bearer" | "apiKey" | "basic";237type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
145type SourceAuthMode = "workspace" | "actor";238type SourceAuthMode = "workspace" | "actor";
146 239
147function readSourceAuth(source: ToolSourceRecord): {240function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): {
241 type: SourceAuthType;
242 mode?: SourceAuthMode;
243 header?: string;
244 inferred?: boolean;
245} {
246 if (!profile) {
247 return { type: "none" };
248 }
249
250 const type = profile.type === "mixed"
251 ? "mixed"
252 : profile.type === "basic"
253 ? "basic"
254 : profile.type === "apiKey"
255 ? "apiKey"
256 : profile.type === "bearer"
257 ? "bearer"
258 : "none";
259
260 const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined;
261 const header = typeof profile.header === "string" && profile.header.trim().length > 0
262 ? profile.header.trim()
263 : undefined;
264
265 return {
266 type,
267 ...(mode ? { mode } : {}),
268 ...(header ? { header } : {}),
269 inferred: Boolean(profile.inferred),
270 };
271}
272
273function readSourceAuth(
274 source: ToolSourceRecord,
275 inferredProfile?: SourceAuthProfile,
276): {
148 type: SourceAuthType;277 type: SourceAuthType;
149 mode?: SourceAuthMode;278 mode?: SourceAuthMode;
150 header?: string;279 header?: string;
280 inferred?: boolean;
151} {281} {
152 if (source.type !== "openapi" && source.type !== "graphql") {282 if (source.type !== "openapi" && source.type !== "graphql") {
153 return { type: "none" };283 return { type: "none" };
154 }284 }
155 285
286 const inferred = normalizeSourceAuthProfile(inferredProfile);
287
156 const auth = source.config.auth as Record<string, unknown> | undefined;288 const auth = source.config.auth as Record<string, unknown> | undefined;
157 const type =289 const type =
158 auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic"].includes(auth.type)290 auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type)
159 ? (auth.type as SourceAuthType)291 ? (auth.type as SourceAuthType)
160 : "none";292 : inferred.type;
161 293
162 const mode =294 const mode =
163 auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor")295 auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor")
164 ? (auth.mode as SourceAuthMode)296 ? (auth.mode as SourceAuthMode)
165 : undefined;297 : inferred.mode;
166 298
167 const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0299 const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0
168 ? auth.header.trim()300 ? auth.header.trim()
169 : undefined;301 : inferred.header;
170 302
171 return {303 return {
172 type,304 type,
173 ...(mode ? { mode } : {}),305 ...(mode ? { mode } : {}),
174 ...(header ? { header } : {}),306 ...(header ? { header } : {}),
307 inferred: auth?.type === undefined ? inferred.inferred : false,
175 };308 };
176}309}
177 310
178function formatSourceAuthBadge(source: ToolSourceRecord): string | null {311function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null {
179 const auth = readSourceAuth(source);312 const auth = readSourceAuth(source, inferredProfile);
180 if (auth.type === "none") return null;313 if (auth.type === "none") return null;
314 if (auth.type === "mixed") return "Mixed auth";
181 const mode = auth.mode ?? "workspace";315 const mode = auth.mode ?? "workspace";
182 return `${auth.type}:${mode}`;316 const authLabel =
317 auth.type === "apiKey"
318 ? "API Key"
319 : auth.type === "bearer"
320 ? "Bearer"
321 : auth.type === "basic"
322 ? "Basic"
323 : "Auth";
324 return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`;
183}325}
184 326
185function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): {327function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): {
@@ -206,14 +348,62 @@ function formatQualityPercent(value: number): string { · 20 unmodified lines
206 return `${Math.round(value * 100)}%`;348 return `${Math.round(value * 100)}%`;
207}349}
208 350
209function qualityBadgeClass(quality: OpenApiSourceQuality): string {351function qualityToneClass(quality: OpenApiSourceQuality): string {
210 if (quality.overallQuality >= 0.95) {352 if (quality.overallQuality >= 0.95) {
211 return "text-terminal-green border-terminal-green/30";353 return "text-terminal-green";
212 }354 }
213 if (quality.overallQuality >= 0.85) {355 if (quality.overallQuality >= 0.85) {
214 return "text-terminal-amber border-terminal-amber/30";356 return "text-terminal-amber";
357 }
358 return "text-terminal-red";
359}
360
361function qualitySummaryLabel(quality: OpenApiSourceQuality): string {
362 if (quality.overallQuality >= 0.95) {
363 return "strong typing";
364 }
365 if (quality.overallQuality >= 0.85) {
366 return "mostly typed";
367 }
368 return "needs type cleanup";
369}
370
371function displaySourceName(name: string): string {
372 const parts = name.split(/[-_.]+/).filter(Boolean);
373 if (parts.length === 0) return name;
374
375 const deduped = parts.filter((part, index, all) => {
376 if (index === 0) return true;
377 return part.toLowerCase() !== all[index - 1]?.toLowerCase();
378 });
379
380 const tokenMap: Record<string, string> = {
381 api: "API",
382 oauth: "OAuth",
383 graphql: "GraphQL",
384 mcp: "MCP",
385 github: "GitHub",
386 };
387
388 return deduped
389 .map((token) => {
390 const lower = token.toLowerCase();
391 if (tokenMap[lower]) return tokenMap[lower];
392 return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`;
393 })
394 .join(" ");
395}
396
397function compactEndpointLabel(source: ToolSourceRecord): string {
398 const endpoint = sourceEndpointLabel(source);
399 if (endpoint.startsWith("catalog:")) return endpoint;
400 try {
401 const parsed = new URL(endpoint);
402 const path = parsed.pathname === "/" ? "" : parsed.pathname;
403 return `${parsed.hostname}${path}`;
404 } catch {
405 return endpoint;
215 }406 }
216 return "text-terminal-red border-terminal-red/30";
217}407}
218 408
219// ── Add Source Dialog ──409// ── Add Source Dialog ──
@@ -284,8 +474,10 @@ function catalogSourceName(item: CatalogCollectionItem): string { · 64 unmodified lines
284 474
285function AddSourceDialog({475function AddSourceDialog({
286 existingSourceNames,476 existingSourceNames,
477 onSourceAdded,
287}: {478}: {
288 existingSourceNames: Set<string>;479 existingSourceNames: Set<string>;
480 onSourceAdded?: (source: ToolSourceRecord) => void;
289}) {481}) {
290 const { context } = useSession();482 const { context } = useSession();
291 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);483 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
@@ -302,18 +494,27 @@ function AddSourceDialog({ · 10 unmodified lines
302 const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]);494 const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]);
303 const [catalogQuery, setCatalogQuery] = useState("");495 const [catalogQuery, setCatalogQuery] = useState("");
304 const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular");496 const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular");
305 const [catalogItems, setCatalogItems] = useState<CatalogCollectionItem[]>([]);
306 const [catalogOffset, setCatalogOffset] = useState(0);
307 const [catalogHasMore, setCatalogHasMore] = useState(true);
308 const [catalogTotalCount, setCatalogTotalCount] = useState<number | null>(null);
309 const [catalogLoading, setCatalogLoading] = useState(false);
310 const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
311 const [catalogError, setCatalogError] = useState<string | null>(null);
312 const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null);497 const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null);
313 const catalogRequestIdRef = useRef(0);
314 const catalogInFlightRef = useRef(false);
315 498
316 const CATALOG_PAGE_SIZE = 20;499 const visibleCatalogItems = useMemo(() => {
500 const query = catalogQuery.trim().toLowerCase();
501 const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => {
502 if (!query) return true;
503 return [
504 item.name,
505 item.providerName,
506 item.summary,
507 item.categories ?? "",
508 ].some((value) => value.toLowerCase().includes(query));
509 });
510
511 return [...filtered].sort((a, b) => {
512 if (catalogSort === "recent") {
513 return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime();
514 }
515 return a.rank - b.rank;
516 });
517 }, [catalogQuery, catalogSort]);
317 518
318 const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]);519 const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]);
319 520
@@ -343,8 +544,6 @@ function AddSourceDialog({ · 23 unmodified lines
343 };544 };
344 545
345 const resetDialogState = () => {546 const resetDialogState = () => {
346 catalogRequestIdRef.current += 1;
347 catalogInFlightRef.current = false;
348 setView("catalog");547 setView("catalog");
349 setType("mcp");548 setType("mcp");
350 setName("");549 setName("");
@@ -356,117 +555,14 @@ function AddSourceDialog({ · 5 unmodified lines
356 setLocallyReservedNames([]);555 setLocallyReservedNames([]);
357 setCatalogQuery("");556 setCatalogQuery("");
358 setCatalogSort("popular");557 setCatalogSort("popular");
359 setCatalogItems([]);
360 setCatalogOffset(0);
361 setCatalogHasMore(true);
362 setCatalogTotalCount(null);
363 setCatalogLoading(false);
364 setCatalogLoadingMore(false);
365 setCatalogError(null);
366 setAddingCatalogId(null);558 setAddingCatalogId(null);
367 };559 };
368 560
369 const loadCatalogPage = async ({
370 mode,
371 query,
372 sort,
373 }: {
374 mode: "reset" | "next";
375 query?: string;
376 sort?: "popular" | "recent";
377 }) => {
378 const resolvedQuery = (query ?? catalogQuery).trim();
379 const resolvedSort = sort ?? catalogSort;
380 const nextOffset = mode === "reset" ? 0 : catalogOffset;
381
382 if (mode === "next") {
383 if (catalogLoading || catalogLoadingMore || catalogInFlightRef.current || !catalogHasMore) {
384 return;
385 }
386 setCatalogLoadingMore(true);
387 } else {
388 setCatalogLoading(true);
389 setCatalogLoadingMore(false);
390 setCatalogError(null);
391 }
392
393 const requestId = catalogRequestIdRef.current + 1;
394 catalogRequestIdRef.current = requestId;
395 catalogInFlightRef.current = true;
396
397 try {
398 const params = new URLSearchParams({
399 sort: resolvedSort,
400 limit: String(CATALOG_PAGE_SIZE),
401 offset: String(nextOffset),
402 });
403 if (resolvedQuery.length > 0) {
404 params.set("q", resolvedQuery);
405 }
406
407 const catalogBase = process.env.NEXT_PUBLIC_SOURCES_URL ?? "http://127.0.0.1:4343";
408 const response = await fetch(`${catalogBase}/collections?${params.toString()}`, {
409 cache: "no-store",
410 });
411 const body = await response.json() as CatalogCollectionsResponse;
412
413 if (requestId !== catalogRequestIdRef.current) {
414 return;
415 }
416
417 if (!response.ok) {
418 throw new Error(body.error || "Failed to load API catalog");
419 }
420
421 const nextItems = Array.isArray(body.items) ? body.items : [];
422 setCatalogItems((current) => {
423 if (mode === "reset") {
424 return nextItems;
425 }
426
427 const merged = [...current];
428 const seen = new Set(current.map((entry) => entry.id));
429 for (const item of nextItems) {
430 if (seen.has(item.id)) continue;
431 seen.add(item.id);
432 merged.push(item);
433 }
434 return merged;
435 });
436 setCatalogOffset(nextOffset + nextItems.length);
437 setCatalogHasMore(typeof body.hasMore === "boolean" ? body.hasMore : nextItems.length >= CATALOG_PAGE_SIZE);
438 setCatalogTotalCount(typeof body.totalCount === "number" ? body.totalCount : null);
439 setCatalogError(null);
440 } catch (error) {
441 if (requestId !== catalogRequestIdRef.current) {
442 return;
443 }
444
445 if (mode === "reset") {
446 setCatalogItems([]);
447 setCatalogOffset(0);
448 }
449 setCatalogHasMore(false);
450 setCatalogTotalCount(null);
451 setCatalogError(error instanceof Error ? error.message : "Failed to load API catalog");
452 } finally {
453 if (requestId === catalogRequestIdRef.current) {
454 setCatalogLoading(false);
455 setCatalogLoadingMore(false);
456 catalogInFlightRef.current = false;
457 }
458 }
459 };
460
461 const handleOpenChange = (isOpen: boolean) => {561 const handleOpenChange = (isOpen: boolean) => {
462 setOpen(isOpen);562 setOpen(isOpen);
463 if (!isOpen) {563 if (!isOpen) {
464 resetDialogState();564 resetDialogState();
465 return;
466 }565 }
467
468 resetDialogState();
469 void loadCatalogPage({ mode: "reset", query: "", sort: "popular" });
470 };566 };
471 567
472 const addSource = async (568 const addSource = async (
@@ -475,13 +571,14 @@ function AddSourceDialog({ · 2 unmodified lines
475 config: Record<string, unknown>,571 config: Record<string, unknown>,
476 ) => {572 ) => {
477 if (!context) return;573 if (!context) return;
478 await upsertToolSource({574 const created = await upsertToolSource({
479 workspaceId: context.workspaceId,575 workspaceId: context.workspaceId,
480 sessionId: context.sessionId,576 sessionId: context.sessionId,
481 name: sourceName,577 name: sourceName,
482 type: sourceType,578 type: sourceType,
483 config,579 config,
484 });580 });
581 onSourceAdded?.(created as ToolSourceRecord);
485 toast.success(`Source "${sourceName}" added — loading tools…`);582 toast.success(`Source "${sourceName}" added — loading tools…`);
486 };583 };
487 584
@@ -563,20 +660,10 @@ function AddSourceDialog({ · 75 unmodified lines
563 onChange={(event) => setCatalogQuery(event.target.value)}660 onChange={(event) => setCatalogQuery(event.target.value)}
564 placeholder="Search APIs"661 placeholder="Search APIs"
565 className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]"662 className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]"
566 onKeyDown={(event) => {
567 if (event.key === "Enter") {
568 event.preventDefault();
569 void loadCatalogPage({ mode: "reset" });
570 }
571 }}
572 />663 />
573 <Select664 <Select
574 value={catalogSort}665 value={catalogSort}
575 onValueChange={(value) => {666 onValueChange={(value) => setCatalogSort(value as "popular" | "recent")}
576 const nextSort = value as "popular" | "recent";
577 setCatalogSort(nextSort);
578 void loadCatalogPage({ mode: "reset", sort: nextSort });
579 }}
580 >667 >
581 <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0">668 <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0">
582 <SelectValue />669 <SelectValue />
@@ -586,35 +673,15 @@ function AddSourceDialog({ · 3 unmodified lines
586 <SelectItem value="recent" className="text-xs">Recent</SelectItem>673 <SelectItem value="recent" className="text-xs">Recent</SelectItem>
587 </SelectContent>674 </SelectContent>
588 </Select>675 </Select>
589 <Button
590 type="button"
591 variant="outline"
592 size="sm"
593 className="h-8 px-3 text-xs shrink-0"
594 onClick={() => void loadCatalogPage({ mode: "reset" })}
595 disabled={catalogLoading}
596 >
597 {catalogLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Search"}
598 </Button>
599 </div>676 </div>
600 677
601 <p className="text-[11px] text-muted-foreground">678 <p className="text-[11px] text-muted-foreground">
602 Browse API collections and add them as tool sources.679 Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}.
603 {catalogTotalCount !== null ? ` Found ${catalogTotalCount.toLocaleString()} total.` : ""}
604 </p>680 </p>
605 681
606 <Separator />682 <Separator />
607 683
608 <div684 <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1">
609 className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1"
610 onScroll={(event) => {
611 const target = event.currentTarget;
612 const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 120;
613 if (nearBottom) {
614 void loadCatalogPage({ mode: "next" });
615 }
616 }}
617 >
618 <button685 <button
619 type="button"686 type="button"
620 onClick={() => setView("custom")}687 onClick={() => setView("custom")}
@@ -631,7 +698,7 @@ function AddSourceDialog({ · 10 unmodified lines
631 </div>698 </div>
632 </button>699 </button>
633 700
634 {catalogItems.map((item) => (701 {visibleCatalogItems.map((item) => (
635 <div702 <div
636 key={item.id}703 key={item.id}
637 className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50"704 className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50"
@@ -673,30 +740,11 @@ function AddSourceDialog({ · 35 unmodified lines
673 </div>740 </div>
674 ))}741 ))}
675 742
676 {catalogLoadingMore && (743 {visibleCatalogItems.length === 0 && (
677 <div className="flex items-center justify-center py-2 text-[11px] text-muted-foreground">
678 <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
679 Loading more...
680 </div>
681 )}
682
683 {!catalogLoading && !catalogLoadingMore && catalogItems.length === 0 && !catalogError && (
684 <p className="text-[11px] text-muted-foreground px-1 py-1">744 <p className="text-[11px] text-muted-foreground px-1 py-1">
685 No collections found for this query.745 No collections found for this query.
686 </p>746 </p>
687 )}747 )}
688
689 {catalogError && (
690 <p className="text-[11px] text-terminal-red px-1 py-1">
691 {catalogError}
692 </p>
693 )}
694
695 {!catalogHasMore && catalogItems.length > 0 && (
696 <p className="text-[10px] text-muted-foreground/70 px-1 py-1">
697 End of results.
698 </p>
699 )}
700 </div>748 </div>
701 </div>749 </div>
702 ) : (750 ) : (
@@ -820,24 +868,27 @@ function AddSourceDialog({ · 117 unmodified lines
820 868
821function ConfigureSourceAuthDialog({869function ConfigureSourceAuthDialog({
822 source,870 source,
871 inferredProfile,
823}: {872}: {
824 source: ToolSourceRecord;873 source: ToolSourceRecord;
874 inferredProfile?: SourceAuthProfile;
825}) {875}) {
826 const { context } = useSession();876 const { context } = useSession();
827 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);877 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
828 const [open, setOpen] = useState(false);878 const [open, setOpen] = useState(false);
829 const [saving, setSaving] = useState(false);879 const [saving, setSaving] = useState(false);
830 880
831 const currentAuth = readSourceAuth(source);881 const currentAuth = readSourceAuth(source, inferredProfile);
832 const [authType, setAuthType] = useState<SourceAuthType>(currentAuth.type);882 const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type;
883 const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType);
833 const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace");884 const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace");
834 const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key");885 const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key");
835 886
836 const configurable = source.type === "openapi" || source.type === "graphql";887 const configurable = source.type === "openapi" || source.type === "graphql";
837 888
838 const resetFromSource = () => {889 const resetFromSource = () => {
839 const auth = readSourceAuth(source);890 const auth = readSourceAuth(source, inferredProfile);
840 setAuthType(auth.type);891 setAuthType(auth.type === "mixed" ? "bearer" : auth.type);
841 setAuthMode(auth.mode ?? "workspace");892 setAuthMode(auth.mode ?? "workspace");
842 setApiKeyHeader(auth.header ?? "x-api-key");893 setApiKeyHeader(auth.header ?? "x-api-key");
843 };894 };
@@ -905,7 +956,7 @@ function ConfigureSourceAuthDialog({ · 61 unmodified lines
905 956
906 <div className="space-y-1.5">957 <div className="space-y-1.5">
907 <Label className="text-xs text-muted-foreground">Auth Type</Label>958 <Label className="text-xs text-muted-foreground">Auth Type</Label>
908 <Select value={authType} onValueChange={(value) => setAuthType(value as SourceAuthType)}>959 <Select value={authType} onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}>
909 <SelectTrigger className="h-8 text-xs bg-background">960 <SelectTrigger className="h-8 text-xs bg-background">
910 <SelectValue />961 <SelectValue />
911 </SelectTrigger>962 </SelectTrigger>
@@ -918,6 +969,12 @@ function ConfigureSourceAuthDialog({ · 6 unmodified lines
918 </Select>969 </Select>
919 </div>970 </div>
920 971
972 {currentAuth.inferred && (
973 <p className="text-[11px] text-muted-foreground">
974 Suggested from spec inference. Save to pin an explicit auth config.
975 </p>
976 )}
977
921 {authType !== "none" && (978 {authType !== "none" && (
922 <>979 <>
923 <div className="space-y-1.5">980 <div className="space-y-1.5">
@@ -946,7 +1003,7 @@ function ConfigureSourceAuthDialog({ · 22 unmodified lines
946 )}1003 )}
947 1004
948 <p className="text-[11px] text-muted-foreground">1005 <p className="text-[11px] text-muted-foreground">
949 Save this first, then add credentials in the Credentials tab using source key1006 Save this first, then add a connection in the Connections tab using source key
950 <code className="ml-1">{sourceKeyForSource(source)}</code>.1007 <code className="ml-1">{sourceKeyForSource(source)}</code>.
951 </p>1008 </p>
952 </>1009 </>
@@ -966,15 +1023,22 @@ function SourceCard({ · 13 unmodified lines
966 quality,1023 quality,
967 qualityLoading,1024 qualityLoading,
968 credentialStats,1025 credentialStats,
1026 sourceAuthProfiles,
1027 selected = false,
1028 onFocusSource,
969}: {1029}: {
970 source: ToolSourceRecord;1030 source: ToolSourceRecord;
971 quality?: OpenApiSourceQuality;1031 quality?: OpenApiSourceQuality;
972 qualityLoading?: boolean;1032 qualityLoading?: boolean;
973 credentialStats: { workspaceCount: number; actorCount: number };1033 credentialStats: { workspaceCount: number; actorCount: number };
1034 sourceAuthProfiles: Record<string, SourceAuthProfile>;
1035 selected?: boolean;
1036 onFocusSource?: (sourceName: string) => void;
974}) {1037}) {
975 const { context } = useSession();1038 const { context } = useSession();
976 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);1039 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
977 const [deleting, setDeleting] = useState(false);1040 const [deleting, setDeleting] = useState(false);
1041 const [detailsOpen, setDetailsOpen] = useState(false);
978 1042
979 const handleDelete = async () => {1043 const handleDelete = async () => {
980 if (!context) return;1044 if (!context) return;
@@ -995,14 +1059,23 @@ function SourceCard({ · 14 unmodified lines
995 1059
996 const TypeIcon = source.type === "mcp" ? Server : Globe;1060 const TypeIcon = source.type === "mcp" ? Server : Globe;
997 const favicon = getSourceFavicon(source);1061 const favicon = getSourceFavicon(source);
998 const authBadge = formatSourceAuthBadge(source);1062 const sourceKey = sourceKeyForSource(source) ?? "";
999 const auth = readSourceAuth(source);1063 const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]);
1064 const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]);
1000 const hasAuthConfigured = auth.type !== "none";1065 const hasAuthConfigured = auth.type !== "none";
1001 const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount;1066 const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount;
1067 const prettyName = displaySourceName(source.name);
1068 const compactEndpoint = compactEndpointLabel(source);
1069 const showTypeSummary = source.type === "openapi" && (quality || qualityLoading);
1002 1070
1003 return (1071 return (
1004 <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group">1072 <div
1005 <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">1073 className={cn(
1074 "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3",
1075 selected && "border-primary/35 bg-primary/5",
1076 )}
1077 >
1078 <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden">
1006 {favicon ? (1079 {favicon ? (
1007 <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />1080 <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />
1008 ) : (1081 ) : (
@@ -1010,20 +1083,20 @@ function SourceCard({ · 1 unmodified line
1010 )}1083 )}
1011 </div>1084 </div>
1012 <div className="flex-1 min-w-0">1085 <div className="flex-1 min-w-0">
1013 <div className="flex items-center gap-2">1086 <div className="flex items-center gap-2 min-w-0">
1014 <span className="text-sm font-mono font-medium truncate">1087 <span className="text-sm font-medium truncate" title={source.name}>
1015 {source.name}1088 {prettyName}
1016 </span>1089 </span>
1017 <Badge1090 <Badge
1018 variant="outline"1091 variant="outline"
1019 className="text-[9px] font-mono uppercase tracking-wider"1092 className="text-[9px] uppercase tracking-wide"
1020 >1093 >
1021 {source.type}1094 {source.type}
1022 </Badge>1095 </Badge>
1023 {!source.enabled && (1096 {!source.enabled && (
1024 <Badge1097 <Badge
1025 variant="outline"1098 variant="outline"
1026 className="text-[9px] font-mono uppercase tracking-wider text-terminal-red border-terminal-red/30"1099 className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30"
1027 >1100 >
1028 disabled1101 disabled
1029 </Badge>1102 </Badge>
@@ -1031,7 +1104,7 @@ function SourceCard({ · 1 unmodified line
1031 {authBadge && (1104 {authBadge && (
1032 <Badge1105 <Badge
1033 variant="outline"1106 variant="outline"
1034 className="text-[9px] font-mono uppercase tracking-wider text-primary border-primary/30"1107 className="text-[9px] uppercase tracking-wide text-primary border-primary/30"
1035 >1108 >
1036 {authBadge}1109 {authBadge}
1037 </Badge>1110 </Badge>
@@ -1040,43 +1113,100 @@ function SourceCard({ · 2 unmodified lines
1040 <Badge1113 <Badge
1041 variant="outline"1114 variant="outline"
1042 className={cn(1115 className={cn(
1043 "text-[9px] font-mono uppercase tracking-wider",1116 "text-[9px] uppercase tracking-wide",
1044 totalCredentials > 01117 totalCredentials > 0
1045 ? "text-terminal-green border-terminal-green/30"1118 ? "text-terminal-green border-terminal-green/30"
1046 : "text-terminal-amber border-terminal-amber/30",1119 : "text-terminal-amber border-terminal-amber/30",
1047 )}1120 )}
1048 >1121 >
1049 creds ws:{credentialStats.workspaceCount} actor:{credentialStats.actorCount}1122 {totalCredentials > 0 ? "connections ready" : "connection needed"}
1050 </Badge>
1051 )}
1052 {source.type === "openapi" && quality && (
1053 <Badge
1054 variant="outline"
1055 className={cn("text-[9px] font-mono uppercase tracking-wider", qualityBadgeClass(quality))}
1056 >
1057 quality {formatQualityPercent(quality.overallQuality)}
1058 </Badge>1123 </Badge>
1059 )}1124 )}
1060 </div>1125 </div>
1061 <span className="text-[11px] text-muted-foreground font-mono truncate block">1126 <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}>
1062 {sourceEndpointLabel(source)}1127 {compactEndpoint}
1063 </span>1128 </span>
1064 {source.type === "openapi" && quality && (1129 {showTypeSummary && (
1065 <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5">1130 <div className="mt-1.5 flex items-center gap-2">
1066 args {formatQualityPercent(quality.argsQuality)} | returns {formatQualityPercent(quality.returnsQuality)}1131 {quality ? (
1067 {quality.unknownReturnsCount > 01132 <Badge
1068 ? ` | ${quality.unknownReturnsCount} unknown returns`1133 variant="outline"
1069 : " | fully typed returns"}1134 className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))}
1070 </span>1135 >
1136 {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)}
1137 </Badge>
1138 ) : (
1139 <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground">
1140 analyzing type quality
1141 </Badge>
1142 )}
1143 </div>
1071 )}1144 )}
1072 {source.type === "openapi" && !quality && qualityLoading && (1145 {source.type === "openapi" && (
1073 <span className="text-[10px] text-muted-foreground/70 font-mono truncate block mt-0.5">1146 <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
1074 Computing OpenAPI type quality...1147 <div className="mt-1.5">
1075 </span>1148 <CollapsibleTrigger asChild>
1149 <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground">
1150 <ChevronRight
1151 className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")}
1152 />
1153 {detailsOpen ? "Hide details" : "View details"}
1154 </Button>
1155 </CollapsibleTrigger>
1156 </div>
1157 <CollapsibleContent className="mt-1.5">
1158 <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2">
1159 {quality && (
1160 <div className="space-y-1.5 text-[10px]">
1161 <div className="flex items-center justify-between">
1162 <span className="text-muted-foreground">Type quality</span>
1163 <span className={cn("font-medium", qualityToneClass(quality))}>
1164 {formatQualityPercent(quality.overallQuality)}
1165 </span>
1166 </div>
1167 <div className="flex items-center justify-between text-muted-foreground">
1168 <span>Args quality</span>
1169 <span>{formatQualityPercent(quality.argsQuality)}</span>
1170 </div>
1171 <div className="flex items-center justify-between text-muted-foreground">
1172 <span>Returns quality</span>
1173 <span>{formatQualityPercent(quality.returnsQuality)}</span>
1174 </div>
1175 <div className="flex items-center justify-between text-muted-foreground">
1176 <span>Inferred returns</span>
1177 <span>{quality.unknownReturnsCount}</span>
1178 </div>
1179 </div>
1180 )}
1181 {!quality && qualityLoading && (
1182 <div className="flex items-center justify-between text-[10px] text-muted-foreground">
1183 <span>Analyzing OpenAPI typing</span>
1184 <span className="inline-flex items-center gap-1 text-[10px]">
1185 <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" />
1186 in progress
1187 </span>
1188 </div>
1189 )}
1190 {!quality && !qualityLoading && (
1191 <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div>
1192 )}
1193 </div>
1194 </CollapsibleContent>
1195 </Collapsible>
1076 )}1196 )}
1077 </div>1197 </div>
1078 <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">1198 <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
1079 <ConfigureSourceAuthDialog source={source} />1199 {onFocusSource ? (
1200 <Button
1201 variant={selected ? "default" : "outline"}
1202 size="sm"
1203 className="h-7 text-[11px]"
1204 onClick={() => onFocusSource(source.name)}
1205 >
1206 {selected ? "Viewing" : "View tools"}
1207 </Button>
1208 ) : null}
1209 <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} />
1080 <Button1210 <Button
1081 variant="ghost"1211 variant="ghost"
1082 size="icon"1212 size="icon"
@@ -1091,62 +1221,109 @@ function SourceCard({ · 8 unmodified lines
1091 );1221 );
1092}1222}
1093 1223
1094function formatCredentialSecret(secretJson: Record<string, unknown>): string {
1095 try {
1096 return JSON.stringify(secretJson, null, 2);
1097 } catch {
1098 return "{}";
1099 }
1100}
1101
1102type SourceOption = { source: ToolSourceRecord; key: string; label: string };1224type SourceOption = { source: ToolSourceRecord; key: string; label: string };
1103 1225
1104function sourceAuthForKey(sourceOptions: SourceOption[], key: string): {1226function sourceAuthForKey(
1227 sourceOptions: SourceOption[],
1228 key: string,
1229 inferredProfiles: Record<string, SourceAuthProfile> = {},
1230): {
1105 type: SourceAuthType;1231 type: SourceAuthType;
1106 mode?: SourceAuthMode;1232 mode?: SourceAuthMode;
1107 header?: string;1233 header?: string;
1234 inferred?: boolean;
1108} {1235} {
1109 const match = sourceOptions.find((entry) => entry.key === key);1236 const match = sourceOptions.find((entry) => entry.key === key);
1110 if (!match) {1237 if (!match) {
1111 return { type: "bearer" };1238 return { type: "bearer" };
1112 }1239 }
1113 return readSourceAuth(match.source);1240 return readSourceAuth(match.source, inferredProfiles[key]);
1114}1241}
1115 1242
1116function sourceOptionLabel(source: ToolSourceRecord): string {1243function sourceOptionLabel(source: ToolSourceRecord): string {
1117 return `${source.name} (${source.type})`;1244 return `${source.name} (${source.type})`;
1118}1245}
1119 1246
1120function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } {1247function providerLabel(provider: "local-convex" | "workos-vault"): string {
1121 try {1248 return provider === "workos-vault" ? "encrypted" : "local";
1122 const parsed = JSON.parse(text);1249}
1123 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {1250
1124 return { error: "Credential JSON must be an object" };1251function connectionDisplayName(
1252 sources: ToolSourceRecord[],
1253 connection: {
1254 scope: CredentialScope;
1255 sourceKeys: Set<string>;
1256 actorId?: string;
1257 },
1258): string {
1259 const sourceNames = [...connection.sourceKeys]
1260 .map((sourceKey) => sourceForCredentialKey(sources, sourceKey))
1261 .filter((source): source is ToolSourceRecord => Boolean(source))
1262 .map((source) => displaySourceName(source.name));
1263
1264 const primary = sourceNames[0] ?? "API";
1265 const extraCount = Math.max(sourceNames.length - 1, 0);
1266 const base = extraCount > 0 ? `${primary} +${extraCount}` : primary;
1267
1268 if (connection.scope === "actor") {
1269 if (connection.actorId) {
1270 return `${base} personal (${connection.actorId})`;
1125 }1271 }
1126 return { value: parsed as Record<string, unknown> };1272 return `${base} personal`;
1127 } catch (error) {
1128 return { error: error instanceof Error ? error.message : "Invalid credential JSON" };
1129 }1273 }
1274
1275 return `${base} workspace`;
1130}1276}
1131 1277
1132function asString(value: unknown): string {1278function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } {
1133 return typeof value === "string" ? value : "";1279 const lines = text
1280 .split("\n")
1281 .map((line) => line.trim())
1282 .filter((line) => line.length > 0);
1283 if (lines.length === 0) {
1284 return { value: {} };
1285 }
1286
1287 const headers: Record<string, string> = {};
1288 for (const line of lines) {
1289 const separator = line.indexOf(":");
1290 if (separator <= 0) {
1291 return { error: `Invalid header line: ${line}` };
1292 }
1293 const name = line.slice(0, separator).trim();
1294 const value = line.slice(separator + 1).trim();
1295 if (!name || !value) {
1296 return { error: `Invalid header line: ${line}` };
1297 }
1298 headers[name] = value;
1299 }
1300
1301 return { value: headers };
1134}1302}
1135 1303
1136function providerLabel(provider: "managed" | "workos-vault"): string {1304function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
1137 return provider === "workos-vault" ? "encrypted" : "managed";1305 const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};
1306 return Object.entries(headers)
1307 .map(([key, value]) => `${key}: ${String(value)}`)
1308 .join("\n");
1138}1309}
1139 1310
1140function CredentialsPanel({1311function CredentialsPanel({
1141 sources,1312 sources,
1142 credentials,1313 credentials,
1314 sourceAuthProfiles,
1143 loading,1315 loading,
1316 focusSourceKey,
1317 onFocusHandled,
1144}: {1318}: {
1145 sources: ToolSourceRecord[];1319 sources: ToolSourceRecord[];
1146 credentials: CredentialRecord[];1320 credentials: CredentialRecord[];
1321 sourceAuthProfiles: Record<string, SourceAuthProfile>;
1147 loading: boolean;1322 loading: boolean;
1323 focusSourceKey?: string | null;
1324 onFocusHandled?: () => void;
1148}) {1325}) {
1149 const { context } = useSession();1326 const { context, clientConfig } = useSession();
1150 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);1327 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
1151 const [open, setOpen] = useState(false);1328 const [open, setOpen] = useState(false);
1152 const [saving, setSaving] = useState(false);1329 const [saving, setSaving] = useState(false);
@@ -1154,13 +1331,17 @@ function CredentialsPanel({ · 1 unmodified line
1154 const [sourceKey, setSourceKey] = useState("");1331 const [sourceKey, setSourceKey] = useState("");
1155 const [scope, setScope] = useState<CredentialScope>("workspace");1332 const [scope, setScope] = useState<CredentialScope>("workspace");
1156 const [actorId, setActorId] = useState("");1333 const [actorId, setActorId] = useState("");
1157 const [provider, setProvider] = useState<"managed" | "workos-vault">("managed");1334 const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new");
1158 const [managedToken, setManagedToken] = useState("");1335 const [existingConnectionId, setExistingConnectionId] = useState("");
1336 const [tokenValue, setTokenValue] = useState("");
1159 const [apiKeyValue, setApiKeyValue] = useState("");1337 const [apiKeyValue, setApiKeyValue] = useState("");
1160 const [basicUsername, setBasicUsername] = useState("");1338 const [basicUsername, setBasicUsername] = useState("");
1161 const [basicPassword, setBasicPassword] = useState("");1339 const [basicPassword, setBasicPassword] = useState("");
1162 const [advancedMode, setAdvancedMode] = useState(false);1340 const [customHeadersText, setCustomHeadersText] = useState("");
1163 const [secretJsonText, setSecretJsonText] = useState("{}");1341
1342 const storageCopy = clientConfig?.authProviderMode === "workos"
1343 ? "Stored encrypted"
1344 : "Stored locally on this machine";
1164 1345
1165 const sourceOptions = sources1346 const sourceOptions = sources
1166 .map((source) => {1347 .map((source) => {
@@ -1173,50 +1354,99 @@ function CredentialsPanel({ · 6 unmodified lines
1173 })1354 })
1174 .filter((entry): entry is SourceOption => entry.key !== null);1355 .filter((entry): entry is SourceOption => entry.key !== null);
1175 1356
1176 const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey);1357 const connectionOptions = useMemo(() => {
1358 const grouped = new Map<string, {
1359 id: string;
1360 scope: CredentialScope;
1361 actorId?: string;
1362 provider: "local-convex" | "workos-vault";
1363 sourceKeys: Set<string>;
1364 updatedAt: number;
1365 }>();
1366
1367 for (const credential of credentials) {
1368 const existing = grouped.get(credential.id);
1369 if (existing) {
1370 existing.sourceKeys.add(credential.sourceKey);
1371 existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt);
1372 } else {
1373 grouped.set(credential.id, {
1374 id: credential.id,
1375 scope: credential.scope,
1376 actorId: credential.actorId,
1377 provider: credential.provider,
1378 sourceKeys: new Set([credential.sourceKey]),
1379 updatedAt: credential.updatedAt,
1380 });
1381 }
1382 }
1383
1384 return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt);
1385 }, [credentials]);
1386
1387 const representativeCredentialByConnection = useMemo(() => {
1388 const map = new Map<string, CredentialRecord>();
1389 for (const credential of credentials) {
1390 if (!map.has(credential.id)) {
1391 map.set(credential.id, credential);
1392 }
1393 }
1394 return map;
1395 }, [credentials]);
1396
1397 const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles);
1398 const compatibleConnectionOptions = connectionOptions.filter((connection) => {
1399 if (connection.scope !== scope) {
1400 return false;
1401 }
1402 if (scope === "actor") {
1403 return connection.actorId === actorId.trim();
1404 }
1405 return true;
1406 });
1177 const selectedAuthBadge = selectedAuth.type === "none"1407 const selectedAuthBadge = selectedAuth.type === "none"
1178 ? "none"1408 ? "none"
1179 : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`;1409 : selectedAuth.type === "mixed"
1410 ? "mixed"
1411 : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`;
1180 1412
1181 const buildDraftSecretFromInputs = (): Record<string, unknown> => {1413 useEffect(() => {
1182 if (selectedAuth.type === "apiKey") {1414 if (!existingConnectionId) {
1183 return { value: apiKeyValue.trim() };1415 return;
1184 }1416 }
1185 if (selectedAuth.type === "basic") {1417 if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
1186 return {1418 setExistingConnectionId("");
1187 username: basicUsername,
1188 password: basicPassword,
1189 };
1190 }1419 }
1191 return { token: managedToken.trim() };1420 }, [compatibleConnectionOptions, existingConnectionId]);
1192 };
1193
1194 const setFormFromCredential = (credential: CredentialRecord) => {
1195 const secret = credential.secretJson;
1196 setManagedToken(asString(secret.token) || asString(secret.value));
1197 setApiKeyValue(asString(secret.value) || asString(secret.token));
1198 setBasicUsername(asString(secret.username));
1199 setBasicPassword(asString(secret.password));
1200 setSecretJsonText(formatCredentialSecret(secret));
1201 setAdvancedMode(false);
1202 };
1203 1421
1204 const resetForm = () => {1422 const resetForm = () => {
1205 const defaultSourceKey = sourceOptions[0]?.key ?? "";1423 const defaultSourceKey = sourceOptions[0]?.key ?? "";
1206 setSourceKey(defaultSourceKey);1424 setSourceKey(defaultSourceKey);
1207 const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey);1425 const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles);
1208 setScope(defaultAuth.mode ?? "workspace");1426 setScope(defaultAuth.mode ?? "workspace");
1209 setActorId(context?.actorId ?? "");1427 setActorId(context?.actorId ?? "");
1210 setProvider("managed");1428 setConnectionMode("new");
1211 setManagedToken("");1429 setExistingConnectionId("");
1430 setTokenValue("");
1212 setApiKeyValue("");1431 setApiKeyValue("");
1213 setBasicUsername("");1432 setBasicUsername("");
1214 setBasicPassword("");1433 setBasicPassword("");
1215 setAdvancedMode(false);1434 setCustomHeadersText("");
1216 setSecretJsonText("{}");
1217 setEditing(null);1435 setEditing(null);
1218 };1436 };
1219 1437
1438 useEffect(() => {
1439 if (!focusSourceKey) {
1440 return;
1441 }
1442 resetForm();
1443 setSourceKey(focusSourceKey);
1444 const auth = sourceAuthForKey(sourceOptions, focusSourceKey, sourceAuthProfiles);
1445 setScope(auth.mode ?? "workspace");
1446 setOpen(true);
1447 onFocusHandled?.();
1448 }, [focusSourceKey, onFocusHandled, sourceAuthProfiles, sourceOptions]);
1449
1220 const openForCreate = () => {1450 const openForCreate = () => {
1221 resetForm();1451 resetForm();
1222 setOpen(true);1452 setOpen(true);
@@ -1227,30 +1457,24 @@ function CredentialsPanel({ · 4 unmodified lines
1227 setSourceKey(credential.sourceKey);1457 setSourceKey(credential.sourceKey);
1228 setScope(credential.scope);1458 setScope(credential.scope);
1229 setActorId(credential.actorId ?? context?.actorId ?? "");1459 setActorId(credential.actorId ?? context?.actorId ?? "");
1230 setProvider(credential.provider === "workos-vault" ? "workos-vault" : "managed");1460 setConnectionMode("new");
1231 setFormFromCredential(credential);1461 setExistingConnectionId(credential.id);
1462 setTokenValue("");
1463 setApiKeyValue("");
1464 setBasicUsername("");
1465 setBasicPassword("");
1466 setCustomHeadersText(formatHeaderOverrides(credential.overridesJson));
1232 setOpen(true);1467 setOpen(true);
1233 };1468 };
1234 1469
1235 const handleSourceKeyChange = (nextSourceKey: string) => {1470 const handleSourceKeyChange = (nextSourceKey: string) => {
1236 setSourceKey(nextSourceKey);1471 setSourceKey(nextSourceKey);
1237 const auth = sourceAuthForKey(sourceOptions, nextSourceKey);1472 const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles);
1238 if (!editing) {1473 if (!editing) {
1239 setScope(auth.mode ?? "workspace");1474 setScope(auth.mode ?? "workspace");
1240 }1475 }
1241 };1476 };
1242 1477
1243 const handleProviderChange = (value: "managed" | "workos-vault") => {
1244 setProvider(value);
1245 };
1246
1247 const handleAdvancedModeChange = (next: boolean) => {
1248 setAdvancedMode(next);
1249 if (next) {
1250 setSecretJsonText(formatCredentialSecret(buildDraftSecretFromInputs()));
1251 }
1252 };
1253
1254 const handleSave = async () => {1478 const handleSave = async () => {
1255 if (!context) return;1479 if (!context) return;
1256 if (!sourceKey.trim()) {1480 if (!sourceKey.trim()) {
@@ -1262,61 +1486,61 @@ function CredentialsPanel({ · 5 unmodified lines
1262 return;1486 return;
1263 }1487 }
1264 1488
1265 let secretJson: Record<string, unknown> = {};1489 const parsedHeaders = parseHeaderOverrides(customHeadersText);
1266 const keepExistingEncryptedSecret = provider === "workos-vault" && Boolean(editing);1490 if (!parsedHeaders.value) {
1491 toast.error(parsedHeaders.error ?? "Invalid header overrides");
1492 return;
1493 }
1267 1494
1268 if (advancedMode) {1495 const linkExisting = !editing && connectionMode === "existing";
1269 const parsed = parseJsonObject(secretJsonText);1496 if (linkExisting && !existingConnectionId) {
1270 if (!parsed.value) {1497 toast.error("Select an existing connection to link");
1271 toast.error(parsed.error ?? "Invalid credential JSON");1498 return;
1272 return;1499 }
1273 }1500 if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
1274 secretJson = parsed.value;1501 toast.error("Selected connection does not match this scope");
1275 } else {1502 return;
1276 if (selectedAuth.type === "none") {1503 }
1277 toast.error("Configure source auth before saving credentials");1504
1278 return;1505 if (selectedAuth.type === "none") {
1279 }1506 toast.error("This source does not require auth");
1507 return;
1508 }
1509
1510 if (selectedAuth.type === "mixed" && !linkExisting && !editing) {
1511 toast.error("Mixed-auth sources must link to an existing connection");
1512 return;
1513 }
1514
1515 const secretJson: Record<string, unknown> = {};
1516 if (!linkExisting) {
1280 if (selectedAuth.type === "basic") {1517 if (selectedAuth.type === "basic") {
1281 const hasUsername = basicUsername.trim().length > 0;1518 const hasUsername = basicUsername.trim().length > 0;
1282 const hasPassword = basicPassword.trim().length > 0;1519 const hasPassword = basicPassword.trim().length > 0;
1283 if (!hasUsername && !hasPassword && keepExistingEncryptedSecret) {1520 if (hasUsername || hasPassword) {
1284 secretJson = {};1521 if (!hasUsername || !hasPassword) {
1285 } else if (!hasUsername || !hasPassword) {1522 toast.error("Username and password are required for basic auth");
1286 toast.error("Username and password are required for basic auth");
1287 return;
1288 } else {
1289 secretJson = {
1290 username: basicUsername,
1291 password: basicPassword,
1292 };
1293 }
1294 } else if (selectedAuth.type === "apiKey") {
1295 if (!apiKeyValue.trim()) {
1296 if (keepExistingEncryptedSecret) {
1297 secretJson = {};
1298 } else {
1299 toast.error("API key value is required");
1300 return;1523 return;
1301 }1524 }
1302 } else {1525 secretJson.username = basicUsername;
1303 secretJson = { value: apiKeyValue.trim() };1526 secretJson.password = basicPassword;
1304 }1527 }
1305 } else {1528 } else if (selectedAuth.type === "apiKey") {
1306 if (!managedToken.trim()) {1529 if (apiKeyValue.trim()) {
1307 if (keepExistingEncryptedSecret) {1530 secretJson.value = apiKeyValue.trim();
1308 secretJson = {};1531 }
1309 } else {1532 } else if (selectedAuth.type === "bearer") {
1310 toast.error("Token is required");1533 if (tokenValue.trim()) {
1311 return;1534 secretJson.token = tokenValue.trim();
1312 }
1313 } else {
1314 secretJson = { token: managedToken.trim() };
1315 }1535 }
1316 }1536 }
1317 }1537 }
1318 1538
1319 if (provider === "workos-vault" && !editing && Object.keys(secretJson).length === 0) {1539 if (Object.keys(parsedHeaders.value).length > 0) {
1540 secretJson.__headers = parsedHeaders.value;
1541 }
1542
1543 if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) {
1320 if (selectedAuth.type === "basic") {1544 if (selectedAuth.type === "basic") {
1321 toast.error("Username and password are required");1545 toast.error("Username and password are required");
1322 } else if (selectedAuth.type === "apiKey") {1546 } else if (selectedAuth.type === "apiKey") {
@@ -1330,20 +1554,20 @@ function CredentialsPanel({ · 7 unmodified lines
1330 setSaving(true);1554 setSaving(true);
1331 try {1555 try {
1332 await upsertCredential({1556 await upsertCredential({
1557 ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}),
1333 workspaceId: context.workspaceId,1558 workspaceId: context.workspaceId,
1334 sessionId: context.sessionId,1559 sessionId: context.sessionId,
1335 sourceKey: sourceKey.trim(),1560 sourceKey: sourceKey.trim(),
1336 scope,1561 scope,
1337 ...(scope === "actor" ? { actorId: actorId.trim() } : {}),1562 ...(scope === "actor" ? { actorId: actorId.trim() } : {}),
1338 provider,
1339 secretJson,1563 secretJson,
1340 });1564 });
1341 1565
1342 toast.success(editing ? "Credential updated" : "Credential saved");1566 toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved");
1343 setOpen(false);1567 setOpen(false);
1344 resetForm();1568 resetForm();
1345 } catch (err) {1569 } catch (err) {
1346 toast.error(err instanceof Error ? err.message : "Failed to save credential");1570 toast.error(err instanceof Error ? err.message : "Failed to save connection");
1347 } finally {1571 } finally {
1348 setSaving(false);1572 setSaving(false);
1349 }1573 }
@@ -1355,11 +1579,11 @@ function CredentialsPanel({ · 5 unmodified lines
1355 <div className="flex items-center justify-between">1579 <div className="flex items-center justify-between">
1356 <CardTitle className="text-sm font-medium flex items-center gap-2">1580 <CardTitle className="text-sm font-medium flex items-center gap-2">
1357 <KeyRound className="h-4 w-4 text-muted-foreground" />1581 <KeyRound className="h-4 w-4 text-muted-foreground" />
1358 Credentials1582 Connections
1359 </CardTitle>1583 </CardTitle>
1360 <Button size="sm" className="h-8 text-xs" onClick={openForCreate}>1584 <Button size="sm" className="h-8 text-xs" onClick={openForCreate}>
1361 <Plus className="h-3.5 w-3.5 mr-1.5" />1585 <Plus className="h-3.5 w-3.5 mr-1.5" />
1362 Add Credential1586 Add Connection
1363 </Button>1587 </Button>
1364 </div>1588 </div>
1365 </CardHeader>1589 </CardHeader>
@@ -1370,82 +1594,71 @@ function CredentialsPanel({ · 4 unmodified lines
1370 <Skeleton key={i} className="h-14" />1594 <Skeleton key={i} className="h-14" />
1371 ))}1595 ))}
1372 </div>1596 </div>
1373 ) : credentials.length === 0 ? (1597 ) : connectionOptions.length === 0 ? (
1374 <div className="flex flex-col items-center justify-center py-10 gap-2">1598 <div className="flex flex-col items-center justify-center py-10 gap-2">
1375 <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">1599 <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
1376 <KeyRound className="h-5 w-5 text-muted-foreground/50" />1600 <KeyRound className="h-5 w-5 text-muted-foreground/50" />
1377 </div>1601 </div>
1378 <p className="text-sm text-muted-foreground">No credentials configured</p>1602 <p className="text-sm text-muted-foreground">No connections configured</p>
1379 <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">1603 <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
1380 Configure source auth on an OpenAPI or GraphQL source, then add workspace or actor credentials.1604 Add a source, then create or link a reusable connection.
1381 </p>1605 </p>
1382 </div>1606 </div>
1383 ) : (1607 ) : (
1384 <div className="space-y-2">1608 <div className="space-y-2">
1385 {credentials.map((credential) => (1609 {connectionOptions.map((connection) => {
1386 <div1610 const representative = representativeCredentialByConnection.get(connection.id);
1387 key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`}1611 if (!representative) return null;
1388 className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"1612 const firstSource = sourceForCredentialKey(sources, representative.sourceKey);
1389 >1613 const favicon = firstSource ? getSourceFavicon(firstSource) : null;
1390 {(() => {1614
1391 const source = sourceForCredentialKey(sources, credential.sourceKey);1615 return (
1392 const favicon = source ? getSourceFavicon(source) : null;1616 <div
1393 return (1617 key={connection.id}
1394 <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">1618 className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
1395 {favicon ? (1619 >
1396 <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />1620 <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">
1397 ) : (1621 {favicon ? (
1398 <KeyRound className="h-4 w-4 text-muted-foreground" />1622 <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />
1623 ) : (
1624 <KeyRound className="h-4 w-4 text-muted-foreground" />
1625 )}
1626 </div>
1627 <div className="flex-1 min-w-0">
1628 <div className="flex items-center gap-1.5 mb-1 flex-wrap">
1629 <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span>
1630 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1631 {connection.scope}
1632 </Badge>
1633 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1634 {providerLabel(connection.provider)}
1635 </Badge>
1636 {connection.scope === "actor" && connection.actorId && (
1637 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1638 {connection.actorId}
1639 </span>
1399 )}1640 )}
1400 </div>1641 </div>
1401 );1642 <p className="text-[11px] text-muted-foreground">
1402 })()}1643 Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy}
1403 <div className="flex-1 min-w-0">1644 </p>
1404 {(() => {1645 <p className="text-[11px] text-muted-foreground mt-0.5">
1405 const source = sourceForCredentialKey(sources, credential.sourceKey);1646 ID: <span className="font-mono">{connection.id}</span> •
1406 if (!source) {1647 {" "}
1407 return (1648 Updated {new Date(connection.updatedAt).toLocaleString()}
1408 <p className="text-[11px] text-muted-foreground/80 font-mono mb-1">1649 </p>
1409 {credential.sourceKey}
1410 </p>
1411 );
1412 }
1413 return (
1414 <div className="flex items-center gap-1.5 mb-1">
1415 <span className="text-sm font-mono font-medium">{source.name}</span>
1416 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1417 {source.type}
1418 </Badge>
1419 </div>
1420 );
1421 })()}
1422 <div className="flex items-center gap-2 flex-wrap">
1423 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1424 {credential.scope}
1425 </Badge>
1426 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1427 {providerLabel(credential.provider === "workos-vault" ? "workos-vault" : "managed")}
1428 </Badge>
1429 {credential.scope === "actor" && credential.actorId && (
1430 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1431 {credential.actorId}
1432 </span>
1433 )}
1434 </div>1650 </div>
1435 <p className="text-[11px] text-muted-foreground mt-0.5">1651 <Button
1436 Updated {new Date(credential.updatedAt).toLocaleString()}1652 variant="outline"
1437 </p>1653 size="sm"
1654 className="h-7 text-[11px]"
1655 onClick={() => openForEdit(representative)}
1656 >
1657 Edit
1658 </Button>
1438 </div>1659 </div>
1439 <Button1660 );
1440 variant="outline"1661 })}
1441 size="sm"
1442 className="h-7 text-[11px]"
1443 onClick={() => openForEdit(credential)}
1444 >
1445 Edit
1446 </Button>
1447 </div>
1448 ))}
1449 </div>1662 </div>
1450 )}1663 )}
1451 </CardContent>1664 </CardContent>
@@ -1454,7 +1667,7 @@ function CredentialsPanel({ · 2 unmodified lines
1454 <DialogContent className="bg-card border-border sm:max-w-lg">1667 <DialogContent className="bg-card border-border sm:max-w-lg">
1455 <DialogHeader>1668 <DialogHeader>
1456 <DialogTitle className="text-sm font-medium">1669 <DialogTitle className="text-sm font-medium">
1457 {editing ? "Edit Credential" : "Add Credential"}1670 {editing ? "Edit Connection" : "Add Connection"}
1458 </DialogTitle>1671 </DialogTitle>
1459 </DialogHeader>1672 </DialogHeader>
1460 <div className="space-y-3">1673 <div className="space-y-3">
@@ -1491,6 +1704,11 @@ function CredentialsPanel({ · 30 unmodified lines
1491 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">1704 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1492 {selectedAuthBadge}1705 {selectedAuthBadge}
1493 </Badge>1706 </Badge>
1707 {selectedAuth.inferred && (
1708 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1709 inferred
1710 </Badge>
1711 )}
1494 {selectedAuth.type === "apiKey" && selectedAuth.header && (1712 {selectedAuth.type === "apiKey" && selectedAuth.header && (
1495 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">1713 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1496 header: {selectedAuth.header}1714 header: {selectedAuth.header}
@@ -1511,18 +1729,9 @@ function CredentialsPanel({ · 14 unmodified lines
1511 </SelectContent>1729 </SelectContent>
1512 </Select>1730 </Select>
1513 </div>1731 </div>
1514
1515 <div className="space-y-1.5">1732 <div className="space-y-1.5">
1516 <Label className="text-xs text-muted-foreground">Provider</Label>1733 <Label className="text-xs text-muted-foreground">Storage</Label>
1517 <Select value={provider} onValueChange={(value) => handleProviderChange(value as "managed" | "workos-vault") }>1734 <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" />
1518 <SelectTrigger className="h-8 text-xs bg-background">
1519 <SelectValue />
1520 </SelectTrigger>
1521 <SelectContent>
1522 <SelectItem value="managed" className="text-xs">Managed storage</SelectItem>
1523 <SelectItem value="workos-vault" className="text-xs">Encrypted storage</SelectItem>
1524 </SelectContent>
1525 </Select>
1526 </div>1735 </div>
1527 </div>1736 </div>
1528 1737
@@ -1538,79 +1747,114 @@ function CredentialsPanel({ · 9 unmodified lines
1538 </div>1747 </div>
1539 )}1748 )}
1540 1749
1541 {provider === "workos-vault" && editing && (1750 {!editing && connectionOptions.length > 0 && (
1542 <p className="text-[11px] text-muted-foreground">
1543 Stored secret is hidden. Enter a new value below to rotate, or leave fields blank to keep existing.
1544 </p>
1545 )}
1546
1547 {selectedAuth.type === "none" ? (
1548 <p className="text-[11px] text-terminal-amber">
1549 This source has auth set to <code>none</code>. Configure source auth first.
1550 </p>
1551 ) : selectedAuth.type === "apiKey" ? (
1552 <div className="space-y-1.5">1751 <div className="space-y-1.5">
1553 <Label className="text-xs text-muted-foreground">API Key Value</Label>1752 <Label className="text-xs text-muted-foreground">Connection Mode</Label>
1554 <Input1753 <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }>
1555 value={apiKeyValue}1754 <SelectTrigger className="h-8 text-xs bg-background">
1556 onChange={(e) => setApiKeyValue(e.target.value)}1755 <SelectValue />
1557 placeholder="sk_live_..."1756 </SelectTrigger>
1558 className="h-8 text-xs font-mono bg-background"1757 <SelectContent>
1559 />1758 <SelectItem value="new" className="text-xs">Create new connection</SelectItem>
1560 </div>1759 <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem>
1561 ) : selectedAuth.type === "basic" ? (1760 </SelectContent>
1562 <div className="grid grid-cols-2 gap-3">1761 </Select>
1563 <div className="space-y-1.5">
1564 <Label className="text-xs text-muted-foreground">Username</Label>
1565 <Input
1566 value={basicUsername}
1567 onChange={(e) => setBasicUsername(e.target.value)}
1568 className="h-8 text-xs font-mono bg-background"
1569 />
1570 </div>
1571 <div className="space-y-1.5">
1572 <Label className="text-xs text-muted-foreground">Password</Label>
1573 <Input
1574 type="password"
1575 value={basicPassword}
1576 onChange={(e) => setBasicPassword(e.target.value)}
1577 className="h-8 text-xs font-mono bg-background"
1578 />
1579 </div>
1580 </div>1762 </div>
1581 ) : (1763 )}
1764
1765 {!editing && connectionMode === "existing" && (
1582 <div className="space-y-1.5">1766 <div className="space-y-1.5">
1583 <Label className="text-xs text-muted-foreground">Bearer Token</Label>1767 <Label className="text-xs text-muted-foreground">Existing Connection</Label>
1584 <Input1768 <Select value={existingConnectionId} onValueChange={setExistingConnectionId}>
1585 type="password"1769 <SelectTrigger className="h-8 text-xs bg-background">
1586 value={managedToken}1770 <SelectValue placeholder="Select a connection" />
1587 onChange={(e) => setManagedToken(e.target.value)}1771 </SelectTrigger>
1588 placeholder="ghp_..."1772 <SelectContent>
1589 className="h-8 text-xs font-mono bg-background"1773 {compatibleConnectionOptions.map((connection) => (
1590 />1774 <SelectItem key={connection.id} value={connection.id} className="text-xs">
1775 {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"})
1776 </SelectItem>
1777 ))}
1778 </SelectContent>
1779 </Select>
1780 {compatibleConnectionOptions.length === 0 && (
1781 <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p>
1782 )}
1591 </div>1783 </div>
1592 )}1784 )}
1593 1785
1594 <Collapsible open={advancedMode} onOpenChange={handleAdvancedModeChange}>1786 {(editing || connectionMode === "new") && (
1595 <CollapsibleTrigger asChild>1787 <>
1596 <Button variant="outline" size="sm" className="h-7 text-[11px]">1788 {editing && (
1597 Advanced JSON1789 <p className="text-[11px] text-muted-foreground">
1598 <ChevronRight className={cn("ml-1.5 h-3 w-3 transition-transform", advancedMode && "rotate-90")} />1790 Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it.
1599 </Button>1791 </p>
1600 </CollapsibleTrigger>1792 )}
1601 <CollapsibleContent className="mt-2 space-y-1.5">1793
1602 <Label className="text-xs text-muted-foreground">Override Secret JSON</Label>1794 {selectedAuth.type === "none" ? (
1603 <Textarea1795 <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p>
1604 value={secretJsonText}1796 ) : selectedAuth.type === "mixed" ? (
1605 onChange={(e) => setSecretJsonText(e.target.value)}1797 <p className="text-[11px] text-terminal-amber">
1606 rows={6}1798 This source has mixed auth requirements. Link an existing connection for now.
1607 className="text-xs font-mono bg-background"1799 </p>
1608 />1800 ) : selectedAuth.type === "apiKey" ? (
1609 </CollapsibleContent>1801 <div className="space-y-1.5">
1610 </Collapsible>1802 <Label className="text-xs text-muted-foreground">API Key Value</Label>
1803 <Input
1804 value={apiKeyValue}
1805 onChange={(e) => setApiKeyValue(e.target.value)}
1806 placeholder="sk_live_..."
1807 className="h-8 text-xs font-mono bg-background"
1808 />
1809 </div>
1810 ) : selectedAuth.type === "basic" ? (
1811 <div className="grid grid-cols-2 gap-3">
1812 <div className="space-y-1.5">
1813 <Label className="text-xs text-muted-foreground">Username</Label>
1814 <Input
1815 value={basicUsername}
1816 onChange={(e) => setBasicUsername(e.target.value)}
1817 className="h-8 text-xs font-mono bg-background"
1818 />
1819 </div>
1820 <div className="space-y-1.5">
1821 <Label className="text-xs text-muted-foreground">Password</Label>
1822 <Input
1823 type="password"
1824 value={basicPassword}
1825 onChange={(e) => setBasicPassword(e.target.value)}
1826 className="h-8 text-xs font-mono bg-background"
1827 />
1828 </div>
1829 </div>
1830 ) : (
1831 <div className="space-y-1.5">
1832 <Label className="text-xs text-muted-foreground">Bearer Token</Label>
1833 <Input
1834 type="password"
1835 value={tokenValue}
1836 onChange={(e) => setTokenValue(e.target.value)}
1837 placeholder="ghp_..."
1838 className="h-8 text-xs font-mono bg-background"
1839 />
1840 </div>
1841 )}
1842 </>
1843 )}
1844
1845 <div className="space-y-1.5">
1846 <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label>
1847 <Textarea
1848 value={customHeadersText}
1849 onChange={(e) => setCustomHeadersText(e.target.value)}
1850 rows={4}
1851 placeholder="x-tenant-id: acme\nx-env: staging"
1852 className="text-xs font-mono bg-background"
1853 />
1854 </div>
1611 1855
1612 <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">1856 <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
1613 {saving ? "Saving..." : editing ? "Update Credential" : "Save Credential"}1857 {saving ? "Saving..." : editing ? "Update Connection" : connectionMode === "existing" ? "Link Connection" : "Save Connection"}
1614 </Button>1858 </Button>
1615 </div>1859 </div>
1616 </DialogContent>1860 </DialogContent>
@@ -1625,6 +1869,9 @@ function CredentialsPanel({ · 8 unmodified lines
1625 1869
1626export function ToolsView({ initialSource }: { initialSource?: string | null }) {1870export function ToolsView({ initialSource }: { initialSource?: string | null }) {
1627 const { context, loading: sessionLoading } = useSession();1871 const { context, loading: sessionLoading } = useSession();
1872 const [selectedSource, setSelectedSource] = useState<string | null>(initialSource ?? null);
1873 const [activeTab, setActiveTab] = useState<"catalog" | "credentials" | "mcp">("catalog");
1874 const [focusCredentialSourceKey, setFocusCredentialSourceKey] = useState<string | null>(null);
1628 1875
1629 const sources = useQuery(1876 const sources = useQuery(
1630 convexApi.workspace.listToolSources,1877 convexApi.workspace.listToolSources,
@@ -1637,9 +1884,20 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 6 unmodified lines
1637 convexApi.workspace.listCredentials,1884 convexApi.workspace.listCredentials,
1638 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",1885 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
1639 );1886 );
1887 const credentialItems: CredentialRecord[] = credentials ?? [];
1640 const credentialsLoading = !!context && credentials === undefined;1888 const credentialsLoading = !!context && credentials === undefined;
1641 1889
1642 const { tools, warnings, sourceQuality, loading: toolsLoading } = useWorkspaceTools(context ?? null);1890 const {
1891 tools,
1892 warnings,
1893 sourceQuality,
1894 sourceAuthProfiles,
1895 loadingTools,
1896 refreshingTools,
1897 } = useWorkspaceTools(context ?? null);
1898 const selectedSourceRecord = selectedSource
1899 ? sourceItems.find((source) => source.name === selectedSource) ?? null
1900 : null;
1643 1901
1644 if (sessionLoading) {1902 if (sessionLoading) {
1645 return (1903 return (
@@ -1654,108 +1912,113 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 8 unmodified lines
1654 <div className="flex h-full min-h-0 flex-col gap-6">1912 <div className="flex h-full min-h-0 flex-col gap-6">
1655 <PageHeader1913 <PageHeader
1656 title="Tools"1914 title="Tools"
1657 description="Manage sources, auth, credentials, and available tools"1915 description="Manage sources, auth, connections, and available tools"
1658 />1916 />
1659 1917
1660 <Tabs1918 <Tabs
1661 defaultValue={initialSource ? "inventory" : "sources"}1919 value={activeTab}
1920 onValueChange={(value) => setActiveTab(value as "catalog" | "credentials" | "mcp")}
1662 className="w-full min-h-0 flex-1"1921 className="w-full min-h-0 flex-1"
1663 >1922 >
1664 <TabsList className="bg-muted/50 h-9">1923 <TabsList className="bg-muted/50 h-9">
1665 <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background">1924 <TabsTrigger value="catalog" className="text-xs data-[state=active]:bg-background">
1666 Sources1925 Catalog
1667 {sources && (1926 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1668 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">1927 {loadingTools ? "..." : tools.length}
1669 {sourceItems.length}1928 </span>
1670 </span>
1671 )}
1672 </TabsTrigger>1929 </TabsTrigger>
1673 <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background">1930 <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background">
1674 Credentials1931 Connections
1675 {credentials && (1932 {credentials && (
1676 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">1933 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1677 {credentials.length}1934 {new Set(credentialItems.map((credential) => credential.id)).size}
1678 </span>1935 </span>
1679 )}1936 )}
1680 </TabsTrigger>1937 </TabsTrigger>
1681 <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background">
1682 Inventory
1683 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1684 {toolsLoading ? "…" : tools.length}
1685 </span>
1686 </TabsTrigger>
1687 <TabsTrigger value="mcp" className="text-xs data-[state=active]:bg-background">1938 <TabsTrigger value="mcp" className="text-xs data-[state=active]:bg-background">
1688 MCP Setup1939 MCP Setup
1689 </TabsTrigger>1940 </TabsTrigger>
1690 </TabsList>1941 </TabsList>
1691 1942
1692 <TabsContent value="sources" className="mt-4">1943 <TabsContent value="catalog" className="mt-4 min-h-0">
1693 <Card className="bg-card border-border">1944 <Card className="bg-card border-border min-h-0 flex flex-col">
1694 <CardHeader className="pb-3">1945 <CardHeader className="pb-3">
1695 <div className="flex items-center justify-between">1946 <div className="flex items-center justify-between gap-3">
1696 <CardTitle className="text-sm font-medium flex items-center gap-2">1947 <CardTitle className="text-sm font-medium flex items-center gap-2">
1697 <Server className="h-4 w-4 text-muted-foreground" />1948 <Globe className="h-4 w-4 text-muted-foreground" />
1698 Tool Sources1949 Tools + Sources
1950 <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
1951 {loadingTools ? "..." : tools.length}
1952 </span>
1699 </CardTitle>1953 </CardTitle>
1700 <AddSourceDialog existingSourceNames={new Set(sourceItems.map((s) => s.name))} />1954 <div className="flex items-center gap-2">
1955 {selectedSource ? (
1956 <Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setSelectedSource(null)}>
1957 Clear source filter
1958 </Button>
1959 ) : null}
1960 <AddSourceDialog
1961 existingSourceNames={new Set(sourceItems.map((s) => s.name))}
1962 onSourceAdded={(source) => {
1963 setActiveTab("credentials");
1964 setSelectedSource(source.name);
1965 const key = sourceKeyForSource(source);
1966 if (key) {
1967 setFocusCredentialSourceKey(key);
1968 }
1969 }}
1970 />
1971 </div>
1701 </div>1972 </div>
1973 <p className="text-[11px] text-muted-foreground">
1974 {selectedSource
1975 ? `Filtering and managing ${selectedSource}.`
1976 : "Source management and tool inventory are unified here."}
1977 </p>
1702 </CardHeader>1978 </CardHeader>
1703 <CardContent className="pt-0">1979 <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3">
1704 {sourcesLoading ? (1980 {sourcesLoading ? (
1705 <div className="space-y-2">1981 <div className="space-y-2">
1706 {Array.from({ length: 3 }).map((_, i) => (1982 {Array.from({ length: 2 }).map((_, i) => (
1707 <Skeleton key={i} className="h-16" />1983 <Skeleton key={i} className="h-16" />
1708 ))}1984 ))}
1709 </div>1985 </div>
1710 ) : sourceItems.length === 0 ? (1986 ) : null}
1711 <div className="flex flex-col items-center justify-center py-12 gap-3">1987
1712 <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">1988 {!sourcesLoading && sourceItems.length === 0 ? (
1713 <Wrench className="h-6 w-6 text-muted-foreground/40" />1989 <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2.5">
1714 </div>1990 <p className="text-[11px] text-muted-foreground">
1715 <p className="text-sm text-muted-foreground">1991 No external sources yet. Add MCP, OpenAPI, or GraphQL to expand available tools.
1716 No external tool sources
1717 </p>
1718 <p className="text-[11px] text-muted-foreground/60">
1719 Add MCP, OpenAPI, or GraphQL sources to extend available tools
1720 </p>1992 </p>
1721 </div>1993 </div>
1722 ) : (1994 ) : null}
1723 <div className="space-y-2">1995
1724 {warnings.length > 0 && (1996 {selectedSourceRecord ? (
1725 <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/10 px-3 py-2.5">1997 <div className="space-y-1.5">
1726 <div className="flex items-center gap-1.5 text-[11px] font-medium text-terminal-amber">1998 <p className="text-[10px] uppercase tracking-widest text-muted-foreground">Selected source</p>
1727 <AlertTriangle className="h-3.5 w-3.5" />1999 <SourceCard
1728 Source load warnings ({warnings.length})2000 source={selectedSourceRecord}
1729 </div>2001 quality={sourceQuality[toolSourceLabelForSource(selectedSourceRecord)]}
1730 <div className="mt-1.5 space-y-1">2002 qualityLoading={selectedSourceRecord.type === "openapi" && !sourceQuality[toolSourceLabelForSource(selectedSourceRecord)] && refreshingTools}
1731 {warnings.slice(0, 3).map((warning: string, i: number) => (2003 credentialStats={credentialStatsForSource(selectedSourceRecord, credentialItems)}
1732 <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90">2004 sourceAuthProfiles={sourceAuthProfiles}
1733 {warning}2005 selected
1734 </p>2006 onFocusSource={setSelectedSource}
1735 ))}2007 />
1736 {warnings.length > 3 && (
1737 <p className="text-[10px] text-terminal-amber/80">
1738 +{warnings.length - 3} more warning{warnings.length - 3 === 1 ? "" : "s"}
1739 </p>
1740 )}
1741 </div>
1742 </div>
1743 )}
1744 {sourceItems.map((s) => {
1745 const quality = sourceQuality[toolSourceLabelForSource(s)];
1746 const credentialStats = credentialStatsForSource(s, credentials ?? []);
1747 return (
1748 <SourceCard
1749 key={s.id}
1750 source={s}
1751 quality={quality}
1752 qualityLoading={toolsLoading}
1753 credentialStats={credentialStats}
1754 />
1755 );
1756 })}
1757 </div>2008 </div>
1758 )}2009 ) : null}
2010
2011 <div className="min-h-0 flex-1">
2012 <ToolExplorer
2013 tools={tools}
2014 sources={sourceItems}
2015 loading={loadingTools}
2016 warnings={warnings}
2017 initialSource={initialSource}
2018 activeSource={selectedSource}
2019 onActiveSourceChange={setSelectedSource}
2020 />
2021 </div>
1759 </CardContent>2022 </CardContent>
1760 </Card>2023 </Card>
1761 </TabsContent>2024 </TabsContent>
@@ -1763,18 +2026,11 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 1 unmodified line
1763 <TabsContent value="credentials" className="mt-4">2026 <TabsContent value="credentials" className="mt-4">
1764 <CredentialsPanel2027 <CredentialsPanel
1765 sources={sourceItems}2028 sources={sourceItems}
1766 credentials={credentials ?? []}2029 credentials={credentialItems}
2030 sourceAuthProfiles={sourceAuthProfiles}
1767 loading={credentialsLoading || sourcesLoading}2031 loading={credentialsLoading || sourcesLoading}
1768 />2032 focusSourceKey={focusCredentialSourceKey}
1769 </TabsContent>2033 onFocusHandled={() => setFocusCredentialSourceKey(null)}
1770
1771 <TabsContent value="inventory" className="mt-4 min-h-0">
1772 <ToolExplorer
1773 tools={tools}
1774 sources={sourceItems}
1775 loading={toolsLoading}
1776 warnings={warnings}
1777 initialSource={initialSource}
1778 />2034 />
1779 </TabsContent>2035 </TabsContent>
1780 2036
executor/apps/web/src/hooks/use-workspace-tools.ts+49−10
@@ -3,7 +3,7 @@ · 2 unmodified lines
3import { useQuery as useTanstackQuery } from "@tanstack/react-query";3import { useQuery as useTanstackQuery } from "@tanstack/react-query";
4import { useAction, useQuery as useConvexQuery } from "convex/react";4import { useAction, useQuery as useConvexQuery } from "convex/react";
5import { convexApi } from "@/lib/convex-api";5import { convexApi } from "@/lib/convex-api";
6import type { OpenApiSourceQuality, ToolDescriptor } from "@/lib/types";6import type { OpenApiSourceQuality, SourceAuthProfile, ToolDescriptor, ToolSourceRecord } from "@/lib/types";
7import type { Id } from "@executor/convex/_generated/dataModel";7import type { Id } from "@executor/convex/_generated/dataModel";
8 8
9interface WorkspaceContext {9interface WorkspaceContext {
@@ -16,8 +16,12 @@ interface WorkspaceContext { · 6 unmodified lines
16interface WorkspaceToolsQueryResult {16interface WorkspaceToolsQueryResult {
17 tools: ToolDescriptor[];17 tools: ToolDescriptor[];
18 warnings: string[];18 warnings: string[];
19 dtsUrls: Record<string, string>;
20 sourceQuality: Record<string, OpenApiSourceQuality>;19 sourceQuality: Record<string, OpenApiSourceQuality>;
20 sourceAuthProfiles: Record<string, SourceAuthProfile>;
21}
22
23interface WorkspaceToolDtsResult {
24 dtsUrls: Record<string, string>;
21}25}
22 26
23/**27/**
@@ -28,6 +32,7 @@ interface WorkspaceToolsQueryResult { · 4 unmodified lines
28 */32 */
29export function useWorkspaceTools(context: WorkspaceContext | null) {33export function useWorkspaceTools(context: WorkspaceContext | null) {
30 const listToolsWithWarnings = useAction(convexApi.executorNode.listToolsWithWarnings);34 const listToolsWithWarnings = useAction(convexApi.executorNode.listToolsWithWarnings);
35 const listToolDtsUrls = useAction(convexApi.executorNode.listToolDtsUrls);
31 36
32 // Watch tool sources reactively so we invalidate when sources change37 // Watch tool sources reactively so we invalidate when sources change
33 const toolSources = useConvexQuery(38 const toolSources = useConvexQuery(
@@ -35,9 +40,13 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 1 unmodified line
35 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",40 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
36 );41 );
37 42
38 const { data, isLoading } = useTanstackQuery({43 const {
44 data: inventoryData,
45 isLoading: toolsLoading,
46 isFetching: toolsFetching,
47 } = useTanstackQuery({
39 queryKey: [48 queryKey: [
40 "workspace-tools",49 "workspace-tools-inventory",
41 context?.workspaceId,50 context?.workspaceId,
42 context?.actorId,51 context?.actorId,
43 context?.clientId,52 context?.clientId,
@@ -45,7 +54,7 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 1 unmodified line
45 ],54 ],
46 queryFn: async (): Promise<WorkspaceToolsQueryResult> => {55 queryFn: async (): Promise<WorkspaceToolsQueryResult> => {
47 if (!context) {56 if (!context) {
48 return { tools: [], warnings: [], dtsUrls: {}, sourceQuality: {} };57 return { tools: [], warnings: [], sourceQuality: {}, sourceAuthProfiles: {} };
49 }58 }
50 return await listToolsWithWarnings({59 return await listToolsWithWarnings({
51 workspaceId: context.workspaceId,60 workspaceId: context.workspaceId,
@@ -55,15 +64,45 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 3 unmodified lines
55 });64 });
56 },65 },
57 enabled: !!context,66 enabled: !!context,
67 placeholderData: (previousData) => previousData,
68 });
69
70 const hasOpenApiSource = (toolSources ?? []).some(
71 (source: ToolSourceRecord) => source.type === "openapi" && source.enabled,
72 );
73 const { data: dtsData, isLoading: dtsLoading } = useTanstackQuery({
74 queryKey: [
75 "workspace-tools-dts",
76 context?.workspaceId,
77 context?.actorId,
78 toolSources,
79 ],
80 queryFn: async (): Promise<WorkspaceToolDtsResult> => {
81 if (!context) {
82 return { dtsUrls: {} };
83 }
84 return await listToolDtsUrls({
85 workspaceId: context.workspaceId,
86 ...(context.actorId && { actorId: context.actorId }),
87 ...(context.sessionId && { sessionId: context.sessionId }),
88 });
89 },
90 enabled: !!context && !!inventoryData && hasOpenApiSource,
91 placeholderData: (previousData) => previousData,
58 });92 });
59 93
60 return {94 return {
61 tools: data?.tools ?? [],95 tools: inventoryData?.tools ?? [],
62 warnings: data?.warnings ?? [],96 warnings: inventoryData?.warnings ?? [],
63 /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */97 /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */
64 dtsUrls: data?.dtsUrls ?? {},98 dtsUrls: dtsData?.dtsUrls ?? {},
65 /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */99 /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */
66 sourceQuality: data?.sourceQuality ?? {},100 sourceQuality: inventoryData?.sourceQuality ?? {},
67 loading: !!context && isLoading,101 sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {},
102 loadingTools: !!context && toolsLoading,
103 refreshingTools: !!context && toolsFetching,
104 loadingTypes: !!context && hasOpenApiSource && !!inventoryData && dtsLoading,
105 // Backward compatibility for callers that still use a single loading state.
106 loading: !!context && toolsLoading,
68 };107 };
69}108}
executor/apps/web/src/lib/types.ts+14−3
@@ -6,10 +6,19 @@ export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_ · 5 unmodified lines
6export type ApprovalStatus = "pending" | "approved" | "denied";6export type ApprovalStatus = "pending" | "approved" | "denied";
7export type PolicyDecision = "allow" | "require_approval" | "deny";7export type PolicyDecision = "allow" | "require_approval" | "deny";
8export type CredentialScope = "workspace" | "actor";8export type CredentialScope = "workspace" | "actor";
9export type CredentialProvider = "managed" | "workos-vault";9export type CredentialProvider = "local-convex" | "workos-vault";
10export type ToolApprovalMode = "auto" | "required";10export type ToolApprovalMode = "auto" | "required";
11export type ToolSourceType = "mcp" | "openapi" | "graphql";11export type ToolSourceType = "mcp" | "openapi" | "graphql";
12 12
13export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
14
15export interface SourceAuthProfile {
16 type: SourceAuthType;
17 mode?: CredentialScope;
18 header?: string;
19 inferred: boolean;
20}
21
13export interface TaskRecord {22export interface TaskRecord {
14 id: string;23 id: string;
15 code: string;24 code: string;
@@ -25,8 +34,7 @@ export interface TaskRecord { · 9 unmodified lines
25 startedAt?: number;34 startedAt?: number;
26 completedAt?: number;35 completedAt?: number;
27 error?: string;36 error?: string;
28 stdout?: string;37 result?: unknown;
29 stderr?: string;
30 exitCode?: number;38 exitCode?: number;
31}39}
32 40
@@ -69,10 +77,13 @@ export interface AccessPolicyRecord { · 36 unmodified lines
69 77
70export interface CredentialRecord {78export interface CredentialRecord {
71 id: string;79 id: string;
80 bindingId?: string;
72 workspaceId: string;81 workspaceId: string;
73 sourceKey: string;82 sourceKey: string;
74 scope: CredentialScope;83 scope: CredentialScope;
75 actorId?: string;84 actorId?: string;
85 overridesJson?: Record<string, unknown>;
86 boundAuthFingerprint?: string;
76 provider: CredentialProvider;87 provider: CredentialProvider;
77 secretJson: Record<string, unknown>;88 secretJson: Record<string, unknown>;
78 createdAt: number;89 createdAt: number;