History

Polish source-management UI for typed tools

Rhys Sullivan committed 8 months ago

Commit cb248f81 parent 7e6277a11 files · +320 −174Browse files

Unified · Split

11 files
  1. executor/apps/web/src/components/app-shell.tsx +37 −2
  2. executor/apps/web/src/components/tasks/code-editor.tsx +4 −2
  3. executor/apps/web/src/components/tools/add/source/dialog-sections.tsx +2 −2
  4. executor/apps/web/src/components/tools/connection/form-dialog.tsx +87 −13
  5. executor/apps/web/src/components/tools/explorer-groups.tsx +19 −37
  6. executor/apps/web/src/components/tools/explorer-toolbar.tsx +0 −32
  7. executor/apps/web/src/components/tools/explorer.tsx +24 −52
  8. executor/apps/web/src/components/tools/explorer/tool-detail.tsx +0 −11
  9. executor/apps/web/src/components/tools/source-favicon.tsx +2 −1
  10. executor/apps/web/src/components/tools/view.tsx +83 −22
  11. executor/apps/web/src/lib/credentials/source-helpers.ts +62 −0
executor/apps/web/src/components/app-shell.tsx+37−2
@@ -2,7 +2,17 @@ · 1 unmodified line
2 2
3import { Suspense, useState } from "react";3import { Suspense, useState } from "react";
4import { Link, Navigate, useLocation } from "react-router";4import { Link, Navigate, useLocation } from "react-router";
5import { ExternalLink, Github, LayoutDashboard, ListTodo, ShieldCheck, Wrench, Menu, X } from "lucide-react";5import {
6 ExternalLink,
7 Github,
8 LayoutDashboard,
9 ListTodo,
10 MessageCircle,
11 Menu,
12 ShieldCheck,
13 Wrench,
14 X,
15} from "lucide-react";
6import { Button } from "@/components/ui/button";16import { Button } from "@/components/ui/button";
7import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";17import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
8import { useSession } from "@/lib/session-context";18import { useSession } from "@/lib/session-context";
@@ -21,6 +31,8 @@ const NAV_ITEMS = [ · 12 unmodified lines
21];31];
22 32
23const EXECUTOR_REPO_URL = "https://github.com/RhysSullivan/executor";33const EXECUTOR_REPO_URL = "https://github.com/RhysSullivan/executor";
34const TWITTER_FEEDBACK_URL =
35 "https://twitter.com/intent/tweet?text=%40rhyssullivan%20Executor%20feedback:%20";
24 36
25function NavLinks({ onClick }: { onClick?: () => void }) {37function NavLinks({ onClick }: { onClick?: () => void }) {
26 const location = useLocation();38 const location = useLocation();
@@ -66,7 +78,24 @@ function RepoStarLink() { · 39 unmodified lines
66 <Github className="h-3.5 w-3.5" />78 <Github className="h-3.5 w-3.5" />
67 Star Executor79 Star Executor
68 </span>80 </span>
69 <ExternalLink className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" />81 <ExternalLink className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100" />
82 </a>
83 );
84}
85
86function FeedbackLink() {
87 return (
88 <a
89 href={TWITTER_FEEDBACK_URL}
90 target="_blank"
91 rel="noreferrer"
92 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"
93 >
94 <span className="flex items-center gap-1.5">
95 <MessageCircle className="h-3.5 w-3.5" />
96 Feedback
97 </span>
98 <ExternalLink className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100" />
70 </a>99 </a>
71 );100 );
72}101}
@@ -85,6 +114,9 @@ function Sidebar() { · 12 unmodified lines
85 <SessionInfo />114 <SessionInfo />
86 </Suspense>115 </Suspense>
87 </div>116 </div>
117 <div className="px-3 pb-1">
118 <FeedbackLink />
119 </div>
88 <div className="px-3 pb-1">120 <div className="px-3 pb-1">
89 <RepoStarLink />121 <RepoStarLink />
90 </div>122 </div>
@@ -122,6 +154,9 @@ function MobileHeader() { · 31 unmodified lines
122 <SessionInfo />154 <SessionInfo />
123 </Suspense>155 </Suspense>
124 </div>156 </div>
157 <div className="px-3 pb-1">
158 <FeedbackLink />
159 </div>
125 <div className="px-3 pb-1">160 <div className="px-3 pb-1">
126 <RepoStarLink />161 <RepoStarLink />
127 </div>162 </div>
executor/apps/web/src/components/tasks/code-editor.tsx+4−2
@@ -178,8 +178,10 @@ export function CodeEditor({ · 177 unmodified lines
178 onMount={handleMount}178 onMount={handleMount}
179 options={CODE_EDITOR_OPTIONS}179 options={CODE_EDITOR_OPTIONS}
180 loading={180 loading={
181 <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">181 <div className="flex h-full w-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">
182 Loading editor...182 <span className="w-full max-w-xs rounded-md border border-border/80 bg-muted/70 px-3 py-1 text-center">
183 Loading editor...
184 </span>
183 </div>185 </div>
184 }186 }
185 />187 />
executor/apps/web/src/components/tools/add/source/dialog-sections.tsx+2−2
@@ -89,14 +89,14 @@ export function CatalogViewSection({ · 88 unmodified lines
89 <img89 <img
90 src={item.logoUrl}90 src={item.logoUrl}
91 alt=""91 alt=""
92 className="w-5 h-5 rounded shrink-0 mt-0.5 object-contain"92 className="w-5 h-5 rounded-full shrink-0 mt-0.5 object-cover"
93 />93 />
94 ) : (94 ) : (
95 <SourceFavicon95 <SourceFavicon
96 sourceUrl={item.originUrl || item.specUrl}96 sourceUrl={item.originUrl || item.specUrl}
97 fallbackType={item.sourceType ?? "openapi"}97 fallbackType={item.sourceType ?? "openapi"}
98 iconClassName="h-5 w-5 text-muted-foreground"98 iconClassName="h-5 w-5 text-muted-foreground"
99 imageClassName="w-5 h-5 rounded object-contain"99 imageClassName="w-5 h-5"
100 />100 />
101 )}101 )}
102 <div className="flex-1 min-w-0 w-0 overflow-hidden">102 <div className="flex-1 min-w-0 w-0 overflow-hidden">
executor/apps/web/src/components/tools/connection/form-dialog.tsx+87−13
@@ -1,13 +1,13 @@
1"use client";1"use client";
2 2
3import { useState } from "react";3import { type ChangeEvent, useEffect, useState } from "react";
4import { useAction } from "convex/react";4import { useAction } from "convex/react";
5import { toast } from "sonner";5import { toast } from "sonner";
6import { Button } from "@/components/ui/button";6import { Button } from "@/components/ui/button";
7import { Input } from "@/components/ui/input";7import { Input } from "@/components/ui/input";
8import { Label } from "@/components/ui/label";8import { Label } from "@/components/ui/label";
9import { Textarea } from "@/components/ui/textarea";
10import { Badge } from "@/components/ui/badge";9import { Badge } from "@/components/ui/badge";
10import { Plus, Trash2 } from "lucide-react";
11import {11import {
12 Select,12 Select,
13 SelectContent,13 SelectContent,
@@ -30,7 +30,10 @@ import type { · 16 unmodified lines
30} from "@/lib/types";30} from "@/lib/types";
31import {31import {
32 connectionDisplayName,32 connectionDisplayName,
33 parseHeaderOverrides,33 parseHeaderOverrideEntries,
34 parseHeaderOverrideRows,
35 serializeHeaderOverrideRows,
36 type HeaderOverrideEntry,
34} from "@/lib/credentials/source-helpers";37} from "@/lib/credentials/source-helpers";
35import { sourceForCredentialKey } from "@/lib/tools/source-helpers";38import { sourceForCredentialKey } from "@/lib/tools/source-helpers";
36import { ConnectionAuthFields } from "./auth-fields";39import { ConnectionAuthFields } from "./auth-fields";
@@ -64,6 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines
64 const { context, clientConfig } = useSession();67 const { context, clientConfig } = useSession();
65 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);68 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
66 const [saving, setSaving] = useState(false);69 const [saving, setSaving] = useState(false);
70 const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries(""));
67 const {71 const {
68 sourceKey,72 sourceKey,
69 scopeType,73 scopeType,
@@ -116,6 +120,14 @@ export function ConnectionFormDialog({ · 46 unmodified lines
116 && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false);120 && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false);
117 const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge;121 const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge;
118 122
123 useEffect(() => {
124 if (!open) {
125 return;
126 }
127
128 setHeaderRows(parseHeaderOverrideEntries(customHeadersText));
129 }, [open, customHeadersText]);
130
119 const handleSave = async () => {131 const handleSave = async () => {
120 if (!context) {132 if (!context) {
121 return;133 return;
@@ -129,12 +141,15 @@ export function ConnectionFormDialog({ · 7 unmodified lines
129 return;141 return;
130 }142 }
131 143
132 const parsedHeaders = parseHeaderOverrides(customHeadersText);144 const parsedHeaders = parseHeaderOverrideRows(headerRows);
133 if (!parsedHeaders.value) {145 if (!parsedHeaders.value) {
134 toast.error(parsedHeaders.error ?? "Invalid header overrides");146 toast.error(parsedHeaders.error ?? "Invalid header overrides");
135 return;147 return;
136 }148 }
137 149
150 const serializedHeaders = serializeHeaderOverrideRows(headerRows);
151 setCustomHeadersText(serializedHeaders);
152
138 const linkExisting = !editing && connectionMode === "existing";153 const linkExisting = !editing && connectionMode === "existing";
139 if (linkExisting && !existingConnectionKey) {154 if (linkExisting && !existingConnectionKey) {
140 toast.error("Select saved credentials");155 toast.error("Select saved credentials");
@@ -205,6 +220,31 @@ export function ConnectionFormDialog({ · 64 unmodified lines
205 }220 }
206 };221 };
207 222
223 const addHeaderRow = () => {
224 setHeaderRows((current) => [...current, { key: "", value: "" }]);
225 };
226
227 const removeHeaderRow = (index: number) => {
228 setHeaderRows((current) => {
229 const next = current.filter((_, rowIndex) => rowIndex !== index);
230 return next.length === 0 ? [{ key: "", value: "" }] : next;
231 });
232 };
233
234 const updateHeaderRow = (index: number, patch: Partial<HeaderOverrideEntry>) => {
235 setHeaderRows((current) =>
236 current.map((entry, rowIndex) => {
237 if (rowIndex !== index) {
238 return entry;
239 }
240 return {
241 ...entry,
242 ...patch,
243 };
244 }),
245 );
246 };
247
208 return (248 return (
209 <Dialog open={open} onOpenChange={onOpenChange}>249 <Dialog open={open} onOpenChange={onOpenChange}>
210 <DialogContent className="bg-card border-border sm:max-w-lg">250 <DialogContent className="bg-card border-border sm:max-w-lg">
@@ -345,15 +385,49 @@ export function ConnectionFormDialog({ · 134 unmodified lines
345 onBasicPasswordChange={setBasicPassword}385 onBasicPasswordChange={setBasicPassword}
346 />386 />
347 387
348 <div className="space-y-1.5">388 <div className="space-y-2">
349 <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label>389 <div className="flex items-center justify-between gap-2">
350 <Textarea390 <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label>
351 value={customHeadersText}391 <Button type="button" variant="outline" size="sm" onClick={addHeaderRow} className="h-7 px-2 text-[11px]">
352 onChange={(e) => setCustomHeadersText(e.target.value)}392 <Plus className="h-3.5 w-3.5 mr-1.5" />
353 rows={4}393 Add header
354 placeholder="x-tenant-id: acme\nx-env: staging"394 </Button>
355 className="text-xs font-mono bg-background"395 </div>
356 />396 <div className="space-y-2">
397 {headerRows.map((headerRow, index) => (
398 <div key={`${headerRow.key || "header"}-${index}`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2 items-start">
399 <Input
400 value={headerRow.key}
401 onChange={(event: ChangeEvent<HTMLInputElement>) =>
402 updateHeaderRow(index, { key: event.target.value })
403 }
404 placeholder="Header"
405 className="h-8 text-xs font-mono bg-background"
406 />
407 <Input
408 value={headerRow.value}
409 onChange={(event: ChangeEvent<HTMLInputElement>) =>
410 updateHeaderRow(index, { value: event.target.value })
411 }
412 placeholder="Value"
413 className="h-8 text-xs font-mono bg-background"
414 />
415 <Button
416 type="button"
417 variant="outline"
418 size="sm"
419 className="h-8 px-2"
420 disabled={headerRows.length === 1}
421 onClick={() => void removeHeaderRow(index)}
422 >
423 <Trash2 className="h-3.5 w-3.5" />
424 </Button>
425 </div>
426 ))}
427 </div>
428 <p className="text-[10px] text-muted-foreground">
429 Add one `Header` and `Value` pair per line.
430 </p>
357 </div>431 </div>
358 432
359 <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm">433 <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm">
executor/apps/web/src/components/tools/explorer-groups.tsx+19−37
@@ -115,13 +115,13 @@ export function GroupNode({ · 114 unmodified lines
115 115
116 {isSource && (116 {isSource && (
117 <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0">117 <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0">
118 {source ? (118 {source ? (
119 <SourceFavicon119 <SourceFavicon
120 source={source}120 source={source}
121 iconClassName="h-3 w-3 text-muted-foreground"121 iconClassName="h-3 w-3 text-muted-foreground"
122 imageClassName="w-full h-full object-contain"122 imageClassName="w-full h-full"
123 />123 />
124 ) : (124 ) : (
125 sourceTypeFallback === "mcp"125 sourceTypeFallback === "mcp"
126 ? <Server className="h-3 w-3 text-muted-foreground" />126 ? <Server className="h-3 w-3 text-muted-foreground" />
127 : sourceTypeFallback === "graphql"127 : sourceTypeFallback === "graphql"
@@ -259,39 +259,18 @@ export function SourceSidebar({ · 131 unmodified lines
259 );259 );
260 260
261 const groups = useMemo(() => {261 const groups = useMemo(() => {
262 const map = new Map<262 return sources
263 string,263 .filter((source) => source.enabled)
264 { name: string; type: string; count: number; isLoading: boolean; warningCount: number; source?: ToolSourceRecord }264 .map((source) => ({
265 >();
266
267 for (const source of sources) {
268 if (!source.enabled) {
269 continue;
270 }
271
272 const count = sourceCounts[source.name] ?? 0;
273 const isLoading = loadingSources.has(source.name);
274 const warningCount = warningCountsBySource[source.name] ?? 0;
275
276 if (count === 0 && !isLoading && warningCount === 0) {
277 continue;
278 }
279
280 map.set(source.name, {
281 name: source.name,265 name: source.name,
282 type: source.type,266 type: source.type,
283 count,267 count: sourceCounts[source.name] ?? 0,
284 isLoading,268 isLoading: loadingSources.has(source.name),
285 warningCount,269 warningCount: warningCountsBySource[source.name] ?? 0,
286 source,270 source,
287 });271 }))
288 }272 .sort((a, b) => a.name.localeCompare(b.name));
289 273 }, [loadingSources, sourceCounts, sources, warningCountsBySource]);
290 return Array.from(map.values()).sort((a, b) => {
291 if (a.count !== b.count) return b.count - a.count;
292 return a.name.localeCompare(b.name);
293 });
294 }, [sourceCounts, sources, loadingSources, warningCountsBySource]);
295 274
296 return (275 return (
297 <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">276 <div className="w-52 shrink-0 border-r border-border/50 pr-0 hidden lg:block">
@@ -302,6 +281,9 @@ export function SourceSidebar({ · 4 unmodified lines
302 </div>281 </div>
303 282
304 <div className="space-y-0.5 px-1">283 <div className="space-y-0.5 px-1">
284 {groups.length === 0 ? (
285 <p className="px-2 py-2 text-[11px] text-muted-foreground/60">No sources configured yet.</p>
286 ) : null}
305 {groups.map((g) => {287 {groups.map((g) => {
306 const editMeta = g.source ? sourceDialogMeta?.[g.name] : undefined;288 const editMeta = g.source ? sourceDialogMeta?.[g.name] : undefined;
307 return (289 return (
executor/apps/web/src/components/tools/explorer-toolbar.tsx+0−32
@@ -53,8 +53,6 @@ interface ToolExplorerToolbarProps { · 52 unmodified lines
53export function ToolExplorerToolbar({53export function ToolExplorerToolbar({
54 search,54 search,
55 filteredToolCount,55 filteredToolCount,
56 hasSearch,
57 resultCount,
58 loadingInventory = false,56 loadingInventory = false,
59 viewMode,57 viewMode,
60 groupBy,58 groupBy,
@@ -72,8 +70,6 @@ export function ToolExplorerToolbar({ · 11 unmodified lines
72 addSourceAction,70 addSourceAction,
73 onSelectAll,71 onSelectAll,
74 onClearSelection,72 onClearSelection,
75 onExpandAll,
76 onCollapseAll,
77}: ToolExplorerToolbarProps) {73}: ToolExplorerToolbarProps) {
78 return (74 return (
79 <div className="shrink-0">75 <div className="shrink-0">
@@ -286,34 +282,6 @@ export function ToolExplorerToolbar({ · 206 unmodified lines
286 </Button>282 </Button>
287 </div>283 </div>
288 )}284 )}
289
290 <div className="flex items-center justify-end pb-1.5">
291 {hasSearch ? (
292 <span className="text-[10px] font-mono text-muted-foreground/50">
293 {resultCount} results
294 {activeSource && ` in ${activeSource}`}
295 </span>
296 ) : loadingInventory ? (
297 <span className="text-[10px] font-mono text-muted-foreground/50">Loading tool inventory...</span>
298 ) : null}
299 {viewMode === "tree" && (
300 <div className="flex gap-1">
301 <button
302 onClick={onExpandAll}
303 className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
304 >
305 Expand all
306 </button>
307 <span className="text-[10px] text-muted-foreground/30">·</span>
308 <button
309 onClick={onCollapseAll}
310 className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
311 >
312 Collapse all
313 </button>
314 </div>
315 )}
316 </div>
317 </div>285 </div>
318 );286 );
319}287}
executor/apps/web/src/components/tools/explorer.tsx+24−52
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue, useEffect } from "react";3import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue } from "react";
4import { cn } from "@/lib/utils";4import { cn } from "@/lib/utils";
5import {5import {
6 collectGroupKeys,6 collectGroupKeys,
@@ -142,50 +142,16 @@ export function ToolExplorer({ · 135 unmodified lines
142 return counts;142 return counts;
143 }, [hydratedTools]);143 }, [hydratedTools]);
144 144
145 const [stableSources, setStableSources] = useState<ToolSourceRecord[]>(sources);145 const visibleSources = useMemo(() => {
146 146 const enabledByName = new Map<string, ToolSourceRecord>();
147 useEffect(() => {147 for (const source of sources) {
148 setStableSources((previous) => {148 if (source.enabled) {
149 const nextByName = new Map<string, ToolSourceRecord>();149 enabledByName.set(source.name, source);
150
151 for (const source of previous) {
152 if (source.enabled) {
153 nextByName.set(source.name, source);
154 }
155 }
156
157 const enabledSourceNames = new Set<string>();
158 for (const source of sources) {
159 if (!source.enabled) {
160 continue;
161 }
162 enabledSourceNames.add(source.name);
163 nextByName.set(source.name, source);
164 }
165
166 for (const [name] of nextByName) {
167 const hasTools = (sourceCounts[name] ?? 0) > 0;
168 const isLoading = loadingSourceSet.has(name);
169 const stillEnabled = enabledSourceNames.has(name);
170 if (!stillEnabled && !hasTools && !isLoading) {
171 nextByName.delete(name);
172 }
173 }150 }
151 }
174 152
175 const next = Array.from(nextByName.values()).sort((a, b) => a.name.localeCompare(b.name));153 return Array.from(enabledByName.values()).sort((a, b) => a.name.localeCompare(b.name));
176 const unchanged =154 }, [sources]);
177 next.length === previous.length
178 && next.every((source, index) => {
179 const prior = previous[index];
180 return prior
181 && prior.id === source.id
182 && prior.name === source.name
183 && prior.type === source.type
184 && prior.enabled === source.enabled;
185 });
186 return unchanged ? previous : next;
187 });
188 }, [loadingSourceSet, sourceCounts, sources]);
189 155
190 const searchedTools = useMemo(() => {156 const searchedTools = useMemo(() => {
191 return filterToolsBySearch(filteredTools, search);157 return filterToolsBySearch(filteredTools, search);
@@ -194,16 +160,16 @@ export function ToolExplorer({ · 2 unmodified lines
194 const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]);160 const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]);
195 161
196 const sidebarExistingSourceNames = useMemo(() => {162 const sidebarExistingSourceNames = useMemo(() => {
197 return existingSourceNames ?? new Set(stableSources.map((source) => source.name));163 return existingSourceNames ?? new Set(visibleSources.map((source) => source.name));
198 }, [existingSourceNames, stableSources]);164 }, [existingSourceNames, visibleSources]);
199 165
200 const treeGroups = useMemo(() => {166 const treeGroups = useMemo(() => {
201 return treeGroupsForView(searchedTools, viewMode, groupBy, {167 return treeGroupsForView(searchedTools, viewMode, groupBy, {
202 loadingSources: visibleLoadingSources,168 loadingSources: visibleLoadingSources,
203 sourceRecords: stableSources,169 sourceRecords: visibleSources,
204 activeSource: resolvedActiveSource,170 activeSource: resolvedActiveSource,
205 });171 });
206 }, [searchedTools, viewMode, groupBy, visibleLoadingSources, stableSources, resolvedActiveSource]);172 }, [searchedTools, viewMode, groupBy, visibleLoadingSources, visibleSources, resolvedActiveSource]);
207 173
208 const flatTools = useMemo(() => {174 const flatTools = useMemo(() => {
209 return flatToolsForView(searchedTools, viewMode);175 return flatToolsForView(searchedTools, viewMode);
@@ -211,11 +177,11 @@ export function ToolExplorer({ · 1 unmodified line
211 177
212 const sourceByName = useMemo(() => {178 const sourceByName = useMemo(() => {
213 const map = new Map<string, ToolSourceRecord>();179 const map = new Map<string, ToolSourceRecord>();
214 for (const source of stableSources) {180 for (const source of visibleSources) {
215 map.set(source.name, source);181 map.set(source.name, source);
216 }182 }
217 return map;183 return map;
218 }, [stableSources]);184 }, [visibleSources]);
219 185
220 const autoExpandedKeys = useMemo(() => {186 const autoExpandedKeys = useMemo(() => {
221 return autoExpandedKeysForSearch(search, filteredTools, viewMode);187 return autoExpandedKeysForSearch(search, filteredTools, viewMode);
@@ -289,8 +255,14 @@ export function ToolExplorer({ · 67 unmodified lines
289 }, [selectedKeys, filteredTools]);255 }, [selectedKeys, filteredTools]);
290 256
291 const sourceOptions = useMemo(257 const sourceOptions = useMemo(
292 () => sourceOptionsFromTools(hydratedTools, loadingSources),258 () => {
293 [hydratedTools, loadingSources],259 const optionSet = new Set(sourceOptionsFromTools(hydratedTools, loadingSources));
260 for (const source of visibleSources) {
261 optionSet.add(source.name);
262 }
263 return Array.from(optionSet).sort((a, b) => a.localeCompare(b));
264 },
265 [hydratedTools, loadingSources, visibleSources],
294 );266 );
295 267
296 const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => {268 const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => {
@@ -383,7 +355,7 @@ export function ToolExplorer({ · 86 unmodified lines
383 <div className="flex" onWheelCapture={handleExplorerWheel}>355 <div className="flex" onWheelCapture={handleExplorerWheel}>
384 {showSourceSidebar ? (356 {showSourceSidebar ? (
385 <SourceSidebar357 <SourceSidebar
386 sources={stableSources}358 sources={visibleSources}
387 sourceCounts={sourceCounts}359 sourceCounts={sourceCounts}
388 loadingSources={loadingSourceSet}360 loadingSources={loadingSourceSet}
389 warningsBySource={warningsBySource}361 warningsBySource={warningsBySource}
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+0−11
@@ -56,17 +56,6 @@ export function ToolDetail({ · 55 unmodified lines
56 56
57 {inputHint && <TypeSignature raw={inputHint} label="Arguments" />}57 {inputHint && <TypeSignature raw={inputHint} label="Arguments" />}
58 {outputHint && <TypeSignature raw={outputHint} label="Returns" />}58 {outputHint && <TypeSignature raw={outputHint} label="Returns" />}
59
60 {required.length > 0 ? (
61 <div className="space-y-1">
62 <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50">
63 Required Keys
64 </p>
65 <p className="text-[11px] font-mono text-muted-foreground break-words">
66 {required.join(", ")}
67 </p>
68 </div>
69 ) : null}
70 </div>59 </div>
71 );60 );
72}61}
executor/apps/web/src/components/tools/source-favicon.tsx+2−1
@@ -4,6 +4,7 @@ import { useState } from "react"; · 3 unmodified lines
4import { Layers, Globe, Server } from "lucide-react";4import { Layers, Globe, Server } from "lucide-react";
5import Image from "next/image";5import Image from "next/image";
6import type { ToolSourceRecord } from "@/lib/types";6import type { ToolSourceRecord } from "@/lib/types";
7import { cn } from "@/lib/utils";
7import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers";8import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers";
8 9
9interface SourceFaviconProps {10interface SourceFaviconProps {
@@ -53,7 +54,7 @@ export function SourceFavicon({ · 43 unmodified lines
53 alt=""54 alt=""
54 width={imageSize}55 width={imageSize}
55 height={imageSize}56 height={imageSize}
56 className={imageClassName ?? "w-full h-full object-contain"}57 className={cn("w-full h-full object-cover rounded-full", imageClassName)}
57 loading="lazy"58 loading="lazy"
58 unoptimized59 unoptimized
59 onError={() => setFailedSrc(sourceFavicon)}60 onError={() => setFailedSrc(sourceFavicon)}
executor/apps/web/src/components/tools/view.tsx+83−22
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { useCallback, useMemo, useState } from "react";3import { useCallback, useMemo, useRef, useState } from "react";
4import {4import {
5 Plus,5 Plus,
6} from "lucide-react";6} from "lucide-react";
@@ -24,6 +24,7 @@ import type { · 17 unmodified lines
24 CredentialRecord,24 CredentialRecord,
25} from "@/lib/types";25} from "@/lib/types";
26import {26import {
27 parseWarningSourceName,
27 warningsBySourceName,28 warningsBySourceName,
28} from "@/lib/tools/source-helpers";29} from "@/lib/tools/source-helpers";
29import { sourceLabel } from "@/lib/tool/source-utils";30import { sourceLabel } from "@/lib/tool/source-utils";
@@ -35,6 +36,7 @@ import type { SourceDialogMeta } from "@/components/tools/add/source-dialog"; · 5 unmodified lines
35type OptimisticAdd = { kind: "add"; source: ToolSourceRecord; addedAt: number };36type OptimisticAdd = { kind: "add"; source: ToolSourceRecord; addedAt: number };
36type OptimisticRemove = { kind: "remove"; sourceName: string; removedAt: number };37type OptimisticRemove = { kind: "remove"; sourceName: string; removedAt: number };
37type OptimisticOp = OptimisticAdd | OptimisticRemove;38type OptimisticOp = OptimisticAdd | OptimisticRemove;
39const OPTIMISTIC_ADD_TTL_MS = 45_000;
38 40
39/** Merge server sources with pending optimistic operations. */41/** Merge server sources with pending optimistic operations. */
40function applyOptimisticOps(42function applyOptimisticOps(
@@ -60,11 +62,19 @@ function applyOptimisticOps( · 19 unmodified lines
60function pruneStaleOps(62function pruneStaleOps(
61 ops: OptimisticOp[],63 ops: OptimisticOp[],
62 serverSources: ToolSourceRecord[],64 serverSources: ToolSourceRecord[],
65 toolSourceNames: Set<string>,
63): OptimisticOp[] {66): OptimisticOp[] {
64 const serverNames = new Set(serverSources.map((s) => s.name));67 const serverNames = new Set(serverSources.map((s) => s.name));
68 const now = Date.now();
65 return ops.filter((op) => {69 return ops.filter((op) => {
66 if (op.kind === "add") {70 if (op.kind === "add") {
67 return !serverNames.has(op.source.name);71 if (toolSourceNames.has(op.source.name)) {
72 return false;
73 }
74 if (serverNames.has(op.source.name) && (now - op.addedAt) > OPTIMISTIC_ADD_TTL_MS) {
75 return false;
76 }
77 return true;
68 }78 }
69 if (op.kind === "remove") {79 if (op.kind === "remove") {
70 return serverNames.has(op.sourceName);80 return serverNames.has(op.sourceName);
@@ -108,20 +118,49 @@ export function ToolsView({ · 37 unmodified lines
108 convexApi.workspace.listToolSources,118 convexApi.workspace.listToolSources,
109 workspaceQueryArgs(context),119 workspaceQueryArgs(context),
110 );120 );
121 const sourceCacheRef = useRef<{ workspaceId: string | null; items: ToolSourceRecord[] }>({
122 workspaceId: null,
123 items: [],
124 });
125 const workspaceId = context?.workspaceId ?? null;
126 if (sourceCacheRef.current.workspaceId !== workspaceId) {
127 sourceCacheRef.current = {
128 workspaceId,
129 items: [],
130 };
131 }
132 if (sources !== undefined) {
133 sourceCacheRef.current.items = sources;
134 }
111 const serverSourceItems = useMemo<ToolSourceRecord[]>(135 const serverSourceItems = useMemo<ToolSourceRecord[]>(
112 () => sources ?? [],136 () => sources ?? sourceCacheRef.current.items,
113 [sources],137 [sources],
114 );138 );
115 const sourcesLoading = !!context && sources === undefined;139 const sourcesLoading = !!context && sources === undefined && serverSourceItems.length === 0;
140
141 const {
142 tools,
143 warnings,
144 sourceQuality,
145 sourceAuthProfiles,
146 loadingSources,
147 loadingTools,
148 refreshingTools,
149 loadToolDetails,
150 } = useWorkspaceTools(context ?? null, { includeDetails: false });
151
152 const toolSourceNames = useMemo(
153 () => new Set(tools.map((tool) => sourceLabel(tool.source))),
154 [tools],
155 );
116 156
117 // ── Optimistic source state ──157 // ── Optimistic source state ──
118 const [rawOptimisticOps, setOptimisticOps] = useState<OptimisticOp[]>([]);158 const [rawOptimisticOps, setOptimisticOps] = useState<OptimisticOp[]>([]);
119 159
120 // Prune stale ops: if the server already reflects an add/remove, drop it.160 // Prune stale ops: if the server already reflects an add/remove, drop it.
121 // This is a pure derivation — no side effects, no refs.
122 const optimisticOps = useMemo(161 const optimisticOps = useMemo(
123 () => pruneStaleOps(rawOptimisticOps, serverSourceItems),162 () => pruneStaleOps(rawOptimisticOps, serverSourceItems, toolSourceNames),
124 [rawOptimisticOps, serverSourceItems],163 [rawOptimisticOps, serverSourceItems, toolSourceNames],
125 );164 );
126 165
127 const sourceItems = useMemo(166 const sourceItems = useMemo(
@@ -144,33 +183,42 @@ export function ToolsView({ · 16 unmodified lines
144 const credentialItems: CredentialRecord[] = credentials ?? [];183 const credentialItems: CredentialRecord[] = credentials ?? [];
145 const credentialsLoading = !!context && credentials === undefined;184 const credentialsLoading = !!context && credentials === undefined;
146 185
147 const {186 const globalWarnings = useMemo(
148 tools,187 () => warnings.filter((warning) => !parseWarningSourceName(warning)),
149 warnings,188 [warnings],
150 sourceQuality,189 );
151 sourceAuthProfiles,190 const hasGlobalInventoryWarning = useMemo(
152 loadingSources,191 () => globalWarnings.some((warning) =>
153 loadingTools,192 warning.includes("Tool inventory is still loading")
154 refreshingTools,193 || warning.includes("showing previous results while refreshing"),
155 loadToolDetails,194 ),
156 } = useWorkspaceTools(context ?? null, { includeDetails: false });195 [globalWarnings],
196 );
157 197
158 // Merge optimistic loading with real loading sources198 // Merge optimistic loading with real loading sources
159 const mergedLoadingSources = useMemo(() => {199 const mergedLoadingSources = useMemo(() => {
160 const combined = [...loadingSources];200 const combined = [...loadingSources];
201
202 if (hasGlobalInventoryWarning) {
203 for (const source of sourceItems) {
204 if (toolSourceNames.has(source.name)) {
205 continue;
206 }
207 if (!combined.includes(source.name)) {
208 combined.push(source.name);
209 }
210 }
211 }
212
161 for (const name of optimisticallyLoadingNames) {213 for (const name of optimisticallyLoadingNames) {
162 if (!combined.includes(name)) {214 if (!combined.includes(name)) {
163 combined.push(name);215 combined.push(name);
164 }216 }
165 }217 }
166 return combined;218 return combined;
167 }, [loadingSources, optimisticallyLoadingNames]);219 }, [hasGlobalInventoryWarning, loadingSources, optimisticallyLoadingNames, sourceItems, toolSourceNames]);
168 220
169 const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]);221 const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]);
170 const toolSourceNames = useMemo(
171 () => new Set(tools.map((tool) => sourceLabel(tool.source))),
172 [tools],
173 );
174 const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]);222 const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]);
175 const sourceDialogMeta = useMemo(() => {223 const sourceDialogMeta = useMemo(() => {
176 const bySource: Record<string, SourceDialogMeta> = {};224 const bySource: Record<string, SourceDialogMeta> = {};
@@ -300,6 +348,19 @@ export function ToolsView({ · 123 unmodified lines
300 <TabsContent value="catalog" className="mt-4 min-h-0">348 <TabsContent value="catalog" className="mt-4 min-h-0">
301 <Card className="bg-card border-border min-h-0 flex flex-col pt-4 gap-3">349 <Card className="bg-card border-border min-h-0 flex flex-col pt-4 gap-3">
302 <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3">350 <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3">
351 {globalWarnings.length > 0 ? (
352 <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/5 px-3 py-2">
353 <p className="text-[11px] font-mono text-terminal-amber/90">Inventory status</p>
354 <div className="mt-1 space-y-1">
355 {globalWarnings.map((warning, index) => (
356 <p key={`global-warning-${index}`} className="text-[11px] leading-4 text-muted-foreground">
357 {warning}
358 </p>
359 ))}
360 </div>
361 </div>
362 ) : null}
363
303 <div className="min-h-0 flex-1">364 <div className="min-h-0 flex-1">
304 <ToolExplorer365 <ToolExplorer
305 tools={tools}366 tools={tools}
executor/apps/web/src/lib/credentials/source-helpers.ts+62−0
@@ -98,6 +98,68 @@ export function parseHeaderOverrides(text: string): { value?: Record<string, str · 97 unmodified lines
98 return { value: headers };98 return { value: headers };
99}99}
100 100
101export type HeaderOverrideEntry = {
102 key: string;
103 value: string;
104};
105
106export function parseHeaderOverrideEntries(text: string): HeaderOverrideEntry[] {
107 const rows = text
108 .split("\n")
109 .map((line) => line.trim())
110 .filter((line) => line.length > 0);
111
112 if (rows.length === 0) {
113 return [{ key: "", value: "" }];
114 }
115
116 return rows.map((row) => {
117 const separator = row.indexOf(":");
118 if (separator <= 0) {
119 return { key: row.trim(), value: "" };
120 }
121
122 return {
123 key: row.slice(0, separator).trim(),
124 value: row.slice(separator + 1).trim(),
125 };
126 });
127}
128
129export function parseHeaderOverrideRows(entries: HeaderOverrideEntry[]): { value?: Record<string, string>; error?: string } {
130 const headers: Record<string, string> = {};
131
132 for (let index = 0; index < entries.length; index += 1) {
133 const rawKey = entries[index]?.key ?? "";
134 const rawValue = entries[index]?.value ?? "";
135 const key = rawKey.trim();
136 const value = rawValue.trim();
137
138 if (!key && !value) {
139 continue;
140 }
141
142 if (!key || !value) {
143 return { error: `Invalid header row ${index + 1}` };
144 }
145
146 headers[key] = value;
147 }
148
149 return { value: headers };
150}
151
152export function serializeHeaderOverrideRows(entries: HeaderOverrideEntry[]): string {
153 return entries
154 .map((entry) => ({
155 key: entry.key.trim(),
156 value: entry.value.trim(),
157 }))
158 .filter((entry) => entry.key.length > 0 && entry.value.length > 0)
159 .map((entry) => `${entry.key}: ${entry.value}`)
160 .join("\n");
161}
162
101export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {163export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
102 const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};164 const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};
103 return Object.entries(headers)165 return Object.entries(headers)