History

Refine web tool source configuration UX

Rhys Sullivan committed 8 months ago

Commit 5a76db71 parent b11f3f234 files · +1361 −890Browse files

Unified · Split

34 files
  1. executor/apps/web/src/app/globals.css +8 −1
  2. executor/apps/web/src/components/app-shell.tsx +2 −2
  3. executor/apps/web/src/components/approvals/approvals-view.tsx +93 −40
  4. executor/apps/web/src/components/organization/members-view.tsx +34 −19
  5. executor/apps/web/src/components/session-info.tsx +2 −2
  6. executor/apps/web/src/components/status-badge.tsx +1 −1
  7. executor/apps/web/src/components/tasks/task-composer.tsx +108 −93
  8. executor/apps/web/src/components/tasks/task-detail.tsx +18 −17
  9. executor/apps/web/src/components/tasks/task/list-item.tsx +3 −3
  10. executor/apps/web/src/components/tasks/tasks-view.tsx +77 −70
  11. executor/apps/web/src/components/tools/add/source-dialog.tsx +1 −3
  12. executor/apps/web/src/components/tools/add/source-submit.ts +36 −44
  13. executor/apps/web/src/components/tools/connection/form-dialog.tsx +19 −15
  14. executor/apps/web/src/components/tools/connection/form-panel.tsx +441 −0
  15. executor/apps/web/src/components/tools/connection/form-save.ts +0 −7
  16. executor/apps/web/src/components/tools/connection/form/dialog-form.ts +9 −9
  17. executor/apps/web/src/components/tools/credentials.tsx +180 −99
  18. executor/apps/web/src/components/tools/explorer-derived.ts +0 −9
  19. executor/apps/web/src/components/tools/explorer-groups.tsx +13 −25
  20. executor/apps/web/src/components/tools/explorer-rows.tsx +1 −34
  21. executor/apps/web/src/components/tools/explorer-toolbar.tsx +1 −50
  22. executor/apps/web/src/components/tools/explorer.tsx +1 −66
  23. executor/apps/web/src/components/tools/explorer/source-form-panel.tsx +4 −6
  24. executor/apps/web/src/components/tools/explorer/tool-detail.tsx +21 −21
  25. executor/apps/web/src/components/tools/policies.tsx +93 −91
  26. executor/apps/web/src/components/tools/storage.tsx +34 −0
  27. executor/apps/web/src/components/tools/storage_panel_sidebar.tsx +51 −30
  28. executor/apps/web/src/components/tools/storage_panel_sql_tab.tsx +36 −35
  29. executor/apps/web/src/components/tools/view.tsx +22 −58
  30. executor/apps/web/src/components/ui/textarea.tsx +20 −0
  31. executor/apps/web/src/lib/credentials/source-helpers.ts +19 −9
  32. executor/apps/web/src/lib/tools/source-helpers.ts +4 −2
  33. executor/apps/web/src/lib/types.ts +9 −23
  34. executor/apps/web/src/lib/url-state/tools.ts +0 −6
executor/apps/web/src/app/globals.css+8−1
@@ -150,6 +150,14 @@ · 149 unmodified lines
150150 }
151151 }
152152
153+@media (max-width: 768px) {
154+ input,
155+ textarea,
156+ select {
157+ font-size: 16px !important;
158+ }
159+}
160+
153161 /* ── Shiki / Streamdown code block theme overrides ─────────────────────────── */
154162 /* Light mode: use shiki-light tokens */
155163 [style*="--shiki-light"] {
@@ -493,4 +501,3 @@ · 337 unmodified lines
493501 margin: 0;
494502 padding: 0 0 0 1rem;
495503 }
496−
executor/apps/web/src/components/app-shell.tsx+2−2
@@ -117,7 +117,7 @@ function RepoStarLink() { · 116 unmodified lines
117117 href={EXECUTOR_REPO_URL}
118118 target="_blank"
119119 rel="noreferrer"
120− className="group flex items-center justify-between px-1 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
120+ className="group flex items-center justify-between px-1 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
121121 >
122122 <span className="flex items-center gap-1.5">
123123 <Github className="h-3.5 w-3.5" />
@@ -134,7 +134,7 @@ function FeedbackLink() { · 10 unmodified lines
134134 href={TWITTER_FEEDBACK_URL}
135135 target="_blank"
136136 rel="noreferrer"
137− className="group flex items-center justify-between px-1 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
137+ className="group flex items-center justify-between px-1 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
138138 >
139139 <span className="flex items-center gap-1.5">
140140 <MessageCircle className="h-3.5 w-3.5" />
executor/apps/web/src/components/approvals/approvals-view.tsx+93−40
@@ -67,25 +67,25 @@ function ApprovalCard({ · 66 unmodified lines
6767 );
6868
6969 return (
70− <Card className="rounded-none bg-card/40 border-border/50 border-l-2 border-l-terminal-amber glow-amber">
70+ <Card className="rounded-none border-border/50 border-l-2 border-l-primary/40 bg-card/40">
7171 <CardContent className="p-4 space-y-3">
7272 {/* Header */}
7373 <div className="flex items-start justify-between gap-3">
7474 <div className="min-w-0">
7575 <div className="flex items-center gap-2">
76− <ShieldCheck className="h-4 w-4 text-terminal-amber shrink-0" />
77− <span className="text-sm font-mono font-medium text-foreground truncate">
76+ <ShieldCheck className="h-4 w-4 shrink-0 text-primary" />
77+ <span className="truncate text-sm font-medium text-foreground">
7878 {approval.toolPath}
7979 </span>
8080 </div>
8181 <div className="flex items-center gap-2 mt-1 pl-6">
82− <span className="text-[11px] text-muted-foreground">
82+ <span className="text-xs text-muted-foreground">
8383 Task: {approval.taskId}
8484 </span>
85− <span className="text-[11px] text-muted-foreground">
85+ <span className="text-xs text-muted-foreground">
8686 &middot;
8787 </span>
88− <span className="text-[11px] text-muted-foreground">
88+ <span className="text-xs text-muted-foreground">
8989 {formatTimeAgo(approval.createdAt)}
9090 </span>
9191 </div>
@@ -106,7 +106,7 @@ function ApprovalCard({ · 14 unmodified lines
106106 {/* Input */}
107107 {inputDisplay && (
108108 <div>
109− <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1.5">
109+ <span className="mb-1.5 block text-xs font-medium text-muted-foreground">
110110 Input
111111 </span>
112112 <FormattedCodeBlock
@@ -124,7 +124,7 @@ function ApprovalCard({ · 11 unmodified lines
124124 <Button
125125 onClick={() => handleResolve("approved")}
126126 disabled={resolving !== null}
127− className="flex-1 bg-terminal-green/15 text-terminal-green border border-terminal-green/30 hover:bg-terminal-green/25 h-9"
127+ className="h-9 flex-1 border border-primary/30 bg-primary/10 text-primary hover:bg-primary/20"
128128 variant="outline"
129129 size="sm"
130130 >
@@ -134,7 +134,7 @@ function ApprovalCard({ · 3 unmodified lines
134134 <Button
135135 onClick={() => handleResolve("denied")}
136136 disabled={resolving !== null}
137− className="flex-1 bg-terminal-red/15 text-terminal-red border border-terminal-red/30 hover:bg-terminal-red/25 h-9"
137+ className="h-9 flex-1 border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20"
138138 variant="outline"
139139 size="sm"
140140 >
@@ -158,13 +158,16 @@ export function ApprovalsView() { · 17 unmodified lines
158158
159159 if (sessionLoading) {
160160 return (
161− <div className="p-4 md:p-6 lg:p-8 space-y-6">
162− <Skeleton className="h-8 w-48" />
163− <div className="space-y-4">
161+ <div className="flex h-full min-h-0 overflow-hidden bg-background">
162+ <div className="w-72 shrink-0 border-r border-border/40 p-3 space-y-3">
163+ <Skeleton className="h-8 w-full" />
164164 {Array.from({ length: 3 }).map((_, i) => (
165− <Skeleton key={i} className="h-40" />
165+ <Skeleton key={i} className="h-16" />
166166 ))}
167167 </div>
168+ <div className="flex-1 p-4">
169+ <Skeleton className="h-40" />
170+ </div>
168171 </div>
169172 );
170173 }
@@ -172,40 +175,90 @@ export function ApprovalsView() { · 1 unmodified line
172175 const count = approvals?.length ?? 0;
173176
174177 return (
175− <div className="flex h-full min-h-0 flex-col p-4 md:p-6 lg:p-8">
176− {approvalsLoading ? (
177− <div className="space-y-4">
178− {Array.from({ length: 3 }).map((_, i) => (
179− <Skeleton key={i} className="h-40" />
180− ))}
178+ <div className="flex h-full min-h-0 overflow-hidden bg-background">
179+ {/* ── Left sidebar: approval list ── */}
180+ <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-80">
181+ {/* Sidebar header */}
182+ <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
183+ <div className="flex items-center gap-2">
184+ <ShieldCheck className="h-4 w-4 text-muted-foreground" />
185+ <h3 className="text-sm font-medium leading-none">Approvals</h3>
186+ </div>
187+ <p className="text-[10px] text-muted-foreground mt-1">
188+ {count} pending
189+ </p>
190+ </div>
191+
192+ {/* Approval items in sidebar */}
193+ <div className="flex-1 min-h-0 overflow-y-auto">
194+ {approvalsLoading ? (
195+ <div className="space-y-2 p-3">
196+ {Array.from({ length: 3 }).map((_, i) => (
197+ <Skeleton key={i} className="h-16" />
198+ ))}
199+ </div>
200+ ) : count === 0 ? (
201+ <div className="flex flex-col items-center justify-center py-16 gap-3 px-4">
202+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
203+ <CheckCircle2 className="h-5 w-5 text-primary/60" />
204+ </div>
205+ <p className="text-xs text-muted-foreground text-center">No pending approvals</p>
206+ <p className="text-[10px] text-muted-foreground/60 text-center">
207+ Tool calls requiring approval will appear here
208+ </p>
209+ </div>
210+ ) : (
211+ <div className="p-2 space-y-1">
212+ {(approvals ?? []).map((a: PendingApprovalRecord) => (
213+ <div
214+ key={a.id}
215+ className="rounded-md border border-border/40 bg-background/70 px-2.5 py-2 transition-colors hover:bg-accent/20"
216+ >
217+ <div className="flex items-center gap-2">
218+ <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-primary" />
219+ <span className="text-xs font-medium truncate">{a.toolPath}</span>
220+ </div>
221+ <div className="flex items-center gap-2 mt-1 pl-5">
222+ <span className="text-[10px] text-muted-foreground truncate">
223+ {formatTimeAgo(a.createdAt)}
224+ </span>
225+ <TaskStatusBadge status={a.task.status} />
226+ </div>
227+ </div>
228+ ))}
229+ </div>
230+ )}
181231 </div>
182− ) : count === 0 ? (
183− <Card className="rounded-none bg-card/40 border-border/50 flex-1">
184− <CardContent className="flex flex-col items-center justify-center py-20 gap-3">
185− <div className="h-12 w-12 rounded-full bg-terminal-green/10 flex items-center justify-center">
186− <CheckCircle2 className="h-6 w-6 text-terminal-green/60" />
232+ </aside>
233+
234+ {/* ── Right content: full approval cards ── */}
235+ <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50">
236+ {approvalsLoading ? (
237+ <div className="p-4 space-y-4">
238+ {Array.from({ length: 3 }).map((_, i) => (
239+ <Skeleton key={i} className="h-40" />
240+ ))}
241+ </div>
242+ ) : count === 0 ? (
243+ <div className="flex h-full items-center justify-center">
244+ <div className="text-center space-y-2">
245+ <CheckCircle2 className="h-8 w-8 text-primary/40 mx-auto" />
246+ <p className="text-sm text-muted-foreground">All clear</p>
247+ <p className="text-[11px] text-muted-foreground/60">No approvals waiting for review</p>
187248 </div>
188− <p className="text-sm text-muted-foreground">
189− No pending approvals
190− </p>
191− <p className="text-[11px] text-muted-foreground/60">
192− Tool calls requiring approval will appear here
193− </p>
194− </CardContent>
195− </Card>
196− ) : (
197− <div className="flex min-h-0 flex-1 flex-col max-w-2xl">
198− <div className="mb-3 flex items-center gap-2 text-xs text-muted-foreground">
199− <Clock className="h-3.5 w-3.5" />
200− {count} pending approval{count !== 1 ? "s" : ""}
201249 </div>
202− <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
250+ ) : (
251+ <div className="p-4 sm:p-6 space-y-4 max-w-3xl">
252+ <div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
253+ <Clock className="h-3.5 w-3.5" />
254+ {count} pending approval{count !== 1 ? "s" : ""}
255+ </div>
203256 {(approvals ?? []).map((a: PendingApprovalRecord) => (
204257 <ApprovalCard key={a.id} approval={a} />
205258 ))}
206259 </div>
207− </div>
208− )}
260+ )}
261+ </div>
209262 </div>
210263 );
211264 }
executor/apps/web/src/components/organization/members-view.tsx+34−19
@@ -7,6 +7,13 @@ import { PageHeader } from "@/components/page-header"; · 6 unmodified lines
77 import { Button } from "@/components/ui/button";
88 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
99 import { Input } from "@/components/ui/input";
10+import {
11+ Select,
12+ SelectContent,
13+ SelectItem,
14+ SelectTrigger,
15+ SelectValue,
16+} from "@/components/ui/select";
1017 import { useSession } from "@/lib/session-context";
1118 import { convexApi } from "@/lib/convex-api";
1219 import type { Id } from "@executor/database/convex/_generated/dataModel";
@@ -159,18 +166,22 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 146 unmodified lines
159166 type="email"
160167 disabled={!canManageMembers || inviteState === "sending"}
161168 />
162− <select
163− className="h-9 rounded-md border border-input bg-background px-3 text-sm"
169+ <Select
164170 value={inviteRole}
165− onChange={(event) => setInviteRole(event.target.value as Role)}
171+ onValueChange={(value) => setInviteRole(value as Role)}
166172 disabled={!canManageMembers || inviteState === "sending"}
167173 >
168− {ROLE_OPTIONS.map((role) => (
169− <option key={role} value={role}>
170− {role}
171− </option>
172− ))}
173− </select>
174+ <SelectTrigger className="h-9 min-w-40">
175+ <SelectValue />
176+ </SelectTrigger>
177+ <SelectContent>
178+ {ROLE_OPTIONS.map((role) => (
179+ <SelectItem key={role} value={role}>
180+ {role}
181+ </SelectItem>
182+ ))}
183+ </SelectContent>
184+ </Select>
174185 <Button
175186 type="submit"
176187 disabled={!canManageMembers || inviteState === "sending" || inviteEmail.trim().length === 0}
@@ -207,11 +218,10 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 30 unmodified lines
207218 <p className="text-xs text-muted-foreground">Status: {member.status}</p>
208219 </div>
209220 <div className="flex flex-wrap items-center gap-2">
210− <select
211− className="h-8 rounded-md border border-input bg-background px-2 text-xs"
221+ <Select
212222 value={member.role}
213223 disabled={!canManageMembers || busyMemberAccountId === member.accountId}
214− onChange={async (event) => {
224+ onValueChange={async (value) => {
215225 setBusyMemberAccountId(member.accountId);
216226 try {
217227 if (!typedOrganizationId) {
@@ -220,7 +230,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 2 unmodified lines
220230 await updateRole({
221231 organizationId: typedOrganizationId,
222232 accountId: member.accountId,
223− role: event.target.value as Role,
233+ role: value as Role,
224234 sessionId: context?.sessionId ?? undefined,
225235 });
226236 } finally {
@@ -228,12 +238,17 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 1 unmodified line
228238 }
229239 }}
230240 >
231− {ROLE_OPTIONS.map((role) => (
232− <option key={role} value={role}>
233− {role}
234− </option>
235− ))}
236− </select>
241+ <SelectTrigger className="h-8 min-w-36">
242+ <SelectValue />
243+ </SelectTrigger>
244+ <SelectContent>
245+ {ROLE_OPTIONS.map((role) => (
246+ <SelectItem key={role} value={role}>
247+ {role}
248+ </SelectItem>
249+ ))}
250+ </SelectContent>
251+ </Select>
237252
238253 <Button
239254 variant="outline"
executor/apps/web/src/components/session-info.tsx+2−2
@@ -96,7 +96,7 @@ export function SessionInfo() { · 95 unmodified lines
9696 if (loading) {
9797 return (
9898 <div className="border-t border-border px-3 py-2">
99− <span className="text-[11px] font-mono text-muted-foreground">Loading session...</span>
99+ <span className="text-xs text-muted-foreground">Loading session...</span>
100100 </div>
101101 );
102102 }
@@ -121,7 +121,7 @@ export function SessionInfo() { · 18 unmodified lines
121121 referrerPolicy="no-referrer"
122122 />
123123 ) : (
124− <span className="h-6 w-6 rounded-full border border-border bg-muted text-[10px] font-mono text-muted-foreground flex items-center justify-center">
124+ <span className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-muted text-xs text-muted-foreground">
125125 {avatarInitial}
126126 </span>
127127 )}
executor/apps/web/src/components/status-badge.tsx+1−1
@@ -41,7 +41,7 @@ export function TaskStatusBadge({ status }: { status: TaskStatus }) { · 40 unmodified lines
4141 return (
4242 <Badge
4343 variant="outline"
44− className={cn("text-[10px] font-mono uppercase tracking-wider", config.className)}
44+ className={cn("text-xs font-medium", config.className)}
4545 >
4646 {status === "running" && (
4747 <span className="inline-block h-1.5 w-1.5 rounded-full bg-current mr-1.5 pulse-dot" />
executor/apps/web/src/components/tasks/task-composer.tsx+108−93
@@ -1,10 +1,9 @@
11 "use client";
22
3−import { useEffect, useMemo, useRef, useState } from "react";
3+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44 import { Send } from "lucide-react";
55 import { useAction } from "convex/react";
66 import { toast } from "sonner";
7−import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
87 import { Button } from "@/components/ui/button";
98 import { Input } from "@/components/ui/input";
109 import { Label } from "@/components/ui/label";
@@ -142,6 +141,8 @@ export function TaskComposer() { · 131 unmodified lines
142141 : runtimeTargets[0]?.id ?? "";
143142 const selectedRuntime = runtimeTargets.find((runtime) => runtime.id === effectiveRuntimeId);
144143 const showRuntimeSelector = runtimeTargets.length > 1;
144+ const isMac = useMemo(() => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent), []);
145+ const modKey = isMac ? "⌘" : "Ctrl";
145146
146147 useEffect(() => {
147148 const draft = readCodeDraftForWorkspace(storageWorkspaceId);
@@ -178,7 +179,9 @@ export function TaskComposer() { · 30 unmodified lines
178179 };
179180 }, [codeDraftStorageKey]);
180181
181− const handleSubmit = async () => {
182+ const handleSubmitRef = useRef<(() => void) | undefined>(undefined);
183+
184+ const handleSubmit = useCallback(async () => {
182185 if (!context || !code.trim()) return;
183186 setSubmitting(true);
184187 try {
@@ -213,79 +216,91 @@ export function TaskComposer() { · 28 unmodified lines
213216 } finally {
214217 setSubmitting(false);
215218 }
216− };
219+ }, [context, code, effectiveRuntimeId, timeoutMs, createTask, submitting]);
220+
221+ handleSubmitRef.current = handleSubmit;
222+
223+ useEffect(() => {
224+ const onKeyDown = (e: KeyboardEvent) => {
225+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
226+ e.preventDefault();
227+ handleSubmitRef.current?.();
228+ }
229+ };
230+ window.addEventListener("keydown", onKeyDown);
231+ return () => window.removeEventListener("keydown", onKeyDown);
232+ }, []);
217233
218234 return (
219− <Card className="bg-card border-border">
220− <CardHeader className="pb-3">
221− <CardTitle className="text-sm font-medium">Editor</CardTitle>
222− </CardHeader>
223− <CardContent className="space-y-4">
224− <div className="grid grid-cols-2 gap-3">
225− <div className="space-y-1.5">
226− {showRuntimeSelector ? <Label className="text-xs text-muted-foreground">Runtime</Label> : null}
227− {showRuntimeSelector ? (
228− <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>
229− <SelectTrigger className="h-8 text-xs font-mono bg-background">
230− <SelectValue />
231− </SelectTrigger>
232− <SelectContent>
233− {runtimeTargets.map((r: RuntimeTargetDescriptor) => (
234− <SelectItem key={r.id} value={r.id} className="text-xs">
235− {r.label}
236− </SelectItem>
237− ))}
238− </SelectContent>
239− </Select>
240− ) : (
241− null
242− )}
243− </div>
244− <div className="space-y-1.5">
245− <Label className="text-xs text-muted-foreground">
246− Timeout (ms)
247− </Label>
235+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
236+ {/* ── Toolbar ── */}
237+ <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border/40 px-4 py-2">
238+ <div className="flex items-center gap-3">
239+ <h3 className="text-sm font-medium">Editor</h3>
240+ <span className="text-[10px] font-mono text-muted-foreground">
241+ {loadingTools
242+ ? "Loading tool inventory..."
243+ : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`}
244+ </span>
245+ </div>
246+ <div className="flex items-center gap-3">
247+ {showRuntimeSelector ? (
248+ <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>
249+ <SelectTrigger className="h-7 text-xs font-mono bg-background w-[160px]">
250+ <SelectValue />
251+ </SelectTrigger>
252+ <SelectContent>
253+ {runtimeTargets.map((r: RuntimeTargetDescriptor) => (
254+ <SelectItem key={r.id} value={r.id} className="text-xs">
255+ {r.label}
256+ </SelectItem>
257+ ))}
258+ </SelectContent>
259+ </Select>
260+ ) : null}
261+ <div className="flex items-center gap-1.5">
262+ <Label className="text-[10px] text-muted-foreground shrink-0">Timeout</Label>
248263 <Input
249264 type="number"
250265 value={timeoutMs}
251266 onChange={(e) => setTimeoutMs(e.target.value)}
252− className="h-8 text-xs font-mono bg-background"
267+ className="h-7 text-xs font-mono bg-background w-[100px]"
253268 />
254269 </div>
270+ <Button
271+ onClick={handleSubmit}
272+ disabled={submitting || !code.trim()}
273+ className="h-7 bg-primary text-primary-foreground hover:bg-primary/90 text-xs gap-1.5 px-3"
274+ size="sm"
275+ >
276+ <Send className="h-3 w-3" />
277+ {submitting ? "Executing..." : "Execute"}
278+ <kbd className="ml-1 pointer-events-none inline-flex items-center gap-0.5 rounded border border-primary-foreground/20 bg-primary-foreground/10 px-1 py-0.5 font-mono text-[10px] font-medium text-primary-foreground/70">
279+ {modKey}↵
280+ </kbd>
281+ </Button>
255282 </div>
256− <div className="space-y-1.5">
257− <div className="flex items-center justify-between gap-2">
258− <Label className="text-xs text-muted-foreground">Code</Label>
259− <span className="text-[10px] font-mono text-muted-foreground">
260− {loadingTools
261− ? "Loading tool inventory..."
262− : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`}
263− </span>
264− </div>
265− <div className="rounded-md border border-border">
266− <CodeEditor
267− value={code}
268− onChange={handleCodeChange}
269− tools={tools}
270− typesUrl={typesUrl}
271− stateStorageKey={editorViewStateStorageKey}
272− height="400px"
273− />
274− </div>
283+ </div>
284+
285+ {/* ── Main content area ── */}
286+ <div className="flex flex-1 min-h-0 overflow-hidden">
287+ {/* Code editor - fills available space */}
288+ <div className="flex-1 min-w-0 min-h-0 flex flex-col" style={{ borderRight: lastExecution ? '1px solid hsl(var(--border) / 0.4)' : undefined }}>
289+ <CodeEditor
290+ value={code}
291+ onChange={handleCodeChange}
292+ tools={tools}
293+ typesUrl={typesUrl}
294+ stateStorageKey={editorViewStateStorageKey}
295+ className="h-full"
296+ height="100%"
297+ />
275298 </div>
276− <Button
277− onClick={handleSubmit}
278− disabled={submitting || !code.trim()}
279− className="w-full bg-primary text-primary-foreground hover:bg-primary/90 h-9"
280− size="sm"
281− >
282− <Send className="h-3.5 w-3.5 mr-2" />
283− {submitting ? "Executing..." : "Execute Task"}
284− </Button>
285299
300+ {/* Result panel - right side */}
286301 {lastExecution && (
287− <div className="space-y-2">
288− <div className="flex items-center justify-between gap-2">
302+ <div className="w-[400px] lg:w-[480px] shrink-0 flex flex-col min-h-0 overflow-hidden">
303+ <div className="shrink-0 flex items-center justify-between gap-2 border-b border-border/30 px-3 py-2">
289304 <span className="text-[10px] uppercase tracking-widest text-muted-foreground">
290305 Last execution
291306 </span>
@@ -293,36 +308,36 @@ export function TaskComposer() { · 1 unmodified line
293308 {lastExecution.status} - {lastExecution.taskId}
294309 </span>
295310 </div>
296−
297− {lastExecution.result !== undefined && (
298− <div>
299− <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
300− Returned result
301− </span>
302− <FormattedCodeBlock
303− content={lastExecution.result}
304− language="json"
305− className="min-h-40 max-h-[65vh] overflow-auto resize-y"
306− />
307− </div>
308− )}
309−
310− {lastExecution.error && (
311− <div>
312− <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
313− Error
314− </span>
315− <FormattedCodeBlock
316− content={lastExecution.error}
317− language="text"
318− tone="red"
319− className="min-h-32 max-h-[50vh] overflow-auto resize-y"
320− />
321− </div>
322− )}
311+ <div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
312+ {lastExecution.result !== undefined && (
313+ <div>
314+ <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
315+ Returned result
316+ </span>
317+ <FormattedCodeBlock
318+ content={lastExecution.result}
319+ language="json"
320+ className="min-h-40 max-h-full overflow-auto resize-y"
321+ />
322+ </div>
323+ )}
324+ {lastExecution.error && (
325+ <div>
326+ <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
327+ Error
328+ </span>
329+ <FormattedCodeBlock
330+ content={lastExecution.error}
331+ language="text"
332+ tone="red"
333+ className="min-h-32 max-h-full overflow-auto resize-y"
334+ />
335+ </div>
336+ )}
337+ </div>
323338 </div>
324339 )}
325− </CardContent>
326− </Card>
340+ </div>
341+ </div>
327342 );
328343 }
executor/apps/web/src/components/tasks/task-detail.tsx+18−17
@@ -99,9 +99,10 @@ export function TaskDetail({ · 98 unmodified lines
9999 <Card className="bg-card border-border">
100100 <CardHeader className="pb-3">
101101 <div className="flex items-center justify-between">
102− <CardTitle className="text-sm font-medium font-mono truncate pr-4">
103− {liveTask.id}
104− </CardTitle>
102+ <div className="min-w-0 pr-4">
103+ <CardTitle className="truncate text-sm font-semibold">Task details</CardTitle>
104+ <p className="truncate text-xs text-muted-foreground">{liveTask.id}</p>
105+ </div>
105106 <Button
106107 variant="ghost"
107108 size="icon"
@@ -116,19 +117,19 @@ export function TaskDetail({ · 8 unmodified lines
116117 <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
117118 {[
118119 { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> },
119− { label: "Runtime", value: <span className="font-mono text-xs">{runtimeLabel ?? liveTask.runtimeId}</span> },
120− { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> },
120+ { label: "Runtime", value: <span className="text-sm">{runtimeLabel ?? liveTask.runtimeId}</span> },
121+ { label: "Duration", value: <span className="text-sm">{duration}</span> },
121122 {
122123 label: "Exit Code",
123124 value: (
124− <span className={cn("font-mono text-xs", liveTask.exitCode === 0 ? "text-terminal-green" : liveTask.exitCode ? "text-terminal-red" : "text-muted-foreground")}>
125+ <span className={cn("text-sm", liveTask.exitCode === 0 ? "text-primary" : liveTask.exitCode ? "text-destructive" : "text-muted-foreground")}>
125126 {liveTask.exitCode ?? "—"}
126127 </span>
127128 ),
128129 },
129130 ].map((item) => (
130131 <div key={item.label}>
131− <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1">
132+ <span className="mb-1 block text-xs font-medium text-muted-foreground">
132133 {item.label}
133134 </span>
134135 {item.value}
@@ -141,10 +142,10 @@ export function TaskDetail({ · 6 unmodified lines
141142 <Separator />
142143 <div className="space-y-2.5">
143144 <div className="flex items-center justify-between">
144− <span className="text-[10px] uppercase tracking-widest text-terminal-amber">
145+ <span className="text-xs font-medium text-primary">
145146 Pending approvals
146147 </span>
147− <span className="text-[10px] font-mono bg-terminal-amber/10 text-terminal-amber px-1.5 py-0.5 rounded">
148+ <span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
148149 {pendingApprovals.length}
149150 </span>
150151 </div>
@@ -154,14 +155,14 @@ export function TaskDetail({ · 3 unmodified lines
154155 return (
155156 <div
156157 key={approval.id}
157− className="rounded-md border border-terminal-amber/30 bg-terminal-amber/6 p-3 space-y-2"
158+ className="space-y-2 rounded-md border border-primary/20 bg-primary/5 p-3"
158159 >
159160 <div className="flex items-center justify-between gap-3">
160161 <div className="min-w-0">
161− <p className="text-xs font-mono text-foreground truncate">
162+ <p className="truncate text-sm font-medium text-foreground">
162163 {approval.toolPath}
163164 </p>
164− <p className="text-[10px] text-muted-foreground">
165+ <p className="text-xs text-muted-foreground">
165166 Requested {formatDate(approval.createdAt)}
166167 </p>
167168 </div>
@@ -169,7 +170,7 @@ export function TaskDetail({ · 1 unmodified line
169170 <Button
170171 variant="outline"
171172 size="sm"
172− className="h-7 text-[11px] border-terminal-green/30 text-terminal-green hover:bg-terminal-green/10"
173+ className="h-7 border-primary/30 bg-primary/10 text-primary hover:bg-primary/20"
173174 disabled={resolvingApprovalId !== null}
174175 onClick={() =>
175176 void handleResolveApproval(
@@ -185,7 +186,7 @@ export function TaskDetail({ · 9 unmodified lines
185186 <Button
186187 variant="outline"
187188 size="sm"
188− className="h-7 text-[11px] border-terminal-red/30 text-terminal-red hover:bg-terminal-red/10"
189+ className="h-7 border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20"
189190 disabled={resolvingApprovalId !== null}
190191 onClick={() =>
191192 void handleResolveApproval(
@@ -217,7 +218,7 @@ export function TaskDetail({ · 25 unmodified lines
217218 <Separator />
218219
219220 <div>
220− <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2">
221+ <span className="mb-2 block text-xs font-medium text-muted-foreground">
221222 Code
222223 </span>
223224 <FormattedCodeBlock
@@ -229,7 +230,7 @@ export function TaskDetail({ · 5 unmodified lines
229230
230231 {liveResult && (
231232 <div>
232− <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
233+ <span className="mb-2 block text-xs font-medium text-primary">
233234 Result
234235 </span>
235236 <FormattedCodeBlock
@@ -242,7 +243,7 @@ export function TaskDetail({ · 6 unmodified lines
242243
243244 {liveTask.error && (
244245 <div>
245− <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
246+ <span className="mb-2 block text-xs font-medium text-destructive">
246247 Error
247248 </span>
248249 <FormattedCodeBlock
executor/apps/web/src/components/tasks/task/list-item.tsx+3−3
@@ -34,15 +34,15 @@ export function TaskListItem({ task, selected, onClick, runtimeLabel = task.runt · 33 unmodified lines
3434 >
3535 <div className="flex-1 min-w-0">
3636 <div className="flex items-center gap-2">
37− <span className="text-xs font-mono text-foreground truncate">
37+ <span className="truncate text-sm font-medium text-foreground">
3838 {task.id}
3939 </span>
4040 </div>
4141 <div className="flex items-center gap-2 mt-0.5">
42− <span className="text-[10px] font-mono text-muted-foreground">
42+ <span className="text-xs text-muted-foreground">
4343 {runtimeLabel}
4444 </span>
45− <span className="text-[10px] text-muted-foreground">
45+ <span className="text-xs text-muted-foreground">
4646 {formatDate(task.createdAt)}
4747 </span>
4848 </div>
executor/apps/web/src/components/tasks/tasks-view.tsx+77−70
@@ -4,7 +4,6 @@ import { useCallback } from "react"; · 3 unmodified lines
44 import { useQueryStates } from "nuqs";
55 import { useNavigate } from "@/lib/router";
66 import { ShieldCheck } from "lucide-react";
7−import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
87 import { Button } from "@/components/ui/button";
98 import { Skeleton } from "@/components/ui/skeleton";
109 import { TaskDetail } from "@/components/tasks/task-detail";
@@ -59,83 +58,91 @@ export function TasksView() { · 48 unmodified lines
5958
6059 if (sessionLoading) {
6160 return (
62− <div className="p-4 md:p-6 lg:p-8 space-y-6">
63− <Skeleton className="h-8 w-48" />
64− <Skeleton className="h-64" />
61+ <div className="flex h-full min-h-0 overflow-hidden bg-background">
62+ <div className="w-80 shrink-0 border-r border-border/40 p-3 space-y-3">
63+ <Skeleton className="h-8 w-full" />
64+ {Array.from({ length: 4 }).map((_, i) => (
65+ <Skeleton key={i} className="h-14" />
66+ ))}
67+ </div>
68+ <div className="flex-1 p-4">
69+ <Skeleton className="h-64" />
70+ </div>
6571 </div>
6672 );
6773 }
6874
6975 return (
70− <div className="flex h-full min-h-0 flex-col p-4 md:p-6 lg:p-8">
71− <div className="mb-4 flex items-center justify-end gap-2">
72− <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => navigate("/approvals")}>
73− <ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
74− {pendingApprovals.length} pending
75− </Button>
76− </div>
77−
78− <div className="flex min-h-0 flex-1 flex-col">
79− <div className="grid h-full min-h-0 gap-6 lg:grid-cols-[minmax(360px,440px)_1fr]">
80− <Card className="rounded-none bg-card/40 border-border/50 min-h-0 flex flex-col">
81− <CardHeader className="pb-3">
82− <CardTitle className="text-sm font-medium flex items-center gap-2">
83− Task History
84− {tasks && (
85− <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
86− {taskItems.length}
87− </span>
88− )}
89− </CardTitle>
90− </CardHeader>
91− <CardContent className="pt-0 min-h-0 flex-1">
92− {tasksLoading ? (
93− <div className="space-y-2">
94− {Array.from({ length: 4 }).map((_, i) => (
95− <Skeleton key={i} className="h-14" />
96− ))}
97− </div>
98− ) : taskItems.length === 0 ? (
99− <div className="flex flex-col items-center justify-center py-12 text-sm text-muted-foreground gap-2">
100− <p>No tasks yet.</p>
101− </div>
102− ) : (
103− <div className="h-full overflow-y-auto space-y-1 pr-1">
104− {taskItems.map((task: TaskRecord) => (
105− <TaskListItem
106− key={task.id}
107− task={task}
108− selected={task.id === selectedId}
109− runtimeLabel={getTaskRuntimeLabel(task.runtimeId, runtimeItems)}
110− onClick={() => selectTask(task.id)}
111− />
112− ))}
113− </div>
76+ <div className="flex h-full min-h-0 overflow-hidden bg-background">
77+ {/* ── Left sidebar: task list ── */}
78+ <aside className="flex h-full w-80 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-[360px]">
79+ {/* Sidebar header */}
80+ <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
81+ <div className="flex items-center justify-between">
82+ <div className="flex items-center gap-2">
83+ <h3 className="text-sm font-medium leading-none">Tasks</h3>
84+ {tasks && (
85+ <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
86+ {taskItems.length}
87+ </span>
11488 )}
115− </CardContent>
116− </Card>
117−
118− <div className="min-h-0">
119− {selectedTask && context ? (
120− <TaskDetail
121− task={selectedTask}
122− workspaceId={context.workspaceId}
123− sessionId={context?.sessionId}
124− runtimeLabel={getTaskRuntimeLabel(selectedTask.runtimeId, runtimeItems)}
125− pendingApprovals={selectedTaskApprovals}
126− onClose={() => selectTask(null)}
127− />
128− ) : (
129− <Card className="rounded-none bg-card/40 border-border/50 h-full">
130− <CardContent className="flex items-center justify-center py-24">
131− <p className="text-sm text-muted-foreground">
132− Select a task to view logs, output, and approval actions
133− </p>
134− </CardContent>
135− </Card>
136− )}
89+ </div>
90+ <Button variant="outline" size="sm" className="h-7 text-[10px] gap-1" onClick={() => navigate("/approvals")}>
91+ <ShieldCheck className="h-3 w-3" />
92+ {pendingApprovals.length} pending
93+ </Button>
13794 </div>
13895 </div>
96+
97+ {/* Task list */}
98+ <div className="flex-1 min-h-0 overflow-y-auto">
99+ {tasksLoading ? (
100+ <div className="space-y-2 p-3">
101+ {Array.from({ length: 4 }).map((_, i) => (
102+ <Skeleton key={i} className="h-14" />
103+ ))}
104+ </div>
105+ ) : taskItems.length === 0 ? (
106+ <div className="flex flex-col items-center justify-center py-16 text-sm text-muted-foreground gap-2 px-4">
107+ <p>No tasks yet.</p>
108+ <p className="text-[11px] text-muted-foreground/60">
109+ Execute a task from the editor to see it here.
110+ </p>
111+ </div>
112+ ) : (
113+ <div className="p-2 space-y-1">
114+ {taskItems.map((task: TaskRecord) => (
115+ <TaskListItem
116+ key={task.id}
117+ task={task}
118+ selected={task.id === selectedId}
119+ runtimeLabel={getTaskRuntimeLabel(task.runtimeId, runtimeItems)}
120+ onClick={() => selectTask(task.id)}
121+ />
122+ ))}
123+ </div>
124+ )}
125+ </div>
126+ </aside>
127+
128+ {/* ── Right content: task detail ── */}
129+ <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50">
130+ {selectedTask && context ? (
131+ <TaskDetail
132+ task={selectedTask}
133+ workspaceId={context.workspaceId}
134+ sessionId={context?.sessionId}
135+ runtimeLabel={getTaskRuntimeLabel(selectedTask.runtimeId, runtimeItems)}
136+ pendingApprovals={selectedTaskApprovals}
137+ onClose={() => selectTask(null)}
138+ />
139+ ) : (
140+ <div className="flex h-full items-center justify-center">
141+ <p className="text-sm text-muted-foreground">
142+ Select a task to view logs, output, and approval actions
143+ </p>
144+ </div>
145+ )}
139146 </div>
140147 </div>
141148 );
executor/apps/web/src/components/tools/add/source-dialog.tsx+1−3
@@ -85,9 +85,8 @@ export function AddSourceDialog({ · 84 unmodified lines
8585 trigger?: ReactNode;
8686 }) {
8787 const { context } = useSession();
88− const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
88+ const upsertToolSource = useAction(convexApi.workspace.upsertToolSource);
8989 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
90− const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
9190 const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade);
9291 const credentials = useQuery(
9392 convexApi.workspace.listCredentials,
@@ -218,7 +217,6 @@ export function AddSourceDialog({ · 124 unmodified lines
218217 sourceToEdit,
219218 credentialsLoading,
220219 upsertToolSource,
221− upsertCredential,
222220 form: {
223221 name: form.name,
224222 endpoint: form.endpoint,
executor/apps/web/src/components/tools/add/source-submit.ts+36−44
@@ -1,4 +1,3 @@
1−import { sourceKeyForSource } from "@/lib/tools/source-helpers";
21 import type {
32 AnonymousContext,
43 CredentialRecord,
@@ -18,16 +17,12 @@ type UpsertToolSourceFn = (args: { · 13 unmodified lines
1817 name: string;
1918 type: SourceType;
2019 config: Record<string, unknown>;
21−}) => Promise<unknown>;
22−
23−type UpsertCredentialFn = (args: {
24− id?: CredentialRecord["id"];
25− workspaceId: AnonymousContext["workspaceId"];
26− sessionId: AnonymousContext["sessionId"];
27− scopeType?: CredentialScope;
28− sourceKey: string;
29− accountId?: AnonymousContext["accountId"];
30− secretJson: Record<string, unknown>;
20+ credential?: {
21+ id?: CredentialRecord["id"];
22+ scopeType?: CredentialScope;
23+ accountId?: AnonymousContext["accountId"];
24+ secretJson: Record<string, unknown>;
25+ };
3126 }) => Promise<unknown>;
3227
3328 type SaveFormSnapshot = {
@@ -63,46 +58,29 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines
6358 form,
6459 credentialsLoading,
6560 upsertToolSource,
66− upsertCredential,
6761 }: {
6862 context: AnonymousContext;
6963 sourceToEdit?: ToolSourceRecord;
7064 form: SaveFormSnapshot;
7165 credentialsLoading: boolean;
7266 upsertToolSource: UpsertToolSourceFn;
73− upsertCredential: UpsertCredentialFn;
7467 }): Promise<{ source: ToolSourceRecord; connected: boolean }> {
7568 const authConfig = form.type === "openapi" || form.type === "graphql" || form.type === "mcp"
7669 ? form.buildAuthConfig()
7770 : undefined;
7871
79− const config = createCustomSourceConfig({
80− type: form.type,
81− endpoint: form.endpoint.trim(),
82− baseUrl: form.baseUrl,
83− auth: authConfig,
84− mcpTransport: form.mcpTransport,
85− accountId: context.accountId,
86− });
87−
88− const created = await upsertToolSource({
89− ...(sourceToEdit ? { id: sourceToEdit.id } : {}),
90− workspaceId: context.workspaceId,
91− sessionId: context.sessionId,
92− scopeType: form.scopeType,
93− name: form.name.trim(),
94− type: form.type,
95− config,
96− }) as ToolSourceRecord;
97−
9872 let linkedCredential = false;
73+ let credentialPayload:
74+ | {
75+ id?: CredentialRecord["id"];
76+ scopeType: CredentialScope;
77+ accountId?: AnonymousContext["accountId"];
78+ secretJson: Record<string, unknown>;
79+ }
80+ | undefined;
9981
10082 if ((form.type === "openapi" || form.type === "graphql" || form.type === "mcp") && form.authType !== "none") {
10183 const credentialScopeType = credentialScopeTypeForAuthScope(form.authScope, form.scopeType);
102− const sourceKey = sourceKeyForSource(created);
103− if (!sourceKey) {
104− throw new Error("Failed to resolve source key for credentials");
105− }
10684
10785 if (form.authScope === "account" && !context.accountId) {
10886 throw new Error("Account credentials require an authenticated account");
@@ -119,30 +97,44 @@ export async function saveSourceWithCredentials({ · 10 unmodified lines
11997 throw new Error(secret.error ?? "Credential values are required");
12098 }
12199
122− await upsertCredential({
100+ credentialPayload = {
123101 ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}),
124− workspaceId: context.workspaceId,
125− sessionId: context.sessionId,
126102 scopeType: credentialScopeType,
127− sourceKey,
128103 ...(credentialScopeType === "account" ? { accountId: context.accountId } : {}),
129104 secretJson: secret.value,
130− });
105+ };
131106 linkedCredential = true;
132107 } else if (form.existingScopedCredential) {
133108 if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) {
134− // Existing credential doesn't match — save source without credentials.
135109 linkedCredential = false;
136110 } else {
137111 linkedCredential = true;
138112 }
139113 } else {
140− // No credentials entered and none saved — save source without credentials.
141− // The caller will show a warning toast.
142114 linkedCredential = false;
143115 }
144116 }
145117
118+ const config = createCustomSourceConfig({
119+ type: form.type,
120+ endpoint: form.endpoint.trim(),
121+ baseUrl: form.baseUrl,
122+ auth: authConfig,
123+ mcpTransport: form.mcpTransport,
124+ accountId: context.accountId,
125+ });
126+
127+ const created = await upsertToolSource({
128+ ...(sourceToEdit ? { id: sourceToEdit.id } : {}),
129+ workspaceId: context.workspaceId,
130+ sessionId: context.sessionId,
131+ scopeType: form.scopeType,
132+ name: form.name.trim(),
133+ type: form.type,
134+ config,
135+ ...(credentialPayload ? { credential: credentialPayload } : {}),
136+ }) as ToolSourceRecord;
137+
146138 return {
147139 source: created,
148140 connected: linkedCredential,
executor/apps/web/src/components/tools/connection/form-dialog.tsx+19−15
@@ -30,10 +30,10 @@ import type { · 29 unmodified lines
3030 } from "@/lib/types";
3131 import {
3232 connectionDisplayName,
33− parseHeaderOverrideEntries,
34− parseHeaderOverrideRows,
35− serializeHeaderOverrideRows,
36− type HeaderOverrideEntry,
33+ parseAdditionalHeaderEntries,
34+ parseAdditionalHeaderRows,
35+ serializeAdditionalHeaderRows,
36+ type AdditionalHeaderEntry,
3737 } from "@/lib/credentials/source-helpers";
3838 import { sourceForCredentialKey } from "@/lib/tools/source-helpers";
3939 import { ConnectionAuthFields } from "./auth-fields";
@@ -67,7 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines
6767 const { context, clientConfig } = useSession();
6868 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
6969 const [saving, setSaving] = useState(false);
70− const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries(""));
70+ const [headerRows, setHeaderRows] = useState<AdditionalHeaderEntry[]>(() => parseAdditionalHeaderEntries(""));
7171 const {
7272 sourceKey,
7373 scopeType,
@@ -80,7 +80,7 @@ export function ConnectionFormDialog({ · 6 unmodified lines
8080 apiKeyValue,
8181 basicUsername,
8282 basicPassword,
83− customHeadersText,
83+ additionalHeadersText,
8484 sourceOptions,
8585 connectionOptions,
8686 compatibleConnectionOptions,
@@ -94,7 +94,7 @@ export function ConnectionFormDialog({ · 7 unmodified lines
9494 setApiKeyValue,
9595 setBasicUsername,
9696 setBasicPassword,
97− setCustomHeadersText,
97+ setAdditionalHeadersText,
9898 handleSourceKeyChange,
9999 } = useConnectionFormDialogForm({
100100 open,
@@ -125,8 +125,8 @@ export function ConnectionFormDialog({ · 24 unmodified lines
125125 return;
126126 }
127127
128− setHeaderRows(parseHeaderOverrideEntries(customHeadersText));
129− }, [open, customHeadersText]);
128+ setHeaderRows(parseAdditionalHeaderEntries(additionalHeadersText));
129+ }, [open, additionalHeadersText]);
130130
131131 const handleSave = async () => {
132132 if (!context) {
@@ -141,14 +141,14 @@ export function ConnectionFormDialog({ · 8 unmodified lines
141141 return;
142142 }
143143
144− const parsedHeaders = parseHeaderOverrideRows(headerRows);
144+ const parsedHeaders = parseAdditionalHeaderRows(headerRows);
145145 if (!parsedHeaders.value) {
146− toast.error(parsedHeaders.error ?? "Invalid header overrides");
146+ toast.error(parsedHeaders.error ?? "Invalid additional headers");
147147 return;
148148 }
149149
150− const serializedHeaders = serializeHeaderOverrideRows(headerRows);
151− setCustomHeadersText(serializedHeaders);
150+ const serializedHeaders = serializeAdditionalHeaderRows(headerRows);
151+ setAdditionalHeadersText(serializedHeaders);
152152
153153 const linkExisting = !editing && connectionMode === "existing";
154154 if (linkExisting && !existingConnectionKey) {
@@ -185,7 +185,6 @@ export function ConnectionFormDialog({ · 30 unmodified lines
185185 basicPassword,
186186 apiKeyValue,
187187 tokenValue,
188− parsedHeaders: parsedHeaders.value,
189188 });
190189
191190 if (!secretResult.secretJson) {
@@ -200,6 +199,10 @@ export function ConnectionFormDialog({ · 8 unmodified lines
200199 : scopeType === "organization"
201200 ? "organization"
202201 : "workspace";
202+ const additionalHeaders = Object.entries(parsedHeaders.value).map(([name, value]) => ({
203+ name,
204+ value,
205+ }));
203206
204207 await upsertCredential({
205208 ...(editing ? { id: editing.id } : selectedExistingConnection ? { id: selectedExistingConnection.id } : {}),
@@ -209,6 +212,7 @@ export function ConnectionFormDialog({ · 3 unmodified lines
209212 scopeType: credentialScopeType,
210213 ...(credentialScopeType === "account" ? { accountId: accountId.trim() as typeof context.accountId } : {}),
211214 secretJson: secretResult.secretJson,
215+ additionalHeaders,
212216 });
213217
214218 toast.success(connectionSuccessCopy(Boolean(editing), linkExisting));
@@ -231,7 +235,7 @@ export function ConnectionFormDialog({ · 16 unmodified lines
231235 });
232236 };
233237
234− const updateHeaderRow = (index: number, patch: Partial<HeaderOverrideEntry>) => {
238+ const updateHeaderRow = (index: number, patch: Partial<AdditionalHeaderEntry>) => {
235239 setHeaderRows((current) =>
236240 current.map((entry, rowIndex) => {
237241 if (rowIndex !== index) {
executor/apps/web/src/components/tools/connection/form-panel.tsx+441−0
@@ -0,0 +1,441 @@
1+"use client";
2+
3+import { type ChangeEvent, useEffect, useState } from "react";
4+import { useAction } from "convex/react";
5+import { toast } from "sonner";
6+import { Button } from "@/components/ui/button";
7+import { Input } from "@/components/ui/input";
8+import { Label } from "@/components/ui/label";
9+import { Badge } from "@/components/ui/badge";
10+import { Plus, Trash2, X } from "lucide-react";
11+import {
12+ Select,
13+ SelectContent,
14+ SelectItem,
15+ SelectTrigger,
16+ SelectValue,
17+} from "@/components/ui/select";
18+import { useSession } from "@/lib/session-context";
19+import { convexApi } from "@/lib/convex-api";
20+import type {
21+ CredentialRecord,
22+ SourceAuthProfile,
23+ ToolSourceRecord,
24+} from "@/lib/types";
25+import {
26+ connectionDisplayName,
27+ parseAdditionalHeaderEntries,
28+ parseAdditionalHeaderRows,
29+ serializeAdditionalHeaderRows,
30+ type AdditionalHeaderEntry,
31+} from "@/lib/credentials/source-helpers";
32+import { sourceForCredentialKey } from "@/lib/tools/source-helpers";
33+import { ConnectionAuthFields } from "./auth-fields";
34+import { type ConnectionMode } from "./form/dialog-state";
35+import { useConnectionFormDialogForm } from "./form/dialog-form";
36+import {
37+ buildSecretJson,
38+ connectionSubmitCopy,
39+ connectionSuccessCopy,
40+} from "./form-save";
41+
42+export function ConnectionFormPanel({
43+ editing,
44+ initialSourceKey,
45+ sources,
46+ credentials,
47+ sourceAuthProfiles,
48+ loadingSourceNames = [],
49+ onClose,
50+ onSaved,
51+}: {
52+ editing: CredentialRecord | null;
53+ initialSourceKey?: string | null;
54+ sources: ToolSourceRecord[];
55+ credentials: CredentialRecord[];
56+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
57+ loadingSourceNames?: string[];
58+ onClose: () => void;
59+ onSaved?: () => void;
60+}) {
61+ const { context, clientConfig } = useSession();
62+ const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
63+ const [saving, setSaving] = useState(false);
64+ const [headerRows, setHeaderRows] = useState<AdditionalHeaderEntry[]>(() => parseAdditionalHeaderEntries(""));
65+ const {
66+ sourceKey,
67+ scopeType,
68+ scopePreset,
69+ scope,
70+ accountId,
71+ connectionMode,
72+ existingConnectionKey,
73+ tokenValue,
74+ apiKeyValue,
75+ basicUsername,
76+ basicPassword,
77+ additionalHeadersText,
78+ sourceOptions,
79+ connectionOptions,
80+ compatibleConnectionOptions,
81+ selectedAuth,
82+ authBadge,
83+ setScopePreset,
84+ setAccountId,
85+ setConnectionMode,
86+ setExistingConnectionKey,
87+ setTokenValue,
88+ setApiKeyValue,
89+ setBasicUsername,
90+ setBasicPassword,
91+ setAdditionalHeadersText,
92+ handleSourceKeyChange,
93+ } = useConnectionFormDialogForm({
94+ open: true,
95+ editing,
96+ initialSourceKey,
97+ sources,
98+ credentials,
99+ sourceAuthProfiles,
100+ accountIdFallback: context?.accountId,
101+ });
102+
103+ const storageCopy = clientConfig?.authProviderMode === "workos"
104+ ? "Stored encrypted"
105+ : "Stored locally on this machine";
106+ const selectedSource = sourceForCredentialKey(sources, sourceKey);
107+ const selectedSourceName = selectedSource?.name;
108+ const authDetectionPending =
109+ !editing
110+ && selectedAuth.type === "none"
111+ && Boolean(selectedSource)
112+ && Boolean(selectedSource?.type === "openapi" || selectedSource?.type === "graphql")
113+ && selectedSource?.config.auth === undefined
114+ && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false);
115+ const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge;
116+
117+ useEffect(() => {
118+ setHeaderRows(parseAdditionalHeaderEntries(additionalHeadersText));
119+ }, [additionalHeadersText]);
120+
121+ const handleSave = async () => {
122+ if (!context) {
123+ return;
124+ }
125+ if (!sourceKey.trim()) {
126+ toast.error("Choose an API source");
127+ return;
128+ }
129+ if (scope === "account" && !accountId.trim()) {
130+ toast.error("Account ID is required for personal credentials");
131+ return;
132+ }
133+
134+ const parsedHeaders = parseAdditionalHeaderRows(headerRows);
135+ if (!parsedHeaders.value) {
136+ toast.error(parsedHeaders.error ?? "Invalid additional headers");
137+ return;
138+ }
139+
140+ const serializedHeaders = serializeAdditionalHeaderRows(headerRows);
141+ setAdditionalHeadersText(serializedHeaders);
142+
143+ const linkExisting = !editing && connectionMode === "existing";
144+ if (linkExisting && !existingConnectionKey) {
145+ toast.error("Select saved credentials");
146+ return;
147+ }
148+ if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.key === existingConnectionKey)) {
149+ toast.error("Selected credentials do not match this scope");
150+ return;
151+ }
152+ const selectedExistingConnection = linkExisting
153+ ? compatibleConnectionOptions.find((connection) => connection.key === existingConnectionKey) ?? null
154+ : null;
155+
156+ if (selectedAuth.type === "none") {
157+ if (authDetectionPending) {
158+ toast.error("Still detecting auth from the API spec. Try again in a few seconds.");
159+ return;
160+ }
161+ toast.error("This source is currently set to no auth. Configure auth before adding a connection.");
162+ return;
163+ }
164+
165+ if (selectedAuth.type === "mixed" && !linkExisting && !editing) {
166+ toast.error("This API uses mixed auth and must reuse saved credentials");
167+ return;
168+ }
169+
170+ const secretResult = buildSecretJson({
171+ selectedAuthType: selectedAuth.type,
172+ linkExisting,
173+ editing: Boolean(editing),
174+ basicUsername,
175+ basicPassword,
176+ apiKeyValue,
177+ tokenValue,
178+ });
179+
180+ if (!secretResult.secretJson) {
181+ toast.error(secretResult.error ?? "Failed to prepare secret payload");
182+ return;
183+ }
184+
185+ setSaving(true);
186+ try {
187+ const credentialScopeType = scope === "account"
188+ ? "account"
189+ : scopeType === "organization"
190+ ? "organization"
191+ : "workspace";
192+ const additionalHeaders = Object.entries(parsedHeaders.value).map(([name, value]) => ({
193+ name,
194+ value,
195+ }));
196+
197+ await upsertCredential({
198+ ...(editing ? { id: editing.id } : selectedExistingConnection ? { id: selectedExistingConnection.id } : {}),
199+ workspaceId: context.workspaceId,
200+ sessionId: context.sessionId,
201+ sourceKey: sourceKey.trim(),
202+ scopeType: credentialScopeType,
203+ ...(credentialScopeType === "account" ? { accountId: accountId.trim() as typeof context.accountId } : {}),
204+ secretJson: secretResult.secretJson,
205+ additionalHeaders,
206+ });
207+
208+ toast.success(connectionSuccessCopy(Boolean(editing), linkExisting));
209+ onSaved?.();
210+ onClose();
211+ } catch (err) {
212+ toast.error(err instanceof Error ? err.message : "Failed to save connection");
213+ } finally {
214+ setSaving(false);
215+ }
216+ };
217+
218+ const addHeaderRow = () => {
219+ setHeaderRows((current) => [...current, { key: "", value: "" }]);
220+ };
221+
222+ const removeHeaderRow = (index: number) => {
223+ setHeaderRows((current) => {
224+ const next = current.filter((_, rowIndex) => rowIndex !== index);
225+ return next.length === 0 ? [{ key: "", value: "" }] : next;
226+ });
227+ };
228+
229+ const updateHeaderRow = (index: number, patch: Partial<AdditionalHeaderEntry>) => {
230+ setHeaderRows((current) =>
231+ current.map((entry, rowIndex) => {
232+ if (rowIndex !== index) {
233+ return entry;
234+ }
235+ return {
236+ ...entry,
237+ ...patch,
238+ };
239+ }),
240+ );
241+ };
242+
243+ return (
244+ <div className="flex h-full flex-col">
245+ {/* Panel header */}
246+ <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border/30 px-4 py-2.5">
247+ <h3 className="text-sm font-medium">
248+ {editing ? "Update Connection" : "New Connection"}
249+ </h3>
250+ <Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}>
251+ <X className="h-3.5 w-3.5" />
252+ </Button>
253+ </div>
254+
255+ {/* Form content */}
256+ <div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-5">
257+ <div className="max-w-lg space-y-4">
258+ <div className="space-y-1.5">
259+ <Label className="text-xs text-muted-foreground">API Source</Label>
260+ {sourceOptions.length > 0 ? (
261+ <Select value={sourceKey} onValueChange={handleSourceKeyChange}>
262+ <SelectTrigger className="h-8 text-xs bg-background">
263+ <SelectValue placeholder="Select a source" />
264+ </SelectTrigger>
265+ <SelectContent>
266+ {sourceOptions.map((entry) => (
267+ <SelectItem key={entry.key} value={entry.key} className="text-xs">
268+ {entry.label}
269+ </SelectItem>
270+ ))}
271+ </SelectContent>
272+ </Select>
273+ ) : (
274+ <Input
275+ value={sourceKey}
276+ onChange={(e) => handleSourceKeyChange(e.target.value)}
277+ placeholder="Enter source id"
278+ className="h-8 text-xs font-mono bg-background"
279+ />
280+ )}
281+ </div>
282+
283+ <div className="rounded-md border border-border/70 bg-muted/30 px-2.5 py-2">
284+ <div className="flex items-center gap-2 flex-wrap">
285+ <span className="text-[11px] text-muted-foreground">Detected auth</span>
286+ <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
287+ {detectedAuthLabel}
288+ </Badge>
289+ {selectedAuth.inferred && !authDetectionPending && (
290+ <Badge variant="outline" className="text-[9px] uppercase tracking-wider">
291+ inferred from spec
292+ </Badge>
293+ )}
294+ </div>
295+ {authDetectionPending && (
296+ <p className="text-[10px] text-muted-foreground mt-1">
297+ This spec is still loading. Auth settings will auto-fill once parsing completes.
298+ </p>
299+ )}
300+ {selectedAuth.type === "apiKey" && selectedAuth.header && (
301+ <p className="text-[10px] text-muted-foreground mt-1">
302+ API key header: <span className="font-mono">{selectedAuth.header}</span>
303+ </p>
304+ )}
305+ </div>
306+
307+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
308+ <div className="space-y-1.5">
309+ <Label className="text-xs text-muted-foreground">Scope</Label>
310+ <Select value={scopePreset} onValueChange={(value) => setScopePreset(value as "only_me" | "workspace" | "organization") }>
311+ <SelectTrigger className="h-8 text-xs bg-background">
312+ <SelectValue />
313+ </SelectTrigger>
314+ <SelectContent>
315+ <SelectItem value="only_me" className="text-xs">Only me</SelectItem>
316+ <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
317+ <SelectItem value="organization" className="text-xs">Organization</SelectItem>
318+ </SelectContent>
319+ </Select>
320+ </div>
321+ <div className="space-y-1.5">
322+ <Label className="text-xs text-muted-foreground">Storage</Label>
323+ <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" />
324+ </div>
325+ </div>
326+
327+ {scopePreset === "only_me" && (
328+ <div className="space-y-1.5">
329+ <Label className="text-xs text-muted-foreground">Account ID</Label>
330+ <Input
331+ value={accountId}
332+ onChange={(e) => setAccountId(e.target.value)}
333+ placeholder="account_123"
334+ className="h-8 text-xs font-mono bg-background"
335+ />
336+ </div>
337+ )}
338+
339+ {!editing && connectionOptions.length > 0 && (
340+ <div className="space-y-1.5">
341+ <Label className="text-xs text-muted-foreground">How to connect</Label>
342+ <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as ConnectionMode)}>
343+ <SelectTrigger className="h-8 text-xs bg-background">
344+ <SelectValue />
345+ </SelectTrigger>
346+ <SelectContent>
347+ <SelectItem value="new" className="text-xs">Enter new credentials</SelectItem>
348+ <SelectItem value="existing" className="text-xs">Reuse saved credentials</SelectItem>
349+ </SelectContent>
350+ </Select>
351+ </div>
352+ )}
353+
354+ {!editing && connectionMode === "existing" && (
355+ <div className="space-y-1.5">
356+ <Label className="text-xs text-muted-foreground">Saved Credentials</Label>
357+ <Select value={existingConnectionKey} onValueChange={setExistingConnectionKey}>
358+ <SelectTrigger className="h-8 text-xs bg-background">
359+ <SelectValue placeholder="Select saved credentials" />
360+ </SelectTrigger>
361+ <SelectContent>
362+ {compatibleConnectionOptions.map((connection) => (
363+ <SelectItem key={connection.key} value={connection.key} className="text-xs">
364+ {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"})
365+ </SelectItem>
366+ ))}
367+ </SelectContent>
368+ </Select>
369+ {compatibleConnectionOptions.length === 0 && (
370+ <p className="text-[10px] text-muted-foreground">No compatible saved credentials for this scope.</p>
371+ )}
372+ </div>
373+ )}
374+
375+ <ConnectionAuthFields
376+ editing={Boolean(editing)}
377+ connectionMode={connectionMode}
378+ selectedAuth={selectedAuth}
379+ tokenValue={tokenValue}
380+ apiKeyValue={apiKeyValue}
381+ basicUsername={basicUsername}
382+ basicPassword={basicPassword}
383+ onTokenValueChange={setTokenValue}
384+ onApiKeyValueChange={setApiKeyValue}
385+ onBasicUsernameChange={setBasicUsername}
386+ onBasicPasswordChange={setBasicPassword}
387+ />
388+
389+ <div className="space-y-2">
390+ <div className="flex items-center justify-between gap-2">
391+ <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label>
392+ <Button type="button" variant="outline" size="sm" onClick={addHeaderRow} className="h-7 px-2 text-[11px]">
393+ <Plus className="h-3.5 w-3.5 mr-1.5" />
394+ Add header
395+ </Button>
396+ </div>
397+ <div className="space-y-2">
398+ {headerRows.map((headerRow, index) => (
399+ <div key={`${headerRow.key || "header"}-${index}`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2 items-start">
400+ <Input
401+ value={headerRow.key}
402+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
403+ updateHeaderRow(index, { key: event.target.value })
404+ }
405+ placeholder="Header"
406+ className="h-8 text-xs font-mono bg-background"
407+ />
408+ <Input
409+ value={headerRow.value}
410+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
411+ updateHeaderRow(index, { value: event.target.value })
412+ }
413+ placeholder="Value"
414+ className="h-8 text-xs font-mono bg-background"
415+ />
416+ <Button
417+ type="button"
418+ variant="outline"
419+ size="sm"
420+ className="h-8 px-2"
421+ disabled={headerRows.length === 1}
422+ onClick={() => void removeHeaderRow(index)}
423+ >
424+ <Trash2 className="h-3.5 w-3.5" />
425+ </Button>
426+ </div>
427+ ))}
428+ </div>
429+ <p className="text-[10px] text-muted-foreground">
430+ Add one `Header` and `Value` pair per line.
431+ </p>
432+ </div>
433+
434+ <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm">
435+ {connectionSubmitCopy(Boolean(editing), saving, connectionMode)}
436+ </Button>
437+ </div>
438+ </div>
439+ </div>
440+ );
441+}
executor/apps/web/src/components/tools/connection/form-save.ts+0−7
@@ -8,7 +8,6 @@ type BuildSecretJsonParams = { · 7 unmodified lines
88 basicPassword: string;
99 apiKeyValue: string;
1010 tokenValue: string;
11− parsedHeaders: Record<string, string>;
1211 };
1312
1413 export function buildSecretJson({
@@ -19,7 +18,6 @@ export function buildSecretJson({ · 4 unmodified lines
1918 basicPassword,
2019 apiKeyValue,
2120 tokenValue,
22− parsedHeaders,
2321 }: BuildSecretJsonParams): { secretJson?: Record<string, unknown>; error?: string } {
2422 const secretJson: Record<string, unknown> = {};
2523 if (!linkExisting) {
@@ -43,11 +41,6 @@ export function buildSecretJson({ · 17 unmodified lines
4341 }
4442 }
4543 }
46−
47− if (Object.keys(parsedHeaders).length > 0) {
48− secretJson.__headers = parsedHeaders;
49− }
50−
5144 if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) {
5245 if (selectedAuthType === "basic") {
5346 return { error: "Username and password are required" };
executor/apps/web/src/components/tools/connection/form/dialog-form.ts+9−9
@@ -6,7 +6,7 @@ import type { · 5 unmodified lines
66 ToolSourceRecord,
77 } from "@/lib/types";
88 import {
9− formatHeaderOverrides,
9+ formatAdditionalHeaders,
1010 sourceAuthForKey,
1111 } from "@/lib/credentials/source-helpers";
1212 import {
@@ -40,7 +40,7 @@ type FormState = { · 27 unmodified lines
4040 apiKeyValue: string;
4141 basicUsername: string;
4242 basicPassword: string;
43− customHeadersText: string;
43+ additionalHeadersText: string;
4444 };
4545
4646 type SharingScope = "only_me" | "workspace" | "organization";
@@ -102,7 +102,7 @@ function initialFormState({ · 55 unmodified lines
102102 apiKeyValue: "",
103103 basicUsername: "",
104104 basicPassword: "",
105− customHeadersText: formatHeaderOverrides(editing.overridesJson),
105+ additionalHeadersText: formatAdditionalHeaders(editing.additionalHeaders),
106106 };
107107 }
108108
@@ -120,7 +120,7 @@ function initialFormState({ · 11 unmodified lines
120120 apiKeyValue: "",
121121 basicUsername: "",
122122 basicPassword: "",
123− customHeadersText: "",
123+ additionalHeadersText: "",
124124 };
125125 }
126126
@@ -157,7 +157,7 @@ export function useConnectionFormDialogForm({ · 30 unmodified lines
157157 apiKeyValue,
158158 basicUsername,
159159 basicPassword,
160− customHeadersText,
160+ additionalHeadersText,
161161 } = form;
162162 const compatibleConnectionOptions = useMemo(
163163 () => compatibleConnections(connectionOptions, scopeType, scope, accountId),
@@ -253,8 +253,8 @@ export function useConnectionFormDialogForm({ · 89 unmodified lines
253253 dispatch({ type: "patch", patch: { basicPassword: nextBasicPassword } });
254254 };
255255
256− const setCustomHeadersText = (nextCustomHeadersText: string) => {
257− dispatch({ type: "patch", patch: { customHeadersText: nextCustomHeadersText } });
256+ const setAdditionalHeadersText = (nextAdditionalHeadersText: string) => {
257+ dispatch({ type: "patch", patch: { additionalHeadersText: nextAdditionalHeadersText } });
258258 };
259259
260260 return {
@@ -269,7 +269,7 @@ export function useConnectionFormDialogForm({ · 8 unmodified lines
269269 apiKeyValue,
270270 basicUsername,
271271 basicPassword,
272− customHeadersText,
272+ additionalHeadersText,
273273 sourceOptions,
274274 connectionOptions,
275275 compatibleConnectionOptions,
@@ -283,7 +283,7 @@ export function useConnectionFormDialogForm({ · 7 unmodified lines
283283 setApiKeyValue,
284284 setBasicUsername,
285285 setBasicPassword,
286− setCustomHeadersText,
286+ setAdditionalHeadersText,
287287 handleSourceKeyChange,
288288 };
289289 }
executor/apps/web/src/components/tools/credentials.tsx+180−99
@@ -1,46 +1,47 @@
11 "use client";
22
3−import { useMemo } from "react";
3+import { useMemo, useState } from "react";
44 import { KeyRound, Plus } from "lucide-react";
55 import { Button } from "@/components/ui/button";
66 import { Badge } from "@/components/ui/badge";
77 import { Skeleton } from "@/components/ui/skeleton";
8−import { useSession } from "@/lib/session-context";
98 import type {
109 CredentialRecord,
10+ SourceAuthProfile,
1111 ToolSourceScopeType,
1212 ToolSourceRecord,
1313 } from "@/lib/types";
1414 import {
1515 connectionDisplayName,
16− ownerScopeLabel,
17− providerLabel,
1816 } from "@/lib/credentials/source-helpers";
1917 import {
2018 sourceForCredentialKey,
2119 } from "@/lib/tools/source-helpers";
2220 import { SourceFavicon } from "./source-favicon";
21+import { ConnectionFormPanel } from "./connection/form-panel";
22+import { cn } from "@/lib/utils";
2323
2424 type ConnectionScope = "account" | "workspace";
2525
26+type PanelState =
27+ | { mode: "idle" }
28+ | { mode: "create"; sourceKey?: string }
29+ | { mode: "edit"; credential: CredentialRecord };
30+
2631 export function CredentialsPanel({
2732 sources,
2833 credentials,
2934 loading,
30− onCreateConnection,
31− onEditConnection,
35+ sourceAuthProfiles,
36+ loadingSourceNames,
3237 }: {
3338 sources: ToolSourceRecord[];
3439 credentials: CredentialRecord[];
3540 loading: boolean;
36− onCreateConnection: (sourceKey?: string) => void;
37− onEditConnection: (credential: CredentialRecord) => void;
41+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
42+ loadingSourceNames?: string[];
3843 }) {
39− const { clientConfig } = useSession();
40−
41− const storageCopy = clientConfig?.authProviderMode === "workos"
42− ? "Stored encrypted"
43− : "Stored locally on this machine";
44+ const [panel, setPanel] = useState<PanelState>({ mode: "idle" });
4445
4546 const connectionOptions = useMemo(() => {
4647 const grouped = new Map<string, {
@@ -91,104 +92,184 @@ export function CredentialsPanel({ · 44 unmodified lines
9192 return map;
9293 }, [credentials]);
9394
95+ const selectedConnectionKey = panel.mode === "edit"
96+ ? (() => {
97+ const scopeType = panel.credential.scopeType === "organization" ? "organization" : "workspace";
98+ return `${scopeType}:${panel.credential.id}`;
99+ })()
100+ : null;
101+
94102 return (
95− <section className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col rounded-none border border-border/50 bg-card/30">
96− <div className="flex items-center justify-between border-b border-border/40 px-4 py-3 sm:px-5">
97− <div className="flex items-center gap-2">
98− <div className="flex h-7 w-7 items-center justify-center border border-border/60 bg-background/80">
99− <KeyRound className="h-4 w-4 text-muted-foreground" />
103+ <section className="flex h-full min-h-0 w-full overflow-hidden bg-background">
104+ {/* ── Left sidebar ── */}
105+ <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72">
106+ {/* Sidebar header */}
107+ <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
108+ <div className="flex items-center justify-between">
109+ <div className="flex items-center gap-2">
110+ <KeyRound className="h-4 w-4 text-muted-foreground" />
111+ <h3 className="text-sm font-medium leading-none">Connections</h3>
112+ </div>
113+ <Button
114+ size="sm"
115+ variant="ghost"
116+ className="h-7 w-7 p-0"
117+ onClick={() => setPanel({ mode: "create" })}
118+ >
119+ <Plus className="h-3.5 w-3.5" />
120+ </Button>
100121 </div>
101− <h2 className="text-sm font-medium">Connections</h2>
102− </div>
103− <div className="flex items-center gap-2">
104− <Badge variant="outline" className="h-5 px-2 text-[10px] font-mono uppercase tracking-wide text-muted-foreground">
105− {connectionOptions.length}
106− </Badge>
107− <Button size="sm" className="h-8 text-xs" onClick={() => onCreateConnection()}>
108− <Plus className="h-3.5 w-3.5 mr-1.5" />
109− Add Connection
110− </Button>
122+ <p className="text-[10px] text-muted-foreground mt-1">
123+ {connectionOptions.length} {connectionOptions.length === 1 ? "connection" : "connections"}
124+ </p>
111125 </div>
112− </div>
113126
114− <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
115− {loading ? (
116− <div className="space-y-2">
117− {Array.from({ length: 3 }).map((_, i) => (
118− <Skeleton key={i} className="h-14" />
119− ))}
120− </div>
121− ) : connectionOptions.length === 0 ? (
122− <div className="flex h-full min-h-[280px] flex-col items-center justify-center gap-2 border border-dashed border-border/50 bg-background/50">
123− <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">
124− <KeyRound className="h-5 w-5 text-muted-foreground/50" />
127+ {/* Sidebar connection list */}
128+ <div className="flex-1 min-h-0 overflow-y-auto">
129+ {loading ? (
130+ <div className="space-y-2 p-3">
131+ {Array.from({ length: 4 }).map((_, i) => (
132+ <Skeleton key={i} className="h-12 rounded-md" />
133+ ))}
125134 </div>
126− <p className="text-sm text-muted-foreground">No connections configured</p>
127− <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
128− Add a source, then create or link a reusable connection.
129− </p>
130− </div>
131− ) : (
132− <div className="space-y-2">
133− {connectionOptions.map((connection) => {
134− const representative = representativeCredentialByConnection.get(connection.key);
135− if (!representative) {
136− return null;
137− }
138− const firstSource = sourceForCredentialKey(sources, representative.sourceKey);
139− return (
140− <div
141− key={connection.key}
142− className="group flex items-center gap-3 border border-border/50 bg-background/70 px-3 py-2.5 transition-colors hover:border-border hover:bg-accent/20"
143− style={{ boxShadow: "inset 2px 0 0 0 color-mix(in oklch, var(--border) 70%, transparent)" }}
144− >
145− <div className="flex h-8 w-8 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden">
146− {firstSource ? (
147− <SourceFavicon
148− source={firstSource}
149− iconClassName="h-4 w-4 text-muted-foreground"
150− imageClassName="w-5 h-5"
151− />
152− ) : (
153− <KeyRound className="h-4 w-4 text-muted-foreground" />
135+ ) : connectionOptions.length === 0 ? (
136+ <div className="flex flex-col items-center gap-2.5 px-4 py-10">
137+ <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">
138+ <KeyRound className="h-5 w-5 text-muted-foreground/50" />
139+ </div>
140+ <div className="text-center">
141+ <p className="text-xs text-muted-foreground">No connections</p>
142+ <p className="text-[10px] text-muted-foreground/60 mt-0.5">
143+ Add a source, then create or link a reusable connection.
144+ </p>
145+ </div>
146+ <Button
147+ variant="outline"
148+ size="sm"
149+ className="h-7 text-[11px] gap-1 mt-1"
150+ onClick={() => setPanel({ mode: "create" })}
151+ >
152+ <Plus className="h-3 w-3" />
153+ Add connection
154+ </Button>
155+ </div>
156+ ) : (
157+ <div className="p-2 space-y-1">
158+ {connectionOptions.map((connection) => {
159+ const representative = representativeCredentialByConnection.get(connection.key);
160+ if (!representative) {
161+ return null;
162+ }
163+ const firstSource = sourceForCredentialKey(sources, representative.sourceKey);
164+ const isSelected = selectedConnectionKey === connection.key;
165+ return (
166+ <button
167+ key={connection.key}
168+ type="button"
169+ onClick={() => setPanel({ mode: "edit", credential: representative })}
170+ className={cn(
171+ "group flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors",
172+ isSelected
173+ ? "border-primary/40 bg-primary/5"
174+ : "border-border/40 bg-background/70 hover:border-border hover:bg-accent/20",
154175 )}
155− </div>
156− <div className="flex-1 min-w-0">
157− <div className="mb-1 flex items-center gap-1.5 flex-wrap">
158− <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span>
159− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
160− {connection.scope}
161− </Badge>
162− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
163− {ownerScopeLabel(connection.scopeType)}
164− </Badge>
165− <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
166− {providerLabel(connection.provider)}
167− </Badge>
168− {connection.scope === "account" && connection.accountId ? (
169− <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded-sm">
170− {connection.accountId}
176+ >
177+ <div className="flex h-7 w-7 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden rounded-sm">
178+ {firstSource ? (
179+ <SourceFavicon
180+ source={firstSource}
181+ iconClassName="h-3.5 w-3.5 text-muted-foreground"
182+ imageClassName="w-4 h-4"
183+ />
184+ ) : (
185+ <KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
186+ )}
187+ </div>
188+ <div className="flex-1 min-w-0">
189+ <span className="text-xs font-medium truncate block">{connectionDisplayName(sources, connection)}</span>
190+ <div className="flex items-center gap-1 mt-0.5">
191+ <Badge variant="outline" className="text-[8px] px-1 py-0 font-mono uppercase tracking-wider">
192+ {connection.scope}
193+ </Badge>
194+ <span className="text-[9px] text-muted-foreground">
195+ {connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"}
171196 </span>
172− ) : null}
197+ </div>
173198 </div>
174− <p className="text-[11px] text-muted-foreground">
175− Linked to {connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"} - {storageCopy}
176− </p>
177− <p className="mt-0.5 text-[11px] text-muted-foreground">
178− Updated {new Date(connection.updatedAt).toLocaleString()}
179− </p>
180− </div>
199+ </button>
200+ );
201+ })}
202+ </div>
203+ )}
204+ </div>
205+ </aside>
206+
207+ {/* ── Right content panel ── */}
208+ <div className="flex-1 min-w-0 max-h-screen overflow-hidden bg-background/50">
209+ {panel.mode === "create" ? (
210+ <ConnectionFormPanel
211+ key="create"
212+ editing={null}
213+ initialSourceKey={panel.sourceKey}
214+ sources={sources}
215+ credentials={credentials}
216+ sourceAuthProfiles={sourceAuthProfiles}
217+ loadingSourceNames={loadingSourceNames}
218+ onClose={() => setPanel({ mode: "idle" })}
219+ />
220+ ) : panel.mode === "edit" ? (
221+ <ConnectionFormPanel
222+ key={`edit-${panel.credential.id}`}
223+ editing={panel.credential}
224+ sources={sources}
225+ credentials={credentials}
226+ sourceAuthProfiles={sourceAuthProfiles}
227+ loadingSourceNames={loadingSourceNames}
228+ onClose={() => setPanel({ mode: "idle" })}
229+ />
230+ ) : (
231+ <div className="flex h-full items-center justify-center">
232+ <div className="text-center space-y-3 max-w-sm px-6">
233+ <div className="flex h-12 w-12 mx-auto items-center justify-center border border-border/60 bg-muted/40 rounded-sm">
234+ <KeyRound className="h-6 w-6 text-muted-foreground/40" />
235+ </div>
236+ {loading ? (
237+ <p className="text-xs text-muted-foreground">Loading connections...</p>
238+ ) : connectionOptions.length === 0 ? (
239+ <>
240+ <p className="text-sm text-muted-foreground">No connections configured</p>
241+ <p className="text-[11px] text-muted-foreground/60">
242+ Add a source, then create or link a reusable connection to get started.
243+ </p>
181244 <Button
182− variant="outline"
183245 size="sm"
184− className="h-7 text-[11px] border-border/70 group-hover:border-border"
185− onClick={() => onEditConnection(representative)}
246+ className="h-8 text-xs gap-1.5 mt-2"
247+ onClick={() => setPanel({ mode: "create" })}
186248 >
187− Edit
249+ <Plus className="h-3.5 w-3.5" />
250+ Add Connection
188251 </Button>
189− </div>
190− );
191− })}
252+ </>
253+ ) : (
254+ <>
255+ <p className="text-sm text-muted-foreground">
256+ {connectionOptions.length} {connectionOptions.length === 1 ? "connection" : "connections"} configured
257+ </p>
258+ <p className="text-[11px] text-muted-foreground/60">
259+ Select a connection from the sidebar to edit it, or add a new one.
260+ </p>
261+ <Button
262+ size="sm"
263+ variant="outline"
264+ className="h-8 text-xs gap-1.5 mt-2"
265+ onClick={() => setPanel({ mode: "create" })}
266+ >
267+ <Plus className="h-3.5 w-3.5" />
268+ Add Connection
269+ </Button>
270+ </>
271+ )}
272+ </div>
192273 </div>
193274 )}
194275 </div>
executor/apps/web/src/components/tools/explorer-derived.ts+0−9
@@ -252,12 +252,3 @@ export function autoExpandedKeysForSearch( · 251 unmodified lines
252252
253253 return allGroupKeys;
254254 }
255−
256−export function countSelectedTools(
257− selectedKeys: Set<string>,
258− filteredTools: ToolDescriptor[],
259−): number {
260− return Array.from(selectedKeys).filter((key) =>
261− filteredTools.some((tool) => tool.path === key),
262− ).length;
263−}
executor/apps/web/src/components/tools/explorer-groups.tsx+13−25
@@ -130,7 +130,7 @@ export function GroupNode({ · 129 unmodified lines
130130
131131 <span
132132 className={cn(
133− "font-mono text-[13px] truncate",
133+ "truncate text-sm",
134134 isSource
135135 ? "font-semibold text-foreground"
136136 : "font-medium text-foreground/90",
@@ -139,9 +139,9 @@ export function GroupNode({ · 2 unmodified lines
139139 {displayLabel}
140140 </span>
141141
142− <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto flex items-center gap-2 shrink-0">
142+ <span className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground/70">
143143 {isSource && group.sourceType && (
144− <span className="uppercase tracking-wider opacity-70">
144+ <span className="opacity-80">
145145 {group.sourceType}
146146 </span>
147147 )}
@@ -226,9 +226,7 @@ export function NavGroupNode({ · 78 unmodified lines
226226 onToggle,
227227 focusedPath,
228228 focusedSource,
229− selectedKeys,
230229 onFocusTool,
231− onSelectTool,
232230 onSourceClick,
233231 source,
234232 }: {
@@ -238,9 +236,7 @@ export function NavGroupNode({ · 3 unmodified lines
238236 onToggle: (key: string) => void;
239237 focusedPath: string | null;
240238 focusedSource?: string | null;
241− selectedKeys: Set<string>;
242239 onFocusTool: (tool: ToolDescriptor) => void;
243− onSelectTool?: (path: string, e: React.MouseEvent) => void;
244240 onSourceClick?: (sourceName: string) => void;
245241 source?: ToolSourceRecord;
246242 }) {
@@ -316,11 +312,11 @@ export function NavGroupNode({ · 69 unmodified lines
316312 )}
317313 </div>
318314
319− <span className="font-mono text-[11px] font-semibold text-foreground/90 truncate">
315+ <span className="truncate text-xs font-semibold text-foreground/90">
320316 {displayLabel}
321317 </span>
322318
323− <span className="text-[9px] font-mono text-muted-foreground/50 ml-auto flex items-center gap-1 shrink-0">
319+ <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60">
324320 {isLoading ? (
325321 <Loader2 className="h-2.5 w-2.5 animate-spin" />
326322 ) : (
@@ -360,11 +356,11 @@ export function NavGroupNode({ · 33 unmodified lines
360356 )}
361357 </div>
362358
363− <span className="font-mono text-[11px] font-medium text-foreground/80 truncate">
359+ <span className="truncate text-xs font-medium text-foreground/80">
364360 {displayLabel}
365361 </span>
366362
367− <span className="text-[9px] font-mono text-muted-foreground/50 ml-auto flex items-center gap-1 shrink-0">
363+ <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground/60">
368364 {group.approvalCount > 0 && (
369365 <span className="inline-flex items-center gap-0.5 text-terminal-amber">
370366 <ShieldCheck className="h-2 w-2" />
@@ -390,9 +386,7 @@ export function NavGroupNode({ · 19 unmodified lines
390386 onToggle={onToggle}
391387 focusedPath={focusedPath}
392388 focusedSource={focusedSource}
393− selectedKeys={selectedKeys}
394389 onFocusTool={onFocusTool}
395− onSelectTool={onSelectTool}
396390 onSourceClick={onSourceClick}
397391 />
398392 );
@@ -404,13 +398,7 @@ export function NavGroupNode({ · 5 unmodified lines
404398 tool={child}
405399 label={toolDisplayOperation(child.path)}
406400 focused={child.path === focusedPath}
407− selected={selectedKeys.has(child.path)}
408401 onFocus={onFocusTool}
409− onSelect={
410− onSelectTool
411− ? (e) => onSelectTool(child.path, e)
412− : undefined
413− }
414402 depth={depth + 1}
415403 />
416404 );
@@ -568,7 +556,7 @@ export function SourceSidebar({ · 151 unmodified lines
568556 <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">
569557 <div className="px-3 pb-2 pt-1">
570558 <div className="flex items-center justify-between">
571− <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50">
559+ <p className="text-xs font-medium text-muted-foreground/70">
572560 Sources
573561 </p>
574562 {onRegenerate ? (
@@ -597,7 +585,7 @@ export function SourceSidebar({ · 22 unmodified lines
597585 </div>
598586 <p
599587 className={cn(
600− "mt-1 flex items-center gap-1 text-[10px] font-mono",
588+ "mt-1 flex items-center gap-1 text-xs",
601589 inventoryStatus.tone === "loading"
602590 ? "text-terminal-amber/90"
603591 : inventoryStatus.tone === "error"
@@ -644,8 +632,8 @@ export function SourceSidebar({ · 40 unmodified lines
644632 className="h-3 w-3 shrink-0 text-muted-foreground"
645633 />
646634 )}
647− <span className="font-mono font-medium truncate">{g.name}</span>
648− <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60 flex items-center gap-1">
635+ <span className="truncate font-medium">{g.name}</span>
636+ <span className="ml-auto flex items-center gap-1 text-xs tabular-nums opacity-70">
649637 {g.warningCount > 0 ? (
650638 <span
651639 className="inline-flex items-center gap-0.5 text-terminal-amber"
@@ -690,12 +678,12 @@ export function SourceSidebar({ · 38 unmodified lines
690678
691679 {activeSource && activeSourceWarnings.length > 0 ? (
692680 <div className="mt-2 rounded-md border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-2">
693− <p className="text-[10px] font-mono text-terminal-amber/90">
681+ <p className="text-xs font-medium text-terminal-amber/90">
694682 {activeSourceWarnings.length} warning{activeSourceWarnings.length !== 1 ? "s" : ""}
695683 </p>
696684 <div className="mt-1.5 space-y-1">
697685 {activeSourceWarnings.map((warning, index) => (
698− <p key={`${activeSource}-${index}`} className="text-[10px] leading-4 text-muted-foreground">
686+ <p key={`${activeSource}-${index}`} className="text-xs leading-4 text-muted-foreground">
699687 {warning}
700688 </p>
701689 ))}
executor/apps/web/src/components/tools/explorer-rows.tsx+1−34
@@ -25,17 +25,13 @@ export const ToolListItem = memo(function ToolListItem({ · 24 unmodified lines
2525 tool,
2626 label,
2727 focused,
28− selected,
2928 onFocus,
30− onSelect,
3129 depth = 0,
3230 }: {
3331 tool: ToolDescriptor;
3432 label: string;
3533 focused: boolean;
36− selected: boolean;
3734 onFocus: (tool: ToolDescriptor) => void;
38− onSelect?: (e: React.MouseEvent) => void;
3935 depth?: number;
4036 }) {
4137 const handleClick = useCallback(() => {
@@ -46,31 +42,13 @@ export const ToolListItem = memo(function ToolListItem({ · 4 unmodified lines
4642 <div
4743 onClick={handleClick}
4844 className={cn(
49− "flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group/item",
45+ "flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors",
5046 focused
5147 ? "bg-accent/50 border-l-2 border-l-primary"
5248 : "hover:bg-accent/20 border-l-2 border-l-transparent",
53− selected && !focused && "bg-primary/5",
5449 )}
5550 style={{ paddingLeft: `${depth * 14 + 8}px` }}
5651 >
57− {onSelect ? (
58− <button
59− onClick={(e) => {
60− e.stopPropagation();
61− onSelect(e);
62− }}
63− className={cn(
64− "h-3.5 w-3.5 rounded border flex items-center justify-center shrink-0 transition-colors",
65− selected
66− ? "bg-primary border-primary text-primary-foreground"
67− : "border-border/60 opacity-0 group-hover/item:opacity-100",
68− )}
69− >
70− {selected && <Check className="h-2 w-2" />}
71− </button>
72− ) : null}
73−
7452 <Zap className="h-2.5 w-2.5 text-primary/50 shrink-0" />
7553
7654 <span
@@ -92,7 +70,6 @@ export const ToolListItem = memo(function ToolListItem({ · 15 unmodified lines
9270 prev.tool === next.tool &&
9371 prev.label === next.label &&
9472 prev.focused === next.focused &&
95− prev.selected === next.selected &&
9673 prev.depth === next.depth,
9774 );
9875
@@ -235,9 +212,7 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 136 unmodified lines
235212 export function ToolListSidebar({
236213 tools,
237214 focusedPath,
238− selectedKeys,
239215 onFocusTool,
240− onSelectTool,
241216 loadingRows,
242217 hasMoreTools = false,
243218 loadingMoreTools = false,
@@ -247,9 +222,7 @@ export function ToolListSidebar({ · 3 unmodified lines
247222 }: {
248223 tools: ToolDescriptor[];
249224 focusedPath: string | null;
250− selectedKeys: Set<string>;
251225 onFocusTool: (tool: ToolDescriptor) => void;
252− onSelectTool?: (path: string, e: React.MouseEvent) => void;
253226 loadingRows?: { source: string; count: number }[];
254227 hasMoreTools?: boolean;
255228 loadingMoreTools?: boolean;
@@ -284,13 +257,7 @@ export function ToolListSidebar({ · 28 unmodified lines
284257 tool={tool}
285258 label={toolDisplayPath(tool.path)}
286259 focused={tool.path === focusedPath}
287− selected={selectedKeys.has(tool.path)}
288260 onFocus={onFocusTool}
289− onSelect={
290− onSelectTool
291− ? (e) => onSelectTool(tool.path, e)
292− : undefined
293− }
294261 />
295262 ))}
296263
executor/apps/web/src/components/tools/explorer-toolbar.tsx+1−50
@@ -1,6 +1,5 @@
11 import {
22 Search,
3− ShieldCheck,
43 Filter,
54 Plus,
65 X,
@@ -29,13 +28,10 @@ interface ToolExplorerToolbarProps { · 22 unmodified lines
2928 loadingInventory?: boolean;
3029 filterApproval: FilterApproval;
3130 activeSource: string | null;
32− selectedToolCount: number;
3331 onSearchChange: (value: string) => void;
3432 onClearSearch: () => void;
3533 onFilterApprovalChange: (filter: FilterApproval) => void;
3634 onAddSource?: () => void;
37− onSelectAll: () => void;
38− onClearSelection: () => void;
3935 }
4036
4137 export function ToolExplorerToolbar({
@@ -43,18 +39,13 @@ export function ToolExplorerToolbar({ · 1 unmodified line
4339 filteredToolCount,
4440 loadingInventory = false,
4541 filterApproval,
46− selectedToolCount,
4742 onSearchChange,
4843 onClearSearch,
4944 onFilterApprovalChange,
5045 onAddSource,
51− onSelectAll,
52− onClearSelection,
5346 }: ToolExplorerToolbarProps) {
54− const hasSelection = selectedToolCount > 0;
55−
5647 return (
57− <div className="shrink-0 relative space-y-1.5">
48+ <div className="shrink-0 space-y-1.5">
5849 {/* Search — own row */}
5950 <div className="relative">
6051 <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground/50" />
@@ -135,46 +126,6 @@ export function ToolExplorerToolbar({ · 74 unmodified lines
135126 </Button>
136127 ) : null}
137128 </div>
138−
139− <div
140− className={cn(
141− "absolute inset-x-0 top-full mt-1 z-30 transition-opacity duration-150 pointer-events-none",
142− hasSelection ? "opacity-100 pointer-events-auto" : "opacity-0",
143− )}
144− aria-hidden={!hasSelection}
145− >
146− <div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-primary/5 border border-primary/10">
147− <span className="text-[11px] font-mono text-primary">
148− {selectedToolCount} selected
149− </span>
150− <div className="flex-1" />
151− <Button
152− variant="ghost"
153− size="sm"
154− className="h-5 text-[10px] text-muted-foreground px-1.5"
155− onClick={onSelectAll}
156− >
157− All ({filteredToolCount})
158− </Button>
159− <Button
160− variant="ghost"
161− size="sm"
162− className="h-5 text-[10px] text-muted-foreground px-1.5"
163− onClick={onClearSelection}
164− >
165− Clear
166− </Button>
167− <div className="h-3 w-px bg-border/50" />
168− <Button
169− variant="outline"
170− size="sm"
171− className="h-5 text-[10px] border-terminal-amber/30 text-terminal-amber hover:bg-terminal-amber/10 px-1.5"
172− >
173− <ShieldCheck className="h-2.5 w-2.5 mr-0.5" />
174− Approval
175− </Button>
176− </div>
177− </div>
178129 </div>
179130 );
180131 }
executor/apps/web/src/components/tools/explorer.tsx+1−66
@@ -12,13 +12,11 @@ import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; · 11 unmodified lines
1212 import type { SourceAuthProfile } from "@/lib/types";
1313 import {
1414 autoExpandedKeysForSearch,
15− countSelectedTools,
1615 filterToolsBySearch,
1716 filterToolsBySourceAndApproval,
1817 treeGroupsForView,
1918 type FilterApproval,
2019 } from "./explorer-derived";
21−import { collectGroupKeys } from "@/lib/tool/explorer-grouping";
2220 import { sourceLabel } from "@/lib/tool/source-utils";
2321 import { traverseSchema } from "@/lib/tool/schema-traverse";
2422 import {
@@ -79,12 +77,10 @@ interface ToolExplorerProps { · 54 unmodified lines
7977 searchValue: string;
8078 filterApprovalValue: FilterApproval;
8179 focusedToolPathValue: string | null;
82− selectedToolPathsValue: string[];
8380 focusedSourceNameValue: string | null;
8481 onSearchValueChange: (value: string) => void;
8582 onFilterApprovalValueChange: (filter: FilterApproval) => void;
8683 onFocusedToolPathChange: (toolPath: string | null) => void;
87− onSelectedToolPathsChange: (toolPaths: string[]) => void;
8884 onFocusedSourceNameChange: (sourceName: string | null) => void;
8985 onSourceAdded?: (source: ToolSourceRecord, options?: SourceAddedOptions) => void;
9086 sourceDialogMeta?: Record<string, SourceDialogMeta>;
@@ -113,12 +109,10 @@ export function ToolExplorer({ · 22 unmodified lines
113109 searchValue,
114110 filterApprovalValue,
115111 focusedToolPathValue,
116− selectedToolPathsValue,
117112 focusedSourceNameValue,
118113 onSearchValueChange,
119114 onFilterApprovalValueChange,
120115 onFocusedToolPathChange,
121− onSelectedToolPathsChange,
122116 onFocusedSourceNameChange,
123117 onSourceAdded,
124118 sourceDialogMeta,
@@ -155,7 +149,6 @@ export function ToolExplorer({ · 30 unmodified lines
155149 const searchInput = searchValue;
156150 const filterApproval = filterApprovalValue;
157151 const focusedToolPath = focusedToolPathValue;
158− const selectedKeys = useMemo(() => new Set(selectedToolPathsValue), [selectedToolPathsValue]);
159152 const focusedSourceName = focusedSourceNameValue;
160153 const search = useDeferredValue(searchInput);
161154
@@ -171,10 +164,6 @@ export function ToolExplorer({ · 9 unmodified lines
171164 onFocusedToolPathChange(toolPath);
172165 }, [onFocusedToolPathChange]);
173166
174− const setSelectedKeys = useCallback((next: Set<string>) => {
175− onSelectedToolPathsChange(Array.from(next));
176− }, [onSelectedToolPathsChange]);
177−
178167 const setFocusedSourceName = useCallback((sourceName: string | null) => {
179168 onFocusedSourceNameChange(sourceName);
180169 }, [onFocusedSourceNameChange]);
@@ -257,10 +246,6 @@ export function ToolExplorer({ · 76 unmodified lines
257246 return filterToolsBySearch(filteredTools, search);
258247 }, [filteredTools, search]);
259248
260− const selectedToolCount = useMemo(() => {
261− return countSelectedTools(selectedKeys, filteredTools);
262− }, [selectedKeys, filteredTools]);
263−
264249 const sidebarExistingSourceNames = useMemo(() => {
265250 return existingSourceNames ?? new Set(visibleSources.map((source) => source.name));
266251 }, [existingSourceNames, visibleSources]);
@@ -290,29 +275,6 @@ export function ToolExplorer({ · 23 unmodified lines
290275 }
291276 }, [focusedSourceNameValue, formSource, sourceByName, setFocusedToolPath]);
292277
293− const toggleSelectTool = useCallback(
294− (path: string, e: React.MouseEvent) => {
295− e.stopPropagation();
296− e.preventDefault();
297− const next = new Set(selectedKeys);
298− if (next.has(path)) {
299− next.delete(path);
300− } else {
301− next.add(path);
302− }
303− setSelectedKeys(next);
304− },
305− [selectedKeys, setSelectedKeys],
306− );
307−
308− const clearSelection = useCallback(() => {
309− setSelectedKeys(new Set());
310− }, []);
311−
312− const selectAll = useCallback(() => {
313− setSelectedKeys(new Set(filteredTools.map((t) => t.path)));
314− }, [filteredTools]);
315−
316278 const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => {
317279 if (!expanded || !onLoadToolDetails) {
318280 return;
@@ -459,14 +421,7 @@ export function ToolExplorer({ · 140 unmodified lines
459421 });
460422 }, [searchedTools, loadingSources, visibleSources, sourceCounts, resolvedActiveSource]);
461423
462− const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => {
463− // Start with all source-level groups expanded
464− const keys = new Set<string>();
465− for (const group of treeGroups) {
466− keys.add(group.key);
467− }
468− return keys;
469− });
424+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
470425
471426 // Auto-expand all groups when search is active
472427 const searchAutoExpanded = useMemo(
@@ -476,21 +431,6 @@ export function ToolExplorer({ · 3 unmodified lines
476431
477432 const effectiveExpandedKeys = searchAutoExpanded ?? expandedKeys;
478433
479− // When new source groups appear, auto-expand them
480− useEffect(() => {
481− const allKeys = collectGroupKeys(treeGroups);
482− setExpandedKeys((prev) => {
483− const next = new Set(prev);
484− let changed = false;
485− for (const key of allKeys) {
486− if (key.startsWith("source:") && !key.includes(":ns:") && !next.has(key)) {
487− next.add(key);
488− changed = true;
489− }
490− }
491− return changed ? next : prev;
492− });
493− }, [treeGroups]);
494434
495435 const toggleExpand = useCallback((key: string) => {
496436 setExpandedKeys((prev) => {
@@ -609,13 +549,10 @@ export function ToolExplorer({ · 112 unmodified lines
609549 loadingInventory={awaitingInitialInventory}
610550 filterApproval={filterApproval}
611551 activeSource={resolvedActiveSource}
612− selectedToolCount={selectedToolCount}
613552 onSearchChange={setSearchInput}
614553 onClearSearch={() => setSearchInput("")}
615554 onFilterApprovalChange={setFilterApproval}
616555 onAddSource={handleAddSource}
617− onSelectAll={selectAll}
618− onClearSelection={clearSelection}
619556 />
620557 </div>
621558
@@ -659,9 +596,7 @@ export function ToolExplorer({ · 37 unmodified lines
659596 onToggle={toggleExpand}
660597 focusedPath={focusedToolPath}
661598 focusedSource={focusedSourceName}
662− selectedKeys={selectedKeys}
663599 onFocusTool={handleFocusTool}
664− onSelectTool={toggleSelectTool}
665600 onSourceClick={handleSourceClick}
666601 source={sourceByName.get(group.label) ?? undefined}
667602 />
executor/apps/web/src/components/tools/explorer/source-form-panel.tsx+4−6
@@ -69,9 +69,8 @@ export function SourceFormPanel({ · 68 unmodified lines
6969 onClose: () => void;
7070 }) {
7171 const { context } = useSession();
72− const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
72+ const upsertToolSource = useAction(convexApi.workspace.upsertToolSource);
7373 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
74− const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
7574 const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade);
7675 const credentials = useQuery(
7776 convexApi.workspace.listCredentials,
@@ -195,7 +194,6 @@ export function SourceFormPanel({ · 117 unmodified lines
195194 sourceToEdit,
196195 credentialsLoading,
197196 upsertToolSource,
198− upsertCredential,
199197 form: {
200198 name: form.name,
201199 endpoint: form.endpoint,
@@ -336,10 +334,10 @@ export function SourceFormPanel({ · 134 unmodified lines
336334 <ArrowLeft className="h-3.5 w-3.5" />
337335 </button>
338336 <div className="min-w-0 flex-1">
339− <h2 className="text-[14px] font-mono font-semibold text-foreground">
337+ <h2 className="text-sm font-semibold text-foreground">
340338 {editing ? "Edit source" : "Add source"}
341339 </h2>
342− <p className="text-[10px] text-muted-foreground/60 mt-0.5">
340+ <p className="mt-0.5 text-xs text-muted-foreground/70">
343341 {editing
344342 ? "Update endpoint, auth, and credentials."
345343 : "Connect a source and configure credentials."}
@@ -365,7 +363,7 @@ export function SourceFormPanel({ · 19 unmodified lines
365363 <p className="text-[13px] font-medium truncate">
366364 {displaySourceName(sourceToEdit.name)}
367365 </p>
368− <p className="text-[10px] text-muted-foreground truncate mt-0.5">
366+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
369367 {compactEndpointLabel(sourceToEdit)}
370368 </p>
371369 <div className="mt-2 flex items-center gap-1.5 flex-wrap">
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+21−21
@@ -86,7 +86,7 @@ function ConstraintBadges({ entry }: { entry: SchemaFieldEntry }) { · 85 unmodified lines
8686 return (
8787 <span className="inline-flex items-center gap-1 ml-0.5">
8888 {parts.map((p, i) => (
89− <span key={i} className="font-mono text-[9px] text-muted-foreground/65 bg-muted rounded-sm px-1 leading-[1.4]">
89+ <span key={i} className="rounded-sm bg-muted px-1 text-[11px] leading-[1.4] text-muted-foreground/70">
9090 {p}
9191 </span>
9292 ))}
@@ -104,7 +104,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 11 unmodified lines
104104 >
105105 {/* Name + type line */}
106106 <div className="flex items-center gap-1.5 flex-wrap">
107− <span className="font-mono text-[11px] font-semibold text-foreground leading-tight">
107+ <span className="text-sm font-semibold leading-tight text-foreground">
108108 {entry.path.split(".").pop()}
109109 </span>
110110
@@ -117,7 +117,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 6 unmodified lines
117117
118118 <span
119119 className={cn(
120− "font-mono text-[10px] leading-none px-1.5 py-0.5 rounded-[3px] border font-medium",
120+ "rounded-[3px] border px-1.5 py-0.5 text-[11px] leading-none font-medium",
121121 typeColorClasses(entry.type),
122122 )}
123123 >
@@ -125,7 +125,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 1 unmodified line
125125 </span>
126126
127127 {entry.deprecated ? (
128− <span className="font-mono text-[9px] font-medium uppercase tracking-wide text-[oklch(0.6_0.16_75)] dark:text-[oklch(0.78_0.14_75)] bg-[oklch(0.6_0.16_75_/_0.08)] dark:bg-[oklch(0.78_0.14_75_/_0.1)] border border-[oklch(0.6_0.16_75_/_0.18)] dark:border-[oklch(0.78_0.14_75_/_0.2)] rounded-[3px] px-1.5 py-px leading-none">
128+ <span className="rounded-[3px] border border-[oklch(0.6_0.16_75_/_0.18)] bg-[oklch(0.6_0.16_75_/_0.08)] px-1.5 py-px text-[10px] font-medium leading-none text-[oklch(0.6_0.16_75)] dark:border-[oklch(0.78_0.14_75_/_0.2)] dark:bg-[oklch(0.78_0.14_75_/_0.1)] dark:text-[oklch(0.78_0.14_75)]">
129129 deprecated
130130 </span>
131131 ) : null}
@@ -135,7 +135,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 3 unmodified lines
135135
136136 {/* Description */}
137137 {entry.description ? (
138− <div className="text-[11px] leading-relaxed text-muted-foreground [&_p]:m-0 [&_p+p]:mt-0.5 [&_code]:font-mono [&_code]:text-[10px] [&_code]:bg-muted [&_code]:border [&_code]:border-border/70 [&_code]:rounded-sm [&_code]:px-1 [&_code]:py-px [&_code]:text-terminal-cyan [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-primary/25 hover:[&_a]:decoration-primary/70">
138+ <div className="text-xs leading-relaxed text-muted-foreground [&_p]:m-0 [&_p+p]:mt-0.5 [&_code]:font-mono [&_code]:text-[11px] [&_code]:bg-muted [&_code]:border [&_code]:border-border/70 [&_code]:rounded-sm [&_code]:px-1 [&_code]:py-px [&_code]:text-primary [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-primary/25 hover:[&_a]:decoration-primary/70">
139139 <Streamdown plugins={{ code: codePlugin }} controls={false}>{entry.description}</Streamdown>
140140 </div>
141141 ) : null}
@@ -144,7 +144,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 2 unmodified lines
144144 {entry.enumValues && entry.enumValues.length > 0 && entry.enumValues.length <= 12 ? (
145145 <div className="flex flex-wrap gap-1 mt-0.5">
146146 {entry.enumValues.map((v, i) => (
147− <code key={i} className="font-mono text-[10px] bg-muted border border-border/60 rounded-[3px] px-1.5 py-px text-foreground/80">
147+ <code key={i} className="rounded-[3px] border border-border/60 bg-muted px-1.5 py-px font-mono text-[11px] text-foreground/80">
148148 {v}
149149 </code>
150150 ))}
@@ -156,14 +156,14 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 5 unmodified lines
156156 <div className="flex flex-wrap gap-2 mt-0.5">
157157 {entry.example ? (
158158 <span className="inline-flex items-center gap-1">
159− <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">example</span>
160− <code className="font-mono text-[10px] text-muted-foreground/85">{entry.example}</code>
159+ <span className="text-[11px] text-muted-foreground/60">Example</span>
160+ <code className="font-mono text-xs text-muted-foreground/85">{entry.example}</code>
161161 </span>
162162 ) : null}
163163 {entry.defaultValue ? (
164164 <span className="inline-flex items-center gap-1">
165− <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">default</span>
166− <code className="font-mono text-[10px] text-muted-foreground/85">{entry.defaultValue}</code>
165+ <span className="text-[11px] text-muted-foreground/60">Default</span>
166+ <code className="font-mono text-xs text-muted-foreground/85">{entry.defaultValue}</code>
167167 </span>
168168 ) : null}
169169 </div>
@@ -212,11 +212,11 @@ function SchemaFieldsSection({ · 42 unmodified lines
212212 <div className="flex flex-col gap-2">
213213 {/* Section header */}
214214 <div className="flex items-baseline gap-2">
215− <span className="font-mono text-[9px] font-medium tracking-wider uppercase text-muted-foreground/60">
215+ <span className="text-xs font-medium text-muted-foreground/70">
216216 {label}
217217 </span>
218218 {hasEntries ? (
219− <span className="font-mono text-[10px] text-muted-foreground/50">
219+ <span className="text-xs text-muted-foreground/60">
220220 {entries.length} field{entries.length !== 1 ? "s" : ""}
221221 {truncated ? "+" : ""}
222222 </span>
@@ -228,7 +228,7 @@ function SchemaFieldsSection({ · 5 unmodified lines
228228 <div className="border border-border rounded-md bg-muted/30 overflow-hidden">
229229 {collapsedByDefault ? (
230230 <details open={false}>
231− <summary className="cursor-pointer select-none font-mono text-[10px] text-muted-foreground/60 px-3.5 py-2 block transition-opacity hover:opacity-100">
231+ <summary className="block cursor-pointer select-none px-3.5 py-2 text-xs text-muted-foreground/70 transition-opacity hover:opacity-100">
232232 Show all fields ({entries.length}{truncated ? "+" : ""})
233233 </summary>
234234 <div className="flex flex-col">
@@ -236,7 +236,7 @@ function SchemaFieldsSection({ · 1 unmodified line
236236 <FieldRow key={`${entry.path}-${i}`} entry={entry} />
237237 ))}
238238 {truncated ? (
239− <p className="font-mono text-[10px] text-muted-foreground/50 px-3.5 py-2">
239+ <p className="px-3.5 py-2 text-xs text-muted-foreground/60">
240240 Showing first {entries.length} fields...
241241 </p>
242242 ) : null}
@@ -251,7 +251,7 @@ function SchemaFieldsSection({ · 8 unmodified lines
251251 )}
252252 </div>
253253 ) : empty ? (
254− <p className="text-[11px] font-mono text-muted-foreground/50 italic pl-0.5">
254+ <p className="pl-0.5 text-xs italic text-muted-foreground/60">
255255 Empty object
256256 </p>
257257 ) : schemaJson ? (
@@ -261,7 +261,7 @@ function SchemaFieldsSection({ · 3 unmodified lines
261261 {/* Raw schema toggle */}
262262 {hasEntries && schemaJson ? (
263263 <details className="mt-1">
264− <summary className="cursor-pointer select-none font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50 transition-opacity hover:opacity-80">
264+ <summary className="cursor-pointer select-none text-xs font-medium text-muted-foreground/60 transition-opacity hover:opacity-80">
265265 Raw schema
266266 </summary>
267267 <div className="mt-1.5">
@@ -308,11 +308,11 @@ export function ToolDetailPanel({ · 40 unmodified lines
308308 <div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border/40 px-5 py-3">
309309 <div className="flex items-center gap-2.5 min-w-0">
310310 <Zap className="h-3.5 w-3.5 text-primary/70 shrink-0" />
311− <h2 className="text-[14px] font-mono font-semibold text-foreground truncate">
311+ <h2 className="truncate text-sm font-semibold text-foreground">
312312 {tool.path}
313313 </h2>
314314 {tool.approval === "required" ? (
315− <span className="inline-flex items-center gap-0.5 text-[9px] font-mono uppercase tracking-wider text-terminal-amber bg-terminal-amber/8 px-1.5 py-0.5 rounded border border-terminal-amber/15 shrink-0">
315+ <span className="inline-flex shrink-0 items-center gap-0.5 rounded border border-terminal-amber/15 bg-terminal-amber/8 px-1.5 py-0.5 text-xs font-medium text-terminal-amber">
316316 <ShieldCheck className="h-2.5 w-2.5" />
317317 gated
318318 </span>
@@ -322,7 +322,7 @@ export function ToolDetailPanel({ · 3 unmodified lines
322322 </div>
323323 </div>
324324 {tool.source ? (
325− <p className="mt-0.5 text-[10px] font-mono text-muted-foreground/50 pl-6">
325+ <p className="mt-0.5 pl-6 text-xs text-muted-foreground/60">
326326 {tool.source}
327327 </p>
328328 ) : null}
@@ -404,11 +404,11 @@ function ToolDetailContent({ · 75 unmodified lines
404404 <div className="space-y-3">
405405 <Skeleton className="h-3.5 w-72" />
406406 <div>
407− <p className="font-mono text-[9px] font-medium tracking-wider uppercase text-muted-foreground/60">Arguments</p>
407+ <p className="text-xs font-medium text-muted-foreground/70">Arguments</p>
408408 <Skeleton className="h-20 w-full rounded-md mt-1" />
409409 </div>
410410 <div>
411− <p className="font-mono text-[9px] font-medium tracking-wider uppercase text-muted-foreground/60">Returns</p>
411+ <p className="text-xs font-medium text-muted-foreground/70">Returns</p>
412412 <Skeleton className="h-14 w-full rounded-md mt-1" />
413413 </div>
414414 </div>
executor/apps/web/src/components/tools/policies.tsx+93−91
@@ -577,22 +577,22 @@ function ToolPicker({ · 576 unmodified lines
577577 variant="outline"
578578 role="combobox"
579579 aria-expanded={open}
580− className="h-9 w-full justify-between text-xs font-mono bg-background hover:bg-muted/50 border-border/70"
580+ className="h-9 w-full justify-between bg-background text-sm hover:bg-muted/50 border-border/70"
581581 >
582582 <span className="truncate text-left">{selectionSummary}</span>
583583 <ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
584584 </Button>
585585 </PopoverTrigger>
586586 <PopoverContent className="w-[460px] p-0" align="start">
587− {/* Search input — raw input, deferred for filtering so typing never lags */}
587+ {/* Search input — deferred for filtering so typing never lags */}
588588 <div className="flex h-9 items-center gap-2 border-b px-3">
589589 <Search className="size-4 shrink-0 opacity-50" />
590− <input
590+ <Input
591591 ref={searchRef}
592592 value={searchInput}
593593 onChange={(e) => setSearchInput(e.target.value)}
594594 placeholder="Search tools..."
595− className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
595+ className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
596596 />
597597 {searchInput && (
598598 <button
@@ -616,11 +616,11 @@ function ToolPicker({ · 17 unmodified lines
616616 {/* Footer */}
617617 {selectedPaths.length > 0 && (
618618 <div className="border-t border-border/50 p-2 flex items-center justify-between">
619− <span className="text-[10px] text-muted-foreground">{selectedPaths.length} selected</span>
619+ <span className="text-xs text-muted-foreground">{selectedPaths.length} selected</span>
620620 <Button
621621 variant="ghost"
622622 size="sm"
623− className="h-6 text-[10px] px-2"
623+ className="h-6 px-2 text-xs"
624624 onClick={() => { onSelectionChange([]); onPatternChange("*"); }}
625625 >
626626 Clear
@@ -1286,97 +1286,99 @@ export function PoliciesPanel({ · 659 unmodified lines
12861286 // ── Render ──
12871287
12881288 return (
1289− <section className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col border border-border/50 bg-card/40">
1290− <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
1291− <div className="space-y-4">
1292− {/* Header */}
1293− <div className="flex items-center justify-between">
1294− <div className="flex items-center gap-2.5">
1295− <Shield className="h-4 w-4 text-muted-foreground" />
1296− <div>
1297− <h3 className="text-sm font-medium leading-none">Tool Policies</h3>
1298− <p className="text-[11px] text-muted-foreground mt-1">
1299− Control which tools or sources are approved, gated, or blocked
1300− </p>
1289+ <section className="flex h-full min-h-0 w-full overflow-hidden bg-background">
1290+ {/* ── Left sidebar ── */}
1291+ <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72">
1292+ {/* Sidebar header */}
1293+ <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
1294+ <div className="flex items-center justify-between">
1295+ <div className="flex items-center gap-2">
1296+ <Shield className="h-4 w-4 text-muted-foreground" />
1297+ <h3 className="text-sm font-medium leading-none">Policies</h3>
1298+ </div>
1299+ <Button
1300+ onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }}
1301+ size="sm"
1302+ variant="ghost"
1303+ className="h-7 w-7 p-0"
1304+ disabled={!context}
1305+ >
1306+ <Plus className="h-3.5 w-3.5" />
1307+ </Button>
13011308 </div>
1309+ <p className="text-[10px] text-muted-foreground mt-1">
1310+ {policies.length} {policies.length === 1 ? "policy" : "policies"}
1311+ </p>
13021312 </div>
1303− <Button
1304− onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }}
1305− size="sm"
1306− className="h-8 text-xs gap-1.5"
1307− disabled={!context}
1308− >
1309− <Plus className="h-3.5 w-3.5" />
1310− Quick Policy
1311− </Button>
1312− </div>
1313−
1314− <Separator className="bg-border/40" />
13151313
1316− {/* Policies list */}
1317− {loading || loadingTools ? (
1318− <div className="space-y-2">
1319− {Array.from({ length: 3 }).map((_, i) => (
1320− <Skeleton key={i} className="h-16 rounded-lg" />
1321− ))}
1322− </div>
1323− ) : policies.length === 0 ? (
1324− <div className="rounded-none border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5">
1325− <Shield className="h-8 w-8 text-muted-foreground/30" />
1326− <div className="text-center">
1327− <p className="text-xs text-muted-foreground">No tool policies configured</p>
1328− <p className="text-[11px] text-muted-foreground/60 mt-0.5">
1329− Tools use default approval behavior. Create a tool policy to customize it.
1330− </p>
1331− </div>
1332− <Button
1333− variant="outline"
1334− size="sm"
1335− className="h-7 text-[11px] gap-1 mt-1"
1336− onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }}
1337− >
1338− <Plus className="h-3 w-3" />
1339− Create first policy
1340− </Button>
1341− </div>
1342− ) : (
1343− <div className="space-y-4">
1344− {(["deny", "require_approval", "allow"] as const).map((decisionType) => {
1345− const group = groupedPolicies[decisionType];
1346− if (group.length === 0) return null;
1347− const config = DECISION_CONFIG[decisionType];
1348− return (
1349− <div key={decisionType}>
1350− <div className="flex items-center gap-2 mb-2">
1351− <config.icon className={cn("h-3 w-3", config.color)} />
1352− <span className={cn("text-[11px] font-medium uppercase tracking-wider", config.color)}>
1353− {config.label}
1354− </span>
1355− <span className="text-[10px] text-muted-foreground/40">
1356− ({group.length})
1357− </span>
1358− </div>
1359− <div className="space-y-1.5">
1360− {group.map((policy) => (
1361− <PolicyCard
1362− key={policy.id}
1363− policy={policy}
1364− tools={tools}
1365− currentAccountId={context?.accountId}
1366− onDelete={handleDelete}
1367− canDelete={isDirectToolPolicy(policy)}
1368− deleting={deletingId === policy.id}
1369− />
1370− ))}
1371− </div>
1314+ {/* Sidebar policy list */}
1315+ <div className="flex-1 min-h-0 overflow-y-auto">
1316+ {loading || loadingTools ? (
1317+ <div className="space-y-2 p-3">
1318+ {Array.from({ length: 4 }).map((_, i) => (
1319+ <Skeleton key={i} className="h-12 rounded-md" />
1320+ ))}
1321+ </div>
1322+ ) : policies.length === 0 ? (
1323+ <div className="flex flex-col items-center gap-2.5 px-4 py-10">
1324+ <Shield className="h-7 w-7 text-muted-foreground/30" />
1325+ <div className="text-center">
1326+ <p className="text-xs text-muted-foreground">No policies</p>
1327+ <p className="text-[10px] text-muted-foreground/60 mt-0.5">
1328+ Create a policy to customize tool approval behavior.
1329+ </p>
13721330 </div>
1373− );
1374− })}
1331+ <Button
1332+ variant="outline"
1333+ size="sm"
1334+ className="h-7 text-[11px] gap-1 mt-1"
1335+ onClick={() => { setForm(defaultFormState()); setDialogOpen(true); }}
1336+ >
1337+ <Plus className="h-3 w-3" />
1338+ Create first policy
1339+ </Button>
1340+ </div>
1341+ ) : (
1342+ <div className="p-2 space-y-3">
1343+ {(["deny", "require_approval", "allow"] as const).map((decisionType) => {
1344+ const group = groupedPolicies[decisionType];
1345+ if (group.length === 0) return null;
1346+ const config = DECISION_CONFIG[decisionType];
1347+ return (
1348+ <div key={decisionType}>
1349+ <div className="flex items-center gap-2 mb-1.5 px-1">
1350+ <config.icon className={cn("h-3 w-3", config.color)} />
1351+ <span className={cn("text-[10px] font-medium uppercase tracking-wider", config.color)}>
1352+ {config.label}
1353+ </span>
1354+ <span className="text-[9px] text-muted-foreground/40">
1355+ ({group.length})
1356+ </span>
1357+ </div>
1358+ <div className="space-y-1">
1359+ {group.map((policy) => (
1360+ <PolicyCard
1361+ key={policy.id}
1362+ policy={policy}
1363+ tools={tools}
1364+ currentAccountId={context?.accountId}
1365+ onDelete={handleDelete}
1366+ canDelete={isDirectToolPolicy(policy)}
1367+ deleting={deletingId === policy.id}
1368+ />
1369+ ))}
1370+ </div>
1371+ </div>
1372+ );
1373+ })}
1374+ </div>
1375+ )}
13751376 </div>
1376− )}
1377−
1378− <Separator className="bg-border/40" />
1377+ </aside>
13791378
1379+ {/* ── Right content panel ── */}
1380+ <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50">
1381+ <div className="p-4 sm:p-5 space-y-4">
13801382 <details className="group rounded-none border border-border/60 bg-card/30">
13811383 <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2.5">
13821384 <div>
executor/apps/web/src/components/tools/storage.tsx+34−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 KeyRound,
99 Table,
1010 } from "lucide-react";
11+import { Skeleton } from "@/components/ui/skeleton";
1112 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
1213 import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
1314 import { convexApi } from "@/lib/convex-api";
@@ -32,6 +33,37 @@ import { · 18 unmodified lines
3233 type StorageSqlResult,
3334 } from "./storage_panel_shared";
3435
36+function StorageInspectorSkeleton() {
37+ return (
38+ <div className="flex h-full min-h-0 flex-col">
39+ <div className="flex items-center justify-between border-b border-border/40 px-4 py-1.5 bg-card/20">
40+ <div className="space-y-1">
41+ <Skeleton className="h-3 w-40 rounded" />
42+ <Skeleton className="h-2.5 w-56 rounded" />
43+ </div>
44+ <div className="flex items-center gap-1.5">
45+ <Skeleton className="h-7 w-12 rounded-md" />
46+ <Skeleton className="h-7 w-12 rounded-md" />
47+ <Skeleton className="h-7 w-12 rounded-md" />
48+ </div>
49+ </div>
50+
51+ <div className="flex items-center gap-2 border-b border-border/30 px-4 py-2 bg-muted/20">
52+ <Skeleton className="h-7 flex-1 rounded-md" />
53+ <Skeleton className="h-7 w-16 rounded-md" />
54+ </div>
55+
56+ <div className="flex-1 min-h-0 overflow-hidden p-4">
57+ <div className="space-y-2">
58+ {Array.from({ length: 6 }).map((_, index) => (
59+ <Skeleton key={index} className="h-9 w-full rounded-md" />
60+ ))}
61+ </div>
62+ </div>
63+ </div>
64+ );
65+}
66+
3567 // ── Main Component ─────────────────────────────────────────────────────────
3668
3769 export function StoragePanel({
@@ -478,6 +510,8 @@ export function StoragePanel({ · 440 unmodified lines
478510 onSqlViewModeChange={setSqlViewMode}
479511 />
480512 </Tabs>
513+ ) : loading ? (
514+ <StorageInspectorSkeleton />
481515 ) : (
482516 <div className="flex h-full items-center justify-center">
483517 <div className="flex flex-col items-center gap-3">
executor/apps/web/src/components/tools/storage_panel_sidebar.tsx+51−30
@@ -2,6 +2,13 @@ import { Database, Plus, Power, Trash2, X } from "lucide-react"; · 1 unmodified line
22 import { Badge } from "@/components/ui/badge";
33 import { Button } from "@/components/ui/button";
44 import { Input } from "@/components/ui/input";
5+import {
6+ Select,
7+ SelectContent,
8+ SelectItem,
9+ SelectTrigger,
10+ SelectValue,
11+} from "@/components/ui/select";
512 import { Skeleton } from "@/components/ui/skeleton";
613 import { cn } from "@/lib/utils";
714 import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
@@ -59,7 +66,7 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 51 unmodified lines
5966 <Database className="h-3.5 w-3.5 text-muted-foreground" />
6067 <span className="text-xs font-medium text-foreground">Instances</span>
6168 {visibleInstances.length > 0 && (
62− <Badge variant="outline" className="h-4 px-1.5 text-[9px] font-mono text-muted-foreground">
69+ <Badge variant="outline" className="h-4 px-1.5 text-xs text-muted-foreground">
6370 {visibleInstances.length}
6471 </Badge>
6572 )}
@@ -78,36 +85,44 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 12 unmodified lines
7885 <div className="border-b border-border/40 bg-muted/30 px-3 py-3">
7986 <div className="space-y-2">
8087 <div className="grid grid-cols-2 gap-2">
81− <label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
88+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
8289 Scope
83− <select
84− className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
90+ <Select
8591 value={scopeType}
86− onChange={(event) => onScopeTypeChange(event.target.value as StorageScopeType)}
92+ onValueChange={(value) => onScopeTypeChange(value as StorageScopeType)}
8793 >
88− <option value="scratch">scratch</option>
89− <option value="account">account</option>
90− <option value="workspace">workspace</option>
91− <option value="organization">organization</option>
92− </select>
94+ <SelectTrigger className="h-8 w-full">
95+ <SelectValue />
96+ </SelectTrigger>
97+ <SelectContent>
98+ <SelectItem value="scratch">scratch</SelectItem>
99+ <SelectItem value="account">account</SelectItem>
100+ <SelectItem value="workspace">workspace</SelectItem>
101+ <SelectItem value="organization">organization</SelectItem>
102+ </SelectContent>
103+ </Select>
93104 </label>
94− <label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
105+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
95106 Durability
96− <select
97− className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
107+ <Select
98108 value={durability}
99− onChange={(event) => onDurabilityChange(event.target.value as StorageDurability)}
109+ onValueChange={(value) => onDurabilityChange(value as StorageDurability)}
100110 >
101− <option value="ephemeral">ephemeral</option>
102− <option value="durable">durable</option>
103− </select>
111+ <SelectTrigger className="h-8 w-full">
112+ <SelectValue />
113+ </SelectTrigger>
114+ <SelectContent>
115+ <SelectItem value="ephemeral">ephemeral</SelectItem>
116+ <SelectItem value="durable">durable</SelectItem>
117+ </SelectContent>
118+ </Select>
104119 </label>
105120 </div>
106121 <Input
107122 value={purpose}
108123 onChange={(event) => onPurposeChange(event.target.value)}
109124 placeholder="Purpose (optional)"
110− className="h-7 rounded-md text-[11px]"
125+ className="h-8 rounded-md"
111126 />
112127 <div className="flex items-end gap-2">
113128 <Input
@@ -115,9 +130,9 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 1 unmodified line
115130 onChange={(event) => onTtlHoursChange(event.target.value)}
116131 disabled={durability !== "ephemeral"}
117132 placeholder="TTL (hours)"
118− className="h-7 flex-1 rounded-md text-[11px]"
133+ className="h-8 flex-1 rounded-md"
119134 />
120− <Button size="sm" className="h-7 rounded-md px-3 text-[10px]" disabled={creating} onClick={onCreate}>
135+ <Button size="sm" className="h-8 rounded-md px-3 text-xs" disabled={creating} onClick={onCreate}>
121136 Create
122137 </Button>
123138 </div>
@@ -127,9 +142,15 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 3 unmodified lines
127142
128143 <div className="min-h-0 flex-1 overflow-y-auto">
129144 {loading ? (
130− <div className="space-y-1 p-2">
131− {Array.from({ length: 4 }).map((_, i) => (
132− <Skeleton key={i} className="h-14 rounded-md" />
145+ <div className="space-y-1 p-1.5">
146+ {Array.from({ length: 5 }).map((_, index) => (
147+ <div key={index} className="flex items-start gap-2.5 rounded-md px-2.5 py-2">
148+ <Skeleton className="mt-0.5 h-6 w-6 shrink-0 rounded-md" />
149+ <div className="min-w-0 flex-1 space-y-1.5">
150+ <Skeleton className="h-3 w-24 rounded" />
151+ <Skeleton className="h-2.5 w-36 rounded" />
152+ </div>
153+ </div>
133154 ))}
134155 </div>
135156 ) : visibleInstances.length === 0 ? (
@@ -139,7 +160,7 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 3 unmodified lines
139160 </div>
140161 <div className="text-center">
141162 <p className="text-xs font-medium text-muted-foreground">No instances</p>
142− <p className="mt-0.5 text-[10px] text-muted-foreground/60">Create one to get started</p>
163+ <p className="mt-0.5 text-xs text-muted-foreground/60">Create one to get started</p>
143164 </div>
144165 </div>
145166 ) : (
@@ -171,21 +192,21 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 25 unmodified lines
171192 </div>
172193 <div className="min-w-0 flex-1">
173194 <p className={cn(
174− "truncate text-[11px] font-medium",
195+ "truncate text-xs font-medium",
175196 active ? "text-foreground" : "text-foreground/80",
176197 )}>
177198 {instance.purpose || instance.id.slice(0, 12)}
178199 </p>
179200 <div className="mt-0.5 flex items-center gap-1.5">
180− <span className={cn("text-[9px] font-medium uppercase tracking-wider", scopeColor(instance.scopeType))}>
201+ <span className={cn("text-xs font-medium", scopeColor(instance.scopeType))}>
181202 {instance.scopeType}
182203 </span>
183− <span className="text-[9px] text-muted-foreground/40">/</span>
184− <span className="text-[9px] text-muted-foreground">
204+ <span className="text-xs text-muted-foreground/40">/</span>
205+ <span className="text-xs text-muted-foreground">
185206 {prettyBytes(instance.sizeBytes)}
186207 </span>
187− <span className="text-[9px] text-muted-foreground/40">/</span>
188− <span className="text-[9px] text-muted-foreground">
208+ <span className="text-xs text-muted-foreground/40">/</span>
209+ <span className="text-xs text-muted-foreground">
189210 {relativeTime(instance.lastSeenAt)}
190211 </span>
191212 </div>
executor/apps/web/src/components/tools/storage_panel_sql_tab.tsx+36−35
@@ -1,6 +1,7 @@
11 import { Play } from "lucide-react";
22 import { Button } from "@/components/ui/button";
33 import { Input } from "@/components/ui/input";
4+import { Textarea } from "@/components/ui/textarea";
45 import { TabsContent } from "@/components/ui/tabs";
56 import { cn } from "@/lib/utils";
67 import { sqlCellText, truncateText, type StorageSqlObject, type StorageSqlResult } from "./storage_panel_shared";
@@ -67,21 +68,21 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 60 unmodified lines
6768
6869 return (
6970 <TabsContent value="sql" className="flex-1 min-h-0 overflow-hidden">
70− <div className="flex h-full min-h-0 flex-col">
71− {visibleSqlObjects.length > 0 && (
72− <div className="flex items-center gap-1.5 border-b border-border/30 bg-muted/15 px-4 py-1.5 overflow-x-auto shrink-0">
73− <span className="shrink-0 text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider mr-0.5">Tables</span>
74− {visibleSqlObjects.map((entry) => (
75− <button
76− key={entry.name}
77− type="button"
78− className={cn(
79− "shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors",
80− selectedSqlObjectName === entry.name
81− ? "bg-primary/15 text-primary ring-1 ring-primary/20"
82− : "text-foreground/60 hover:bg-accent/50 hover:text-foreground",
83− )}
84− onClick={() => onOpenSqlObject(entry.name)}
71+ <div className="flex h-full min-h-0 flex-col">
72+ {visibleSqlObjects.length > 0 && (
73+ <div className="flex items-center gap-1.5 border-b border-border/30 bg-muted/15 px-4 py-1.5 overflow-x-auto shrink-0">
74+ <span className="mr-0.5 shrink-0 text-xs font-medium text-muted-foreground/70">Tables</span>
75+ {visibleSqlObjects.map((entry) => (
76+ <button
77+ key={entry.name}
78+ type="button"
79+ className={cn(
80+ "shrink-0 rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
81+ selectedSqlObjectName === entry.name
82+ ? "bg-primary/15 text-primary ring-1 ring-primary/20"
83+ : "text-foreground/60 hover:bg-accent/50 hover:text-foreground",
84+ )}
85+ onClick={() => onOpenSqlObject(entry.name)}
8586 >
8687 {entry.name}
8788 </button>
@@ -90,7 +91,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 2 unmodified lines
9091 <Button
9192 variant="ghost"
9293 size="sm"
93− className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50"
94+ className="h-5 rounded px-1.5 text-xs text-muted-foreground/60"
9495 onClick={onToggleShowInternalObjects}
9596 >
9697 {sqlShowInternalObjects ? "Hide sys" : "Show sys"}
@@ -98,7 +99,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 1 unmodified line
9899 <Button
99100 variant="ghost"
100101 size="sm"
101− className="h-5 rounded px-1.5 text-[9px] text-muted-foreground/50"
102+ className="h-5 rounded px-1.5 text-xs text-muted-foreground/60"
102103 onClick={onRefreshSqlObjects}
103104 disabled={sqlObjectsLoading}
104105 >
@@ -115,7 +116,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 10 unmodified lines
115116 )}
116117
117118 <div className="shrink-0 border-b border-border/30 bg-muted/20 p-3">
118− <textarea
119+ <Textarea
119120 value={sqlText}
120121 onChange={(event) => {
121122 onSqlTextChange(event.target.value);
@@ -126,45 +127,45 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 4 unmodified lines
126127 onRunSql();
127128 }
128129 }}
129− className="min-h-[3.5rem] max-h-32 w-full resize-y rounded-md border border-border/40 bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary/30"
130+ className="min-h-[3.5rem] max-h-32 resize-y font-mono leading-relaxed placeholder:text-muted-foreground/40"
130131 placeholder="SELECT * FROM ..."
131132 />
132133 <div className="mt-2 flex flex-wrap items-center gap-1.5">
133134 <Button
134135 size="sm"
135− className="h-6 rounded-md px-2.5 text-[10px]"
136+ className="h-6 rounded-md px-2.5 text-xs"
136137 disabled={sqlLoading || !canInspect}
137138 onClick={onRunSql}
138139 >
139140 <Play className="mr-1 h-2.5 w-2.5" /> Run
140141 </Button>
141142 <div className="h-4 w-px bg-border/40 mx-0.5" />
142− <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunUserTables}>
143+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunUserTables}>
143144 User tables
144145 </Button>
145− <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunAllObjects}>
146+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunAllObjects}>
146147 All objects
147148 </Button>
148− <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunKvSchema}>
149+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunKvSchema}>
149150 KV schema
150151 </Button>
151− <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunKvData}>
152+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunKvData}>
152153 KV data
153154 </Button>
154− <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-[10px] text-muted-foreground" onClick={onRunFsEntries}>
155+ <Button variant="ghost" size="sm" className="h-6 rounded-md px-2 text-xs text-muted-foreground" onClick={onRunFsEntries}>
155156 FS entries
156157 </Button>
157158 <div className="ml-auto flex items-center gap-1.5">
158− <span className="text-[9px] text-muted-foreground/50">Limit</span>
159+ <span className="text-xs text-muted-foreground/60">Limit</span>
159160 <Input
160161 value={sqlMaxRows}
161162 onChange={(event) => onSqlMaxRowsChange(event.target.value)}
162− className="h-6 w-14 rounded-md text-center text-[10px]"
163+ className="h-6 w-14 rounded-md text-center"
163164 />
164165 </div>
165166 </div>
166− <p className="mt-1.5 text-[9px] text-muted-foreground/40">
167− Press <kbd className="rounded border border-border/40 bg-muted/50 px-1 py-0.5 text-[8px] font-mono">Cmd+Enter</kbd> to run
167+ <p className="mt-1.5 text-xs text-muted-foreground/50">
168+ Press <kbd className="rounded border border-border/40 bg-muted/50 px-1 py-0.5 text-[11px] font-mono">Cmd+Enter</kbd> to run
168169 </p>
169170 </div>
170171
@@ -182,7 +183,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 11 unmodified lines
182183 ) : sqlResult ? (
183184 <>
184185 <div className="flex items-center justify-between border-b border-border/20 px-4 py-1.5 bg-muted/10">
185− <span className="text-[10px] text-muted-foreground">
186+ <span className="text-xs text-muted-foreground">
186187 {sqlResult.rowCount} row{sqlResult.rowCount === 1 ? "" : "s"}
187188 {typeof sqlResult.changes === "number" ? ` · ${sqlResult.changes} change${sqlResult.changes === 1 ? "" : "s"}` : ""}
188189 {sqlColumns.length > 0 ? ` · ${sqlColumns.length} col${sqlColumns.length === 1 ? "" : "s"}` : ""}
@@ -191,7 +192,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 2 unmodified lines
191192 <Button
192193 variant={sqlViewMode === "table" ? "default" : "ghost"}
193194 size="sm"
194− className="h-5 rounded-md px-2 text-[9px]"
195+ className="h-5 rounded-md px-2 text-xs"
195196 onClick={() => onSqlViewModeChange("table")}
196197 >
197198 Table
@@ -199,7 +200,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 1 unmodified line
199200 <Button
200201 variant={sqlViewMode === "json" ? "default" : "ghost"}
201202 size="sm"
202− className="h-5 rounded-md px-2 text-[9px]"
203+ className="h-5 rounded-md px-2 text-xs"
203204 onClick={() => onSqlViewModeChange("json")}
204205 >
205206 JSON
@@ -217,14 +218,14 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 11 unmodified lines
217218 <p className="text-xs text-muted-foreground/60">Query returned no rows</p>
218219 </div>
219220 ) : (
220− <table className="min-w-full border-collapse text-[11px]">
221+ <table className="min-w-full border-collapse text-xs">
221222 <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm">
222223 <tr>
223− <th className="border-b border-r border-border/30 px-3 py-1.5 text-left text-[10px] font-medium text-muted-foreground w-10">#</th>
224+ <th className="w-10 border-b border-r border-border/30 px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">#</th>
224225 {sqlColumns.map((column) => (
225226 <th
226227 key={column}
227− className="border-b border-r border-border/30 px-3 py-1.5 text-left text-[10px] font-medium text-muted-foreground last:border-r-0"
228+ className="border-b border-r border-border/30 px-3 py-1.5 text-left text-xs font-medium text-muted-foreground last:border-r-0"
228229 >
229230 {column}
230231 </th>
executor/apps/web/src/components/tools/view.tsx+22−58
@@ -8,7 +8,6 @@ import { ToolExplorer } from "@/components/tools/explorer"; · 7 unmodified lines
88 import { TaskComposer } from "@/components/tasks/task-composer";
99
1010 import { CredentialsPanel } from "@/components/tools/credentials";
11−import { ConnectionFormDialog } from "@/components/tools/connection/form-dialog";
1211 import { PoliciesPanel } from "@/components/tools/policies";
1312 import { StoragePanel } from "@/components/tools/storage";
1413 import { useSession } from "@/lib/session-context";
@@ -34,6 +33,13 @@ import { toolsCatalogQueryParsers } from "@/lib/url-state/tools"; · 19 unmodified lines
3433
3534 type ToolsTab = "catalog" | "connections" | "policies" | "storage" | "editor";
3635 const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration";
36+const TAB_USES_OWN_LOADING_STATE: Record<ToolsTab, boolean> = {
37+ catalog: true,
38+ connections: true,
39+ policies: true,
40+ storage: true,
41+ editor: true,
42+};
3743
3844 function parseToolsTab(pathname: string): ToolsTab {
3945 const segments = pathname.split("/").filter(Boolean);
@@ -76,17 +82,10 @@ export function ToolsView() { · 36 unmodified lines
7682 const catalogFilterValue = catalogQueryState.approval as FilterApproval;
7783 const catalogSourceValue = useMemo(() => mapEmptyQueryValueToNull(catalogQueryState.source), [catalogQueryState.source]);
7884 const catalogActiveToolPath = mapEmptyQueryValueToNull(catalogQueryState.tool);
79− const catalogSelectedToolPaths = useMemo(
80− () => catalogQueryState.selected ?? [],
81− [catalogQueryState.selected],
82− );
8385 const catalogFocusedSourceName = useMemo(
8486 () => mapEmptyQueryValueToNull(catalogQueryState.sourcePanel),
8587 [catalogQueryState.sourcePanel],
8688 );
87− const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
88− const [connectionDialogEditing, setConnectionDialogEditing] = useState<CredentialRecord | null>(null);
89− const [connectionDialogSourceKey, setConnectionDialogSourceKey] = useState<string | null>(null);
9089 const [storageMutationBusyId, setStorageMutationBusyId] = useState<string | undefined>(undefined);
9190 const [storageCreateBusy, setStorageCreateBusy] = useState(false);
9291 const [regenerationInFlight, setRegenerationInFlight] = useState(false);
@@ -207,6 +206,11 @@ export function ToolsView() { · 114 unmodified lines
207206 const credentialsLoading = !!context && credentials === undefined;
208207 const storageItems = storageInstances ?? [];
209208 const storageLoading = !!context && storageInstances === undefined;
209+ const catalogPanelLoading = sessionLoading || loadingTools;
210+ const connectionsPanelLoading = sessionLoading || credentialsLoading || sourcesLoading;
211+ const policiesPanelLoading = sessionLoading || loadingTools;
212+ const storagePanelLoading = sessionLoading || storageLoading;
213+ const shouldRenderShellSkeleton = sessionLoading && !TAB_USES_OWN_LOADING_STATE[activeTab];
210214
211215 const globalWarnings = useMemo(
212216 () => warnings.filter((warning) => !parseWarningSourceName(warning)),
@@ -330,14 +334,6 @@ export function ToolsView() { · 117 unmodified lines
330334 });
331335 }, [setCatalogQueryState]);
332336
333− const setCatalogSelectedToolPaths = useCallback((toolPaths: string[]) => {
334− void setCatalogQueryState({
335− selected: toolPaths,
336− }, {
337− history: "replace",
338− });
339− }, [setCatalogQueryState]);
340−
341337 const setCatalogFocusedSourceName = useCallback((sourceName: string | null) => {
342338 void setCatalogQueryState({
343339 sourcePanel: sourceName ?? "",
@@ -367,26 +363,6 @@ export function ToolsView() { · 23 unmodified lines
367363 setRegenerationInFlight(false);
368364 }
369365 }, [context, rebuildInventoryNow, regenerationInFlight, showRegenerationToast]);
370− const openConnectionCreate = (sourceKey?: string) => {
371− setConnectionDialogEditing(null);
372− setConnectionDialogSourceKey(sourceKey ?? null);
373− setConnectionDialogOpen(true);
374− };
375−
376− const openConnectionEdit = (credential: CredentialRecord) => {
377− setConnectionDialogEditing(credential);
378− setConnectionDialogSourceKey(null);
379− setConnectionDialogOpen(true);
380− };
381−
382− const handleConnectionDialogOpenChange = (open: boolean) => {
383− setConnectionDialogOpen(open);
384− if (!open) {
385− setConnectionDialogEditing(null);
386− setConnectionDialogSourceKey(null);
387− }
388− };
389−
390366 const handleCreateStorageInstance = useCallback(async (args: {
391367 scopeType: StorageScopeType;
392368 durability: StorageDurability;
@@ -455,7 +431,7 @@ export function ToolsView() { · 62 unmodified lines
455431 }
456432 }, [context, deleteStorageInstance, storageMutationBusyId]);
457433
458− if (sessionLoading) {
434+ if (shouldRenderShellSkeleton) {
459435 return (
460436 <div className="flex h-full min-h-0 flex-col">
461437 <div className="flex flex-1 min-h-0 rounded-none border border-border/50 p-4">
@@ -487,7 +463,7 @@ export function ToolsView() { · 25 unmodified lines
487463 return (
488464 <div className="flex h-full min-h-0 flex-col">
489465 {activeTab === "editor" ? (
490− <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8">
466+ <div className="flex-1 min-h-0 overflow-hidden p-0">
491467 <TaskComposer />
492468 </div>
493469 ) : null}
@@ -506,7 +482,7 @@ export function ToolsView() { · 12 unmodified lines
506482 sourceHasMoreTools={sourceHasMoreTools}
507483 sourceLoadingMoreTools={sourceLoadingMoreTools}
508484 onLoadMoreToolsForSource={loadMoreToolsForSource}
509− loading={loadingTools}
485+ loading={catalogPanelLoading}
510486 sourceDialogMeta={sourceDialogMeta}
511487 sourceAuthProfiles={sourceAuthProfiles}
512488 existingSourceNames={existingSourceNames}
@@ -517,12 +493,10 @@ export function ToolsView() { · 4 unmodified lines
517493 searchValue={catalogSearchValue}
518494 filterApprovalValue={catalogFilterValue}
519495 focusedToolPathValue={catalogActiveToolPath}
520− selectedToolPathsValue={catalogSelectedToolPaths}
521496 focusedSourceNameValue={catalogFocusedSourceName}
522497 onSearchValueChange={setCatalogSearch}
523498 onFilterApprovalValueChange={setCatalogApprovalFilter}
524499 onFocusedToolPathChange={setCatalogActiveToolPath}
525− onSelectedToolPathsChange={setCatalogSelectedToolPaths}
526500 onFocusedSourceNameChange={setCatalogFocusedSourceName}
527501 onRegenerate={handleRegenerateInventory}
528502 inventoryState={inventoryStatus?.state}
@@ -537,22 +511,22 @@ export function ToolsView() { · 8 unmodified lines
537511 ) : null}
538512
539513 {activeTab === "connections" ? (
540− <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8">
514+ <div className="flex-1 min-h-0 overflow-hidden p-0">
541515 <CredentialsPanel
542516 sources={sourceItems}
543517 credentials={credentialItems}
544− loading={credentialsLoading || sourcesLoading}
545− onCreateConnection={openConnectionCreate}
546− onEditConnection={openConnectionEdit}
518+ loading={connectionsPanelLoading}
519+ sourceAuthProfiles={sourceAuthProfiles}
520+ loadingSourceNames={visibleLoadingSources}
547521 />
548522 </div>
549523 ) : null}
550524
551525 {activeTab === "policies" ? (
552− <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8">
526+ <div className="flex-1 min-h-0 overflow-hidden p-0">
553527 <PoliciesPanel
554528 tools={visibleTools}
555− loadingTools={loadingTools}
529+ loadingTools={policiesPanelLoading}
556530 />
557531 </div>
558532 ) : null}
@@ -563,7 +537,7 @@ export function ToolsView() { · 4 unmodified lines
563537 workspaceId={context?.workspaceId}
564538 sessionId={context?.sessionId}
565539 instances={storageItems}
566− loading={storageLoading}
540+ loading={storagePanelLoading}
567541 creating={storageCreateBusy}
568542 busyInstanceId={storageMutationBusyId}
569543 onCreate={handleCreateStorageInstance}
@@ -573,16 +547,6 @@ export function ToolsView() { · 3 unmodified lines
573547 </div>
574548 ) : null}
575549
576− <ConnectionFormDialog
577− open={connectionDialogOpen}
578− onOpenChange={handleConnectionDialogOpenChange}
579− editing={connectionDialogEditing}
580− initialSourceKey={connectionDialogSourceKey}
581− sources={sourceItems}
582− credentials={credentialItems}
583− sourceAuthProfiles={sourceAuthProfiles}
584− loadingSourceNames={visibleLoadingSources}
585− />
586550 </div>
587551 );
588552 }
executor/apps/web/src/components/ui/textarea.tsx+20−0
@@ -0,0 +1,20 @@
1+import * as React from "react"
2+
3+import { cn } from "@/lib/utils"
4+
5+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6+ return (
7+ <textarea
8+ data-slot="textarea"
9+ className={cn(
10+ "border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
12+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
13+ className
14+ )}
15+ {...props}
16+ />
17+ )
18+}
19+
20+export { Textarea }
executor/apps/web/src/lib/credentials/source-helpers.ts+19−9
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 type SourceAuthType,
77 } from "@/lib/tools/source-helpers";
88 import type {
9+ CredentialAdditionalHeader,
910 CredentialScope,
1011 SourceAuthProfile,
1112 ToolSourceScopeType,
@@ -72,7 +73,12 @@ export function connectionDisplayName( · 60 unmodified lines
7273 return `${base} ${ownerScopeLabel(connection.scopeType)}`;
7374 }
7475
75−export function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } {
76+type AdditionalHeadersParseResult = {
77+ value?: Record<string, string>;
78+ error?: string;
79+};
80+
81+export function parseAdditionalHeadersText(text: string): AdditionalHeadersParseResult {
7682 const lines = text
7783 .split("\n")
7884 .map((line) => line.trim())
@@ -98,12 +104,12 @@ export function parseHeaderOverrides(text: string): { value?: Record<string, str · 19 unmodified lines
98104 return { value: headers };
99105 }
100106
101−export type HeaderOverrideEntry = {
107+export type AdditionalHeaderEntry = {
102108 key: string;
103109 value: string;
104110 };
105111
106−export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] {
112+export function parseAdditionalHeaderEntries(text: string): AdditionalHeaderEntry[] {
107113 const rows = text
108114 .split("\n")
109115 .map((line) => line.trim())
@@ -126,7 +132,7 @@ export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] · 16 unmodified lines
126132 });
127133 }
128134
129−export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value?: Record<string, string>; error?: string } {
135+export function parseAdditionalHeaderRows(entries: AdditionalHeaderEntry[]): AdditionalHeadersParseResult {
130136 const headers: Record<string, string> = {};
131137
132138 for (let index = 0; index < entries.length; index += 1) {
@@ -149,7 +155,7 @@ export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value · 16 unmodified lines
149155 return { value: headers };
150156 }
151157
152−export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): string {
158+export function serializeAdditionalHeaderRows(entries: AdditionalHeaderEntry[]): string {
153159 return entries
154160 .map((entry) => ({
155161 key: entry.key.trim(),
@@ -160,9 +166,13 @@ export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): str · 4 unmodified lines
160166 .join("\n");
161167 }
162168
163−export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
164− const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};
165− return Object.entries(headers)
166− .map(([key, value]) => `${key}: ${String(value)}`)
169+export function formatAdditionalHeaders(headers: CredentialAdditionalHeader[] | undefined): string {
170+ return (headers ?? [])
171+ .map((header) => ({
172+ key: header.name.trim(),
173+ value: header.value.trim(),
174+ }))
175+ .filter((entry) => entry.key.length > 0 && entry.value.length > 0)
176+ .map((entry) => `${entry.key}: ${entry.value}`)
167177 .join("\n");
168178 }
executor/apps/web/src/lib/tools/source-helpers.ts+4−2
@@ -1,13 +1,15 @@
11 import { parse as parseDomain } from "tldts";
22 import type {
3+ CredentialScope,
34 CredentialRecord,
45 OpenApiSourceQuality,
56 SourceAuthProfile,
7+ SourceAuthType as SharedSourceAuthType,
68 ToolSourceRecord,
79 } from "@/lib/types";
810
9−export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
10−export type SourceAuthMode = "workspace" | "account" | "organization";
11+export type SourceAuthType = SharedSourceAuthType;
12+export type SourceAuthMode = CredentialScope;
1113
1214 const RAW_HOSTS = new Set([
1315 "raw.githubusercontent.com",
executor/apps/web/src/lib/types.ts+9−23
@@ -1,15 +1,19 @@
11 import type { Id } from "@executor/database/convex/_generated/dataModel";
22 import type {
33 ApprovalStatus as CoreApprovalStatus,
4+ ArgumentCondition as CoreArgumentCondition,
45 ArgumentConditionOperator as CoreArgumentConditionOperator,
6+ CredentialAdditionalHeader as CoreCredentialAdditionalHeader,
57 CredentialProvider as CoreCredentialProvider,
68 CredentialScope as CoreCredentialScope,
79 CredentialScopeType as CoreCredentialScopeType,
10+ OpenApiSourceQuality as CoreOpenApiSourceQuality,
811 PolicyApprovalMode as CorePolicyApprovalMode,
912 PolicyDecision as CorePolicyDecision,
1013 PolicyEffect as CorePolicyEffect,
1114 PolicyMatchType as CorePolicyMatchType,
1215 PolicyScopeType as CorePolicyScopeType,
16+ SourceAuthProfile as CoreSourceAuthProfile,
1317 SourceAuthType as CoreSourceAuthType,
1418 StorageDurability as CoreStorageDurability,
1519 StorageInstanceStatus as CoreStorageInstanceStatus,
@@ -40,11 +44,8 @@ export type ToolRoleSelectorType = CoreToolRoleSelectorType; · 24 unmodified lines
4044 export type ToolRoleBindingStatus = CoreToolRoleBindingStatus;
4145 export type ArgumentConditionOperator = CoreArgumentConditionOperator;
4246
43−export interface ArgumentCondition {
44− key: string;
45− operator: ArgumentConditionOperator;
46− value: string;
47−}
47+export type ArgumentCondition = CoreArgumentCondition;
48+export type CredentialAdditionalHeader = CoreCredentialAdditionalHeader;
4849 export type CredentialScope = CoreCredentialScope;
4950 export type CredentialProvider = CoreCredentialProvider;
5051 export type ToolSourceScopeType = CoreToolSourceScopeType;
@@ -58,12 +59,7 @@ export type StorageProvider = CoreStorageProvider; · 7 unmodified lines
5859
5960 export type SourceAuthType = CoreSourceAuthType;
6061
61−export interface SourceAuthProfile {
62− type: SourceAuthType;
63− mode?: CredentialScope;
64− header?: string;
65− inferred: boolean;
66−}
62+export type SourceAuthProfile = CoreSourceAuthProfile;
6763
6864 export interface TaskRecord {
6965 id: string;
@@ -122,7 +118,7 @@ export interface CredentialRecord { · 52 unmodified lines
122118 organizationId?: string;
123119 workspaceId?: string;
124120 sourceKey: string;
125− overridesJson?: Record<string, unknown>;
121+ additionalHeaders: CredentialAdditionalHeader[];
126122 boundAuthFingerprint?: string;
127123 provider: CredentialProvider;
128124 secretJson: Record<string, unknown>;
@@ -191,17 +187,7 @@ export interface ToolDescriptor { · 62 unmodified lines
191187 };
192188 }
193189
194−export interface OpenApiSourceQuality {
195− sourceKey: string;
196− toolCount: number;
197− unknownArgsCount: number;
198− unknownReturnsCount: number;
199− partialUnknownArgsCount: number;
200− partialUnknownReturnsCount: number;
201− argsQuality: number;
202− returnsQuality: number;
203− overallQuality: number;
204−}
190+export type OpenApiSourceQuality = CoreOpenApiSourceQuality;
205191
206192 export interface AnonymousContext {
207193 sessionId: string;
executor/apps/web/src/lib/url-state/tools.ts+0−6
@@ -1,11 +1,9 @@
11 import {
2− parseAsNativeArrayOf,
32 parseAsString,
43 parseAsStringLiteral,
54 } from "nuqs";
65 import {
76 asTrimmedString,
8− asTrimmedStringArray,
97 } from "@/lib/url-state/shared";
108
119 export const toolsApprovalValues = ["all", "required", "auto"] as const;
@@ -16,7 +14,6 @@ export const toolsCatalogQueryParsers = { · 4 unmodified lines
1614 q: parseAsString.withDefault(""),
1715 approval: parseAsStringLiteral(toolsApprovalValues).withDefault("all"),
1816 tool: parseAsString.withDefault(""),
19− selected: parseAsNativeArrayOf(parseAsString),
2017 source: parseAsString.withDefault(""),
2118 sourcePanel: parseAsString.withDefault(""),
2219 };
@@ -25,7 +22,6 @@ export type ToolsSearch = { · 2 unmodified lines
2522 q?: string;
2623 approval?: ToolsApproval;
2724 tool?: string;
28− selected?: string[];
2925 source?: string;
3026 sourcePanel?: string;
3127 };
@@ -42,7 +38,6 @@ export function normalizeToolsSearch(search: Record<string, unknown>): ToolsSear · 10 unmodified lines
4238 const q = asTrimmedString(search.q);
4339 const approval = normalizeToolsApproval(search.approval);
4440 const tool = asTrimmedString(search.tool);
45− const selected = asTrimmedStringArray(search.selected);
4641 const source = asTrimmedString(search.source);
4742 const sourcePanel = asTrimmedString(search.sourcePanel);
4843
@@ -50,7 +45,6 @@ export function normalizeToolsSearch(search: Record<string, unknown>): ToolsSear · 1 unmodified line
5045 ...(q ? { q } : {}),
5146 ...(approval === "all" ? {} : { approval }),
5247 ...(tool ? { tool } : {}),
53− ...(selected ? { selected } : {}),
5448 ...(source ? { source } : {}),
5549 ...(sourcePanel ? { sourcePanel } : {}),
5650 };