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
4343 import { cn } from "@/lib/utils";
4444
4545 const NAV_ITEMS = [
46− { href: "/", label: "Dashboard", icon: LayoutDashboard },
47− { href: "/tasks", label: "Tasks", icon: Play },
46+ { href: "/", label: "Home", icon: LayoutDashboard },
47+ { href: "/tasks", label: "Task Activity", icon: Play },
4848 { href: "/approvals", label: "Approvals", icon: ShieldCheck },
4949 { href: "/tools", label: "Tools", icon: Wrench },
5050 ];
executor/apps/web/src/components/approvals-view.tsx+13−1
@@ -1,6 +1,7 @@
11 "use client";
22
33 import { useMemo, useState } from "react";
4+import { useNavigate } from "react-router";
45 import {
56 ShieldCheck,
67 ShieldX,
@@ -82,6 +83,7 @@ function ApprovalCard({ · 75 unmodified lines
8283 }: {
8384 approval: PendingApprovalRecord;
8485 }) {
86+ const navigate = useNavigate();
8587 const { context } = useSession();
8688 const resolveApproval = useMutation(convexApi.executor.resolveApproval);
8789 const [resolving, setResolving] = useState<"approved" | "denied" | null>(
@@ -139,7 +141,17 @@ function ApprovalCard({ · 51 unmodified lines
139141 </span>
140142 </div>
141143 </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>
143155 </div>
144156
145157 {/* Input */}
executor/apps/web/src/components/code-editor.tsx+42−28
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useRef, useEffect } from "react";
3+import { useRef, useEffect, useState } from "react";
44 import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
55 import { Loader2 } from "lucide-react";
66 import { useTheme } from "next-themes";
@@ -222,11 +222,11 @@ function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): str · 215 unmodified lines
222222
223223 const BASE_ENVIRONMENT_DTS = `
224224 interface Console {
225− /** Log output to stdout (visible in task detail). */
225+ /** Console output is discarded; use explicit return values for results. */
226226 log(...args: any[]): void;
227− /** Log output to stderr (visible in task detail). */
227+ /** Console output is discarded; use explicit return values for results. */
228228 error(...args: any[]): void;
229− /** Log a warning to stderr. */
229+ /** Console output is discarded; use explicit return values for results. */
230230 warn(...args: any[]): void;
231231 info(...args: any[]): void;
232232 debug(...args: any[]): void;
@@ -277,6 +277,7 @@ export function CodeEditor({ · 44 unmodified lines
277277 height = "400px",
278278 }: CodeEditorProps) {
279279 const { resolvedTheme } = useTheme();
280+ const [dtsHydrating, setDtsHydrating] = useState(false);
280281 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
281282 const monacoRef = useRef<Monaco | null>(null);
282283 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);
@@ -285,10 +286,14 @@ export function CodeEditor({ · 2 unmodified lines
285286 const toolsLibVersion = useRef(0);
286287 const fetchedDtsUrls = useRef<string>("");
287288 const dtsSources = new Set(Object.keys(dtsUrls ?? {}));
289+ const toolTypesLoading = typesLoading || dtsHydrating;
288290
289291 // Fetch and register .d.ts blobs from OpenAPI sources
290292 useEffect(() => {
291− if (!dtsUrls || Object.keys(dtsUrls).length === 0) return;
293+ if (!dtsUrls || Object.keys(dtsUrls).length === 0) {
294+ setDtsHydrating(false);
295+ return;
296+ }
292297 const m = monacoRef.current;
293298 if (!m) return;
294299
@@ -305,6 +310,7 @@ export function CodeEditor({ · 10 unmodified lines
305310 dtsLibDisposables.current = [];
306311
307312 let cancelled = false;
313+ setDtsHydrating(true);
308314
309315 // Fetch each .d.ts blob and register with Monaco
310316 const entries = Object.entries(dtsUrls);
@@ -319,25 +325,31 @@ export function CodeEditor({ · 8 unmodified lines
319325 return null;
320326 }
321327 }),
322− ).then((results) => {
323− if (cancelled) return;
324−
325− // Build the helper types + .d.ts declarations
326− let helperDts = OPENAPI_HELPER_TYPES + "\n";
327− for (const result of results) {
328− if (!result) continue;
329− // Strip 'export' keywords so types are ambient in Monaco
330− const ambient = result.content.replace(/^export /gm, "");
331− helperDts += ambient + "\n";
332− }
328+ )
329+ .then((results) => {
330+ if (cancelled) return;
331+
332+ // Build the helper types + .d.ts declarations
333+ let helperDts = OPENAPI_HELPER_TYPES + "\n";
334+ for (const result of results) {
335+ if (!result) continue;
336+ // Strip 'export' keywords so types are ambient in Monaco
337+ const ambient = result.content.replace(/^export /gm, "");
338+ helperDts += ambient + "\n";
339+ }
333340
334− const version = ++toolsLibVersion.current;
335− const disposable = jsDefaults.addExtraLib(
336− helperDts,
337− `file:///node_modules/@types/executor-openapi/v${version}.d.ts`,
338− );
339− dtsLibDisposables.current.push(disposable);
340− });
341+ const version = ++toolsLibVersion.current;
342+ const disposable = jsDefaults.addExtraLib(
343+ helperDts,
344+ `file:///node_modules/@types/executor-openapi/v${version}.d.ts`,
345+ );
346+ dtsLibDisposables.current.push(disposable);
347+ })
348+ .finally(() => {
349+ if (!cancelled) {
350+ setDtsHydrating(false);
351+ }
352+ });
341353
342354 return () => {
343355 cancelled = true;
@@ -370,8 +382,8 @@ export function CodeEditor({ · 26 unmodified lines
370382 useEffect(() => {
371383 const m = monacoRef.current;
372384 if (!m) return;
373− setDiagnosticsOptions(m, typesLoading);
374− }, [typesLoading]);
385+ setDiagnosticsOptions(m, toolTypesLoading);
386+ }, [toolTypesLoading]);
375387
376388 // Clean up on unmount
377389 useEffect(() => {
@@ -391,7 +403,7 @@ export function CodeEditor({ · 13 unmodified lines
391403 // Configure JavaScript/TypeScript defaults for our execution environment
392404 // Code runs inside an AsyncFunction body so top-level await is valid
393405 // and there are no imports/exports.
394− setDiagnosticsOptions(monaco, typesLoading);
406+ setDiagnosticsOptions(monaco, toolTypesLoading);
395407
396408 ts.javascriptDefaults.setCompilerOptions({
397409 target: ts.ScriptTarget.ESNext,
@@ -537,10 +549,12 @@ export function CodeEditor({ · 139 unmodified lines
537549
538550 return (
539551 <div className={cn("relative", className)}>
540− {typesLoading ? (
552+ {toolTypesLoading ? (
541553 <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">
542554 <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..."}
544558 </div>
545559 ) : null}
546560 <Editor
executor/apps/web/src/components/dashboard-view.tsx+83−4
@@ -70,7 +70,7 @@ function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) { · 69 unmodified lines
7070 const navigate = useNavigate();
7171 return (
7272 <button
73− onClick={() => navigate("/approvals")}
73+ onClick={() => navigate(`/tasks?selected=${approval.taskId}`)}
7474 className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
7575 >
7676 <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
242242 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
243243 );
244244
245+ const sources = useQuery(
246+ convexApi.workspace.listToolSources,
247+ context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
248+ );
249+
245250 const { tools } = useWorkspaceTools(context ?? null);
246251
247252 if (sessionLoading) {
@@ -258,6 +263,8 @@ export function DashboardView() { · 10 unmodified lines
258263 }
259264
260265 const pendingCount = approvals?.length ?? 0;
266+ const sourceCount = sources?.length ?? 0;
267+ const taskItems = tasks ?? [];
261268 const runningCount =
262269 tasks?.filter((t: TaskRecord) => t.status === "running").length ?? 0;
263270 const completedCount =
@@ -265,15 +272,87 @@ export function DashboardView() { · 1 unmodified line
265272 const failedCount =
266273 tasks?.filter((t: TaskRecord) => ["failed", "timed_out", "denied"].includes(t.status))
267274 .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+ ];
269300
270301 return (
271302 <div className="space-y-6">
272303 <PageHeader
273− title="Dashboard"
274− description="Overview of your executor workspace"
304+ title="Workspace Home"
305+ description="Start from the current status, then jump straight to execution"
275306 />
276307
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+
277356 {/* Stats row */}
278357 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
279358 <StatCard
executor/apps/web/src/components/tasks-view.tsx+326−114
@@ -1,12 +1,15 @@
11 "use client";
22
3−import { useState, useCallback, useMemo } from "react";
3+import { useState, useCallback } from "react";
44 import { useNavigate, useSearchParams } from "react-router";
55 import {
66 Play,
77 ChevronRight,
88 X,
99 Send,
10+ ShieldCheck,
11+ ShieldX,
12+ CheckCircle2,
1013 } from "lucide-react";
1114 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1215 import { Button } from "@/components/ui/button";
@@ -23,6 +26,7 @@ import { · 10 unmodified lines
2326 import { Skeleton } from "@/components/ui/skeleton";
2427 import { Separator } from "@/components/ui/separator";
2528 import { PageHeader } from "@/components/page-header";
29+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2630 import { TaskStatusBadge } from "@/components/status-badge";
2731 import { FormattedCodeBlock } from "@/components/formatted-code-block";
2832 import { useSession } from "@/lib/session-context";
@@ -31,25 +35,27 @@ import { useMutation, useQuery } from "convex/react"; · 2 unmodified lines
3135 import { convexApi } from "@/lib/convex-api";
3236 import type {
3337 RuntimeTargetDescriptor,
34− TaskEventRecord,
3538 TaskRecord,
39+ PendingApprovalRecord,
3640 } from "@/lib/types";
3741 import type { Id } from "@executor/convex/_generated/dataModel";
3842 import { toast } from "sonner";
3943 import { cn } from "@/lib/utils";
4044
41−const DEFAULT_CODE = `// Example: call some tools
45+const DEFAULT_CODE = `// Example: call some tools and return a compact result
4246 const time = await tools.utils.get_time();
43−console.log("Current time:", time.iso);
44−
45−const result = await tools.math.add({ a: 7, b: 35 });
46−console.log("7 + 35 =", result.result);
47+const sum = await tools.math.add({ a: 7, b: 35 });
4748
4849 // This will require approval:
4950 await tools.admin.send_announcement({
5051 channel: "general",
5152 message: "Hello from executor!"
52−});`;
53+});
54+
55+return {
56+ isoTime: time.iso,
57+ total: sum.result,
58+};`;
5359 const DEFAULT_TIMEOUT_MS = 300_000;
5460
5561 function formatDate(ts: number) {
@@ -61,6 +67,65 @@ function formatDate(ts: number) { · 5 unmodified lines
6167 });
6268 }
6369
70+function 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+
82+function 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+
64129 // ── Task Composer ──
65130
66131 function TaskComposer() {
@@ -72,7 +137,7 @@ function TaskComposer() { · 5 unmodified lines
72137
73138 const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});
74139 const createTask = useMutation(convexApi.executor.createTask);
75− const { tools, dtsUrls, loading: toolsLoading } = useWorkspaceTools(context ?? null);
140+ const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null);
76141
77142 const handleSubmit = async () => {
78143 if (!context || !code.trim()) return;
@@ -134,14 +199,40 @@ function TaskComposer() { · 55 unmodified lines
134199 </div>
135200 </div>
136201 <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+ )}
138229 <div className="rounded-md border border-border">
139230 <CodeEditor
140231 value={code}
141232 onChange={setCode}
142233 tools={tools}
143234 dtsUrls={dtsUrls}
144− typesLoading={toolsLoading}
235+ typesLoading={loadingTypes}
145236 height="400px"
146237 />
147238 </div>
@@ -208,43 +299,24 @@ function TaskDetail({ · 60 unmodified lines
208299 task,
209300 workspaceId,
210301 sessionId,
302+ pendingApprovals,
211303 onClose,
212304 }: {
213305 task: TaskRecord;
214306 workspaceId: Id<"workspaces">;
215307 sessionId?: string;
308+ pendingApprovals: PendingApprovalRecord[];
216309 onClose: () => void;
217310 }) {
311+ const resolveApproval = useMutation(convexApi.executor.resolveApproval);
312+ const [resolvingApprovalId, setResolvingApprovalId] = useState<string | null>(null);
218313 const liveTaskData = useQuery(
219314 convexApi.workspace.getTaskInWorkspace,
220315 workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",
221316 );
222− const taskEvents = useQuery(
223− convexApi.workspace.listTaskEvents,
224− workspaceId ? { taskId: task.id, workspaceId, sessionId } : "skip",
225− );
226317
227318 const liveTask = liveTaskData ?? task;
228− const liveTaskEvents = taskEvents ?? [];
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]);
319+ const liveResult = formatResult(liveTask.result);
248320
249321 const duration =
250322 liveTask.completedAt && liveTask.startedAt
@@ -253,6 +325,31 @@ function TaskDetail({ · 2 unmodified lines
253325 ? "running..."
254326 : "—";
255327
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+
256353 return (
257354 <Card className="bg-card border-border">
258355 <CardHeader className="pb-3">
@@ -295,6 +392,84 @@ function TaskDetail({ · 36 unmodified lines
295392 ))}
296393 </div>
297394
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+
298473 <Separator />
299474
300475 {/* Code */}
@@ -309,31 +484,15 @@ function TaskDetail({ · 8 unmodified lines
309484 />
310485 </div>
311486
312− {/* Stdout */}
313− {liveStdout && (
487+ {/* Result */}
488+ {liveResult && (
314489 <div>
315490 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
316− Stdout
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
491+ Result
332492 </span>
333493 <FormattedCodeBlock
334− content={liveStderr}
335− language="text"
336− tone="amber"
494+ content={liveResult}
495+ language="json"
337496 className="max-h-48 overflow-y-auto"
338497 />
339498 </div>
@@ -364,6 +523,7 @@ export function TasksView() { · 24 unmodified lines
364523 const { context, loading: sessionLoading } = useSession();
365524 const navigate = useNavigate();
366525 const [searchParams] = useSearchParams();
526+ const [activeTab, setActiveTab] = useState<"activity" | "runner">("activity");
367527 const selectedId = searchParams.get("selected");
368528
369529 const tasks = useQuery(
@@ -373,7 +533,16 @@ export function TasksView() { · 3 unmodified lines
373533 const tasksLoading = !!context && tasks === undefined;
374534 const taskItems = tasks ?? [];
375535
536+ const approvals = useQuery(
537+ convexApi.workspace.listPendingApprovals,
538+ context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
539+ );
540+ const pendingApprovals = approvals ?? [];
541+
376542 const selectedTask = taskItems.find((t: TaskRecord) => t.id === selectedId);
543+ const selectedTaskApprovals = selectedTask
544+ ? pendingApprovals.filter((approval: PendingApprovalRecord) => approval.taskId === selectedTask.id)
545+ : [];
377546
378547 const selectTask = useCallback(
379548 (taskId: string | null) => {
@@ -399,72 +568,115 @@ export function TasksView() { · 19 unmodified lines
399568 <div className="space-y-6">
400569 <PageHeader
401570 title="Tasks"
402− description="Execute code and manage task history"
403− />
404−
405− <div className="grid lg:grid-cols-2 gap-6">
406− {/* Left: composer + list */}
407− <div className="space-y-6">
408− <TaskComposer />
409−
410− <Card className="bg-card border-border">
411− <CardHeader className="pb-3">
412− <CardTitle className="text-sm font-medium flex items-center gap-2">
413− Task History
414− {tasks && (
415− <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
416− {taskItems.length}
417− </span>
571+ description="Task activity first, with an advanced editor when you need it"
572+ >
573+ <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => setActiveTab("runner")}>
574+ Advanced runner
575+ </Button>
576+ <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => navigate("/approvals")}>
577+ <ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
578+ {pendingApprovals.length} pending
579+ </Button>
580+ </PageHeader>
581+
582+ <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "activity" | "runner")}>
583+ <TabsList className="bg-muted/50 h-9">
584+ <TabsTrigger value="activity" className="text-xs data-[state=active]:bg-background">
585+ Activity
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>
418629 )}
419− </CardTitle>
420− </CardHeader>
421− <CardContent className="pt-0">
422− {tasksLoading ? (
423− <div className="space-y-2">
424− {Array.from({ length: 4 }).map((_, i) => (
425− <Skeleton key={i} className="h-14" />
426− ))}
427− </div>
428− ) : taskItems.length === 0 ? (
429− <div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
430− No tasks yet. Create one above.
431− </div>
630+ </CardContent>
631+ </Card>
632+
633+ <div>
634+ {selectedTask ? (
635+ <TaskDetail
636+ task={selectedTask}
637+ workspaceId={context!.workspaceId}
638+ sessionId={context?.sessionId}
639+ pendingApprovals={selectedTaskApprovals}
640+ onClose={() => selectTask(null)}
641+ />
432642 ) : (
433− <div className="space-y-1 max-h-[500px] overflow-y-auto">
434− {taskItems.map((task: TaskRecord) => (
435− <TaskListItem
436− key={task.id}
437− task={task}
438− selected={task.id === selectedId}
439− onClick={() => selectTask(task.id)}
440− />
441− ))}
442− </div>
643+ <Card className="bg-card border-border">
644+ <CardContent className="flex items-center justify-center py-24">
645+ <p className="text-sm text-muted-foreground">
646+ Select a task to view logs, output, and approval actions
647+ </p>
648+ </CardContent>
649+ </Card>
443650 )}
444− </CardContent>
445− </Card>
446− </div>
651+ </div>
652+ </div>
653+ </TabsContent>
447654
448− {/* Right: detail panel */}
449− <div>
450− {selectedTask ? (
451− <TaskDetail
452− task={selectedTask}
453− workspaceId={context!.workspaceId}
454− sessionId={context?.sessionId}
455− onClose={() => selectTask(null)}
456− />
457− ) : (
655+ <TabsContent value="runner" className="mt-4">
656+ <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
657+ <TaskComposer />
458658 <Card className="bg-card border-border">
459− <CardContent className="flex items-center justify-center py-24">
460− <p className="text-sm text-muted-foreground">
461− Select a task to view details
659+ <CardHeader className="pb-3">
660+ <CardTitle className="text-sm font-medium flex items-center gap-2">
661+ <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.
462671 </p>
672+ <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => setActiveTab("activity")}>
673+ Back to activity view
674+ </Button>
463675 </CardContent>
464676 </Card>
465− )}
466− </div>
467− </div>
677+ </div>
678+ </TabsContent>
679+ </Tabs>
468680 </div>
469681 );
470682 }
executor/apps/web/src/components/tool-explorer.tsx+66−26
@@ -46,7 +46,6 @@ import { · 45 unmodified lines
4646 CollapsibleContent,
4747 CollapsibleTrigger,
4848 } from "@/components/ui/collapsible";
49−import { Skeleton } from "@/components/ui/skeleton";
5049 import { Streamdown } from "streamdown";
5150 import { createCodePlugin } from "@streamdown/code";
5251 import { cn } from "@/lib/utils";
@@ -90,6 +89,32 @@ function trimLeadingNamespace(path: string, prefix: string): string { · 37 unmodified lines
9089 return path;
9190 }
9291
92+function 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+
93118 // ── Types for grouped tree data ──
94119
95120 interface ToolGroup {
@@ -768,6 +793,9 @@ interface ToolExplorerProps { · 672 unmodified lines
768793 loading?: boolean;
769794 warnings?: string[];
770795 initialSource?: string | null;
796+ activeSource?: string | null;
797+ onActiveSourceChange?: (source: string | null) => void;
798+ showSourceSidebar?: boolean;
771799 }
772800
773801 export function ToolExplorer({
@@ -776,11 +804,14 @@ export function ToolExplorer({ · 2 unmodified lines
776804 loading = false,
777805 warnings = [],
778806 initialSource = null,
807+ activeSource,
808+ onActiveSourceChange,
809+ showSourceSidebar = true,
779810 }: ToolExplorerProps) {
780811 const [search, setSearch] = useState("");
781812 const [viewMode, setViewMode] = useState<ViewMode>("tree");
782813 const [groupBy, setGroupBy] = useState<GroupBy>("source");
783− const [activeSource, setActiveSource] = useState<string | null>(
814+ const [internalActiveSource, setInternalActiveSource] = useState<string | null>(
784815 initialSource,
785816 );
786817 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(
@@ -792,19 +823,24 @@ export function ToolExplorer({ · 5 unmodified lines
792823 >("all");
793824 const treeListRef = useRef<HTMLDivElement>(null);
794825 const flatListRef = useRef<HTMLDivElement>(null);
826+ const resolvedActiveSource =
827+ activeSource === undefined ? internalActiveSource : activeSource;
795828
796829 const handleSourceSelect = useCallback((source: string | null) => {
797− setActiveSource(source);
830+ if (activeSource === undefined) {
831+ setInternalActiveSource(source);
832+ }
833+ onActiveSourceChange?.(source);
798834 setExpandedKeys(source ? new Set([`source:${source}`]) : new Set());
799− }, []);
835+ }, [activeSource, onActiveSourceChange]);
800836
801837 // Filter tools by active source and approval filter
802838 const filteredTools = useMemo(() => {
803839 let result = tools;
804840
805− if (activeSource) {
841+ if (resolvedActiveSource) {
806842 result = result.filter(
807− (t) => sourceLabel(t.source) === activeSource,
843+ (t) => sourceLabel(t.source) === resolvedActiveSource,
808844 );
809845 }
810846
@@ -815,7 +851,7 @@ export function ToolExplorer({ · 4 unmodified lines
815851 }
816852
817853 return result;
818− }, [tools, activeSource, filterApproval]);
854+ }, [tools, resolvedActiveSource, filterApproval]);
819855
820856 // Apply search filter
821857 const searchedTools = useMemo(() => {
@@ -995,21 +1031,23 @@ export function ToolExplorer({ · 173 unmodified lines
9951031 if (loading) {
9961032 const enabledSources = sources.filter((s) => s.enabled);
9971033 return (
998− <div className="space-y-3">
1034+ <div className="space-y-3 rounded-md border border-border/40 bg-muted/20 p-3">
9991035 <div className="flex items-center gap-2 text-sm text-muted-foreground">
10001036 <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...
10021038 </div>
10031039 {enabledSources.length > 0 && (
10041040 <div className="space-y-1">
10051041 {enabledSources.map((s) => (
10061042 <div
10071043 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"
10091045 >
10101046 <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
1011− <span className="font-medium">{s.name}</span>
1012− <span className="text-muted-foreground">{s.type}</span>
1047+ <span className="font-medium truncate">{displaySourceName(s.name)}</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>
10131051 </div>
10141052 ))}
10151053 </div>
@@ -1020,15 +1058,16 @@ export function ToolExplorer({ · 4 unmodified lines
10201058
10211059 return (
10221060 <div className="flex" onWheelCapture={handleExplorerWheel}>
1023− {/* Source sidebar */}
1024− <SourceSidebar
1025− tools={tools}
1026− activeSource={activeSource}
1027− onSelectSource={handleSourceSelect}
1028− />
1061+ {showSourceSidebar ? (
1062+ <SourceSidebar
1063+ tools={tools}
1064+ activeSource={resolvedActiveSource}
1065+ onSelectSource={handleSourceSelect}
1066+ />
1067+ ) : null}
10291068
10301069 {/* 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")}>
10321071 <div className="shrink-0">
10331072 {/* Toolbar */}
10341073 <div className="flex items-center gap-2 pb-3 flex-wrap">
@@ -1182,12 +1221,13 @@ export function ToolExplorer({ · 147 unmodified lines
11821221 variant="outline"
11831222 size="sm"
11841223 className={cn(
1185− "h-8 text-[11px] border-border/50 lg:hidden",
1186− activeSource && "border-primary/30 text-primary",
1224+ "h-8 text-[11px] border-border/50",
1225+ showSourceSidebar && "lg:hidden",
1226+ resolvedActiveSource && "border-primary/30 text-primary",
11871227 )}
11881228 >
11891229 <Server className="h-3 w-3 mr-1" />
1190− {activeSource ?? "All"}
1230+ {resolvedActiveSource ?? "All"}
11911231 </Button>
11921232 </DropdownMenuTrigger>
11931233 <DropdownMenuContent align="end">
@@ -1196,7 +1236,7 @@ export function ToolExplorer({ · 2 unmodified lines
11961236 </DropdownMenuLabel>
11971237 <DropdownMenuSeparator />
11981238 <DropdownMenuCheckboxItem
1199− checked={activeSource === null}
1239+ checked={resolvedActiveSource === null}
12001240 onCheckedChange={() => handleSourceSelect(null)}
12011241 className="text-xs"
12021242 >
@@ -1207,10 +1247,10 @@ export function ToolExplorer({ · 4 unmodified lines
12071247 ).map((src) => (
12081248 <DropdownMenuCheckboxItem
12091249 key={src}
1210− checked={activeSource === src}
1250+ checked={resolvedActiveSource === src}
12111251 onCheckedChange={() =>
12121252 handleSourceSelect(
1213− activeSource === src ? null : src,
1253+ resolvedActiveSource === src ? null : src,
12141254 )
12151255 }
12161256 className="text-xs font-mono"
@@ -1275,7 +1315,7 @@ export function ToolExplorer({ · 58 unmodified lines
12751315 {search
12761316 ? `${searchedTools.length} results`
12771317 : `${filteredTools.length} tools`}
1278− {activeSource && ` in ${activeSource}`}
1318+ {resolvedActiveSource && ` in ${resolvedActiveSource}`}
12791319 </span>
12801320 {viewMode === "tree" && (
12811321 <div className="flex gap-1">
executor/apps/web/src/components/tools-view.tsx+828−572
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useRef, useState } from "react";
3+import { useEffect, useMemo, useState } from "react";
44 import {
55 Wrench,
66 Plus,
@@ -11,7 +11,6 @@ import { · 4 unmodified lines
1111 AlertTriangle,
1212 KeyRound,
1313 Pencil,
14− Loader2,
1514 } from "lucide-react";
1615 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1716 import { Button } from "@/components/ui/button";
@@ -52,6 +51,7 @@ import type { · 34 unmodified lines
5251 ToolSourceRecord,
5352 CredentialRecord,
5453 CredentialScope,
54+ SourceAuthProfile,
5555 OpenApiSourceQuality,
5656 } from "@/lib/types";
5757 import { parse as parseDomain } from "tldts";
@@ -69,15 +69,108 @@ interface CatalogCollectionItem { · 11 unmodified lines
6969 logoUrl?: string;
7070 categories?: string;
7171 version?: string;
72+ rank: number;
73+ addedAt: string;
7274 }
7375
74−interface CatalogCollectionsResponse {
75− items?: CatalogCollectionItem[];
76− totalCount?: number;
77− hasMore?: boolean;
78− error?: string;
79− detail?: string;
80−}
76+const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [
77+ {
78+ id: "github-rest",
79+ name: "GitHub REST API",
80+ summary: "Manage repositories, pull requests, issues, and org settings.",
81+ specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
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+];
81174
82175 /** Derive a favicon URL from any URL string via Google's favicon service. */
83176 function faviconForUrl(url: string | undefined | null): string | null {
@@ -141,45 +234,94 @@ function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): · 57 unmodified lines
141234 return sources.find((source) => source.id === sourceId) ?? null;
142235 }
143236
144−type SourceAuthType = "none" | "bearer" | "apiKey" | "basic";
237+type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
145238 type SourceAuthMode = "workspace" | "actor";
146239
147−function readSourceAuth(source: ToolSourceRecord): {
240+function 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+
273+function readSourceAuth(
274+ source: ToolSourceRecord,
275+ inferredProfile?: SourceAuthProfile,
276+): {
148277 type: SourceAuthType;
149278 mode?: SourceAuthMode;
150279 header?: string;
280+ inferred?: boolean;
151281 } {
152282 if (source.type !== "openapi" && source.type !== "graphql") {
153283 return { type: "none" };
154284 }
155285
286+ const inferred = normalizeSourceAuthProfile(inferredProfile);
287+
156288 const auth = source.config.auth as Record<string, unknown> | undefined;
157289 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)
159291 ? (auth.type as SourceAuthType)
160− : "none";
292+ : inferred.type;
161293
162294 const mode =
163295 auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor")
164296 ? (auth.mode as SourceAuthMode)
165− : undefined;
297+ : inferred.mode;
166298
167299 const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0
168300 ? auth.header.trim()
169− : undefined;
301+ : inferred.header;
170302
171303 return {
172304 type,
173305 ...(mode ? { mode } : {}),
174306 ...(header ? { header } : {}),
307+ inferred: auth?.type === undefined ? inferred.inferred : false,
175308 };
176309 }
177310
178−function formatSourceAuthBadge(source: ToolSourceRecord): string | null {
179− const auth = readSourceAuth(source);
311+function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null {
312+ const auth = readSourceAuth(source, inferredProfile);
180313 if (auth.type === "none") return null;
314+ if (auth.type === "mixed") return "Mixed auth";
181315 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"}`;
183325 }
184326
185327 function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): {
@@ -206,14 +348,62 @@ function formatQualityPercent(value: number): string { · 20 unmodified lines
206348 return `${Math.round(value * 100)}%`;
207349 }
208350
209−function qualityBadgeClass(quality: OpenApiSourceQuality): string {
351+function qualityToneClass(quality: OpenApiSourceQuality): string {
210352 if (quality.overallQuality >= 0.95) {
211− return "text-terminal-green border-terminal-green/30";
353+ return "text-terminal-green";
212354 }
213355 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+
361+function 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+
371+function 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+
397+function 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;
215406 }
216− return "text-terminal-red border-terminal-red/30";
217407 }
218408
219409 // ── Add Source Dialog ──
@@ -284,8 +474,10 @@ function catalogSourceName(item: CatalogCollectionItem): string { · 64 unmodified lines
284474
285475 function AddSourceDialog({
286476 existingSourceNames,
477+ onSourceAdded,
287478 }: {
288479 existingSourceNames: Set<string>;
480+ onSourceAdded?: (source: ToolSourceRecord) => void;
289481 }) {
290482 const { context } = useSession();
291483 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
@@ -302,18 +494,27 @@ function AddSourceDialog({ · 10 unmodified lines
302494 const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]);
303495 const [catalogQuery, setCatalogQuery] = useState("");
304496 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);
312497 const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null);
313− const catalogRequestIdRef = useRef(0);
314− const catalogInFlightRef = useRef(false);
315498
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]);
317518
318519 const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]);
319520
@@ -343,8 +544,6 @@ function AddSourceDialog({ · 23 unmodified lines
343544 };
344545
345546 const resetDialogState = () => {
346− catalogRequestIdRef.current += 1;
347− catalogInFlightRef.current = false;
348547 setView("catalog");
349548 setType("mcp");
350549 setName("");
@@ -356,117 +555,14 @@ function AddSourceDialog({ · 5 unmodified lines
356555 setLocallyReservedNames([]);
357556 setCatalogQuery("");
358557 setCatalogSort("popular");
359− setCatalogItems([]);
360− setCatalogOffset(0);
361− setCatalogHasMore(true);
362− setCatalogTotalCount(null);
363− setCatalogLoading(false);
364− setCatalogLoadingMore(false);
365− setCatalogError(null);
366558 setAddingCatalogId(null);
367559 };
368560
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−
461561 const handleOpenChange = (isOpen: boolean) => {
462562 setOpen(isOpen);
463563 if (!isOpen) {
464564 resetDialogState();
465− return;
466565 }
467−
468− resetDialogState();
469− void loadCatalogPage({ mode: "reset", query: "", sort: "popular" });
470566 };
471567
472568 const addSource = async (
@@ -475,13 +571,14 @@ function AddSourceDialog({ · 2 unmodified lines
475571 config: Record<string, unknown>,
476572 ) => {
477573 if (!context) return;
478− await upsertToolSource({
574+ const created = await upsertToolSource({
479575 workspaceId: context.workspaceId,
480576 sessionId: context.sessionId,
481577 name: sourceName,
482578 type: sourceType,
483579 config,
484580 });
581+ onSourceAdded?.(created as ToolSourceRecord);
485582 toast.success(`Source "${sourceName}" added — loading tools…`);
486583 };
487584
@@ -563,20 +660,10 @@ function AddSourceDialog({ · 75 unmodified lines
563660 onChange={(event) => setCatalogQuery(event.target.value)}
564661 placeholder="Search APIs"
565662 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− }}
572663 />
573664 <Select
574665 value={catalogSort}
575− onValueChange={(value) => {
576− const nextSort = value as "popular" | "recent";
577− setCatalogSort(nextSort);
578− void loadCatalogPage({ mode: "reset", sort: nextSort });
579− }}
666+ onValueChange={(value) => setCatalogSort(value as "popular" | "recent")}
580667 >
581668 <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0">
582669 <SelectValue />
@@ -586,35 +673,15 @@ function AddSourceDialog({ · 3 unmodified lines
586673 <SelectItem value="recent" className="text-xs">Recent</SelectItem>
587674 </SelectContent>
588675 </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>
599676 </div>
600677
601678 <p className="text-[11px] text-muted-foreground">
602− Browse API collections and add them as tool sources.
603− {catalogTotalCount !== null ? ` Found ${catalogTotalCount.toLocaleString()} total.` : ""}
679+ Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}.
604680 </p>
605681
606682 <Separator />
607683
608− <div
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− >
684+ <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1">
618685 <button
619686 type="button"
620687 onClick={() => setView("custom")}
@@ -631,7 +698,7 @@ function AddSourceDialog({ · 10 unmodified lines
631698 </div>
632699 </button>
633700
634− {catalogItems.map((item) => (
701+ {visibleCatalogItems.map((item) => (
635702 <div
636703 key={item.id}
637704 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
673740 </div>
674741 ))}
675742
676− {catalogLoadingMore && (
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 && (
743+ {visibleCatalogItems.length === 0 && (
684744 <p className="text-[11px] text-muted-foreground px-1 py-1">
685745 No collections found for this query.
686746 </p>
687747 )}
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− )}
700748 </div>
701749 </div>
702750 ) : (
@@ -820,24 +868,27 @@ function AddSourceDialog({ · 117 unmodified lines
820868
821869 function ConfigureSourceAuthDialog({
822870 source,
871+ inferredProfile,
823872 }: {
824873 source: ToolSourceRecord;
874+ inferredProfile?: SourceAuthProfile;
825875 }) {
826876 const { context } = useSession();
827877 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
828878 const [open, setOpen] = useState(false);
829879 const [saving, setSaving] = useState(false);
830880
831− const currentAuth = readSourceAuth(source);
832− const [authType, setAuthType] = useState<SourceAuthType>(currentAuth.type);
881+ const currentAuth = readSourceAuth(source, inferredProfile);
882+ const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type;
883+ const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType);
833884 const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace");
834885 const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key");
835886
836887 const configurable = source.type === "openapi" || source.type === "graphql";
837888
838889 const resetFromSource = () => {
839− const auth = readSourceAuth(source);
840− setAuthType(auth.type);
890+ const auth = readSourceAuth(source, inferredProfile);
891+ setAuthType(auth.type === "mixed" ? "bearer" : auth.type);
841892 setAuthMode(auth.mode ?? "workspace");
842893 setApiKeyHeader(auth.header ?? "x-api-key");
843894 };
@@ -905,7 +956,7 @@ function ConfigureSourceAuthDialog({ · 61 unmodified lines
905956
906957 <div className="space-y-1.5">
907958 <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">)}>
909960 <SelectTrigger className="h-8 text-xs bg-background">
910961 <SelectValue />
911962 </SelectTrigger>
@@ -918,6 +969,12 @@ function ConfigureSourceAuthDialog({ · 6 unmodified lines
918969 </Select>
919970 </div>
920971
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+
921978 {authType !== "none" && (
922979 <>
923980 <div className="space-y-1.5">
@@ -946,7 +1003,7 @@ function ConfigureSourceAuthDialog({ · 22 unmodified lines
9461003 )}
9471004
9481005 <p className="text-[11px] text-muted-foreground">
949− Save this first, then add credentials in the Credentials tab using source key
1006+ Save this first, then add a connection in the Connections tab using source key
9501007 <code className="ml-1">{sourceKeyForSource(source)}</code>.
9511008 </p>
9521009 </>
@@ -966,15 +1023,22 @@ function SourceCard({ · 13 unmodified lines
9661023 quality,
9671024 qualityLoading,
9681025 credentialStats,
1026+ sourceAuthProfiles,
1027+ selected = false,
1028+ onFocusSource,
9691029 }: {
9701030 source: ToolSourceRecord;
9711031 quality?: OpenApiSourceQuality;
9721032 qualityLoading?: boolean;
9731033 credentialStats: { workspaceCount: number; actorCount: number };
1034+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
1035+ selected?: boolean;
1036+ onFocusSource?: (sourceName: string) => void;
9741037 }) {
9751038 const { context } = useSession();
9761039 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
9771040 const [deleting, setDeleting] = useState(false);
1041+ const [detailsOpen, setDetailsOpen] = useState(false);
9781042
9791043 const handleDelete = async () => {
9801044 if (!context) return;
@@ -995,14 +1059,23 @@ function SourceCard({ · 14 unmodified lines
9951059
9961060 const TypeIcon = source.type === "mcp" ? Server : Globe;
9971061 const favicon = getSourceFavicon(source);
998− const authBadge = formatSourceAuthBadge(source);
999− const auth = readSourceAuth(source);
1062+ const sourceKey = sourceKeyForSource(source) ?? "";
1063+ const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]);
1064+ const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]);
10001065 const hasAuthConfigured = auth.type !== "none";
10011066 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);
10021070
10031071 return (
1004− <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group">
1005− <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">
1072+ <div
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">
10061079 {favicon ? (
10071080 <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />
10081081 ) : (
@@ -1010,20 +1083,20 @@ function SourceCard({ · 1 unmodified line
10101083 )}
10111084 </div>
10121085 <div className="flex-1 min-w-0">
1013− <div className="flex items-center gap-2">
1014− <span className="text-sm font-mono font-medium truncate">
1015− {source.name}
1086+ <div className="flex items-center gap-2 min-w-0">
1087+ <span className="text-sm font-medium truncate" title={source.name}>
1088+ {prettyName}
10161089 </span>
10171090 <Badge
10181091 variant="outline"
1019− className="text-[9px] font-mono uppercase tracking-wider"
1092+ className="text-[9px] uppercase tracking-wide"
10201093 >
10211094 {source.type}
10221095 </Badge>
10231096 {!source.enabled && (
10241097 <Badge
10251098 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"
10271100 >
10281101 disabled
10291102 </Badge>
@@ -1031,7 +1104,7 @@ function SourceCard({ · 1 unmodified line
10311104 {authBadge && (
10321105 <Badge
10331106 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"
10351108 >
10361109 {authBadge}
10371110 </Badge>
@@ -1040,43 +1113,100 @@ function SourceCard({ · 2 unmodified lines
10401113 <Badge
10411114 variant="outline"
10421115 className={cn(
1043− "text-[9px] font-mono uppercase tracking-wider",
1116+ "text-[9px] uppercase tracking-wide",
10441117 totalCredentials > 0
10451118 ? "text-terminal-green border-terminal-green/30"
10461119 : "text-terminal-amber border-terminal-amber/30",
10471120 )}
10481121 >
1049− creds ws:{credentialStats.workspaceCount} actor:{credentialStats.actorCount}
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)}
1122+ {totalCredentials > 0 ? "connections ready" : "connection needed"}
10581123 </Badge>
10591124 )}
10601125 </div>
1061− <span className="text-[11px] text-muted-foreground font-mono truncate block">
1062− {sourceEndpointLabel(source)}
1126+ <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}>
1127+ {compactEndpoint}
10631128 </span>
1064− {source.type === "openapi" && quality && (
1065− <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5">
1066− args {formatQualityPercent(quality.argsQuality)} | returns {formatQualityPercent(quality.returnsQuality)}
1067− {quality.unknownReturnsCount > 0
1068− ? ` | ${quality.unknownReturnsCount} unknown returns`
1069− : " | fully typed returns"}
1070− </span>
1129+ {showTypeSummary && (
1130+ <div className="mt-1.5 flex items-center gap-2">
1131+ {quality ? (
1132+ <Badge
1133+ variant="outline"
1134+ className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))}
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>
10711144 )}
1072− {source.type === "openapi" && !quality && qualityLoading && (
1073− <span className="text-[10px] text-muted-foreground/70 font-mono truncate block mt-0.5">
1074− Computing OpenAPI type quality...
1075− </span>
1145+ {source.type === "openapi" && (
1146+ <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
1147+ <div className="mt-1.5">
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>
10761196 )}
10771197 </div>
10781198 <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]} />
10801210 <Button
10811211 variant="ghost"
10821212 size="icon"
@@ -1091,62 +1221,109 @@ function SourceCard({ · 8 unmodified lines
10911221 );
10921222 }
10931223
1094−function formatCredentialSecret(secretJson: Record<string, unknown>): string {
1095− try {
1096− return JSON.stringify(secretJson, null, 2);
1097− } catch {
1098− return "{}";
1099− }
1100−}
1101−
11021224 type SourceOption = { source: ToolSourceRecord; key: string; label: string };
11031225
1104−function sourceAuthForKey(sourceOptions: SourceOption[], key: string): {
1226+function sourceAuthForKey(
1227+ sourceOptions: SourceOption[],
1228+ key: string,
1229+ inferredProfiles: Record<string, SourceAuthProfile> = {},
1230+): {
11051231 type: SourceAuthType;
11061232 mode?: SourceAuthMode;
11071233 header?: string;
1234+ inferred?: boolean;
11081235 } {
11091236 const match = sourceOptions.find((entry) => entry.key === key);
11101237 if (!match) {
11111238 return { type: "bearer" };
11121239 }
1113− return readSourceAuth(match.source);
1240+ return readSourceAuth(match.source, inferredProfiles[key]);
11141241 }
11151242
11161243 function sourceOptionLabel(source: ToolSourceRecord): string {
11171244 return `${source.name} (${source.type})`;
11181245 }
11191246
1120−function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } {
1121− try {
1122− const parsed = JSON.parse(text);
1123− if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1124− return { error: "Credential JSON must be an object" };
1247+function providerLabel(provider: "local-convex" | "workos-vault"): string {
1248+ return provider === "workos-vault" ? "encrypted" : "local";
1249+}
1250+
1251+function 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})`;
11251271 }
1126− return { value: parsed as Record<string, unknown> };
1127− } catch (error) {
1128− return { error: error instanceof Error ? error.message : "Invalid credential JSON" };
1272+ return `${base} personal`;
11291273 }
1274+
1275+ return `${base} workspace`;
11301276 }
11311277
1132−function asString(value: unknown): string {
1133− return typeof value === "string" ? value : "";
1278+function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } {
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 };
11341302 }
11351303
1136−function providerLabel(provider: "managed" | "workos-vault"): string {
1137− return provider === "workos-vault" ? "encrypted" : "managed";
1304+function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
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");
11381309 }
11391310
11401311 function CredentialsPanel({
11411312 sources,
11421313 credentials,
1314+ sourceAuthProfiles,
11431315 loading,
1316+ focusSourceKey,
1317+ onFocusHandled,
11441318 }: {
11451319 sources: ToolSourceRecord[];
11461320 credentials: CredentialRecord[];
1321+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
11471322 loading: boolean;
1323+ focusSourceKey?: string | null;
1324+ onFocusHandled?: () => void;
11481325 }) {
1149− const { context } = useSession();
1326+ const { context, clientConfig } = useSession();
11501327 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
11511328 const [open, setOpen] = useState(false);
11521329 const [saving, setSaving] = useState(false);
@@ -1154,13 +1331,17 @@ function CredentialsPanel({ · 1 unmodified line
11541331 const [sourceKey, setSourceKey] = useState("");
11551332 const [scope, setScope] = useState<CredentialScope>("workspace");
11561333 const [actorId, setActorId] = useState("");
1157− const [provider, setProvider] = useState<"managed" | "workos-vault">("managed");
1158− const [managedToken, setManagedToken] = useState("");
1334+ const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new");
1335+ const [existingConnectionId, setExistingConnectionId] = useState("");
1336+ const [tokenValue, setTokenValue] = useState("");
11591337 const [apiKeyValue, setApiKeyValue] = useState("");
11601338 const [basicUsername, setBasicUsername] = useState("");
11611339 const [basicPassword, setBasicPassword] = useState("");
1162− const [advancedMode, setAdvancedMode] = useState(false);
1163− const [secretJsonText, setSecretJsonText] = useState("{}");
1340+ const [customHeadersText, setCustomHeadersText] = useState("");
1341+
1342+ const storageCopy = clientConfig?.authProviderMode === "workos"
1343+ ? "Stored encrypted"
1344+ : "Stored locally on this machine";
11641345
11651346 const sourceOptions = sources
11661347 .map((source) => {
@@ -1173,50 +1354,99 @@ function CredentialsPanel({ · 6 unmodified lines
11731354 })
11741355 .filter((entry): entry is SourceOption => entry.key !== null);
11751356
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+ });
11771407 const selectedAuthBadge = selectedAuth.type === "none"
11781408 ? "none"
1179− : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`;
1409+ : selectedAuth.type === "mixed"
1410+ ? "mixed"
1411+ : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`;
11801412
1181− const buildDraftSecretFromInputs = (): Record<string, unknown> => {
1182− if (selectedAuth.type === "apiKey") {
1183− return { value: apiKeyValue.trim() };
1413+ useEffect(() => {
1414+ if (!existingConnectionId) {
1415+ return;
11841416 }
1185− if (selectedAuth.type === "basic") {
1186− return {
1187− username: basicUsername,
1188− password: basicPassword,
1189− };
1417+ if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
1418+ setExistingConnectionId("");
11901419 }
1191− return { token: managedToken.trim() };
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− };
1420+ }, [compatibleConnectionOptions, existingConnectionId]);
12031421
12041422 const resetForm = () => {
12051423 const defaultSourceKey = sourceOptions[0]?.key ?? "";
12061424 setSourceKey(defaultSourceKey);
1207− const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey);
1425+ const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles);
12081426 setScope(defaultAuth.mode ?? "workspace");
12091427 setActorId(context?.actorId ?? "");
1210− setProvider("managed");
1211− setManagedToken("");
1428+ setConnectionMode("new");
1429+ setExistingConnectionId("");
1430+ setTokenValue("");
12121431 setApiKeyValue("");
12131432 setBasicUsername("");
12141433 setBasicPassword("");
1215− setAdvancedMode(false);
1216− setSecretJsonText("{}");
1434+ setCustomHeadersText("");
12171435 setEditing(null);
12181436 };
12191437
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+
12201450 const openForCreate = () => {
12211451 resetForm();
12221452 setOpen(true);
@@ -1227,30 +1457,24 @@ function CredentialsPanel({ · 4 unmodified lines
12271457 setSourceKey(credential.sourceKey);
12281458 setScope(credential.scope);
12291459 setActorId(credential.actorId ?? context?.actorId ?? "");
1230− setProvider(credential.provider === "workos-vault" ? "workos-vault" : "managed");
1231− setFormFromCredential(credential);
1460+ setConnectionMode("new");
1461+ setExistingConnectionId(credential.id);
1462+ setTokenValue("");
1463+ setApiKeyValue("");
1464+ setBasicUsername("");
1465+ setBasicPassword("");
1466+ setCustomHeadersText(formatHeaderOverrides(credential.overridesJson));
12321467 setOpen(true);
12331468 };
12341469
12351470 const handleSourceKeyChange = (nextSourceKey: string) => {
12361471 setSourceKey(nextSourceKey);
1237− const auth = sourceAuthForKey(sourceOptions, nextSourceKey);
1472+ const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles);
12381473 if (!editing) {
12391474 setScope(auth.mode ?? "workspace");
12401475 }
12411476 };
12421477
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−
12541478 const handleSave = async () => {
12551479 if (!context) return;
12561480 if (!sourceKey.trim()) {
@@ -1262,61 +1486,61 @@ function CredentialsPanel({ · 5 unmodified lines
12621486 return;
12631487 }
12641488
1265− let secretJson: Record<string, unknown> = {};
1266− const keepExistingEncryptedSecret = provider === "workos-vault" && Boolean(editing);
1489+ const parsedHeaders = parseHeaderOverrides(customHeadersText);
1490+ if (!parsedHeaders.value) {
1491+ toast.error(parsedHeaders.error ?? "Invalid header overrides");
1492+ return;
1493+ }
12671494
1268− if (advancedMode) {
1269− const parsed = parseJsonObject(secretJsonText);
1270− if (!parsed.value) {
1271− toast.error(parsed.error ?? "Invalid credential JSON");
1272− return;
1273− }
1274− secretJson = parsed.value;
1275− } else {
1276− if (selectedAuth.type === "none") {
1277− toast.error("Configure source auth before saving credentials");
1278− return;
1279− }
1495+ const linkExisting = !editing && connectionMode === "existing";
1496+ if (linkExisting && !existingConnectionId) {
1497+ toast.error("Select an existing connection to link");
1498+ return;
1499+ }
1500+ if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) {
1501+ toast.error("Selected connection does not match this scope");
1502+ return;
1503+ }
1504+
1505+ if (selectedAuth.type === "none") {
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) {
12801517 if (selectedAuth.type === "basic") {
12811518 const hasUsername = basicUsername.trim().length > 0;
12821519 const hasPassword = basicPassword.trim().length > 0;
1283− if (!hasUsername && !hasPassword && keepExistingEncryptedSecret) {
1284− secretJson = {};
1285− } else if (!hasUsername || !hasPassword) {
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");
1520+ if (hasUsername || hasPassword) {
1521+ if (!hasUsername || !hasPassword) {
1522+ toast.error("Username and password are required for basic auth");
13001523 return;
13011524 }
1302− } else {
1303− secretJson = { value: apiKeyValue.trim() };
1525+ secretJson.username = basicUsername;
1526+ secretJson.password = basicPassword;
13041527 }
1305− } else {
1306− if (!managedToken.trim()) {
1307− if (keepExistingEncryptedSecret) {
1308− secretJson = {};
1309− } else {
1310− toast.error("Token is required");
1311− return;
1312− }
1313− } else {
1314− secretJson = { token: managedToken.trim() };
1528+ } else if (selectedAuth.type === "apiKey") {
1529+ if (apiKeyValue.trim()) {
1530+ secretJson.value = apiKeyValue.trim();
1531+ }
1532+ } else if (selectedAuth.type === "bearer") {
1533+ if (tokenValue.trim()) {
1534+ secretJson.token = tokenValue.trim();
13151535 }
13161536 }
13171537 }
13181538
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) {
13201544 if (selectedAuth.type === "basic") {
13211545 toast.error("Username and password are required");
13221546 } else if (selectedAuth.type === "apiKey") {
@@ -1330,20 +1554,20 @@ function CredentialsPanel({ · 7 unmodified lines
13301554 setSaving(true);
13311555 try {
13321556 await upsertCredential({
1557+ ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}),
13331558 workspaceId: context.workspaceId,
13341559 sessionId: context.sessionId,
13351560 sourceKey: sourceKey.trim(),
13361561 scope,
13371562 ...(scope === "actor" ? { actorId: actorId.trim() } : {}),
1338− provider,
13391563 secretJson,
13401564 });
13411565
1342− toast.success(editing ? "Credential updated" : "Credential saved");
1566+ toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved");
13431567 setOpen(false);
13441568 resetForm();
13451569 } 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");
13471571 } finally {
13481572 setSaving(false);
13491573 }
@@ -1355,11 +1579,11 @@ function CredentialsPanel({ · 5 unmodified lines
13551579 <div className="flex items-center justify-between">
13561580 <CardTitle className="text-sm font-medium flex items-center gap-2">
13571581 <KeyRound className="h-4 w-4 text-muted-foreground" />
1358− Credentials
1582+ Connections
13591583 </CardTitle>
13601584 <Button size="sm" className="h-8 text-xs" onClick={openForCreate}>
13611585 <Plus className="h-3.5 w-3.5 mr-1.5" />
1362− Add Credential
1586+ Add Connection
13631587 </Button>
13641588 </div>
13651589 </CardHeader>
@@ -1370,82 +1594,71 @@ function CredentialsPanel({ · 4 unmodified lines
13701594 <Skeleton key={i} className="h-14" />
13711595 ))}
13721596 </div>
1373− ) : credentials.length === 0 ? (
1597+ ) : connectionOptions.length === 0 ? (
13741598 <div className="flex flex-col items-center justify-center py-10 gap-2">
13751599 <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
13761600 <KeyRound className="h-5 w-5 text-muted-foreground/50" />
13771601 </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>
13791603 <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.
13811605 </p>
13821606 </div>
13831607 ) : (
13841608 <div className="space-y-2">
1385− {credentials.map((credential) => (
1386− <div
1387− key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`}
1388− className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
1389− >
1390− {(() => {
1391− const source = sourceForCredentialKey(sources, credential.sourceKey);
1392− const favicon = source ? getSourceFavicon(source) : null;
1393− return (
1394− <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">
1395− {favicon ? (
1396− <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" />
1397− ) : (
1398− <KeyRound className="h-4 w-4 text-muted-foreground" />
1609+ {connectionOptions.map((connection) => {
1610+ const representative = representativeCredentialByConnection.get(connection.id);
1611+ if (!representative) return null;
1612+ const firstSource = sourceForCredentialKey(sources, representative.sourceKey);
1613+ const favicon = firstSource ? getSourceFavicon(firstSource) : null;
1614+
1615+ return (
1616+ <div
1617+ key={connection.id}
1618+ className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
1619+ >
1620+ <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden">
1621+ {favicon ? (
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>
13991640 )}
14001641 </div>
1401− );
1402− })()}
1403− <div className="flex-1 min-w-0">
1404− {(() => {
1405− const source = sourceForCredentialKey(sources, credential.sourceKey);
1406− if (!source) {
1407− return (
1408− <p className="text-[11px] text-muted-foreground/80 font-mono mb-1">
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− )}
1642+ <p className="text-[11px] text-muted-foreground">
1643+ Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy}
1644+ </p>
1645+ <p className="text-[11px] text-muted-foreground mt-0.5">
1646+ ID: <span className="font-mono">{connection.id}</span> •
1647+ {" "}
1648+ Updated {new Date(connection.updatedAt).toLocaleString()}
1649+ </p>
14341650 </div>
1435− <p className="text-[11px] text-muted-foreground mt-0.5">
1436− Updated {new Date(credential.updatedAt).toLocaleString()}
1437− </p>
1651+ <Button
1652+ variant="outline"
1653+ size="sm"
1654+ className="h-7 text-[11px]"
1655+ onClick={() => openForEdit(representative)}
1656+ >
1657+ Edit
1658+ </Button>
14381659 </div>
1439− <Button
1440− variant="outline"
1441− size="sm"
1442− className="h-7 text-[11px]"
1443− onClick={() => openForEdit(credential)}
1444− >
1445− Edit
1446− </Button>
1447− </div>
1448− ))}
1660+ );
1661+ })}
14491662 </div>
14501663 )}
14511664 </CardContent>
@@ -1454,7 +1667,7 @@ function CredentialsPanel({ · 2 unmodified lines
14541667 <DialogContent className="bg-card border-border sm:max-w-lg">
14551668 <DialogHeader>
14561669 <DialogTitle className="text-sm font-medium">
1457− {editing ? "Edit Credential" : "Add Credential"}
1670+ {editing ? "Edit Connection" : "Add Connection"}
14581671 </DialogTitle>
14591672 </DialogHeader>
14601673 <div className="space-y-3">
@@ -1491,6 +1704,11 @@ function CredentialsPanel({ · 30 unmodified lines
14911704 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
14921705 {selectedAuthBadge}
14931706 </Badge>
1707+ {selectedAuth.inferred && (
1708+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1709+ inferred
1710+ </Badge>
1711+ )}
14941712 {selectedAuth.type === "apiKey" && selectedAuth.header && (
14951713 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
14961714 header: {selectedAuth.header}
@@ -1511,18 +1729,9 @@ function CredentialsPanel({ · 14 unmodified lines
15111729 </SelectContent>
15121730 </Select>
15131731 </div>
1514−
15151732 <div className="space-y-1.5">
1516− <Label className="text-xs text-muted-foreground">Provider</Label>
1517− <Select value={provider} onValueChange={(value) => handleProviderChange(value as "managed" | "workos-vault") }>
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>
1733+ <Label className="text-xs text-muted-foreground">Storage</Label>
1734+ <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" />
15261735 </div>
15271736 </div>
15281737
@@ -1538,79 +1747,114 @@ function CredentialsPanel({ · 9 unmodified lines
15381747 </div>
15391748 )}
15401749
1541− {provider === "workos-vault" && editing && (
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" ? (
1750+ {!editing && connectionOptions.length > 0 && (
15521751 <div className="space-y-1.5">
1553− <Label className="text-xs text-muted-foreground">API Key Value</Label>
1554− <Input
1555− value={apiKeyValue}
1556− onChange={(e) => setApiKeyValue(e.target.value)}
1557− placeholder="sk_live_..."
1558− className="h-8 text-xs font-mono bg-background"
1559− />
1560− </div>
1561− ) : selectedAuth.type === "basic" ? (
1562− <div className="grid grid-cols-2 gap-3">
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>
1752+ <Label className="text-xs text-muted-foreground">Connection Mode</Label>
1753+ <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }>
1754+ <SelectTrigger className="h-8 text-xs bg-background">
1755+ <SelectValue />
1756+ </SelectTrigger>
1757+ <SelectContent>
1758+ <SelectItem value="new" className="text-xs">Create new connection</SelectItem>
1759+ <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem>
1760+ </SelectContent>
1761+ </Select>
15801762 </div>
1581− ) : (
1763+ )}
1764+
1765+ {!editing && connectionMode === "existing" && (
15821766 <div className="space-y-1.5">
1583− <Label className="text-xs text-muted-foreground">Bearer Token</Label>
1584− <Input
1585− type="password"
1586− value={managedToken}
1587− onChange={(e) => setManagedToken(e.target.value)}
1588− placeholder="ghp_..."
1589− className="h-8 text-xs font-mono bg-background"
1590− />
1767+ <Label className="text-xs text-muted-foreground">Existing Connection</Label>
1768+ <Select value={existingConnectionId} onValueChange={setExistingConnectionId}>
1769+ <SelectTrigger className="h-8 text-xs bg-background">
1770+ <SelectValue placeholder="Select a connection" />
1771+ </SelectTrigger>
1772+ <SelectContent>
1773+ {compatibleConnectionOptions.map((connection) => (
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+ )}
15911783 </div>
15921784 )}
15931785
1594− <Collapsible open={advancedMode} onOpenChange={handleAdvancedModeChange}>
1595− <CollapsibleTrigger asChild>
1596− <Button variant="outline" size="sm" className="h-7 text-[11px]">
1597− Advanced JSON
1598− <ChevronRight className={cn("ml-1.5 h-3 w-3 transition-transform", advancedMode && "rotate-90")} />
1599− </Button>
1600− </CollapsibleTrigger>
1601− <CollapsibleContent className="mt-2 space-y-1.5">
1602− <Label className="text-xs text-muted-foreground">Override Secret JSON</Label>
1603− <Textarea
1604− value={secretJsonText}
1605− onChange={(e) => setSecretJsonText(e.target.value)}
1606− rows={6}
1607− className="text-xs font-mono bg-background"
1608− />
1609− </CollapsibleContent>
1610− </Collapsible>
1786+ {(editing || connectionMode === "new") && (
1787+ <>
1788+ {editing && (
1789+ <p className="text-[11px] text-muted-foreground">
1790+ Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it.
1791+ </p>
1792+ )}
1793+
1794+ {selectedAuth.type === "none" ? (
1795+ <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p>
1796+ ) : selectedAuth.type === "mixed" ? (
1797+ <p className="text-[11px] text-terminal-amber">
1798+ This source has mixed auth requirements. Link an existing connection for now.
1799+ </p>
1800+ ) : selectedAuth.type === "apiKey" ? (
1801+ <div className="space-y-1.5">
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>
16111855
16121856 <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"}
16141858 </Button>
16151859 </div>
16161860 </DialogContent>
@@ -1625,6 +1869,9 @@ function CredentialsPanel({ · 8 unmodified lines
16251869
16261870 export function ToolsView({ initialSource }: { initialSource?: string | null }) {
16271871 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);
16281875
16291876 const sources = useQuery(
16301877 convexApi.workspace.listToolSources,
@@ -1637,9 +1884,20 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 6 unmodified lines
16371884 convexApi.workspace.listCredentials,
16381885 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
16391886 );
1887+ const credentialItems: CredentialRecord[] = credentials ?? [];
16401888 const credentialsLoading = !!context && credentials === undefined;
16411889
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;
16431901
16441902 if (sessionLoading) {
16451903 return (
@@ -1654,108 +1912,113 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 8 unmodified lines
16541912 <div className="flex h-full min-h-0 flex-col gap-6">
16551913 <PageHeader
16561914 title="Tools"
1657− description="Manage sources, auth, credentials, and available tools"
1915+ description="Manage sources, auth, connections, and available tools"
16581916 />
16591917
16601918 <Tabs
1661− defaultValue={initialSource ? "inventory" : "sources"}
1919+ value={activeTab}
1920+ onValueChange={(value) => setActiveTab(value as "catalog" | "credentials" | "mcp")}
16621921 className="w-full min-h-0 flex-1"
16631922 >
16641923 <TabsList className="bg-muted/50 h-9">
1665− <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background">
1666− Sources
1667− {sources && (
1668− <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1669− {sourceItems.length}
1670− </span>
1671− )}
1924+ <TabsTrigger value="catalog" className="text-xs data-[state=active]:bg-background">
1925+ Catalog
1926+ <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1927+ {loadingTools ? "..." : tools.length}
1928+ </span>
16721929 </TabsTrigger>
16731930 <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background">
1674− Credentials
1931+ Connections
16751932 {credentials && (
16761933 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1677− {credentials.length}
1934+ {new Set(credentialItems.map((credential) => credential.id)).size}
16781935 </span>
16791936 )}
16801937 </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>
16871938 <TabsTrigger value="mcp" className="text-xs data-[state=active]:bg-background">
16881939 MCP Setup
16891940 </TabsTrigger>
16901941 </TabsList>
16911942
1692− <TabsContent value="sources" className="mt-4">
1693− <Card className="bg-card border-border">
1943+ <TabsContent value="catalog" className="mt-4 min-h-0">
1944+ <Card className="bg-card border-border min-h-0 flex flex-col">
16941945 <CardHeader className="pb-3">
1695− <div className="flex items-center justify-between">
1946+ <div className="flex items-center justify-between gap-3">
16961947 <CardTitle className="text-sm font-medium flex items-center gap-2">
1697− <Server className="h-4 w-4 text-muted-foreground" />
1698− Tool Sources
1948+ <Globe className="h-4 w-4 text-muted-foreground" />
1949+ 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>
16991953 </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>
17011972 </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>
17021978 </CardHeader>
1703− <CardContent className="pt-0">
1979+ <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3">
17041980 {sourcesLoading ? (
17051981 <div className="space-y-2">
1706− {Array.from({ length: 3 }).map((_, i) => (
1982+ {Array.from({ length: 2 }).map((_, i) => (
17071983 <Skeleton key={i} className="h-16" />
17081984 ))}
17091985 </div>
1710− ) : sourceItems.length === 0 ? (
1711− <div className="flex flex-col items-center justify-center py-12 gap-3">
1712− <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
1713− <Wrench className="h-6 w-6 text-muted-foreground/40" />
1714− </div>
1715− <p className="text-sm text-muted-foreground">
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
1986+ ) : null}
1987+
1988+ {!sourcesLoading && sourceItems.length === 0 ? (
1989+ <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2.5">
1990+ <p className="text-[11px] text-muted-foreground">
1991+ No external sources yet. Add MCP, OpenAPI, or GraphQL to expand available tools.
17201992 </p>
17211993 </div>
1722− ) : (
1723− <div className="space-y-2">
1724− {warnings.length > 0 && (
1725− <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/10 px-3 py-2.5">
1726− <div className="flex items-center gap-1.5 text-[11px] font-medium text-terminal-amber">
1727− <AlertTriangle className="h-3.5 w-3.5" />
1728− Source load warnings ({warnings.length})
1729− </div>
1730− <div className="mt-1.5 space-y-1">
1731− {warnings.slice(0, 3).map((warning: string, i: number) => (
1732− <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90">
1733− {warning}
1734− </p>
1735− ))}
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− })}
1994+ ) : null}
1995+
1996+ {selectedSourceRecord ? (
1997+ <div className="space-y-1.5">
1998+ <p className="text-[10px] uppercase tracking-widest text-muted-foreground">Selected source</p>
1999+ <SourceCard
2000+ source={selectedSourceRecord}
2001+ quality={sourceQuality[toolSourceLabelForSource(selectedSourceRecord)]}
2002+ qualityLoading={selectedSourceRecord.type === "openapi" && !sourceQuality[toolSourceLabelForSource(selectedSourceRecord)] && refreshingTools}
2003+ credentialStats={credentialStatsForSource(selectedSourceRecord, credentialItems)}
2004+ sourceAuthProfiles={sourceAuthProfiles}
2005+ selected
2006+ onFocusSource={setSelectedSource}
2007+ />
17572008 </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>
17592022 </CardContent>
17602023 </Card>
17612024 </TabsContent>
@@ -1763,18 +2026,11 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 1 unmodified line
17632026 <TabsContent value="credentials" className="mt-4">
17642027 <CredentialsPanel
17652028 sources={sourceItems}
1766− credentials={credentials ?? []}
2029+ credentials={credentialItems}
2030+ sourceAuthProfiles={sourceAuthProfiles}
17672031 loading={credentialsLoading || sourcesLoading}
1768− />
1769− </TabsContent>
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}
2032+ focusSourceKey={focusCredentialSourceKey}
2033+ onFocusHandled={() => setFocusCredentialSourceKey(null)}
17782034 />
17792035 </TabsContent>
17802036
executor/apps/web/src/hooks/use-workspace-tools.ts+49−10
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 import { useQuery as useTanstackQuery } from "@tanstack/react-query";
44 import { useAction, useQuery as useConvexQuery } from "convex/react";
55 import { convexApi } from "@/lib/convex-api";
6−import type { OpenApiSourceQuality, ToolDescriptor } from "@/lib/types";
6+import type { OpenApiSourceQuality, SourceAuthProfile, ToolDescriptor, ToolSourceRecord } from "@/lib/types";
77 import type { Id } from "@executor/convex/_generated/dataModel";
88
99 interface WorkspaceContext {
@@ -16,8 +16,12 @@ interface WorkspaceContext { · 6 unmodified lines
1616 interface WorkspaceToolsQueryResult {
1717 tools: ToolDescriptor[];
1818 warnings: string[];
19− dtsUrls: Record<string, string>;
2019 sourceQuality: Record<string, OpenApiSourceQuality>;
20+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
21+}
22+
23+interface WorkspaceToolDtsResult {
24+ dtsUrls: Record<string, string>;
2125 }
2226
2327 /**
@@ -28,6 +32,7 @@ interface WorkspaceToolsQueryResult { · 4 unmodified lines
2832 */
2933 export function useWorkspaceTools(context: WorkspaceContext | null) {
3034 const listToolsWithWarnings = useAction(convexApi.executorNode.listToolsWithWarnings);
35+ const listToolDtsUrls = useAction(convexApi.executorNode.listToolDtsUrls);
3136
3237 // Watch tool sources reactively so we invalidate when sources change
3338 const toolSources = useConvexQuery(
@@ -35,9 +40,13 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 1 unmodified line
3540 context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
3641 );
3742
38− const { data, isLoading } = useTanstackQuery({
43+ const {
44+ data: inventoryData,
45+ isLoading: toolsLoading,
46+ isFetching: toolsFetching,
47+ } = useTanstackQuery({
3948 queryKey: [
40− "workspace-tools",
49+ "workspace-tools-inventory",
4150 context?.workspaceId,
4251 context?.actorId,
4352 context?.clientId,
@@ -45,7 +54,7 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 1 unmodified line
4554 ],
4655 queryFn: async (): Promise<WorkspaceToolsQueryResult> => {
4756 if (!context) {
48− return { tools: [], warnings: [], dtsUrls: {}, sourceQuality: {} };
57+ return { tools: [], warnings: [], sourceQuality: {}, sourceAuthProfiles: {} };
4958 }
5059 return await listToolsWithWarnings({
5160 workspaceId: context.workspaceId,
@@ -55,15 +64,45 @@ export function useWorkspaceTools(context: WorkspaceContext | null) { · 3 unmodified lines
5564 });
5665 },
5766 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,
5892 });
5993
6094 return {
61− tools: data?.tools ?? [],
62− warnings: data?.warnings ?? [],
95+ tools: inventoryData?.tools ?? [],
96+ warnings: inventoryData?.warnings ?? [],
6397 /** 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 ?? {},
6599 /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */
66− sourceQuality: data?.sourceQuality ?? {},
67− loading: !!context && isLoading,
100+ sourceQuality: inventoryData?.sourceQuality ?? {},
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,
68107 };
69108 }
executor/apps/web/src/lib/types.ts+14−3
@@ -6,10 +6,19 @@ export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_ · 5 unmodified lines
66 export type ApprovalStatus = "pending" | "approved" | "denied";
77 export type PolicyDecision = "allow" | "require_approval" | "deny";
88 export type CredentialScope = "workspace" | "actor";
9−export type CredentialProvider = "managed" | "workos-vault";
9+export type CredentialProvider = "local-convex" | "workos-vault";
1010 export type ToolApprovalMode = "auto" | "required";
1111 export type ToolSourceType = "mcp" | "openapi" | "graphql";
1212
13+export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
14+
15+export interface SourceAuthProfile {
16+ type: SourceAuthType;
17+ mode?: CredentialScope;
18+ header?: string;
19+ inferred: boolean;
20+}
21+
1322 export interface TaskRecord {
1423 id: string;
1524 code: string;
@@ -25,8 +34,7 @@ export interface TaskRecord { · 9 unmodified lines
2534 startedAt?: number;
2635 completedAt?: number;
2736 error?: string;
28− stdout?: string;
29− stderr?: string;
37+ result?: unknown;
3038 exitCode?: number;
3139 }
3240
@@ -69,10 +77,13 @@ export interface AccessPolicyRecord { · 36 unmodified lines
6977
7078 export interface CredentialRecord {
7179 id: string;
80+ bindingId?: string;
7281 workspaceId: string;
7382 sourceKey: string;
7483 scope: CredentialScope;
7584 actorId?: string;
85+ overridesJson?: Record<string, unknown>;
86+ boundAuthFingerprint?: string;
7687 provider: CredentialProvider;
7788 secretJson: Record<string, unknown>;
7889 createdAt: number;