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
150 }150 }
151}151}
152 152
153@media (max-width: 768px) {
154 input,
155 textarea,
156 select {
157 font-size: 16px !important;
158 }
159}
160
153/* ── Shiki / Streamdown code block theme overrides ─────────────────────────── */161/* ── Shiki / Streamdown code block theme overrides ─────────────────────────── */
154/* Light mode: use shiki-light tokens */162/* Light mode: use shiki-light tokens */
155[style*="--shiki-light"] {163[style*="--shiki-light"] {
@@ -493,4 +501,3 @@ · 337 unmodified lines
493 margin: 0;501 margin: 0;
494 padding: 0 0 0 1rem;502 padding: 0 0 0 1rem;
495}503}
496
executor/apps/web/src/components/app-shell.tsx+2−2
@@ -117,7 +117,7 @@ function RepoStarLink() { · 116 unmodified lines
117 href={EXECUTOR_REPO_URL}117 href={EXECUTOR_REPO_URL}
118 target="_blank"118 target="_blank"
119 rel="noreferrer"119 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"
121 >121 >
122 <span className="flex items-center gap-1.5">122 <span className="flex items-center gap-1.5">
123 <Github className="h-3.5 w-3.5" />123 <Github className="h-3.5 w-3.5" />
@@ -134,7 +134,7 @@ function FeedbackLink() { · 10 unmodified lines
134 href={TWITTER_FEEDBACK_URL}134 href={TWITTER_FEEDBACK_URL}
135 target="_blank"135 target="_blank"
136 rel="noreferrer"136 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"
138 >138 >
139 <span className="flex items-center gap-1.5">139 <span className="flex items-center gap-1.5">
140 <MessageCircle className="h-3.5 w-3.5" />140 <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
67 );67 );
68 68
69 return (69 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">
71 <CardContent className="p-4 space-y-3">71 <CardContent className="p-4 space-y-3">
72 {/* Header */}72 {/* Header */}
73 <div className="flex items-start justify-between gap-3">73 <div className="flex items-start justify-between gap-3">
74 <div className="min-w-0">74 <div className="min-w-0">
75 <div className="flex items-center gap-2">75 <div className="flex items-center gap-2">
76 <ShieldCheck className="h-4 w-4 text-terminal-amber shrink-0" />76 <ShieldCheck className="h-4 w-4 shrink-0 text-primary" />
77 <span className="text-sm font-mono font-medium text-foreground truncate">77 <span className="truncate text-sm font-medium text-foreground">
78 {approval.toolPath}78 {approval.toolPath}
79 </span>79 </span>
80 </div>80 </div>
81 <div className="flex items-center gap-2 mt-1 pl-6">81 <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">
83 Task: {approval.taskId}83 Task: {approval.taskId}
84 </span>84 </span>
85 <span className="text-[11px] text-muted-foreground">85 <span className="text-xs text-muted-foreground">
86 &middot;86 &middot;
87 </span>87 </span>
88 <span className="text-[11px] text-muted-foreground">88 <span className="text-xs text-muted-foreground">
89 {formatTimeAgo(approval.createdAt)}89 {formatTimeAgo(approval.createdAt)}
90 </span>90 </span>
91 </div>91 </div>
@@ -106,7 +106,7 @@ function ApprovalCard({ · 14 unmodified lines
106 {/* Input */}106 {/* Input */}
107 {inputDisplay && (107 {inputDisplay && (
108 <div>108 <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">
110 Input110 Input
111 </span>111 </span>
112 <FormattedCodeBlock112 <FormattedCodeBlock
@@ -124,7 +124,7 @@ function ApprovalCard({ · 11 unmodified lines
124 <Button124 <Button
125 onClick={() => handleResolve("approved")}125 onClick={() => handleResolve("approved")}
126 disabled={resolving !== null}126 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"
128 variant="outline"128 variant="outline"
129 size="sm"129 size="sm"
130 >130 >
@@ -134,7 +134,7 @@ function ApprovalCard({ · 3 unmodified lines
134 <Button134 <Button
135 onClick={() => handleResolve("denied")}135 onClick={() => handleResolve("denied")}
136 disabled={resolving !== null}136 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"
138 variant="outline"138 variant="outline"
139 size="sm"139 size="sm"
140 >140 >
@@ -158,13 +158,16 @@ export function ApprovalsView() { · 17 unmodified lines
158 158
159 if (sessionLoading) {159 if (sessionLoading) {
160 return (160 return (
161 <div className="p-4 md:p-6 lg:p-8 space-y-6">161 <div className="flex h-full min-h-0 overflow-hidden bg-background">
162 <Skeleton className="h-8 w-48" />162 <div className="w-72 shrink-0 border-r border-border/40 p-3 space-y-3">
163 <div className="space-y-4">163 <Skeleton className="h-8 w-full" />
164 {Array.from({ length: 3 }).map((_, i) => (164 {Array.from({ length: 3 }).map((_, i) => (
165 <Skeleton key={i} className="h-40" />165 <Skeleton key={i} className="h-16" />
166 ))}166 ))}
167 </div>167 </div>
168 <div className="flex-1 p-4">
169 <Skeleton className="h-40" />
170 </div>
168 </div>171 </div>
169 );172 );
170 }173 }
@@ -172,40 +175,90 @@ export function ApprovalsView() { · 1 unmodified line
172 const count = approvals?.length ?? 0;175 const count = approvals?.length ?? 0;
173 176
174 return (177 return (
175 <div className="flex h-full min-h-0 flex-col p-4 md:p-6 lg:p-8">178 <div className="flex h-full min-h-0 overflow-hidden bg-background">
176 {approvalsLoading ? (179 {/* ── Left sidebar: approval list ── */}
177 <div className="space-y-4">180 <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-80">
178 {Array.from({ length: 3 }).map((_, i) => (181 {/* Sidebar header */}
179 <Skeleton key={i} className="h-40" />182 <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
180 ))}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 )}
181 </div>231 </div>
182 ) : count === 0 ? (232 </aside>
183 <Card className="rounded-none bg-card/40 border-border/50 flex-1">233
184 <CardContent className="flex flex-col items-center justify-center py-20 gap-3">234 {/* ── Right content: full approval cards ── */}
185 <div className="h-12 w-12 rounded-full bg-terminal-green/10 flex items-center justify-center">235 <div className="flex-1 min-w-0 max-h-screen overflow-y-auto bg-background/50">
186 <CheckCircle2 className="h-6 w-6 text-terminal-green/60" />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>
187 </div>248 </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" : ""}
201 </div>249 </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>
203 {(approvals ?? []).map((a: PendingApprovalRecord) => (256 {(approvals ?? []).map((a: PendingApprovalRecord) => (
204 <ApprovalCard key={a.id} approval={a} />257 <ApprovalCard key={a.id} approval={a} />
205 ))}258 ))}
206 </div>259 </div>
207 </div>260 )}
208 )}261 </div>
209 </div>262 </div>
210 );263 );
211}264}
executor/apps/web/src/components/organization/members-view.tsx+34−19
@@ -7,6 +7,13 @@ import { PageHeader } from "@/components/page-header"; · 6 unmodified lines
7import { Button } from "@/components/ui/button";7import { Button } from "@/components/ui/button";
8import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";8import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9import { Input } from "@/components/ui/input";9import { Input } from "@/components/ui/input";
10import {
11 Select,
12 SelectContent,
13 SelectItem,
14 SelectTrigger,
15 SelectValue,
16} from "@/components/ui/select";
10import { useSession } from "@/lib/session-context";17import { useSession } from "@/lib/session-context";
11import { convexApi } from "@/lib/convex-api";18import { convexApi } from "@/lib/convex-api";
12import type { Id } from "@executor/database/convex/_generated/dataModel";19import type { Id } from "@executor/database/convex/_generated/dataModel";
@@ -159,18 +166,22 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 146 unmodified lines
159 type="email"166 type="email"
160 disabled={!canManageMembers || inviteState === "sending"}167 disabled={!canManageMembers || inviteState === "sending"}
161 />168 />
162 <select169 <Select
163 className="h-9 rounded-md border border-input bg-background px-3 text-sm"
164 value={inviteRole}170 value={inviteRole}
165 onChange={(event) => setInviteRole(event.target.value as Role)}171 onValueChange={(value) => setInviteRole(value as Role)}
166 disabled={!canManageMembers || inviteState === "sending"}172 disabled={!canManageMembers || inviteState === "sending"}
167 >173 >
168 {ROLE_OPTIONS.map((role) => (174 <SelectTrigger className="h-9 min-w-40">
169 <option key={role} value={role}>175 <SelectValue />
170 {role}176 </SelectTrigger>
171 </option>177 <SelectContent>
172 ))}178 {ROLE_OPTIONS.map((role) => (
173 </select>179 <SelectItem key={role} value={role}>
180 {role}
181 </SelectItem>
182 ))}
183 </SelectContent>
184 </Select>
174 <Button185 <Button
175 type="submit"186 type="submit"
176 disabled={!canManageMembers || inviteState === "sending" || inviteEmail.trim().length === 0}187 disabled={!canManageMembers || inviteState === "sending" || inviteEmail.trim().length === 0}
@@ -207,11 +218,10 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 30 unmodified lines
207 <p className="text-xs text-muted-foreground">Status: {member.status}</p>218 <p className="text-xs text-muted-foreground">Status: {member.status}</p>
208 </div>219 </div>
209 <div className="flex flex-wrap items-center gap-2">220 <div className="flex flex-wrap items-center gap-2">
210 <select221 <Select
211 className="h-8 rounded-md border border-input bg-background px-2 text-xs"
212 value={member.role}222 value={member.role}
213 disabled={!canManageMembers || busyMemberAccountId === member.accountId}223 disabled={!canManageMembers || busyMemberAccountId === member.accountId}
214 onChange={async (event) => {224 onValueChange={async (value) => {
215 setBusyMemberAccountId(member.accountId);225 setBusyMemberAccountId(member.accountId);
216 try {226 try {
217 if (!typedOrganizationId) {227 if (!typedOrganizationId) {
@@ -220,7 +230,7 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 2 unmodified lines
220 await updateRole({230 await updateRole({
221 organizationId: typedOrganizationId,231 organizationId: typedOrganizationId,
222 accountId: member.accountId,232 accountId: member.accountId,
223 role: event.target.value as Role,233 role: value as Role,
224 sessionId: context?.sessionId ?? undefined,234 sessionId: context?.sessionId ?? undefined,
225 });235 });
226 } finally {236 } finally {
@@ -228,12 +238,17 @@ export function MembersView({ showHeader = true }: MembersViewProps) { · 1 unmodified line
228 }238 }
229 }}239 }}
230 >240 >
231 {ROLE_OPTIONS.map((role) => (241 <SelectTrigger className="h-8 min-w-36">
232 <option key={role} value={role}>242 <SelectValue />
233 {role}243 </SelectTrigger>
234 </option>244 <SelectContent>
235 ))}245 {ROLE_OPTIONS.map((role) => (
236 </select>246 <SelectItem key={role} value={role}>
247 {role}
248 </SelectItem>
249 ))}
250 </SelectContent>
251 </Select>
237 252
238 <Button253 <Button
239 variant="outline"254 variant="outline"
executor/apps/web/src/components/session-info.tsx+2−2
@@ -96,7 +96,7 @@ export function SessionInfo() { · 95 unmodified lines
96 if (loading) {96 if (loading) {
97 return (97 return (
98 <div className="border-t border-border px-3 py-2">98 <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>
100 </div>100 </div>
101 );101 );
102 }102 }
@@ -121,7 +121,7 @@ export function SessionInfo() { · 18 unmodified lines
121 referrerPolicy="no-referrer"121 referrerPolicy="no-referrer"
122 />122 />
123 ) : (123 ) : (
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">
125 {avatarInitial}125 {avatarInitial}
126 </span>126 </span>
127 )}127 )}
executor/apps/web/src/components/status-badge.tsx+1−1
@@ -41,7 +41,7 @@ export function TaskStatusBadge({ status }: { status: TaskStatus }) { · 40 unmodified lines
41 return (41 return (
42 <Badge42 <Badge
43 variant="outline"43 variant="outline"
44 className={cn("text-[10px] font-mono uppercase tracking-wider", config.className)}44 className={cn("text-xs font-medium", config.className)}
45 >45 >
46 {status === "running" && (46 {status === "running" && (
47 <span className="inline-block h-1.5 w-1.5 rounded-full bg-current mr-1.5 pulse-dot" />47 <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 @@
1"use client";1"use client";
2 2
3import { useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Send } from "lucide-react";4import { Send } from "lucide-react";
5import { useAction } from "convex/react";5import { useAction } from "convex/react";
6import { toast } from "sonner";6import { toast } from "sonner";
7import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8import { Button } from "@/components/ui/button";7import { Button } from "@/components/ui/button";
9import { Input } from "@/components/ui/input";8import { Input } from "@/components/ui/input";
10import { Label } from "@/components/ui/label";9import { Label } from "@/components/ui/label";
@@ -142,6 +141,8 @@ export function TaskComposer() { · 131 unmodified lines
142 : runtimeTargets[0]?.id ?? "";141 : runtimeTargets[0]?.id ?? "";
143 const selectedRuntime = runtimeTargets.find((runtime) => runtime.id === effectiveRuntimeId);142 const selectedRuntime = runtimeTargets.find((runtime) => runtime.id === effectiveRuntimeId);
144 const showRuntimeSelector = runtimeTargets.length > 1;143 const showRuntimeSelector = runtimeTargets.length > 1;
144 const isMac = useMemo(() => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent), []);
145 const modKey = isMac ? "⌘" : "Ctrl";
145 146
146 useEffect(() => {147 useEffect(() => {
147 const draft = readCodeDraftForWorkspace(storageWorkspaceId);148 const draft = readCodeDraftForWorkspace(storageWorkspaceId);
@@ -178,7 +179,9 @@ export function TaskComposer() { · 30 unmodified lines
178 };179 };
179 }, [codeDraftStorageKey]);180 }, [codeDraftStorageKey]);
180 181
181 const handleSubmit = async () => {182 const handleSubmitRef = useRef<(() => void) | undefined>(undefined);
183
184 const handleSubmit = useCallback(async () => {
182 if (!context || !code.trim()) return;185 if (!context || !code.trim()) return;
183 setSubmitting(true);186 setSubmitting(true);
184 try {187 try {
@@ -213,79 +216,91 @@ export function TaskComposer() { · 28 unmodified lines
213 } finally {216 } finally {
214 setSubmitting(false);217 setSubmitting(false);
215 }218 }
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 }, []);
217 233
218 return (234 return (
219 <Card className="bg-card border-border">235 <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
220 <CardHeader className="pb-3">236 {/* ── Toolbar ── */}
221 <CardTitle className="text-sm font-medium">Editor</CardTitle>237 <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border/40 px-4 py-2">
222 </CardHeader>238 <div className="flex items-center gap-3">
223 <CardContent className="space-y-4">239 <h3 className="text-sm font-medium">Editor</h3>
224 <div className="grid grid-cols-2 gap-3">240 <span className="text-[10px] font-mono text-muted-foreground">
225 <div className="space-y-1.5">241 {loadingTools
226 {showRuntimeSelector ? <Label className="text-xs text-muted-foreground">Runtime</Label> : null}242 ? "Loading tool inventory..."
227 {showRuntimeSelector ? (243 : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`}
228 <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>244 </span>
229 <SelectTrigger className="h-8 text-xs font-mono bg-background">245 </div>
230 <SelectValue />246 <div className="flex items-center gap-3">
231 </SelectTrigger>247 {showRuntimeSelector ? (
232 <SelectContent>248 <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>
233 {runtimeTargets.map((r: RuntimeTargetDescriptor) => (249 <SelectTrigger className="h-7 text-xs font-mono bg-background w-[160px]">
234 <SelectItem key={r.id} value={r.id} className="text-xs">250 <SelectValue />
235 {r.label}251 </SelectTrigger>
236 </SelectItem>252 <SelectContent>
237 ))}253 {runtimeTargets.map((r: RuntimeTargetDescriptor) => (
238 </SelectContent>254 <SelectItem key={r.id} value={r.id} className="text-xs">
239 </Select>255 {r.label}
240 ) : (256 </SelectItem>
241 null257 ))}
242 )}258 </SelectContent>
243 </div>259 </Select>
244 <div className="space-y-1.5">260 ) : null}
245 <Label className="text-xs text-muted-foreground">261 <div className="flex items-center gap-1.5">
246 Timeout (ms)262 <Label className="text-[10px] text-muted-foreground shrink-0">Timeout</Label>
247 </Label>
248 <Input263 <Input
249 type="number"264 type="number"
250 value={timeoutMs}265 value={timeoutMs}
251 onChange={(e) => setTimeoutMs(e.target.value)}266 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]"
253 />268 />
254 </div>269 </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>
255 </div>282 </div>
256 <div className="space-y-1.5">283 </div>
257 <div className="flex items-center justify-between gap-2">284
258 <Label className="text-xs text-muted-foreground">Code</Label>285 {/* ── Main content area ── */}
259 <span className="text-[10px] font-mono text-muted-foreground">286 <div className="flex flex-1 min-h-0 overflow-hidden">
260 {loadingTools287 {/* Code editor - fills available space */}
261 ? "Loading tool inventory..."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 }}>
262 : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`}289 <CodeEditor
263 </span>290 value={code}
264 </div>291 onChange={handleCodeChange}
265 <div className="rounded-md border border-border">292 tools={tools}
266 <CodeEditor293 typesUrl={typesUrl}
267 value={code}294 stateStorageKey={editorViewStateStorageKey}
268 onChange={handleCodeChange}295 className="h-full"
269 tools={tools}296 height="100%"
270 typesUrl={typesUrl}297 />
271 stateStorageKey={editorViewStateStorageKey}
272 height="400px"
273 />
274 </div>
275 </div>298 </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>
285 299
300 {/* Result panel - right side */}
286 {lastExecution && (301 {lastExecution && (
287 <div className="space-y-2">302 <div className="w-[400px] lg:w-[480px] shrink-0 flex flex-col min-h-0 overflow-hidden">
288 <div className="flex items-center justify-between gap-2">303 <div className="shrink-0 flex items-center justify-between gap-2 border-b border-border/30 px-3 py-2">
289 <span className="text-[10px] uppercase tracking-widest text-muted-foreground">304 <span className="text-[10px] uppercase tracking-widest text-muted-foreground">
290 Last execution305 Last execution
291 </span>306 </span>
@@ -293,36 +308,36 @@ export function TaskComposer() { · 1 unmodified line
293 {lastExecution.status} - {lastExecution.taskId}308 {lastExecution.status} - {lastExecution.taskId}
294 </span>309 </span>
295 </div>310 </div>
296 311 <div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
297 {lastExecution.result !== undefined && (312 {lastExecution.result !== undefined && (
298 <div>313 <div>
299 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">314 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
300 Returned result315 Returned result
301 </span>316 </span>
302 <FormattedCodeBlock317 <FormattedCodeBlock
303 content={lastExecution.result}318 content={lastExecution.result}
304 language="json"319 language="json"
305 className="min-h-40 max-h-[65vh] overflow-auto resize-y"320 className="min-h-40 max-h-full overflow-auto resize-y"
306 />321 />
307 </div>322 </div>
308 )}323 )}
309 324 {lastExecution.error && (
310 {lastExecution.error && (325 <div>
311 <div>326 <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
312 <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">327 Error
313 Error328 </span>
314 </span>329 <FormattedCodeBlock
315 <FormattedCodeBlock330 content={lastExecution.error}
316 content={lastExecution.error}331 language="text"
317 language="text"332 tone="red"
318 tone="red"333 className="min-h-32 max-h-full overflow-auto resize-y"
319 className="min-h-32 max-h-[50vh] overflow-auto resize-y"334 />
320 />335 </div>
321 </div>336 )}
322 )}337 </div>
323 </div>338 </div>
324 )}339 )}
325 </CardContent>340 </div>
326 </Card>341 </div>
327 );342 );
328}343}
executor/apps/web/src/components/tasks/task-detail.tsx+18−17
@@ -99,9 +99,10 @@ export function TaskDetail({ · 98 unmodified lines
99 <Card className="bg-card border-border">99 <Card className="bg-card border-border">
100 <CardHeader className="pb-3">100 <CardHeader className="pb-3">
101 <div className="flex items-center justify-between">101 <div className="flex items-center justify-between">
102 <CardTitle className="text-sm font-medium font-mono truncate pr-4">102 <div className="min-w-0 pr-4">
103 {liveTask.id}103 <CardTitle className="truncate text-sm font-semibold">Task details</CardTitle>
104 </CardTitle>104 <p className="truncate text-xs text-muted-foreground">{liveTask.id}</p>
105 </div>
105 <Button106 <Button
106 variant="ghost"107 variant="ghost"
107 size="icon"108 size="icon"
@@ -116,19 +117,19 @@ export function TaskDetail({ · 8 unmodified lines
116 <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">117 <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
117 {[118 {[
118 { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> },119 { label: "Status", value: <TaskStatusBadge status={liveTask.status} /> },
119 { label: "Runtime", value: <span className="font-mono text-xs">{runtimeLabel ?? liveTask.runtimeId}</span> },120 { label: "Runtime", value: <span className="text-sm">{runtimeLabel ?? liveTask.runtimeId}</span> },
120 { label: "Duration", value: <span className="font-mono text-xs">{duration}</span> },121 { label: "Duration", value: <span className="text-sm">{duration}</span> },
121 {122 {
122 label: "Exit Code",123 label: "Exit Code",
123 value: (124 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")}>
125 {liveTask.exitCode ?? "—"}126 {liveTask.exitCode ?? "—"}
126 </span>127 </span>
127 ),128 ),
128 },129 },
129 ].map((item) => (130 ].map((item) => (
130 <div key={item.label}>131 <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">
132 {item.label}133 {item.label}
133 </span>134 </span>
134 {item.value}135 {item.value}
@@ -141,10 +142,10 @@ export function TaskDetail({ · 6 unmodified lines
141 <Separator />142 <Separator />
142 <div className="space-y-2.5">143 <div className="space-y-2.5">
143 <div className="flex items-center justify-between">144 <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">
145 Pending approvals146 Pending approvals
146 </span>147 </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">
148 {pendingApprovals.length}149 {pendingApprovals.length}
149 </span>150 </span>
150 </div>151 </div>
@@ -154,14 +155,14 @@ export function TaskDetail({ · 3 unmodified lines
154 return (155 return (
155 <div156 <div
156 key={approval.id}157 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"
158 >159 >
159 <div className="flex items-center justify-between gap-3">160 <div className="flex items-center justify-between gap-3">
160 <div className="min-w-0">161 <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">
162 {approval.toolPath}163 {approval.toolPath}
163 </p>164 </p>
164 <p className="text-[10px] text-muted-foreground">165 <p className="text-xs text-muted-foreground">
165 Requested {formatDate(approval.createdAt)}166 Requested {formatDate(approval.createdAt)}
166 </p>167 </p>
167 </div>168 </div>
@@ -169,7 +170,7 @@ export function TaskDetail({ · 1 unmodified line
169 <Button170 <Button
170 variant="outline"171 variant="outline"
171 size="sm"172 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"
173 disabled={resolvingApprovalId !== null}174 disabled={resolvingApprovalId !== null}
174 onClick={() =>175 onClick={() =>
175 void handleResolveApproval(176 void handleResolveApproval(
@@ -185,7 +186,7 @@ export function TaskDetail({ · 9 unmodified lines
185 <Button186 <Button
186 variant="outline"187 variant="outline"
187 size="sm"188 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"
189 disabled={resolvingApprovalId !== null}190 disabled={resolvingApprovalId !== null}
190 onClick={() =>191 onClick={() =>
191 void handleResolveApproval(192 void handleResolveApproval(
@@ -217,7 +218,7 @@ export function TaskDetail({ · 25 unmodified lines
217 <Separator />218 <Separator />
218 219
219 <div>220 <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">
221 Code222 Code
222 </span>223 </span>
223 <FormattedCodeBlock224 <FormattedCodeBlock
@@ -229,7 +230,7 @@ export function TaskDetail({ · 5 unmodified lines
229 230
230 {liveResult && (231 {liveResult && (
231 <div>232 <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">
233 Result234 Result
234 </span>235 </span>
235 <FormattedCodeBlock236 <FormattedCodeBlock
@@ -242,7 +243,7 @@ export function TaskDetail({ · 6 unmodified lines
242 243
243 {liveTask.error && (244 {liveTask.error && (
244 <div>245 <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">
246 Error247 Error
247 </span>248 </span>
248 <FormattedCodeBlock249 <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
34 >34 >
35 <div className="flex-1 min-w-0">35 <div className="flex-1 min-w-0">
36 <div className="flex items-center gap-2">36 <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">
38 {task.id}38 {task.id}
39 </span>39 </span>
40 </div>40 </div>
41 <div className="flex items-center gap-2 mt-0.5">41 <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">
43 {runtimeLabel}43 {runtimeLabel}
44 </span>44 </span>
45 <span className="text-[10px] text-muted-foreground">45 <span className="text-xs text-muted-foreground">
46 {formatDate(task.createdAt)}46 {formatDate(task.createdAt)}
47 </span>47 </span>
48 </div>48 </div>
executor/apps/web/src/components/tasks/tasks-view.tsx+77−70
@@ -4,7 +4,6 @@ import { useCallback } from "react"; · 3 unmodified lines
4import { useQueryStates } from "nuqs";4import { useQueryStates } from "nuqs";
5import { useNavigate } from "@/lib/router";5import { useNavigate } from "@/lib/router";
6import { ShieldCheck } from "lucide-react";6import { ShieldCheck } from "lucide-react";
7import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8import { Button } from "@/components/ui/button";7import { Button } from "@/components/ui/button";
9import { Skeleton } from "@/components/ui/skeleton";8import { Skeleton } from "@/components/ui/skeleton";
10import { TaskDetail } from "@/components/tasks/task-detail";9import { TaskDetail } from "@/components/tasks/task-detail";
@@ -59,83 +58,91 @@ export function TasksView() { · 48 unmodified lines
59 58
60 if (sessionLoading) {59 if (sessionLoading) {
61 return (60 return (
62 <div className="p-4 md:p-6 lg:p-8 space-y-6">61 <div className="flex h-full min-h-0 overflow-hidden bg-background">
63 <Skeleton className="h-8 w-48" />62 <div className="w-80 shrink-0 border-r border-border/40 p-3 space-y-3">
64 <Skeleton className="h-64" />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>
65 </div>71 </div>
66 );72 );
67 }73 }
68 74
69 return (75 return (
70 <div className="flex h-full min-h-0 flex-col p-4 md:p-6 lg:p-8">76 <div className="flex h-full min-h-0 overflow-hidden bg-background">
71 <div className="mb-4 flex items-center justify-end gap-2">77 {/* ── Left sidebar: task list ── */}
72 <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => navigate("/approvals")}>78 <aside className="flex h-full w-80 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-[360px]">
73 <ShieldCheck className="h-3.5 w-3.5 mr-1.5" />79 {/* Sidebar header */}
74 {pendingApprovals.length} pending80 <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
75 </Button>81 <div className="flex items-center justify-between">
76 </div>82 <div className="flex items-center gap-2">
77 83 <h3 className="text-sm font-medium leading-none">Tasks</h3>
78 <div className="flex min-h-0 flex-1 flex-col">84 {tasks && (
79 <div className="grid h-full min-h-0 gap-6 lg:grid-cols-[minmax(360px,440px)_1fr]">85 <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">
80 <Card className="rounded-none bg-card/40 border-border/50 min-h-0 flex flex-col">86 {taskItems.length}
81 <CardHeader className="pb-3">87 </span>
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>
114 )}88 )}
115 </CardContent>89 </div>
116 </Card>90 <Button variant="outline" size="sm" className="h-7 text-[10px] gap-1" onClick={() => navigate("/approvals")}>
117 91 <ShieldCheck className="h-3 w-3" />
118 <div className="min-h-0">92 {pendingApprovals.length} pending
119 {selectedTask && context ? (93 </Button>
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 )}
137 </div>94 </div>
138 </div>95 </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 )}
139 </div>146 </div>
140 </div>147 </div>
141 );148 );
executor/apps/web/src/components/tools/add/source-dialog.tsx+1−3
@@ -85,9 +85,8 @@ export function AddSourceDialog({ · 84 unmodified lines
85 trigger?: ReactNode;85 trigger?: ReactNode;
86}) {86}) {
87 const { context } = useSession();87 const { context } = useSession();
88 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);88 const upsertToolSource = useAction(convexApi.workspace.upsertToolSource);
89 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);89 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
90 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
91 const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade);90 const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade);
92 const credentials = useQuery(91 const credentials = useQuery(
93 convexApi.workspace.listCredentials,92 convexApi.workspace.listCredentials,
@@ -218,7 +217,6 @@ export function AddSourceDialog({ · 124 unmodified lines
218 sourceToEdit,217 sourceToEdit,
219 credentialsLoading,218 credentialsLoading,
220 upsertToolSource,219 upsertToolSource,
221 upsertCredential,
222 form: {220 form: {
223 name: form.name,221 name: form.name,
224 endpoint: form.endpoint,222 endpoint: form.endpoint,
executor/apps/web/src/components/tools/add/source-submit.ts+36−44
@@ -1,4 +1,3 @@
1import { sourceKeyForSource } from "@/lib/tools/source-helpers";
2import type {1import type {
3 AnonymousContext,2 AnonymousContext,
4 CredentialRecord,3 CredentialRecord,
@@ -18,16 +17,12 @@ type UpsertToolSourceFn = (args: { · 13 unmodified lines
18 name: string;17 name: string;
19 type: SourceType;18 type: SourceType;
20 config: Record<string, unknown>;19 config: Record<string, unknown>;
21}) => Promise<unknown>;20 credential?: {
22 21 id?: CredentialRecord["id"];
23type UpsertCredentialFn = (args: {22 scopeType?: CredentialScope;
24 id?: CredentialRecord["id"];23 accountId?: AnonymousContext["accountId"];
25 workspaceId: AnonymousContext["workspaceId"];24 secretJson: Record<string, unknown>;
26 sessionId: AnonymousContext["sessionId"];25 };
27 scopeType?: CredentialScope;
28 sourceKey: string;
29 accountId?: AnonymousContext["accountId"];
30 secretJson: Record<string, unknown>;
31}) => Promise<unknown>;26}) => Promise<unknown>;
32 27
33type SaveFormSnapshot = {28type SaveFormSnapshot = {
@@ -63,46 +58,29 @@ export async function saveSourceWithCredentials({ · 29 unmodified lines
63 form,58 form,
64 credentialsLoading,59 credentialsLoading,
65 upsertToolSource,60 upsertToolSource,
66 upsertCredential,
67}: {61}: {
68 context: AnonymousContext;62 context: AnonymousContext;
69 sourceToEdit?: ToolSourceRecord;63 sourceToEdit?: ToolSourceRecord;
70 form: SaveFormSnapshot;64 form: SaveFormSnapshot;
71 credentialsLoading: boolean;65 credentialsLoading: boolean;
72 upsertToolSource: UpsertToolSourceFn;66 upsertToolSource: UpsertToolSourceFn;
73 upsertCredential: UpsertCredentialFn;
74}): Promise<{ source: ToolSourceRecord; connected: boolean }> {67}): Promise<{ source: ToolSourceRecord; connected: boolean }> {
75 const authConfig = form.type === "openapi" || form.type === "graphql" || form.type === "mcp"68 const authConfig = form.type === "openapi" || form.type === "graphql" || form.type === "mcp"
76 ? form.buildAuthConfig()69 ? form.buildAuthConfig()
77 : undefined;70 : undefined;
78 71
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
98 let linkedCredential = false;72 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;
99 81
100 if ((form.type === "openapi" || form.type === "graphql" || form.type === "mcp") && form.authType !== "none") {82 if ((form.type === "openapi" || form.type === "graphql" || form.type === "mcp") && form.authType !== "none") {
101 const credentialScopeType = credentialScopeTypeForAuthScope(form.authScope, form.scopeType);83 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 }
106 84
107 if (form.authScope === "account" && !context.accountId) {85 if (form.authScope === "account" && !context.accountId) {
108 throw new Error("Account credentials require an authenticated account");86 throw new Error("Account credentials require an authenticated account");
@@ -119,30 +97,44 @@ export async function saveSourceWithCredentials({ · 10 unmodified lines
119 throw new Error(secret.error ?? "Credential values are required");97 throw new Error(secret.error ?? "Credential values are required");
120 }98 }
121 99
122 await upsertCredential({100 credentialPayload = {
123 ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}),101 ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}),
124 workspaceId: context.workspaceId,
125 sessionId: context.sessionId,
126 scopeType: credentialScopeType,102 scopeType: credentialScopeType,
127 sourceKey,
128 ...(credentialScopeType === "account" ? { accountId: context.accountId } : {}),103 ...(credentialScopeType === "account" ? { accountId: context.accountId } : {}),
129 secretJson: secret.value,104 secretJson: secret.value,
130 });105 };
131 linkedCredential = true;106 linkedCredential = true;
132 } else if (form.existingScopedCredential) {107 } else if (form.existingScopedCredential) {
133 if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) {108 if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) {
134 // Existing credential doesn't match — save source without credentials.
135 linkedCredential = false;109 linkedCredential = false;
136 } else {110 } else {
137 linkedCredential = true;111 linkedCredential = true;
138 }112 }
139 } else {113 } else {
140 // No credentials entered and none saved — save source without credentials.
141 // The caller will show a warning toast.
142 linkedCredential = false;114 linkedCredential = false;
143 }115 }
144 }116 }
145 117
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
146 return {138 return {
147 source: created,139 source: created,
148 connected: linkedCredential,140 connected: linkedCredential,
executor/apps/web/src/components/tools/connection/form-dialog.tsx+19−15
@@ -30,10 +30,10 @@ import type { · 29 unmodified lines
30} from "@/lib/types";30} from "@/lib/types";
31import {31import {
32 connectionDisplayName,32 connectionDisplayName,
33 parseHeaderOverrideEntries,33 parseAdditionalHeaderEntries,
34 parseHeaderOverrideRows,34 parseAdditionalHeaderRows,
35 serializeHeaderOverrideRows,35 serializeAdditionalHeaderRows,
36 type HeaderOverrideEntry,36 type AdditionalHeaderEntry,
37} from "@/lib/credentials/source-helpers";37} from "@/lib/credentials/source-helpers";
38import { sourceForCredentialKey } from "@/lib/tools/source-helpers";38import { sourceForCredentialKey } from "@/lib/tools/source-helpers";
39import { ConnectionAuthFields } from "./auth-fields";39import { ConnectionAuthFields } from "./auth-fields";
@@ -67,7 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines
67 const { context, clientConfig } = useSession();67 const { context, clientConfig } = useSession();
68 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);68 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
69 const [saving, setSaving] = useState(false);69 const [saving, setSaving] = useState(false);
70 const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries(""));70 const [headerRows, setHeaderRows] = useState<AdditionalHeaderEntry[]>(() => parseAdditionalHeaderEntries(""));
71 const {71 const {
72 sourceKey,72 sourceKey,
73 scopeType,73 scopeType,
@@ -80,7 +80,7 @@ export function ConnectionFormDialog({ · 6 unmodified lines
80 apiKeyValue,80 apiKeyValue,
81 basicUsername,81 basicUsername,
82 basicPassword,82 basicPassword,
83 customHeadersText,83 additionalHeadersText,
84 sourceOptions,84 sourceOptions,
85 connectionOptions,85 connectionOptions,
86 compatibleConnectionOptions,86 compatibleConnectionOptions,
@@ -94,7 +94,7 @@ export function ConnectionFormDialog({ · 7 unmodified lines
94 setApiKeyValue,94 setApiKeyValue,
95 setBasicUsername,95 setBasicUsername,
96 setBasicPassword,96 setBasicPassword,
97 setCustomHeadersText,97 setAdditionalHeadersText,
98 handleSourceKeyChange,98 handleSourceKeyChange,
99 } = useConnectionFormDialogForm({99 } = useConnectionFormDialogForm({
100 open,100 open,
@@ -125,8 +125,8 @@ export function ConnectionFormDialog({ · 24 unmodified lines
125 return;125 return;
126 }126 }
127 127
128 setHeaderRows(parseHeaderOverrideEntries(customHeadersText));128 setHeaderRows(parseAdditionalHeaderEntries(additionalHeadersText));
129 }, [open, customHeadersText]);129 }, [open, additionalHeadersText]);
130 130
131 const handleSave = async () => {131 const handleSave = async () => {
132 if (!context) {132 if (!context) {
@@ -141,14 +141,14 @@ export function ConnectionFormDialog({ · 8 unmodified lines
141 return;141 return;
142 }142 }
143 143
144 const parsedHeaders = parseHeaderOverrideRows(headerRows);144 const parsedHeaders = parseAdditionalHeaderRows(headerRows);
145 if (!parsedHeaders.value) {145 if (!parsedHeaders.value) {
146 toast.error(parsedHeaders.error ?? "Invalid header overrides");146 toast.error(parsedHeaders.error ?? "Invalid additional headers");
147 return;147 return;
148 }148 }
149 149
150 const serializedHeaders = serializeHeaderOverrideRows(headerRows);150 const serializedHeaders = serializeAdditionalHeaderRows(headerRows);
151 setCustomHeadersText(serializedHeaders);151 setAdditionalHeadersText(serializedHeaders);
152 152
153 const linkExisting = !editing && connectionMode === "existing";153 const linkExisting = !editing && connectionMode === "existing";
154 if (linkExisting && !existingConnectionKey) {154 if (linkExisting && !existingConnectionKey) {
@@ -185,7 +185,6 @@ export function ConnectionFormDialog({ · 30 unmodified lines
185 basicPassword,185 basicPassword,
186 apiKeyValue,186 apiKeyValue,
187 tokenValue,187 tokenValue,
188 parsedHeaders: parsedHeaders.value,
189 });188 });
190 189
191 if (!secretResult.secretJson) {190 if (!secretResult.secretJson) {
@@ -200,6 +199,10 @@ export function ConnectionFormDialog({ · 8 unmodified lines
200 : scopeType === "organization"199 : scopeType === "organization"
201 ? "organization"200 ? "organization"
202 : "workspace";201 : "workspace";
202 const additionalHeaders = Object.entries(parsedHeaders.value).map(([name, value]) => ({
203 name,
204 value,
205 }));
203 206
204 await upsertCredential({207 await upsertCredential({
205 ...(editing ? { id: editing.id } : selectedExistingConnection ? { id: selectedExistingConnection.id } : {}),208 ...(editing ? { id: editing.id } : selectedExistingConnection ? { id: selectedExistingConnection.id } : {}),
@@ -209,6 +212,7 @@ export function ConnectionFormDialog({ · 3 unmodified lines
209 scopeType: credentialScopeType,212 scopeType: credentialScopeType,
210 ...(credentialScopeType === "account" ? { accountId: accountId.trim() as typeof context.accountId } : {}),213 ...(credentialScopeType === "account" ? { accountId: accountId.trim() as typeof context.accountId } : {}),
211 secretJson: secretResult.secretJson,214 secretJson: secretResult.secretJson,
215 additionalHeaders,
212 });216 });
213 217
214 toast.success(connectionSuccessCopy(Boolean(editing), linkExisting));218 toast.success(connectionSuccessCopy(Boolean(editing), linkExisting));
@@ -231,7 +235,7 @@ export function ConnectionFormDialog({ · 16 unmodified lines
231 });235 });
232 };236 };
233 237
234 const updateHeaderRow = (index: number, patch: Partial<HeaderOverrideEntry>) => {238 const updateHeaderRow = (index: number, patch: Partial<AdditionalHeaderEntry>) => {
235 setHeaderRows((current) =>239 setHeaderRows((current) =>
236 current.map((entry, rowIndex) => {240 current.map((entry, rowIndex) => {
237 if (rowIndex !== index) {241 if (rowIndex !== index) {
executor/apps/web/src/components/tools/connection/form-panel.tsx+441−0
@@ -0,0 +1,441 @@
1"use client";
2
3import { type ChangeEvent, useEffect, useState } from "react";
4import { useAction } from "convex/react";
5import { toast } from "sonner";
6import { Button } from "@/components/ui/button";
7import { Input } from "@/components/ui/input";
8import { Label } from "@/components/ui/label";
9import { Badge } from "@/components/ui/badge";
10import { Plus, Trash2, X } from "lucide-react";
11import {
12 Select,
13 SelectContent,
14 SelectItem,
15 SelectTrigger,
16 SelectValue,
17} from "@/components/ui/select";
18import { useSession } from "@/lib/session-context";
19import { convexApi } from "@/lib/convex-api";
20import type {
21 CredentialRecord,
22 SourceAuthProfile,
23 ToolSourceRecord,
24} from "@/lib/types";
25import {
26 connectionDisplayName,
27 parseAdditionalHeaderEntries,
28 parseAdditionalHeaderRows,
29 serializeAdditionalHeaderRows,
30 type AdditionalHeaderEntry,
31} from "@/lib/credentials/source-helpers";
32import { sourceForCredentialKey } from "@/lib/tools/source-helpers";
33import { ConnectionAuthFields } from "./auth-fields";
34import { type ConnectionMode } from "./form/dialog-state";
35import { useConnectionFormDialogForm } from "./form/dialog-form";
36import {
37 buildSecretJson,
38 connectionSubmitCopy,
39 connectionSuccessCopy,
40} from "./form-save";
41
42export 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
8 basicPassword: string;8 basicPassword: string;
9 apiKeyValue: string;9 apiKeyValue: string;
10 tokenValue: string;10 tokenValue: string;
11 parsedHeaders: Record<string, string>;
12};11};
13 12
14export function buildSecretJson({13export function buildSecretJson({
@@ -19,7 +18,6 @@ export function buildSecretJson({ · 4 unmodified lines
19 basicPassword,18 basicPassword,
20 apiKeyValue,19 apiKeyValue,
21 tokenValue,20 tokenValue,
22 parsedHeaders,
23}: BuildSecretJsonParams): { secretJson?: Record<string, unknown>; error?: string } {21}: BuildSecretJsonParams): { secretJson?: Record<string, unknown>; error?: string } {
24 const secretJson: Record<string, unknown> = {};22 const secretJson: Record<string, unknown> = {};
25 if (!linkExisting) {23 if (!linkExisting) {
@@ -43,11 +41,6 @@ export function buildSecretJson({ · 17 unmodified lines
43 }41 }
44 }42 }
45 }43 }
46
47 if (Object.keys(parsedHeaders).length > 0) {
48 secretJson.__headers = parsedHeaders;
49 }
50
51 if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) {44 if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) {
52 if (selectedAuthType === "basic") {45 if (selectedAuthType === "basic") {
53 return { error: "Username and password are required" };46 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
6 ToolSourceRecord,6 ToolSourceRecord,
7} from "@/lib/types";7} from "@/lib/types";
8import {8import {
9 formatHeaderOverrides,9 formatAdditionalHeaders,
10 sourceAuthForKey,10 sourceAuthForKey,
11} from "@/lib/credentials/source-helpers";11} from "@/lib/credentials/source-helpers";
12import {12import {
@@ -40,7 +40,7 @@ type FormState = { · 27 unmodified lines
40 apiKeyValue: string;40 apiKeyValue: string;
41 basicUsername: string;41 basicUsername: string;
42 basicPassword: string;42 basicPassword: string;
43 customHeadersText: string;43 additionalHeadersText: string;
44};44};
45 45
46type SharingScope = "only_me" | "workspace" | "organization";46type SharingScope = "only_me" | "workspace" | "organization";
@@ -102,7 +102,7 @@ function initialFormState({ · 55 unmodified lines
102 apiKeyValue: "",102 apiKeyValue: "",
103 basicUsername: "",103 basicUsername: "",
104 basicPassword: "",104 basicPassword: "",
105 customHeadersText: formatHeaderOverrides(editing.overridesJson),105 additionalHeadersText: formatAdditionalHeaders(editing.additionalHeaders),
106 };106 };
107 }107 }
108 108
@@ -120,7 +120,7 @@ function initialFormState({ · 11 unmodified lines
120 apiKeyValue: "",120 apiKeyValue: "",
121 basicUsername: "",121 basicUsername: "",
122 basicPassword: "",122 basicPassword: "",
123 customHeadersText: "",123 additionalHeadersText: "",
124 };124 };
125}125}
126 126
@@ -157,7 +157,7 @@ export function useConnectionFormDialogForm({ · 30 unmodified lines
157 apiKeyValue,157 apiKeyValue,
158 basicUsername,158 basicUsername,
159 basicPassword,159 basicPassword,
160 customHeadersText,160 additionalHeadersText,
161 } = form;161 } = form;
162 const compatibleConnectionOptions = useMemo(162 const compatibleConnectionOptions = useMemo(
163 () => compatibleConnections(connectionOptions, scopeType, scope, accountId),163 () => compatibleConnections(connectionOptions, scopeType, scope, accountId),
@@ -253,8 +253,8 @@ export function useConnectionFormDialogForm({ · 89 unmodified lines
253 dispatch({ type: "patch", patch: { basicPassword: nextBasicPassword } });253 dispatch({ type: "patch", patch: { basicPassword: nextBasicPassword } });
254 };254 };
255 255
256 const setCustomHeadersText = (nextCustomHeadersText: string) => {256 const setAdditionalHeadersText = (nextAdditionalHeadersText: string) => {
257 dispatch({ type: "patch", patch: { customHeadersText: nextCustomHeadersText } });257 dispatch({ type: "patch", patch: { additionalHeadersText: nextAdditionalHeadersText } });
258 };258 };
259 259
260 return {260 return {
@@ -269,7 +269,7 @@ export function useConnectionFormDialogForm({ · 8 unmodified lines
269 apiKeyValue,269 apiKeyValue,
270 basicUsername,270 basicUsername,
271 basicPassword,271 basicPassword,
272 customHeadersText,272 additionalHeadersText,
273 sourceOptions,273 sourceOptions,
274 connectionOptions,274 connectionOptions,
275 compatibleConnectionOptions,275 compatibleConnectionOptions,
@@ -283,7 +283,7 @@ export function useConnectionFormDialogForm({ · 7 unmodified lines
283 setApiKeyValue,283 setApiKeyValue,
284 setBasicUsername,284 setBasicUsername,
285 setBasicPassword,285 setBasicPassword,
286 setCustomHeadersText,286 setAdditionalHeadersText,
287 handleSourceKeyChange,287 handleSourceKeyChange,
288 };288 };
289}289}
executor/apps/web/src/components/tools/credentials.tsx+180−99
@@ -1,46 +1,47 @@
1"use client";1"use client";
2 2
3import { useMemo } from "react";3import { useMemo, useState } from "react";
4import { KeyRound, Plus } from "lucide-react";4import { KeyRound, Plus } from "lucide-react";
5import { Button } from "@/components/ui/button";5import { Button } from "@/components/ui/button";
6import { Badge } from "@/components/ui/badge";6import { Badge } from "@/components/ui/badge";
7import { Skeleton } from "@/components/ui/skeleton";7import { Skeleton } from "@/components/ui/skeleton";
8import { useSession } from "@/lib/session-context";
9import type {8import type {
10 CredentialRecord,9 CredentialRecord,
10 SourceAuthProfile,
11 ToolSourceScopeType,11 ToolSourceScopeType,
12 ToolSourceRecord,12 ToolSourceRecord,
13} from "@/lib/types";13} from "@/lib/types";
14import {14import {
15 connectionDisplayName,15 connectionDisplayName,
16 ownerScopeLabel,
17 providerLabel,
18} from "@/lib/credentials/source-helpers";16} from "@/lib/credentials/source-helpers";
19import {17import {
20 sourceForCredentialKey,18 sourceForCredentialKey,
21} from "@/lib/tools/source-helpers";19} from "@/lib/tools/source-helpers";
22import { SourceFavicon } from "./source-favicon";20import { SourceFavicon } from "./source-favicon";
21import { ConnectionFormPanel } from "./connection/form-panel";
22import { cn } from "@/lib/utils";
23 23
24type ConnectionScope = "account" | "workspace";24type ConnectionScope = "account" | "workspace";
25 25
26type PanelState =
27 | { mode: "idle" }
28 | { mode: "create"; sourceKey?: string }
29 | { mode: "edit"; credential: CredentialRecord };
30
26export function CredentialsPanel({31export function CredentialsPanel({
27 sources,32 sources,
28 credentials,33 credentials,
29 loading,34 loading,
30 onCreateConnection,35 sourceAuthProfiles,
31 onEditConnection,36 loadingSourceNames,
32}: {37}: {
33 sources: ToolSourceRecord[];38 sources: ToolSourceRecord[];
34 credentials: CredentialRecord[];39 credentials: CredentialRecord[];
35 loading: boolean;40 loading: boolean;
36 onCreateConnection: (sourceKey?: string) => void;41 sourceAuthProfiles: Record<string, SourceAuthProfile>;
37 onEditConnection: (credential: CredentialRecord) => void;42 loadingSourceNames?: string[];
38}) {43}) {
39 const { clientConfig } = useSession();44 const [panel, setPanel] = useState<PanelState>({ mode: "idle" });
40
41 const storageCopy = clientConfig?.authProviderMode === "workos"
42 ? "Stored encrypted"
43 : "Stored locally on this machine";
44 45
45 const connectionOptions = useMemo(() => {46 const connectionOptions = useMemo(() => {
46 const grouped = new Map<string, {47 const grouped = new Map<string, {
@@ -91,104 +92,184 @@ export function CredentialsPanel({ · 44 unmodified lines
91 return map;92 return map;
92 }, [credentials]);93 }, [credentials]);
93 94
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
94 return (102 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">103 <section className="flex h-full min-h-0 w-full overflow-hidden bg-background">
96 <div className="flex items-center justify-between border-b border-border/40 px-4 py-3 sm:px-5">104 {/* ── Left sidebar ── */}
97 <div className="flex items-center gap-2">105 <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72">
98 <div className="flex h-7 w-7 items-center justify-center border border-border/60 bg-background/80">106 {/* Sidebar header */}
99 <KeyRound className="h-4 w-4 text-muted-foreground" />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>
100 </div>121 </div>
101 <h2 className="text-sm font-medium">Connections</h2>122 <p className="text-[10px] text-muted-foreground mt-1">
102 </div>123 {connectionOptions.length} {connectionOptions.length === 1 ? "connection" : "connections"}
103 <div className="flex items-center gap-2">124 </p>
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>
111 </div>125 </div>
112 </div>
113 126
114 <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">127 {/* Sidebar connection list */}
115 {loading ? (128 <div className="flex-1 min-h-0 overflow-y-auto">
116 <div className="space-y-2">129 {loading ? (
117 {Array.from({ length: 3 }).map((_, i) => (130 <div className="space-y-2 p-3">
118 <Skeleton key={i} className="h-14" />131 {Array.from({ length: 4 }).map((_, i) => (
119 ))}132 <Skeleton key={i} className="h-12 rounded-md" />
120 </div>133 ))}
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" />
125 </div>134 </div>
126 <p className="text-sm text-muted-foreground">No connections configured</p>135 ) : connectionOptions.length === 0 ? (
127 <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">136 <div className="flex flex-col items-center gap-2.5 px-4 py-10">
128 Add a source, then create or link a reusable connection.137 <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">
129 </p>138 <KeyRound className="h-5 w-5 text-muted-foreground/50" />
130 </div>139 </div>
131 ) : (140 <div className="text-center">
132 <div className="space-y-2">141 <p className="text-xs text-muted-foreground">No connections</p>
133 {connectionOptions.map((connection) => {142 <p className="text-[10px] text-muted-foreground/60 mt-0.5">
134 const representative = representativeCredentialByConnection.get(connection.key);143 Add a source, then create or link a reusable connection.
135 if (!representative) {144 </p>
136 return null;145 </div>
137 }146 <Button
138 const firstSource = sourceForCredentialKey(sources, representative.sourceKey);147 variant="outline"
139 return (148 size="sm"
140 <div149 className="h-7 text-[11px] gap-1 mt-1"
141 key={connection.key}150 onClick={() => setPanel({ mode: "create" })}
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"151 >
143 style={{ boxShadow: "inset 2px 0 0 0 color-mix(in oklch, var(--border) 70%, transparent)" }}152 <Plus className="h-3 w-3" />
144 >153 Add connection
145 <div className="flex h-8 w-8 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden">154 </Button>
146 {firstSource ? (155 </div>
147 <SourceFavicon156 ) : (
148 source={firstSource}157 <div className="p-2 space-y-1">
149 iconClassName="h-4 w-4 text-muted-foreground"158 {connectionOptions.map((connection) => {
150 imageClassName="w-5 h-5"159 const representative = representativeCredentialByConnection.get(connection.key);
151 />160 if (!representative) {
152 ) : (161 return null;
153 <KeyRound className="h-4 w-4 text-muted-foreground" />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",
154 )}175 )}
155 </div>176 >
156 <div className="flex-1 min-w-0">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">
157 <div className="mb-1 flex items-center gap-1.5 flex-wrap">178 {firstSource ? (
158 <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span>179 <SourceFavicon
159 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">180 source={firstSource}
160 {connection.scope}181 iconClassName="h-3.5 w-3.5 text-muted-foreground"
161 </Badge>182 imageClassName="w-4 h-4"
162 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">183 />
163 {ownerScopeLabel(connection.scopeType)}184 ) : (
164 </Badge>185 <KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
165 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">186 )}
166 {providerLabel(connection.provider)}187 </div>
167 </Badge>188 <div className="flex-1 min-w-0">
168 {connection.scope === "account" && connection.accountId ? (189 <span className="text-xs font-medium truncate block">{connectionDisplayName(sources, connection)}</span>
169 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded-sm">190 <div className="flex items-center gap-1 mt-0.5">
170 {connection.accountId}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"}
171 </span>196 </span>
172 ) : null}197 </div>
173 </div>198 </div>
174 <p className="text-[11px] text-muted-foreground">199 </button>
175 Linked to {connection.sourceKeys.size} API{connection.sourceKeys.size === 1 ? "" : "s"} - {storageCopy}200 );
176 </p>201 })}
177 <p className="mt-0.5 text-[11px] text-muted-foreground">202 </div>
178 Updated {new Date(connection.updatedAt).toLocaleString()}203 )}
179 </p>204 </div>
180 </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>
181 <Button244 <Button
182 variant="outline"
183 size="sm"245 size="sm"
184 className="h-7 text-[11px] border-border/70 group-hover:border-border"246 className="h-8 text-xs gap-1.5 mt-2"
185 onClick={() => onEditConnection(representative)}247 onClick={() => setPanel({ mode: "create" })}
186 >248 >
187 Edit249 <Plus className="h-3.5 w-3.5" />
250 Add Connection
188 </Button>251 </Button>
189 </div>252 </>
190 );253 ) : (
191 })}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>
192 </div>273 </div>
193 )}274 )}
194 </div>275 </div>
executor/apps/web/src/components/tools/explorer-derived.ts+0−9
@@ -252,12 +252,3 @@ export function autoExpandedKeysForSearch( · 251 unmodified lines
252 252
253 return allGroupKeys;253 return allGroupKeys;
254}254}
255
256export 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
130 130
131 <span131 <span
132 className={cn(132 className={cn(
133 "font-mono text-[13px] truncate",133 "truncate text-sm",
134 isSource134 isSource
135 ? "font-semibold text-foreground"135 ? "font-semibold text-foreground"
136 : "font-medium text-foreground/90",136 : "font-medium text-foreground/90",
@@ -139,9 +139,9 @@ export function GroupNode({ · 2 unmodified lines
139 {displayLabel}139 {displayLabel}
140 </span>140 </span>
141 141
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">
143 {isSource && group.sourceType && (143 {isSource && group.sourceType && (
144 <span className="uppercase tracking-wider opacity-70">144 <span className="opacity-80">
145 {group.sourceType}145 {group.sourceType}
146 </span>146 </span>
147 )}147 )}
@@ -226,9 +226,7 @@ export function NavGroupNode({ · 78 unmodified lines
226 onToggle,226 onToggle,
227 focusedPath,227 focusedPath,
228 focusedSource,228 focusedSource,
229 selectedKeys,
230 onFocusTool,229 onFocusTool,
231 onSelectTool,
232 onSourceClick,230 onSourceClick,
233 source,231 source,
234}: {232}: {
@@ -238,9 +236,7 @@ export function NavGroupNode({ · 3 unmodified lines
238 onToggle: (key: string) => void;236 onToggle: (key: string) => void;
239 focusedPath: string | null;237 focusedPath: string | null;
240 focusedSource?: string | null;238 focusedSource?: string | null;
241 selectedKeys: Set<string>;
242 onFocusTool: (tool: ToolDescriptor) => void;239 onFocusTool: (tool: ToolDescriptor) => void;
243 onSelectTool?: (path: string, e: React.MouseEvent) => void;
244 onSourceClick?: (sourceName: string) => void;240 onSourceClick?: (sourceName: string) => void;
245 source?: ToolSourceRecord;241 source?: ToolSourceRecord;
246}) {242}) {
@@ -316,11 +312,11 @@ export function NavGroupNode({ · 69 unmodified lines
316 )}312 )}
317 </div>313 </div>
318 314
319 <span className="font-mono text-[11px] font-semibold text-foreground/90 truncate">315 <span className="truncate text-xs font-semibold text-foreground/90">
320 {displayLabel}316 {displayLabel}
321 </span>317 </span>
322 318
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">
324 {isLoading ? (320 {isLoading ? (
325 <Loader2 className="h-2.5 w-2.5 animate-spin" />321 <Loader2 className="h-2.5 w-2.5 animate-spin" />
326 ) : (322 ) : (
@@ -360,11 +356,11 @@ export function NavGroupNode({ · 33 unmodified lines
360 )}356 )}
361 </div>357 </div>
362 358
363 <span className="font-mono text-[11px] font-medium text-foreground/80 truncate">359 <span className="truncate text-xs font-medium text-foreground/80">
364 {displayLabel}360 {displayLabel}
365 </span>361 </span>
366 362
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">
368 {group.approvalCount > 0 && (364 {group.approvalCount > 0 && (
369 <span className="inline-flex items-center gap-0.5 text-terminal-amber">365 <span className="inline-flex items-center gap-0.5 text-terminal-amber">
370 <ShieldCheck className="h-2 w-2" />366 <ShieldCheck className="h-2 w-2" />
@@ -390,9 +386,7 @@ export function NavGroupNode({ · 19 unmodified lines
390 onToggle={onToggle}386 onToggle={onToggle}
391 focusedPath={focusedPath}387 focusedPath={focusedPath}
392 focusedSource={focusedSource}388 focusedSource={focusedSource}
393 selectedKeys={selectedKeys}
394 onFocusTool={onFocusTool}389 onFocusTool={onFocusTool}
395 onSelectTool={onSelectTool}
396 onSourceClick={onSourceClick}390 onSourceClick={onSourceClick}
397 />391 />
398 );392 );
@@ -404,13 +398,7 @@ export function NavGroupNode({ · 5 unmodified lines
404 tool={child}398 tool={child}
405 label={toolDisplayOperation(child.path)}399 label={toolDisplayOperation(child.path)}
406 focused={child.path === focusedPath}400 focused={child.path === focusedPath}
407 selected={selectedKeys.has(child.path)}
408 onFocus={onFocusTool}401 onFocus={onFocusTool}
409 onSelect={
410 onSelectTool
411 ? (e) => onSelectTool(child.path, e)
412 : undefined
413 }
414 depth={depth + 1}402 depth={depth + 1}
415 />403 />
416 );404 );
@@ -568,7 +556,7 @@ export function SourceSidebar({ · 151 unmodified lines
568 <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">556 <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">
569 <div className="px-3 pb-2 pt-1">557 <div className="px-3 pb-2 pt-1">
570 <div className="flex items-center justify-between">558 <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">
572 Sources560 Sources
573 </p>561 </p>
574 {onRegenerate ? (562 {onRegenerate ? (
@@ -597,7 +585,7 @@ export function SourceSidebar({ · 22 unmodified lines
597 </div>585 </div>
598 <p586 <p
599 className={cn(587 className={cn(
600 "mt-1 flex items-center gap-1 text-[10px] font-mono",588 "mt-1 flex items-center gap-1 text-xs",
601 inventoryStatus.tone === "loading"589 inventoryStatus.tone === "loading"
602 ? "text-terminal-amber/90"590 ? "text-terminal-amber/90"
603 : inventoryStatus.tone === "error"591 : inventoryStatus.tone === "error"
@@ -644,8 +632,8 @@ export function SourceSidebar({ · 40 unmodified lines
644 className="h-3 w-3 shrink-0 text-muted-foreground"632 className="h-3 w-3 shrink-0 text-muted-foreground"
645 />633 />
646 )}634 )}
647 <span className="font-mono font-medium truncate">{g.name}</span>635 <span className="truncate font-medium">{g.name}</span>
648 <span className="ml-auto text-[10px] font-mono tabular-nums opacity-60 flex items-center gap-1">636 <span className="ml-auto flex items-center gap-1 text-xs tabular-nums opacity-70">
649 {g.warningCount > 0 ? (637 {g.warningCount > 0 ? (
650 <span638 <span
651 className="inline-flex items-center gap-0.5 text-terminal-amber"639 className="inline-flex items-center gap-0.5 text-terminal-amber"
@@ -690,12 +678,12 @@ export function SourceSidebar({ · 38 unmodified lines
690 678
691 {activeSource && activeSourceWarnings.length > 0 ? (679 {activeSource && activeSourceWarnings.length > 0 ? (
692 <div className="mt-2 rounded-md border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-2">680 <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">
694 {activeSourceWarnings.length} warning{activeSourceWarnings.length !== 1 ? "s" : ""}682 {activeSourceWarnings.length} warning{activeSourceWarnings.length !== 1 ? "s" : ""}
695 </p>683 </p>
696 <div className="mt-1.5 space-y-1">684 <div className="mt-1.5 space-y-1">
697 {activeSourceWarnings.map((warning, index) => (685 {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">
699 {warning}687 {warning}
700 </p>688 </p>
701 ))}689 ))}
executor/apps/web/src/components/tools/explorer-rows.tsx+1−34
@@ -25,17 +25,13 @@ export const ToolListItem = memo(function ToolListItem({ · 24 unmodified lines
25 tool,25 tool,
26 label,26 label,
27 focused,27 focused,
28 selected,
29 onFocus,28 onFocus,
30 onSelect,
31 depth = 0,29 depth = 0,
32}: {30}: {
33 tool: ToolDescriptor;31 tool: ToolDescriptor;
34 label: string;32 label: string;
35 focused: boolean;33 focused: boolean;
36 selected: boolean;
37 onFocus: (tool: ToolDescriptor) => void;34 onFocus: (tool: ToolDescriptor) => void;
38 onSelect?: (e: React.MouseEvent) => void;
39 depth?: number;35 depth?: number;
40}) {36}) {
41 const handleClick = useCallback(() => {37 const handleClick = useCallback(() => {
@@ -46,31 +42,13 @@ export const ToolListItem = memo(function ToolListItem({ · 4 unmodified lines
46 <div42 <div
47 onClick={handleClick}43 onClick={handleClick}
48 className={cn(44 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",
50 focused46 focused
51 ? "bg-accent/50 border-l-2 border-l-primary"47 ? "bg-accent/50 border-l-2 border-l-primary"
52 : "hover:bg-accent/20 border-l-2 border-l-transparent",48 : "hover:bg-accent/20 border-l-2 border-l-transparent",
53 selected && !focused && "bg-primary/5",
54 )}49 )}
55 style={{ paddingLeft: `${depth * 14 + 8}px` }}50 style={{ paddingLeft: `${depth * 14 + 8}px` }}
56 >51 >
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
74 <Zap className="h-2.5 w-2.5 text-primary/50 shrink-0" />52 <Zap className="h-2.5 w-2.5 text-primary/50 shrink-0" />
75 53
76 <span54 <span
@@ -92,7 +70,6 @@ export const ToolListItem = memo(function ToolListItem({ · 15 unmodified lines
92 prev.tool === next.tool &&70 prev.tool === next.tool &&
93 prev.label === next.label &&71 prev.label === next.label &&
94 prev.focused === next.focused &&72 prev.focused === next.focused &&
95 prev.selected === next.selected &&
96 prev.depth === next.depth,73 prev.depth === next.depth,
97);74);
98 75
@@ -235,9 +212,7 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 136 unmodified lines
235export function ToolListSidebar({212export function ToolListSidebar({
236 tools,213 tools,
237 focusedPath,214 focusedPath,
238 selectedKeys,
239 onFocusTool,215 onFocusTool,
240 onSelectTool,
241 loadingRows,216 loadingRows,
242 hasMoreTools = false,217 hasMoreTools = false,
243 loadingMoreTools = false,218 loadingMoreTools = false,
@@ -247,9 +222,7 @@ export function ToolListSidebar({ · 3 unmodified lines
247}: {222}: {
248 tools: ToolDescriptor[];223 tools: ToolDescriptor[];
249 focusedPath: string | null;224 focusedPath: string | null;
250 selectedKeys: Set<string>;
251 onFocusTool: (tool: ToolDescriptor) => void;225 onFocusTool: (tool: ToolDescriptor) => void;
252 onSelectTool?: (path: string, e: React.MouseEvent) => void;
253 loadingRows?: { source: string; count: number }[];226 loadingRows?: { source: string; count: number }[];
254 hasMoreTools?: boolean;227 hasMoreTools?: boolean;
255 loadingMoreTools?: boolean;228 loadingMoreTools?: boolean;
@@ -284,13 +257,7 @@ export function ToolListSidebar({ · 28 unmodified lines
284 tool={tool}257 tool={tool}
285 label={toolDisplayPath(tool.path)}258 label={toolDisplayPath(tool.path)}
286 focused={tool.path === focusedPath}259 focused={tool.path === focusedPath}
287 selected={selectedKeys.has(tool.path)}
288 onFocus={onFocusTool}260 onFocus={onFocusTool}
289 onSelect={
290 onSelectTool
291 ? (e) => onSelectTool(tool.path, e)
292 : undefined
293 }
294 />261 />
295 ))}262 ))}
296 263
executor/apps/web/src/components/tools/explorer-toolbar.tsx+1−50
@@ -1,6 +1,5 @@
1import {1import {
2 Search,2 Search,
3 ShieldCheck,
4 Filter,3 Filter,
5 Plus,4 Plus,
6 X,5 X,
@@ -29,13 +28,10 @@ interface ToolExplorerToolbarProps { · 22 unmodified lines
29 loadingInventory?: boolean;28 loadingInventory?: boolean;
30 filterApproval: FilterApproval;29 filterApproval: FilterApproval;
31 activeSource: string | null;30 activeSource: string | null;
32 selectedToolCount: number;
33 onSearchChange: (value: string) => void;31 onSearchChange: (value: string) => void;
34 onClearSearch: () => void;32 onClearSearch: () => void;
35 onFilterApprovalChange: (filter: FilterApproval) => void;33 onFilterApprovalChange: (filter: FilterApproval) => void;
36 onAddSource?: () => void;34 onAddSource?: () => void;
37 onSelectAll: () => void;
38 onClearSelection: () => void;
39}35}
40 36
41export function ToolExplorerToolbar({37export function ToolExplorerToolbar({
@@ -43,18 +39,13 @@ export function ToolExplorerToolbar({ · 1 unmodified line
43 filteredToolCount,39 filteredToolCount,
44 loadingInventory = false,40 loadingInventory = false,
45 filterApproval,41 filterApproval,
46 selectedToolCount,
47 onSearchChange,42 onSearchChange,
48 onClearSearch,43 onClearSearch,
49 onFilterApprovalChange,44 onFilterApprovalChange,
50 onAddSource,45 onAddSource,
51 onSelectAll,
52 onClearSelection,
53}: ToolExplorerToolbarProps) {46}: ToolExplorerToolbarProps) {
54 const hasSelection = selectedToolCount > 0;
55
56 return (47 return (
57 <div className="shrink-0 relative space-y-1.5">48 <div className="shrink-0 space-y-1.5">
58 {/* Search — own row */}49 {/* Search — own row */}
59 <div className="relative">50 <div className="relative">
60 <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground/50" />51 <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
135 </Button>126 </Button>
136 ) : null}127 ) : null}
137 </div>128 </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>
178 </div>129 </div>
179 );130 );
180}131}
executor/apps/web/src/components/tools/explorer.tsx+1−66
@@ -12,13 +12,11 @@ import type { ToolDescriptor, ToolSourceRecord } from "@/lib/types"; · 11 unmodified lines
12import type { SourceAuthProfile } from "@/lib/types";12import type { SourceAuthProfile } from "@/lib/types";
13import {13import {
14 autoExpandedKeysForSearch,14 autoExpandedKeysForSearch,
15 countSelectedTools,
16 filterToolsBySearch,15 filterToolsBySearch,
17 filterToolsBySourceAndApproval,16 filterToolsBySourceAndApproval,
18 treeGroupsForView,17 treeGroupsForView,
19 type FilterApproval,18 type FilterApproval,
20} from "./explorer-derived";19} from "./explorer-derived";
21import { collectGroupKeys } from "@/lib/tool/explorer-grouping";
22import { sourceLabel } from "@/lib/tool/source-utils";20import { sourceLabel } from "@/lib/tool/source-utils";
23import { traverseSchema } from "@/lib/tool/schema-traverse";21import { traverseSchema } from "@/lib/tool/schema-traverse";
24import {22import {
@@ -79,12 +77,10 @@ interface ToolExplorerProps { · 54 unmodified lines
79 searchValue: string;77 searchValue: string;
80 filterApprovalValue: FilterApproval;78 filterApprovalValue: FilterApproval;
81 focusedToolPathValue: string | null;79 focusedToolPathValue: string | null;
82 selectedToolPathsValue: string[];
83 focusedSourceNameValue: string | null;80 focusedSourceNameValue: string | null;
84 onSearchValueChange: (value: string) => void;81 onSearchValueChange: (value: string) => void;
85 onFilterApprovalValueChange: (filter: FilterApproval) => void;82 onFilterApprovalValueChange: (filter: FilterApproval) => void;
86 onFocusedToolPathChange: (toolPath: string | null) => void;83 onFocusedToolPathChange: (toolPath: string | null) => void;
87 onSelectedToolPathsChange: (toolPaths: string[]) => void;
88 onFocusedSourceNameChange: (sourceName: string | null) => void;84 onFocusedSourceNameChange: (sourceName: string | null) => void;
89 onSourceAdded?: (source: ToolSourceRecord, options?: SourceAddedOptions) => void;85 onSourceAdded?: (source: ToolSourceRecord, options?: SourceAddedOptions) => void;
90 sourceDialogMeta?: Record<string, SourceDialogMeta>;86 sourceDialogMeta?: Record<string, SourceDialogMeta>;
@@ -113,12 +109,10 @@ export function ToolExplorer({ · 22 unmodified lines
113 searchValue,109 searchValue,
114 filterApprovalValue,110 filterApprovalValue,
115 focusedToolPathValue,111 focusedToolPathValue,
116 selectedToolPathsValue,
117 focusedSourceNameValue,112 focusedSourceNameValue,
118 onSearchValueChange,113 onSearchValueChange,
119 onFilterApprovalValueChange,114 onFilterApprovalValueChange,
120 onFocusedToolPathChange,115 onFocusedToolPathChange,
121 onSelectedToolPathsChange,
122 onFocusedSourceNameChange,116 onFocusedSourceNameChange,
123 onSourceAdded,117 onSourceAdded,
124 sourceDialogMeta,118 sourceDialogMeta,
@@ -155,7 +149,6 @@ export function ToolExplorer({ · 30 unmodified lines
155 const searchInput = searchValue;149 const searchInput = searchValue;
156 const filterApproval = filterApprovalValue;150 const filterApproval = filterApprovalValue;
157 const focusedToolPath = focusedToolPathValue;151 const focusedToolPath = focusedToolPathValue;
158 const selectedKeys = useMemo(() => new Set(selectedToolPathsValue), [selectedToolPathsValue]);
159 const focusedSourceName = focusedSourceNameValue;152 const focusedSourceName = focusedSourceNameValue;
160 const search = useDeferredValue(searchInput);153 const search = useDeferredValue(searchInput);
161 154
@@ -171,10 +164,6 @@ export function ToolExplorer({ · 9 unmodified lines
171 onFocusedToolPathChange(toolPath);164 onFocusedToolPathChange(toolPath);
172 }, [onFocusedToolPathChange]);165 }, [onFocusedToolPathChange]);
173 166
174 const setSelectedKeys = useCallback((next: Set<string>) => {
175 onSelectedToolPathsChange(Array.from(next));
176 }, [onSelectedToolPathsChange]);
177
178 const setFocusedSourceName = useCallback((sourceName: string | null) => {167 const setFocusedSourceName = useCallback((sourceName: string | null) => {
179 onFocusedSourceNameChange(sourceName);168 onFocusedSourceNameChange(sourceName);
180 }, [onFocusedSourceNameChange]);169 }, [onFocusedSourceNameChange]);
@@ -257,10 +246,6 @@ export function ToolExplorer({ · 76 unmodified lines
257 return filterToolsBySearch(filteredTools, search);246 return filterToolsBySearch(filteredTools, search);
258 }, [filteredTools, search]);247 }, [filteredTools, search]);
259 248
260 const selectedToolCount = useMemo(() => {
261 return countSelectedTools(selectedKeys, filteredTools);
262 }, [selectedKeys, filteredTools]);
263
264 const sidebarExistingSourceNames = useMemo(() => {249 const sidebarExistingSourceNames = useMemo(() => {
265 return existingSourceNames ?? new Set(visibleSources.map((source) => source.name));250 return existingSourceNames ?? new Set(visibleSources.map((source) => source.name));
266 }, [existingSourceNames, visibleSources]);251 }, [existingSourceNames, visibleSources]);
@@ -290,29 +275,6 @@ export function ToolExplorer({ · 23 unmodified lines
290 }275 }
291 }, [focusedSourceNameValue, formSource, sourceByName, setFocusedToolPath]);276 }, [focusedSourceNameValue, formSource, sourceByName, setFocusedToolPath]);
292 277
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
316 const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => {278 const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => {
317 if (!expanded || !onLoadToolDetails) {279 if (!expanded || !onLoadToolDetails) {
318 return;280 return;
@@ -459,14 +421,7 @@ export function ToolExplorer({ · 140 unmodified lines
459 });421 });
460 }, [searchedTools, loadingSources, visibleSources, sourceCounts, resolvedActiveSource]);422 }, [searchedTools, loadingSources, visibleSources, sourceCounts, resolvedActiveSource]);
461 423
462 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => {424 const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
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 });
470 425
471 // Auto-expand all groups when search is active426 // Auto-expand all groups when search is active
472 const searchAutoExpanded = useMemo(427 const searchAutoExpanded = useMemo(
@@ -476,21 +431,6 @@ export function ToolExplorer({ · 3 unmodified lines
476 431
477 const effectiveExpandedKeys = searchAutoExpanded ?? expandedKeys;432 const effectiveExpandedKeys = searchAutoExpanded ?? expandedKeys;
478 433
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]);
494 434
495 const toggleExpand = useCallback((key: string) => {435 const toggleExpand = useCallback((key: string) => {
496 setExpandedKeys((prev) => {436 setExpandedKeys((prev) => {
@@ -609,13 +549,10 @@ export function ToolExplorer({ · 112 unmodified lines
609 loadingInventory={awaitingInitialInventory}549 loadingInventory={awaitingInitialInventory}
610 filterApproval={filterApproval}550 filterApproval={filterApproval}
611 activeSource={resolvedActiveSource}551 activeSource={resolvedActiveSource}
612 selectedToolCount={selectedToolCount}
613 onSearchChange={setSearchInput}552 onSearchChange={setSearchInput}
614 onClearSearch={() => setSearchInput("")}553 onClearSearch={() => setSearchInput("")}
615 onFilterApprovalChange={setFilterApproval}554 onFilterApprovalChange={setFilterApproval}
616 onAddSource={handleAddSource}555 onAddSource={handleAddSource}
617 onSelectAll={selectAll}
618 onClearSelection={clearSelection}
619 />556 />
620 </div>557 </div>
621 558
@@ -659,9 +596,7 @@ export function ToolExplorer({ · 37 unmodified lines
659 onToggle={toggleExpand}596 onToggle={toggleExpand}
660 focusedPath={focusedToolPath}597 focusedPath={focusedToolPath}
661 focusedSource={focusedSourceName}598 focusedSource={focusedSourceName}
662 selectedKeys={selectedKeys}
663 onFocusTool={handleFocusTool}599 onFocusTool={handleFocusTool}
664 onSelectTool={toggleSelectTool}
665 onSourceClick={handleSourceClick}600 onSourceClick={handleSourceClick}
666 source={sourceByName.get(group.label) ?? undefined}601 source={sourceByName.get(group.label) ?? undefined}
667 />602 />
executor/apps/web/src/components/tools/explorer/source-form-panel.tsx+4−6
@@ -69,9 +69,8 @@ export function SourceFormPanel({ · 68 unmodified lines
69 onClose: () => void;69 onClose: () => void;
70}) {70}) {
71 const { context } = useSession();71 const { context } = useSession();
72 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);72 const upsertToolSource = useAction(convexApi.workspace.upsertToolSource);
73 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);73 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
74 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
75 const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade);74 const previewOpenApiSourceUpgrade = useAction(convexApi.executorNode.previewOpenApiSourceUpgrade);
76 const credentials = useQuery(75 const credentials = useQuery(
77 convexApi.workspace.listCredentials,76 convexApi.workspace.listCredentials,
@@ -195,7 +194,6 @@ export function SourceFormPanel({ · 117 unmodified lines
195 sourceToEdit,194 sourceToEdit,
196 credentialsLoading,195 credentialsLoading,
197 upsertToolSource,196 upsertToolSource,
198 upsertCredential,
199 form: {197 form: {
200 name: form.name,198 name: form.name,
201 endpoint: form.endpoint,199 endpoint: form.endpoint,
@@ -336,10 +334,10 @@ export function SourceFormPanel({ · 134 unmodified lines
336 <ArrowLeft className="h-3.5 w-3.5" />334 <ArrowLeft className="h-3.5 w-3.5" />
337 </button>335 </button>
338 <div className="min-w-0 flex-1">336 <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">
340 {editing ? "Edit source" : "Add source"}338 {editing ? "Edit source" : "Add source"}
341 </h2>339 </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">
343 {editing341 {editing
344 ? "Update endpoint, auth, and credentials."342 ? "Update endpoint, auth, and credentials."
345 : "Connect a source and configure credentials."}343 : "Connect a source and configure credentials."}
@@ -365,7 +363,7 @@ export function SourceFormPanel({ · 19 unmodified lines
365 <p className="text-[13px] font-medium truncate">363 <p className="text-[13px] font-medium truncate">
366 {displaySourceName(sourceToEdit.name)}364 {displaySourceName(sourceToEdit.name)}
367 </p>365 </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">
369 {compactEndpointLabel(sourceToEdit)}367 {compactEndpointLabel(sourceToEdit)}
370 </p>368 </p>
371 <div className="mt-2 flex items-center gap-1.5 flex-wrap">369 <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
86 return (86 return (
87 <span className="inline-flex items-center gap-1 ml-0.5">87 <span className="inline-flex items-center gap-1 ml-0.5">
88 {parts.map((p, i) => (88 {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">
90 {p}90 {p}
91 </span>91 </span>
92 ))}92 ))}
@@ -104,7 +104,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 11 unmodified lines
104 >104 >
105 {/* Name + type line */}105 {/* Name + type line */}
106 <div className="flex items-center gap-1.5 flex-wrap">106 <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">
108 {entry.path.split(".").pop()}108 {entry.path.split(".").pop()}
109 </span>109 </span>
110 110
@@ -117,7 +117,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 6 unmodified lines
117 117
118 <span118 <span
119 className={cn(119 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",
121 typeColorClasses(entry.type),121 typeColorClasses(entry.type),
122 )}122 )}
123 >123 >
@@ -125,7 +125,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 1 unmodified line
125 </span>125 </span>
126 126
127 {entry.deprecated ? (127 {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)]">
129 deprecated129 deprecated
130 </span>130 </span>
131 ) : null}131 ) : null}
@@ -135,7 +135,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 3 unmodified lines
135 135
136 {/* Description */}136 {/* Description */}
137 {entry.description ? (137 {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">
139 <Streamdown plugins={{ code: codePlugin }} controls={false}>{entry.description}</Streamdown>139 <Streamdown plugins={{ code: codePlugin }} controls={false}>{entry.description}</Streamdown>
140 </div>140 </div>
141 ) : null}141 ) : null}
@@ -144,7 +144,7 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 2 unmodified lines
144 {entry.enumValues && entry.enumValues.length > 0 && entry.enumValues.length <= 12 ? (144 {entry.enumValues && entry.enumValues.length > 0 && entry.enumValues.length <= 12 ? (
145 <div className="flex flex-wrap gap-1 mt-0.5">145 <div className="flex flex-wrap gap-1 mt-0.5">
146 {entry.enumValues.map((v, i) => (146 {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">
148 {v}148 {v}
149 </code>149 </code>
150 ))}150 ))}
@@ -156,14 +156,14 @@ function FieldRow({ entry }: { entry: SchemaFieldEntry }) { · 5 unmodified lines
156 <div className="flex flex-wrap gap-2 mt-0.5">156 <div className="flex flex-wrap gap-2 mt-0.5">
157 {entry.example ? (157 {entry.example ? (
158 <span className="inline-flex items-center gap-1">158 <span className="inline-flex items-center gap-1">
159 <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">example</span>159 <span className="text-[11px] text-muted-foreground/60">Example</span>
160 <code className="font-mono text-[10px] text-muted-foreground/85">{entry.example}</code>160 <code className="font-mono text-xs text-muted-foreground/85">{entry.example}</code>
161 </span>161 </span>
162 ) : null}162 ) : null}
163 {entry.defaultValue ? (163 {entry.defaultValue ? (
164 <span className="inline-flex items-center gap-1">164 <span className="inline-flex items-center gap-1">
165 <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground/50">default</span>165 <span className="text-[11px] text-muted-foreground/60">Default</span>
166 <code className="font-mono text-[10px] text-muted-foreground/85">{entry.defaultValue}</code>166 <code className="font-mono text-xs text-muted-foreground/85">{entry.defaultValue}</code>
167 </span>167 </span>
168 ) : null}168 ) : null}
169 </div>169 </div>
@@ -212,11 +212,11 @@ function SchemaFieldsSection({ · 42 unmodified lines
212 <div className="flex flex-col gap-2">212 <div className="flex flex-col gap-2">
213 {/* Section header */}213 {/* Section header */}
214 <div className="flex items-baseline gap-2">214 <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">
216 {label}216 {label}
217 </span>217 </span>
218 {hasEntries ? (218 {hasEntries ? (
219 <span className="font-mono text-[10px] text-muted-foreground/50">219 <span className="text-xs text-muted-foreground/60">
220 {entries.length} field{entries.length !== 1 ? "s" : ""}220 {entries.length} field{entries.length !== 1 ? "s" : ""}
221 {truncated ? "+" : ""}221 {truncated ? "+" : ""}
222 </span>222 </span>
@@ -228,7 +228,7 @@ function SchemaFieldsSection({ · 5 unmodified lines
228 <div className="border border-border rounded-md bg-muted/30 overflow-hidden">228 <div className="border border-border rounded-md bg-muted/30 overflow-hidden">
229 {collapsedByDefault ? (229 {collapsedByDefault ? (
230 <details open={false}>230 <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">
232 Show all fields ({entries.length}{truncated ? "+" : ""})232 Show all fields ({entries.length}{truncated ? "+" : ""})
233 </summary>233 </summary>
234 <div className="flex flex-col">234 <div className="flex flex-col">
@@ -236,7 +236,7 @@ function SchemaFieldsSection({ · 1 unmodified line
236 <FieldRow key={`${entry.path}-${i}`} entry={entry} />236 <FieldRow key={`${entry.path}-${i}`} entry={entry} />
237 ))}237 ))}
238 {truncated ? (238 {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">
240 Showing first {entries.length} fields...240 Showing first {entries.length} fields...
241 </p>241 </p>
242 ) : null}242 ) : null}
@@ -251,7 +251,7 @@ function SchemaFieldsSection({ · 8 unmodified lines
251 )}251 )}
252 </div>252 </div>
253 ) : empty ? (253 ) : 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">
255 Empty object255 Empty object
256 </p>256 </p>
257 ) : schemaJson ? (257 ) : schemaJson ? (
@@ -261,7 +261,7 @@ function SchemaFieldsSection({ · 3 unmodified lines
261 {/* Raw schema toggle */}261 {/* Raw schema toggle */}
262 {hasEntries && schemaJson ? (262 {hasEntries && schemaJson ? (
263 <details className="mt-1">263 <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">
265 Raw schema265 Raw schema
266 </summary>266 </summary>
267 <div className="mt-1.5">267 <div className="mt-1.5">
@@ -308,11 +308,11 @@ export function ToolDetailPanel({ · 40 unmodified lines
308 <div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border/40 px-5 py-3">308 <div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border/40 px-5 py-3">
309 <div className="flex items-center gap-2.5 min-w-0">309 <div className="flex items-center gap-2.5 min-w-0">
310 <Zap className="h-3.5 w-3.5 text-primary/70 shrink-0" />310 <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">
312 {tool.path}312 {tool.path}
313 </h2>313 </h2>
314 {tool.approval === "required" ? (314 {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">
316 <ShieldCheck className="h-2.5 w-2.5" />316 <ShieldCheck className="h-2.5 w-2.5" />
317 gated317 gated
318 </span>318 </span>
@@ -322,7 +322,7 @@ export function ToolDetailPanel({ · 3 unmodified lines
322 </div>322 </div>
323 </div>323 </div>
324 {tool.source ? (324 {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">
326 {tool.source}326 {tool.source}
327 </p>327 </p>
328 ) : null}328 ) : null}
@@ -404,11 +404,11 @@ function ToolDetailContent({ · 75 unmodified lines
404 <div className="space-y-3">404 <div className="space-y-3">
405 <Skeleton className="h-3.5 w-72" />405 <Skeleton className="h-3.5 w-72" />
406 <div>406 <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>
408 <Skeleton className="h-20 w-full rounded-md mt-1" />408 <Skeleton className="h-20 w-full rounded-md mt-1" />
409 </div>409 </div>
410 <div>410 <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>
412 <Skeleton className="h-14 w-full rounded-md mt-1" />412 <Skeleton className="h-14 w-full rounded-md mt-1" />
413 </div>413 </div>
414 </div>414 </div>
executor/apps/web/src/components/tools/policies.tsx+93−91
@@ -577,22 +577,22 @@ function ToolPicker({ · 576 unmodified lines
577 variant="outline"577 variant="outline"
578 role="combobox"578 role="combobox"
579 aria-expanded={open}579 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"
581 >581 >
582 <span className="truncate text-left">{selectionSummary}</span>582 <span className="truncate text-left">{selectionSummary}</span>
583 <ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />583 <ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
584 </Button>584 </Button>
585 </PopoverTrigger>585 </PopoverTrigger>
586 <PopoverContent className="w-[460px] p-0" align="start">586 <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 */}
588 <div className="flex h-9 items-center gap-2 border-b px-3">588 <div className="flex h-9 items-center gap-2 border-b px-3">
589 <Search className="size-4 shrink-0 opacity-50" />589 <Search className="size-4 shrink-0 opacity-50" />
590 <input590 <Input
591 ref={searchRef}591 ref={searchRef}
592 value={searchInput}592 value={searchInput}
593 onChange={(e) => setSearchInput(e.target.value)}593 onChange={(e) => setSearchInput(e.target.value)}
594 placeholder="Search tools..."594 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"
596 />596 />
597 {searchInput && (597 {searchInput && (
598 <button598 <button
@@ -616,11 +616,11 @@ function ToolPicker({ · 17 unmodified lines
616 {/* Footer */}616 {/* Footer */}
617 {selectedPaths.length > 0 && (617 {selectedPaths.length > 0 && (
618 <div className="border-t border-border/50 p-2 flex items-center justify-between">618 <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>
620 <Button620 <Button
621 variant="ghost"621 variant="ghost"
622 size="sm"622 size="sm"
623 className="h-6 text-[10px] px-2"623 className="h-6 px-2 text-xs"
624 onClick={() => { onSelectionChange([]); onPatternChange("*"); }}624 onClick={() => { onSelectionChange([]); onPatternChange("*"); }}
625 >625 >
626 Clear626 Clear
@@ -1286,97 +1286,99 @@ export function PoliciesPanel({ · 659 unmodified lines
1286 // ── Render ──1286 // ── Render ──
1287 1287
1288 return (1288 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">1289 <section className="flex h-full min-h-0 w-full overflow-hidden bg-background">
1290 <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">1290 {/* ── Left sidebar ── */}
1291 <div className="space-y-4">1291 <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border/40 bg-card/30 lg:w-72">
1292 {/* Header */}1292 {/* Sidebar header */}
1293 <div className="flex items-center justify-between">1293 <div className="shrink-0 border-b border-border/30 px-3 py-2.5">
1294 <div className="flex items-center gap-2.5">1294 <div className="flex items-center justify-between">
1295 <Shield className="h-4 w-4 text-muted-foreground" />1295 <div className="flex items-center gap-2">
1296 <div>1296 <Shield className="h-4 w-4 text-muted-foreground" />
1297 <h3 className="text-sm font-medium leading-none">Tool Policies</h3>1297 <h3 className="text-sm font-medium leading-none">Policies</h3>
1298 <p className="text-[11px] text-muted-foreground mt-1">1298 </div>
1299 Control which tools or sources are approved, gated, or blocked1299 <Button
1300 </p>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>
1301 </div>1308 </div>
1309 <p className="text-[10px] text-muted-foreground mt-1">
1310 {policies.length} {policies.length === 1 ? "policy" : "policies"}
1311 </p>
1302 </div>1312 </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" />
1315 1313
1316 {/* Policies list */}1314 {/* Sidebar policy list */}
1317 {loading || loadingTools ? (1315 <div className="flex-1 min-h-0 overflow-y-auto">
1318 <div className="space-y-2">1316 {loading || loadingTools ? (
1319 {Array.from({ length: 3 }).map((_, i) => (1317 <div className="space-y-2 p-3">
1320 <Skeleton key={i} className="h-16 rounded-lg" />1318 {Array.from({ length: 4 }).map((_, i) => (
1321 ))}1319 <Skeleton key={i} className="h-12 rounded-md" />
1322 </div>1320 ))}
1323 ) : policies.length === 0 ? (1321 </div>
1324 <div className="rounded-none border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5">1322 ) : policies.length === 0 ? (
1325 <Shield className="h-8 w-8 text-muted-foreground/30" />1323 <div className="flex flex-col items-center gap-2.5 px-4 py-10">
1326 <div className="text-center">1324 <Shield className="h-7 w-7 text-muted-foreground/30" />
1327 <p className="text-xs text-muted-foreground">No tool policies configured</p>1325 <div className="text-center">
1328 <p className="text-[11px] text-muted-foreground/60 mt-0.5">1326 <p className="text-xs text-muted-foreground">No policies</p>
1329 Tools use default approval behavior. Create a tool policy to customize it.1327 <p className="text-[10px] text-muted-foreground/60 mt-0.5">
1330 </p>1328 Create a policy to customize tool approval behavior.
1331 </div>1329 </p>
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>
1372 </div>1330 </div>
1373 );1331 <Button
1374 })}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 )}
1375 </div>1376 </div>
1376 )}1377 </aside>
1377
1378 <Separator className="bg-border/40" />
1379 1378
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">
1380 <details className="group rounded-none border border-border/60 bg-card/30">1382 <details className="group rounded-none border border-border/60 bg-card/30">
1381 <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2.5">1383 <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2.5">
1382 <div>1384 <div>
executor/apps/web/src/components/tools/storage.tsx+34−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 KeyRound,8 KeyRound,
9 Table,9 Table,
10} from "lucide-react";10} from "lucide-react";
11import { Skeleton } from "@/components/ui/skeleton";
11import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";12import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
12import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";13import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
13import { convexApi } from "@/lib/convex-api";14import { convexApi } from "@/lib/convex-api";
@@ -32,6 +33,37 @@ import { · 18 unmodified lines
32 type StorageSqlResult,33 type StorageSqlResult,
33} from "./storage_panel_shared";34} from "./storage_panel_shared";
34 35
36function 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
35// ── Main Component ─────────────────────────────────────────────────────────67// ── Main Component ─────────────────────────────────────────────────────────
36 68
37export function StoragePanel({69export function StoragePanel({
@@ -478,6 +510,8 @@ export function StoragePanel({ · 440 unmodified lines
478 onSqlViewModeChange={setSqlViewMode}510 onSqlViewModeChange={setSqlViewMode}
479 />511 />
480 </Tabs>512 </Tabs>
513 ) : loading ? (
514 <StorageInspectorSkeleton />
481 ) : (515 ) : (
482 <div className="flex h-full items-center justify-center">516 <div className="flex h-full items-center justify-center">
483 <div className="flex flex-col items-center gap-3">517 <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
2import { Badge } from "@/components/ui/badge";2import { Badge } from "@/components/ui/badge";
3import { Button } from "@/components/ui/button";3import { Button } from "@/components/ui/button";
4import { Input } from "@/components/ui/input";4import { Input } from "@/components/ui/input";
5import {
6 Select,
7 SelectContent,
8 SelectItem,
9 SelectTrigger,
10 SelectValue,
11} from "@/components/ui/select";
5import { Skeleton } from "@/components/ui/skeleton";12import { Skeleton } from "@/components/ui/skeleton";
6import { cn } from "@/lib/utils";13import { cn } from "@/lib/utils";
7import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";14import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
@@ -59,7 +66,7 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 51 unmodified lines
59 <Database className="h-3.5 w-3.5 text-muted-foreground" />66 <Database className="h-3.5 w-3.5 text-muted-foreground" />
60 <span className="text-xs font-medium text-foreground">Instances</span>67 <span className="text-xs font-medium text-foreground">Instances</span>
61 {visibleInstances.length > 0 && (68 {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">
63 {visibleInstances.length}70 {visibleInstances.length}
64 </Badge>71 </Badge>
65 )}72 )}
@@ -78,36 +85,44 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 12 unmodified lines
78 <div className="border-b border-border/40 bg-muted/30 px-3 py-3">85 <div className="border-b border-border/40 bg-muted/30 px-3 py-3">
79 <div className="space-y-2">86 <div className="space-y-2">
80 <div className="grid grid-cols-2 gap-2">87 <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">
82 Scope89 Scope
83 <select90 <Select
84 className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
85 value={scopeType}91 value={scopeType}
86 onChange={(event) => onScopeTypeChange(event.target.value as StorageScopeType)}92 onValueChange={(value) => onScopeTypeChange(value as StorageScopeType)}
87 >93 >
88 <option value="scratch">scratch</option>94 <SelectTrigger className="h-8 w-full">
89 <option value="account">account</option>95 <SelectValue />
90 <option value="workspace">workspace</option>96 </SelectTrigger>
91 <option value="organization">organization</option>97 <SelectContent>
92 </select>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>
93 </label>104 </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">
95 Durability106 Durability
96 <select107 <Select
97 className="h-7 rounded-md border border-border/60 bg-background px-2 text-[11px] text-foreground"
98 value={durability}108 value={durability}
99 onChange={(event) => onDurabilityChange(event.target.value as StorageDurability)}109 onValueChange={(value) => onDurabilityChange(value as StorageDurability)}
100 >110 >
101 <option value="ephemeral">ephemeral</option>111 <SelectTrigger className="h-8 w-full">
102 <option value="durable">durable</option>112 <SelectValue />
103 </select>113 </SelectTrigger>
114 <SelectContent>
115 <SelectItem value="ephemeral">ephemeral</SelectItem>
116 <SelectItem value="durable">durable</SelectItem>
117 </SelectContent>
118 </Select>
104 </label>119 </label>
105 </div>120 </div>
106 <Input121 <Input
107 value={purpose}122 value={purpose}
108 onChange={(event) => onPurposeChange(event.target.value)}123 onChange={(event) => onPurposeChange(event.target.value)}
109 placeholder="Purpose (optional)"124 placeholder="Purpose (optional)"
110 className="h-7 rounded-md text-[11px]"125 className="h-8 rounded-md"
111 />126 />
112 <div className="flex items-end gap-2">127 <div className="flex items-end gap-2">
113 <Input128 <Input
@@ -115,9 +130,9 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 1 unmodified line
115 onChange={(event) => onTtlHoursChange(event.target.value)}130 onChange={(event) => onTtlHoursChange(event.target.value)}
116 disabled={durability !== "ephemeral"}131 disabled={durability !== "ephemeral"}
117 placeholder="TTL (hours)"132 placeholder="TTL (hours)"
118 className="h-7 flex-1 rounded-md text-[11px]"133 className="h-8 flex-1 rounded-md"
119 />134 />
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}>
121 Create136 Create
122 </Button>137 </Button>
123 </div>138 </div>
@@ -127,9 +142,15 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 3 unmodified lines
127 142
128 <div className="min-h-0 flex-1 overflow-y-auto">143 <div className="min-h-0 flex-1 overflow-y-auto">
129 {loading ? (144 {loading ? (
130 <div className="space-y-1 p-2">145 <div className="space-y-1 p-1.5">
131 {Array.from({ length: 4 }).map((_, i) => (146 {Array.from({ length: 5 }).map((_, index) => (
132 <Skeleton key={i} className="h-14 rounded-md" />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>
133 ))}154 ))}
134 </div>155 </div>
135 ) : visibleInstances.length === 0 ? (156 ) : visibleInstances.length === 0 ? (
@@ -139,7 +160,7 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 3 unmodified lines
139 </div>160 </div>
140 <div className="text-center">161 <div className="text-center">
141 <p className="text-xs font-medium text-muted-foreground">No instances</p>162 <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>
143 </div>164 </div>
144 </div>165 </div>
145 ) : (166 ) : (
@@ -171,21 +192,21 @@ export function StoragePanelSidebar(props: StoragePanelSidebarProps) { · 25 unmodified lines
171 </div>192 </div>
172 <div className="min-w-0 flex-1">193 <div className="min-w-0 flex-1">
173 <p className={cn(194 <p className={cn(
174 "truncate text-[11px] font-medium",195 "truncate text-xs font-medium",
175 active ? "text-foreground" : "text-foreground/80",196 active ? "text-foreground" : "text-foreground/80",
176 )}>197 )}>
177 {instance.purpose || instance.id.slice(0, 12)}198 {instance.purpose || instance.id.slice(0, 12)}
178 </p>199 </p>
179 <div className="mt-0.5 flex items-center gap-1.5">200 <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))}>
181 {instance.scopeType}202 {instance.scopeType}
182 </span>203 </span>
183 <span className="text-[9px] text-muted-foreground/40">/</span>204 <span className="text-xs text-muted-foreground/40">/</span>
184 <span className="text-[9px] text-muted-foreground">205 <span className="text-xs text-muted-foreground">
185 {prettyBytes(instance.sizeBytes)}206 {prettyBytes(instance.sizeBytes)}
186 </span>207 </span>
187 <span className="text-[9px] text-muted-foreground/40">/</span>208 <span className="text-xs text-muted-foreground/40">/</span>
188 <span className="text-[9px] text-muted-foreground">209 <span className="text-xs text-muted-foreground">
189 {relativeTime(instance.lastSeenAt)}210 {relativeTime(instance.lastSeenAt)}
190 </span>211 </span>
191 </div>212 </div>
executor/apps/web/src/components/tools/storage_panel_sql_tab.tsx+36−35
@@ -1,6 +1,7 @@
1import { Play } from "lucide-react";1import { Play } from "lucide-react";
2import { Button } from "@/components/ui/button";2import { Button } from "@/components/ui/button";
3import { Input } from "@/components/ui/input";3import { Input } from "@/components/ui/input";
4import { Textarea } from "@/components/ui/textarea";
4import { TabsContent } from "@/components/ui/tabs";5import { TabsContent } from "@/components/ui/tabs";
5import { cn } from "@/lib/utils";6import { cn } from "@/lib/utils";
6import { sqlCellText, truncateText, type StorageSqlObject, type StorageSqlResult } from "./storage_panel_shared";7import { sqlCellText, truncateText, type StorageSqlObject, type StorageSqlResult } from "./storage_panel_shared";
@@ -67,21 +68,21 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 60 unmodified lines
67 68
68 return (69 return (
69 <TabsContent value="sql" className="flex-1 min-h-0 overflow-hidden">70 <TabsContent value="sql" className="flex-1 min-h-0 overflow-hidden">
70 <div className="flex h-full min-h-0 flex-col">71 <div className="flex h-full min-h-0 flex-col">
71 {visibleSqlObjects.length > 0 && (72 {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 <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 <span className="mr-0.5 shrink-0 text-xs font-medium text-muted-foreground/70">Tables</span>
74 {visibleSqlObjects.map((entry) => (75 {visibleSqlObjects.map((entry) => (
75 <button76 <button
76 key={entry.name}77 key={entry.name}
77 type="button"78 type="button"
78 className={cn(79 className={cn(
79 "shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors",80 "shrink-0 rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
80 selectedSqlObjectName === entry.name81 selectedSqlObjectName === entry.name
81 ? "bg-primary/15 text-primary ring-1 ring-primary/20"82 ? "bg-primary/15 text-primary ring-1 ring-primary/20"
82 : "text-foreground/60 hover:bg-accent/50 hover:text-foreground",83 : "text-foreground/60 hover:bg-accent/50 hover:text-foreground",
83 )}84 )}
84 onClick={() => onOpenSqlObject(entry.name)}85 onClick={() => onOpenSqlObject(entry.name)}
85 >86 >
86 {entry.name}87 {entry.name}
87 </button>88 </button>
@@ -90,7 +91,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 2 unmodified lines
90 <Button91 <Button
91 variant="ghost"92 variant="ghost"
92 size="sm"93 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"
94 onClick={onToggleShowInternalObjects}95 onClick={onToggleShowInternalObjects}
95 >96 >
96 {sqlShowInternalObjects ? "Hide sys" : "Show sys"}97 {sqlShowInternalObjects ? "Hide sys" : "Show sys"}
@@ -98,7 +99,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 1 unmodified line
98 <Button99 <Button
99 variant="ghost"100 variant="ghost"
100 size="sm"101 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"
102 onClick={onRefreshSqlObjects}103 onClick={onRefreshSqlObjects}
103 disabled={sqlObjectsLoading}104 disabled={sqlObjectsLoading}
104 >105 >
@@ -115,7 +116,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 10 unmodified lines
115 )}116 )}
116 117
117 <div className="shrink-0 border-b border-border/30 bg-muted/20 p-3">118 <div className="shrink-0 border-b border-border/30 bg-muted/20 p-3">
118 <textarea119 <Textarea
119 value={sqlText}120 value={sqlText}
120 onChange={(event) => {121 onChange={(event) => {
121 onSqlTextChange(event.target.value);122 onSqlTextChange(event.target.value);
@@ -126,45 +127,45 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 4 unmodified lines
126 onRunSql();127 onRunSql();
127 }128 }
128 }}129 }}
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"
130 placeholder="SELECT * FROM ..."131 placeholder="SELECT * FROM ..."
131 />132 />
132 <div className="mt-2 flex flex-wrap items-center gap-1.5">133 <div className="mt-2 flex flex-wrap items-center gap-1.5">
133 <Button134 <Button
134 size="sm"135 size="sm"
135 className="h-6 rounded-md px-2.5 text-[10px]"136 className="h-6 rounded-md px-2.5 text-xs"
136 disabled={sqlLoading || !canInspect}137 disabled={sqlLoading || !canInspect}
137 onClick={onRunSql}138 onClick={onRunSql}
138 >139 >
139 <Play className="mr-1 h-2.5 w-2.5" /> Run140 <Play className="mr-1 h-2.5 w-2.5" /> Run
140 </Button>141 </Button>
141 <div className="h-4 w-px bg-border/40 mx-0.5" />142 <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}>
143 User tables144 User tables
144 </Button>145 </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}>
146 All objects147 All objects
147 </Button>148 </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}>
149 KV schema150 KV schema
150 </Button>151 </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}>
152 KV data153 KV data
153 </Button>154 </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}>
155 FS entries156 FS entries
156 </Button>157 </Button>
157 <div className="ml-auto flex items-center gap-1.5">158 <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>
159 <Input160 <Input
160 value={sqlMaxRows}161 value={sqlMaxRows}
161 onChange={(event) => onSqlMaxRowsChange(event.target.value)}162 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"
163 />164 />
164 </div>165 </div>
165 </div>166 </div>
166 <p className="mt-1.5 text-[9px] text-muted-foreground/40">167 <p className="mt-1.5 text-xs text-muted-foreground/50">
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 run168 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
168 </p>169 </p>
169 </div>170 </div>
170 171
@@ -182,7 +183,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 11 unmodified lines
182 ) : sqlResult ? (183 ) : sqlResult ? (
183 <>184 <>
184 <div className="flex items-center justify-between border-b border-border/20 px-4 py-1.5 bg-muted/10">185 <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">
186 {sqlResult.rowCount} row{sqlResult.rowCount === 1 ? "" : "s"}187 {sqlResult.rowCount} row{sqlResult.rowCount === 1 ? "" : "s"}
187 {typeof sqlResult.changes === "number" ? ` · ${sqlResult.changes} change${sqlResult.changes === 1 ? "" : "s"}` : ""}188 {typeof sqlResult.changes === "number" ? ` · ${sqlResult.changes} change${sqlResult.changes === 1 ? "" : "s"}` : ""}
188 {sqlColumns.length > 0 ? ` · ${sqlColumns.length} col${sqlColumns.length === 1 ? "" : "s"}` : ""}189 {sqlColumns.length > 0 ? ` · ${sqlColumns.length} col${sqlColumns.length === 1 ? "" : "s"}` : ""}
@@ -191,7 +192,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 2 unmodified lines
191 <Button192 <Button
192 variant={sqlViewMode === "table" ? "default" : "ghost"}193 variant={sqlViewMode === "table" ? "default" : "ghost"}
193 size="sm"194 size="sm"
194 className="h-5 rounded-md px-2 text-[9px]"195 className="h-5 rounded-md px-2 text-xs"
195 onClick={() => onSqlViewModeChange("table")}196 onClick={() => onSqlViewModeChange("table")}
196 >197 >
197 Table198 Table
@@ -199,7 +200,7 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 1 unmodified line
199 <Button200 <Button
200 variant={sqlViewMode === "json" ? "default" : "ghost"}201 variant={sqlViewMode === "json" ? "default" : "ghost"}
201 size="sm"202 size="sm"
202 className="h-5 rounded-md px-2 text-[9px]"203 className="h-5 rounded-md px-2 text-xs"
203 onClick={() => onSqlViewModeChange("json")}204 onClick={() => onSqlViewModeChange("json")}
204 >205 >
205 JSON206 JSON
@@ -217,14 +218,14 @@ export function StorageSqlTabContent(props: StorageSqlTabContentProps) { · 11 unmodified lines
217 <p className="text-xs text-muted-foreground/60">Query returned no rows</p>218 <p className="text-xs text-muted-foreground/60">Query returned no rows</p>
218 </div>219 </div>
219 ) : (220 ) : (
220 <table className="min-w-full border-collapse text-[11px]">221 <table className="min-w-full border-collapse text-xs">
221 <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm">222 <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm">
222 <tr>223 <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>
224 {sqlColumns.map((column) => (225 {sqlColumns.map((column) => (
225 <th226 <th
226 key={column}227 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"
228 >229 >
229 {column}230 {column}
230 </th>231 </th>
executor/apps/web/src/components/tools/view.tsx+22−58
@@ -8,7 +8,6 @@ import { ToolExplorer } from "@/components/tools/explorer"; · 7 unmodified lines
8import { TaskComposer } from "@/components/tasks/task-composer";8import { TaskComposer } from "@/components/tasks/task-composer";
9 9
10import { CredentialsPanel } from "@/components/tools/credentials";10import { CredentialsPanel } from "@/components/tools/credentials";
11import { ConnectionFormDialog } from "@/components/tools/connection/form-dialog";
12import { PoliciesPanel } from "@/components/tools/policies";11import { PoliciesPanel } from "@/components/tools/policies";
13import { StoragePanel } from "@/components/tools/storage";12import { StoragePanel } from "@/components/tools/storage";
14import { useSession } from "@/lib/session-context";13import { useSession } from "@/lib/session-context";
@@ -34,6 +33,13 @@ import { toolsCatalogQueryParsers } from "@/lib/url-state/tools"; · 19 unmodified lines
34 33
35type ToolsTab = "catalog" | "connections" | "policies" | "storage" | "editor";34type ToolsTab = "catalog" | "connections" | "policies" | "storage" | "editor";
36const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration";35const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration";
36const TAB_USES_OWN_LOADING_STATE: Record<ToolsTab, boolean> = {
37 catalog: true,
38 connections: true,
39 policies: true,
40 storage: true,
41 editor: true,
42};
37 43
38function parseToolsTab(pathname: string): ToolsTab {44function parseToolsTab(pathname: string): ToolsTab {
39 const segments = pathname.split("/").filter(Boolean);45 const segments = pathname.split("/").filter(Boolean);
@@ -76,17 +82,10 @@ export function ToolsView() { · 36 unmodified lines
76 const catalogFilterValue = catalogQueryState.approval as FilterApproval;82 const catalogFilterValue = catalogQueryState.approval as FilterApproval;
77 const catalogSourceValue = useMemo(() => mapEmptyQueryValueToNull(catalogQueryState.source), [catalogQueryState.source]);83 const catalogSourceValue = useMemo(() => mapEmptyQueryValueToNull(catalogQueryState.source), [catalogQueryState.source]);
78 const catalogActiveToolPath = mapEmptyQueryValueToNull(catalogQueryState.tool);84 const catalogActiveToolPath = mapEmptyQueryValueToNull(catalogQueryState.tool);
79 const catalogSelectedToolPaths = useMemo(
80 () => catalogQueryState.selected ?? [],
81 [catalogQueryState.selected],
82 );
83 const catalogFocusedSourceName = useMemo(85 const catalogFocusedSourceName = useMemo(
84 () => mapEmptyQueryValueToNull(catalogQueryState.sourcePanel),86 () => mapEmptyQueryValueToNull(catalogQueryState.sourcePanel),
85 [catalogQueryState.sourcePanel],87 [catalogQueryState.sourcePanel],
86 );88 );
87 const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
88 const [connectionDialogEditing, setConnectionDialogEditing] = useState<CredentialRecord | null>(null);
89 const [connectionDialogSourceKey, setConnectionDialogSourceKey] = useState<string | null>(null);
90 const [storageMutationBusyId, setStorageMutationBusyId] = useState<string | undefined>(undefined);89 const [storageMutationBusyId, setStorageMutationBusyId] = useState<string | undefined>(undefined);
91 const [storageCreateBusy, setStorageCreateBusy] = useState(false);90 const [storageCreateBusy, setStorageCreateBusy] = useState(false);
92 const [regenerationInFlight, setRegenerationInFlight] = useState(false);91 const [regenerationInFlight, setRegenerationInFlight] = useState(false);
@@ -207,6 +206,11 @@ export function ToolsView() { · 114 unmodified lines
207 const credentialsLoading = !!context && credentials === undefined;206 const credentialsLoading = !!context && credentials === undefined;
208 const storageItems = storageInstances ?? [];207 const storageItems = storageInstances ?? [];
209 const storageLoading = !!context && storageInstances === undefined;208 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];
210 214
211 const globalWarnings = useMemo(215 const globalWarnings = useMemo(
212 () => warnings.filter((warning) => !parseWarningSourceName(warning)),216 () => warnings.filter((warning) => !parseWarningSourceName(warning)),
@@ -330,14 +334,6 @@ export function ToolsView() { · 117 unmodified lines
330 });334 });
331 }, [setCatalogQueryState]);335 }, [setCatalogQueryState]);
332 336
333 const setCatalogSelectedToolPaths = useCallback((toolPaths: string[]) => {
334 void setCatalogQueryState({
335 selected: toolPaths,
336 }, {
337 history: "replace",
338 });
339 }, [setCatalogQueryState]);
340
341 const setCatalogFocusedSourceName = useCallback((sourceName: string | null) => {337 const setCatalogFocusedSourceName = useCallback((sourceName: string | null) => {
342 void setCatalogQueryState({338 void setCatalogQueryState({
343 sourcePanel: sourceName ?? "",339 sourcePanel: sourceName ?? "",
@@ -367,26 +363,6 @@ export function ToolsView() { · 23 unmodified lines
367 setRegenerationInFlight(false);363 setRegenerationInFlight(false);
368 }364 }
369 }, [context, rebuildInventoryNow, regenerationInFlight, showRegenerationToast]);365 }, [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
390 const handleCreateStorageInstance = useCallback(async (args: {366 const handleCreateStorageInstance = useCallback(async (args: {
391 scopeType: StorageScopeType;367 scopeType: StorageScopeType;
392 durability: StorageDurability;368 durability: StorageDurability;
@@ -455,7 +431,7 @@ export function ToolsView() { · 62 unmodified lines
455 }431 }
456 }, [context, deleteStorageInstance, storageMutationBusyId]);432 }, [context, deleteStorageInstance, storageMutationBusyId]);
457 433
458 if (sessionLoading) {434 if (shouldRenderShellSkeleton) {
459 return (435 return (
460 <div className="flex h-full min-h-0 flex-col">436 <div className="flex h-full min-h-0 flex-col">
461 <div className="flex flex-1 min-h-0 rounded-none border border-border/50 p-4">437 <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
487 return (463 return (
488 <div className="flex h-full min-h-0 flex-col">464 <div className="flex h-full min-h-0 flex-col">
489 {activeTab === "editor" ? (465 {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">
491 <TaskComposer />467 <TaskComposer />
492 </div>468 </div>
493 ) : null}469 ) : null}
@@ -506,7 +482,7 @@ export function ToolsView() { · 12 unmodified lines
506 sourceHasMoreTools={sourceHasMoreTools}482 sourceHasMoreTools={sourceHasMoreTools}
507 sourceLoadingMoreTools={sourceLoadingMoreTools}483 sourceLoadingMoreTools={sourceLoadingMoreTools}
508 onLoadMoreToolsForSource={loadMoreToolsForSource}484 onLoadMoreToolsForSource={loadMoreToolsForSource}
509 loading={loadingTools}485 loading={catalogPanelLoading}
510 sourceDialogMeta={sourceDialogMeta}486 sourceDialogMeta={sourceDialogMeta}
511 sourceAuthProfiles={sourceAuthProfiles}487 sourceAuthProfiles={sourceAuthProfiles}
512 existingSourceNames={existingSourceNames}488 existingSourceNames={existingSourceNames}
@@ -517,12 +493,10 @@ export function ToolsView() { · 4 unmodified lines
517 searchValue={catalogSearchValue}493 searchValue={catalogSearchValue}
518 filterApprovalValue={catalogFilterValue}494 filterApprovalValue={catalogFilterValue}
519 focusedToolPathValue={catalogActiveToolPath}495 focusedToolPathValue={catalogActiveToolPath}
520 selectedToolPathsValue={catalogSelectedToolPaths}
521 focusedSourceNameValue={catalogFocusedSourceName}496 focusedSourceNameValue={catalogFocusedSourceName}
522 onSearchValueChange={setCatalogSearch}497 onSearchValueChange={setCatalogSearch}
523 onFilterApprovalValueChange={setCatalogApprovalFilter}498 onFilterApprovalValueChange={setCatalogApprovalFilter}
524 onFocusedToolPathChange={setCatalogActiveToolPath}499 onFocusedToolPathChange={setCatalogActiveToolPath}
525 onSelectedToolPathsChange={setCatalogSelectedToolPaths}
526 onFocusedSourceNameChange={setCatalogFocusedSourceName}500 onFocusedSourceNameChange={setCatalogFocusedSourceName}
527 onRegenerate={handleRegenerateInventory}501 onRegenerate={handleRegenerateInventory}
528 inventoryState={inventoryStatus?.state}502 inventoryState={inventoryStatus?.state}
@@ -537,22 +511,22 @@ export function ToolsView() { · 8 unmodified lines
537 ) : null}511 ) : null}
538 512
539 {activeTab === "connections" ? (513 {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">
541 <CredentialsPanel515 <CredentialsPanel
542 sources={sourceItems}516 sources={sourceItems}
543 credentials={credentialItems}517 credentials={credentialItems}
544 loading={credentialsLoading || sourcesLoading}518 loading={connectionsPanelLoading}
545 onCreateConnection={openConnectionCreate}519 sourceAuthProfiles={sourceAuthProfiles}
546 onEditConnection={openConnectionEdit}520 loadingSourceNames={visibleLoadingSources}
547 />521 />
548 </div>522 </div>
549 ) : null}523 ) : null}
550 524
551 {activeTab === "policies" ? (525 {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">
553 <PoliciesPanel527 <PoliciesPanel
554 tools={visibleTools}528 tools={visibleTools}
555 loadingTools={loadingTools}529 loadingTools={policiesPanelLoading}
556 />530 />
557 </div>531 </div>
558 ) : null}532 ) : null}
@@ -563,7 +537,7 @@ export function ToolsView() { · 4 unmodified lines
563 workspaceId={context?.workspaceId}537 workspaceId={context?.workspaceId}
564 sessionId={context?.sessionId}538 sessionId={context?.sessionId}
565 instances={storageItems}539 instances={storageItems}
566 loading={storageLoading}540 loading={storagePanelLoading}
567 creating={storageCreateBusy}541 creating={storageCreateBusy}
568 busyInstanceId={storageMutationBusyId}542 busyInstanceId={storageMutationBusyId}
569 onCreate={handleCreateStorageInstance}543 onCreate={handleCreateStorageInstance}
@@ -573,16 +547,6 @@ export function ToolsView() { · 3 unmodified lines
573 </div>547 </div>
574 ) : null}548 ) : null}
575 549
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 />
586 </div>550 </div>
587 );551 );
588}552}
executor/apps/web/src/components/ui/textarea.tsx+20−0
@@ -0,0 +1,20 @@
1import * as React from "react"
2
3import { cn } from "@/lib/utils"
4
5function 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
20export { Textarea }
executor/apps/web/src/lib/credentials/source-helpers.ts+19−9
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 type SourceAuthType,6 type SourceAuthType,
7} from "@/lib/tools/source-helpers";7} from "@/lib/tools/source-helpers";
8import type {8import type {
9 CredentialAdditionalHeader,
9 CredentialScope,10 CredentialScope,
10 SourceAuthProfile,11 SourceAuthProfile,
11 ToolSourceScopeType,12 ToolSourceScopeType,
@@ -72,7 +73,12 @@ export function connectionDisplayName( · 60 unmodified lines
72 return `${base} ${ownerScopeLabel(connection.scopeType)}`;73 return `${base} ${ownerScopeLabel(connection.scopeType)}`;
73}74}
74 75
75export function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } {76type AdditionalHeadersParseResult = {
77 value?: Record<string, string>;
78 error?: string;
79};
80
81export function parseAdditionalHeadersText(text: string): AdditionalHeadersParseResult {
76 const lines = text82 const lines = text
77 .split("\n")83 .split("\n")
78 .map((line) => line.trim())84 .map((line) => line.trim())
@@ -98,12 +104,12 @@ export function parseHeaderOverrides(text: string): { value?: Record<string, str · 19 unmodified lines
98 return { value: headers };104 return { value: headers };
99}105}
100 106
101export type HeaderOverrideEntry = {107export type AdditionalHeaderEntry = {
102 key: string;108 key: string;
103 value: string;109 value: string;
104};110};
105 111
106export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] {112export function parseAdditionalHeaderEntries(text: string): AdditionalHeaderEntry[] {
107 const rows = text113 const rows = text
108 .split("\n")114 .split("\n")
109 .map((line) => line.trim())115 .map((line) => line.trim())
@@ -126,7 +132,7 @@ export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] · 16 unmodified lines
126 });132 });
127}133}
128 134
129export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value?: Record<string, string>; error?: string } {135export function parseAdditionalHeaderRows(entries: AdditionalHeaderEntry[]): AdditionalHeadersParseResult {
130 const headers: Record<string, string> = {};136 const headers: Record<string, string> = {};
131 137
132 for (let index = 0; index < entries.length; index += 1) {138 for (let index = 0; index < entries.length; index += 1) {
@@ -149,7 +155,7 @@ export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value · 16 unmodified lines
149 return { value: headers };155 return { value: headers };
150}156}
151 157
152export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): string {158export function serializeAdditionalHeaderRows(entries: AdditionalHeaderEntry[]): string {
153 return entries159 return entries
154 .map((entry) => ({160 .map((entry) => ({
155 key: entry.key.trim(),161 key: entry.key.trim(),
@@ -160,9 +166,13 @@ export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): str · 4 unmodified lines
160 .join("\n");166 .join("\n");
161}167}
162 168
163export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {169export function formatAdditionalHeaders(headers: CredentialAdditionalHeader[] | undefined): string {
164 const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};170 return (headers ?? [])
165 return Object.entries(headers)171 .map((header) => ({
166 .map(([key, value]) => `${key}: ${String(value)}`)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}`)
167 .join("\n");177 .join("\n");
168}178}
executor/apps/web/src/lib/tools/source-helpers.ts+4−2
@@ -1,13 +1,15 @@
1import { parse as parseDomain } from "tldts";1import { parse as parseDomain } from "tldts";
2import type {2import type {
3 CredentialScope,
3 CredentialRecord,4 CredentialRecord,
4 OpenApiSourceQuality,5 OpenApiSourceQuality,
5 SourceAuthProfile,6 SourceAuthProfile,
7 SourceAuthType as SharedSourceAuthType,
6 ToolSourceRecord,8 ToolSourceRecord,
7} from "@/lib/types";9} from "@/lib/types";
8 10
9export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";11export type SourceAuthType = SharedSourceAuthType;
10export type SourceAuthMode = "workspace" | "account" | "organization";12export type SourceAuthMode = CredentialScope;
11 13
12const RAW_HOSTS = new Set([14const RAW_HOSTS = new Set([
13 "raw.githubusercontent.com",15 "raw.githubusercontent.com",
executor/apps/web/src/lib/types.ts+9−23
@@ -1,15 +1,19 @@
1import type { Id } from "@executor/database/convex/_generated/dataModel";1import type { Id } from "@executor/database/convex/_generated/dataModel";
2import type {2import type {
3 ApprovalStatus as CoreApprovalStatus,3 ApprovalStatus as CoreApprovalStatus,
4 ArgumentCondition as CoreArgumentCondition,
4 ArgumentConditionOperator as CoreArgumentConditionOperator,5 ArgumentConditionOperator as CoreArgumentConditionOperator,
6 CredentialAdditionalHeader as CoreCredentialAdditionalHeader,
5 CredentialProvider as CoreCredentialProvider,7 CredentialProvider as CoreCredentialProvider,
6 CredentialScope as CoreCredentialScope,8 CredentialScope as CoreCredentialScope,
7 CredentialScopeType as CoreCredentialScopeType,9 CredentialScopeType as CoreCredentialScopeType,
10 OpenApiSourceQuality as CoreOpenApiSourceQuality,
8 PolicyApprovalMode as CorePolicyApprovalMode,11 PolicyApprovalMode as CorePolicyApprovalMode,
9 PolicyDecision as CorePolicyDecision,12 PolicyDecision as CorePolicyDecision,
10 PolicyEffect as CorePolicyEffect,13 PolicyEffect as CorePolicyEffect,
11 PolicyMatchType as CorePolicyMatchType,14 PolicyMatchType as CorePolicyMatchType,
12 PolicyScopeType as CorePolicyScopeType,15 PolicyScopeType as CorePolicyScopeType,
16 SourceAuthProfile as CoreSourceAuthProfile,
13 SourceAuthType as CoreSourceAuthType,17 SourceAuthType as CoreSourceAuthType,
14 StorageDurability as CoreStorageDurability,18 StorageDurability as CoreStorageDurability,
15 StorageInstanceStatus as CoreStorageInstanceStatus,19 StorageInstanceStatus as CoreStorageInstanceStatus,
@@ -40,11 +44,8 @@ export type ToolRoleSelectorType = CoreToolRoleSelectorType; · 24 unmodified lines
40export type ToolRoleBindingStatus = CoreToolRoleBindingStatus;44export type ToolRoleBindingStatus = CoreToolRoleBindingStatus;
41export type ArgumentConditionOperator = CoreArgumentConditionOperator;45export type ArgumentConditionOperator = CoreArgumentConditionOperator;
42 46
43export interface ArgumentCondition {47export type ArgumentCondition = CoreArgumentCondition;
44 key: string;48export type CredentialAdditionalHeader = CoreCredentialAdditionalHeader;
45 operator: ArgumentConditionOperator;
46 value: string;
47}
48export type CredentialScope = CoreCredentialScope;49export type CredentialScope = CoreCredentialScope;
49export type CredentialProvider = CoreCredentialProvider;50export type CredentialProvider = CoreCredentialProvider;
50export type ToolSourceScopeType = CoreToolSourceScopeType;51export type ToolSourceScopeType = CoreToolSourceScopeType;
@@ -58,12 +59,7 @@ export type StorageProvider = CoreStorageProvider; · 7 unmodified lines
58 59
59export type SourceAuthType = CoreSourceAuthType;60export type SourceAuthType = CoreSourceAuthType;
60 61
61export interface SourceAuthProfile {62export type SourceAuthProfile = CoreSourceAuthProfile;
62 type: SourceAuthType;
63 mode?: CredentialScope;
64 header?: string;
65 inferred: boolean;
66}
67 63
68export interface TaskRecord {64export interface TaskRecord {
69 id: string;65 id: string;
@@ -122,7 +118,7 @@ export interface CredentialRecord { · 52 unmodified lines
122 organizationId?: string;118 organizationId?: string;
123 workspaceId?: string;119 workspaceId?: string;
124 sourceKey: string;120 sourceKey: string;
125 overridesJson?: Record<string, unknown>;121 additionalHeaders: CredentialAdditionalHeader[];
126 boundAuthFingerprint?: string;122 boundAuthFingerprint?: string;
127 provider: CredentialProvider;123 provider: CredentialProvider;
128 secretJson: Record<string, unknown>;124 secretJson: Record<string, unknown>;
@@ -191,17 +187,7 @@ export interface ToolDescriptor { · 62 unmodified lines
191 };187 };
192}188}
193 189
194export interface OpenApiSourceQuality {190export type OpenApiSourceQuality = CoreOpenApiSourceQuality;
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}
205 191
206export interface AnonymousContext {192export interface AnonymousContext {
207 sessionId: string;193 sessionId: string;
executor/apps/web/src/lib/url-state/tools.ts+0−6
@@ -1,11 +1,9 @@
1import {1import {
2 parseAsNativeArrayOf,
3 parseAsString,2 parseAsString,
4 parseAsStringLiteral,3 parseAsStringLiteral,
5} from "nuqs";4} from "nuqs";
6import {5import {
7 asTrimmedString,6 asTrimmedString,
8 asTrimmedStringArray,
9} from "@/lib/url-state/shared";7} from "@/lib/url-state/shared";
10 8
11export const toolsApprovalValues = ["all", "required", "auto"] as const;9export const toolsApprovalValues = ["all", "required", "auto"] as const;
@@ -16,7 +14,6 @@ export const toolsCatalogQueryParsers = { · 4 unmodified lines
16 q: parseAsString.withDefault(""),14 q: parseAsString.withDefault(""),
17 approval: parseAsStringLiteral(toolsApprovalValues).withDefault("all"),15 approval: parseAsStringLiteral(toolsApprovalValues).withDefault("all"),
18 tool: parseAsString.withDefault(""),16 tool: parseAsString.withDefault(""),
19 selected: parseAsNativeArrayOf(parseAsString),
20 source: parseAsString.withDefault(""),17 source: parseAsString.withDefault(""),
21 sourcePanel: parseAsString.withDefault(""),18 sourcePanel: parseAsString.withDefault(""),
22};19};
@@ -25,7 +22,6 @@ export type ToolsSearch = { · 2 unmodified lines
25 q?: string;22 q?: string;
26 approval?: ToolsApproval;23 approval?: ToolsApproval;
27 tool?: string;24 tool?: string;
28 selected?: string[];
29 source?: string;25 source?: string;
30 sourcePanel?: string;26 sourcePanel?: string;
31};27};
@@ -42,7 +38,6 @@ export function normalizeToolsSearch(search: Record<string, unknown>): ToolsSear · 10 unmodified lines
42 const q = asTrimmedString(search.q);38 const q = asTrimmedString(search.q);
43 const approval = normalizeToolsApproval(search.approval);39 const approval = normalizeToolsApproval(search.approval);
44 const tool = asTrimmedString(search.tool);40 const tool = asTrimmedString(search.tool);
45 const selected = asTrimmedStringArray(search.selected);
46 const source = asTrimmedString(search.source);41 const source = asTrimmedString(search.source);
47 const sourcePanel = asTrimmedString(search.sourcePanel);42 const sourcePanel = asTrimmedString(search.sourcePanel);
48 43
@@ -50,7 +45,6 @@ export function normalizeToolsSearch(search: Record<string, unknown>): ToolsSear · 1 unmodified line
50 ...(q ? { q } : {}),45 ...(q ? { q } : {}),
51 ...(approval === "all" ? {} : { approval }),46 ...(approval === "all" ? {} : { approval }),
52 ...(tool ? { tool } : {}),47 ...(tool ? { tool } : {}),
53 ...(selected ? { selected } : {}),
54 ...(source ? { source } : {}),48 ...(source ? { source } : {}),
55 ...(sourcePanel ? { sourcePanel } : {}),49 ...(sourcePanel ? { sourcePanel } : {}),
56 };50 };