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
22
33 import { Suspense, useState } from "react";
44 import { Link, Navigate, useLocation } from "react-router";
5−import { ExternalLink, Github, LayoutDashboard, ListTodo, ShieldCheck, Wrench, Menu, X } from "lucide-react";
5+import {
6+ ExternalLink,
7+ Github,
8+ LayoutDashboard,
9+ ListTodo,
10+ MessageCircle,
11+ Menu,
12+ ShieldCheck,
13+ Wrench,
14+ X,
15+} from "lucide-react";
616 import { Button } from "@/components/ui/button";
717 import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
818 import { useSession } from "@/lib/session-context";
@@ -21,6 +31,8 @@ const NAV_ITEMS = [ · 12 unmodified lines
2131 ];
2232
2333 const EXECUTOR_REPO_URL = "https://github.com/RhysSullivan/executor";
34+const TWITTER_FEEDBACK_URL =
35+ "https://twitter.com/intent/tweet?text=%40rhyssullivan%20Executor%20feedback:%20";
2436
2537 function NavLinks({ onClick }: { onClick?: () => void }) {
2638 const location = useLocation();
@@ -66,7 +78,24 @@ function RepoStarLink() { · 39 unmodified lines
6678 <Github className="h-3.5 w-3.5" />
6779 Star Executor
6880 </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+
86+function 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" />
7099 </a>
71100 );
72101 }
@@ -85,6 +114,9 @@ function Sidebar() { · 12 unmodified lines
85114 <SessionInfo />
86115 </Suspense>
87116 </div>
117+ <div className="px-3 pb-1">
118+ <FeedbackLink />
119+ </div>
88120 <div className="px-3 pb-1">
89121 <RepoStarLink />
90122 </div>
@@ -122,6 +154,9 @@ function MobileHeader() { · 31 unmodified lines
122154 <SessionInfo />
123155 </Suspense>
124156 </div>
157+ <div className="px-3 pb-1">
158+ <FeedbackLink />
159+ </div>
125160 <div className="px-3 pb-1">
126161 <RepoStarLink />
127162 </div>
executor/apps/web/src/components/tasks/code-editor.tsx+4−2
@@ -178,8 +178,10 @@ export function CodeEditor({ · 177 unmodified lines
178178 onMount={handleMount}
179179 options={CODE_EDITOR_OPTIONS}
180180 loading={
181− <div className="flex h-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">
182− Loading editor...
181+ <div className="flex h-full w-full items-center justify-center bg-background text-xs font-mono text-muted-foreground">
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>
183185 </div>
184186 }
185187 />
executor/apps/web/src/components/tools/add/source/dialog-sections.tsx+2−2
@@ -89,14 +89,14 @@ export function CatalogViewSection({ · 88 unmodified lines
8989 <img
9090 src={item.logoUrl}
9191 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"
9393 />
9494 ) : (
9595 <SourceFavicon
9696 sourceUrl={item.originUrl || item.specUrl}
9797 fallbackType={item.sourceType ?? "openapi"}
9898 iconClassName="h-5 w-5 text-muted-foreground"
99− imageClassName="w-5 h-5 rounded object-contain"
99+ imageClassName="w-5 h-5"
100100 />
101101 )}
102102 <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 @@
11 "use client";
22
3−import { useState } from "react";
3+import { type ChangeEvent, useEffect, useState } from "react";
44 import { useAction } from "convex/react";
55 import { toast } from "sonner";
66 import { Button } from "@/components/ui/button";
77 import { Input } from "@/components/ui/input";
88 import { Label } from "@/components/ui/label";
9−import { Textarea } from "@/components/ui/textarea";
109 import { Badge } from "@/components/ui/badge";
10+import { Plus, Trash2 } from "lucide-react";
1111 import {
1212 Select,
1313 SelectContent,
@@ -30,7 +30,10 @@ import type { · 16 unmodified lines
3030 } from "@/lib/types";
3131 import {
3232 connectionDisplayName,
33− parseHeaderOverrides,
33+ parseHeaderOverrideEntries,
34+ parseHeaderOverrideRows,
35+ serializeHeaderOverrideRows,
36+ type HeaderOverrideEntry,
3437 } from "@/lib/credentials/source-helpers";
3538 import { sourceForCredentialKey } from "@/lib/tools/source-helpers";
3639 import { ConnectionAuthFields } from "./auth-fields";
@@ -64,6 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines
6467 const { context, clientConfig } = useSession();
6568 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
6669 const [saving, setSaving] = useState(false);
70+ const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries(""));
6771 const {
6872 sourceKey,
6973 scopeType,
@@ -116,6 +120,14 @@ export function ConnectionFormDialog({ · 46 unmodified lines
116120 && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false);
117121 const detectedAuthLabel = authDetectionPending ? "Detecting..." : authBadge;
118122
123+ useEffect(() => {
124+ if (!open) {
125+ return;
126+ }
127+
128+ setHeaderRows(parseHeaderOverrideEntries(customHeadersText));
129+ }, [open, customHeadersText]);
130+
119131 const handleSave = async () => {
120132 if (!context) {
121133 return;
@@ -129,12 +141,15 @@ export function ConnectionFormDialog({ · 7 unmodified lines
129141 return;
130142 }
131143
132− const parsedHeaders = parseHeaderOverrides(customHeadersText);
144+ const parsedHeaders = parseHeaderOverrideRows(headerRows);
133145 if (!parsedHeaders.value) {
134146 toast.error(parsedHeaders.error ?? "Invalid header overrides");
135147 return;
136148 }
137149
150+ const serializedHeaders = serializeHeaderOverrideRows(headerRows);
151+ setCustomHeadersText(serializedHeaders);
152+
138153 const linkExisting = !editing && connectionMode === "existing";
139154 if (linkExisting && !existingConnectionKey) {
140155 toast.error("Select saved credentials");
@@ -205,6 +220,31 @@ export function ConnectionFormDialog({ · 64 unmodified lines
205220 }
206221 };
207222
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+
208248 return (
209249 <Dialog open={open} onOpenChange={onOpenChange}>
210250 <DialogContent className="bg-card border-border sm:max-w-lg">
@@ -345,15 +385,49 @@ export function ConnectionFormDialog({ · 134 unmodified lines
345385 onBasicPasswordChange={setBasicPassword}
346386 />
347387
348− <div className="space-y-1.5">
349− <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label>
350− <Textarea
351− value={customHeadersText}
352− onChange={(e) => setCustomHeadersText(e.target.value)}
353− rows={4}
354− placeholder="x-tenant-id: acme\nx-env: staging"
355− className="text-xs font-mono bg-background"
356− />
388+ <div className="space-y-2">
389+ <div className="flex items-center justify-between gap-2">
390+ <Label className="text-xs text-muted-foreground">Extra Headers (optional)</Label>
391+ <Button type="button" variant="outline" size="sm" onClick={addHeaderRow} className="h-7 px-2 text-[11px]">
392+ <Plus className="h-3.5 w-3.5 mr-1.5" />
393+ Add header
394+ </Button>
395+ </div>
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>
357431 </div>
358432
359433 <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
115115
116116 {isSource && (
117117 <div className="h-5 w-5 rounded bg-muted/60 flex items-center justify-center shrink-0">
118− {source ? (
119− <SourceFavicon
120− source={source}
121− iconClassName="h-3 w-3 text-muted-foreground"
122− imageClassName="w-full h-full object-contain"
123− />
124− ) : (
118+ {source ? (
119+ <SourceFavicon
120+ source={source}
121+ iconClassName="h-3 w-3 text-muted-foreground"
122+ imageClassName="w-full h-full"
123+ />
124+ ) : (
125125 sourceTypeFallback === "mcp"
126126 ? <Server className="h-3 w-3 text-muted-foreground" />
127127 : sourceTypeFallback === "graphql"
@@ -259,39 +259,18 @@ export function SourceSidebar({ · 131 unmodified lines
259259 );
260260
261261 const groups = useMemo(() => {
262− const map = new Map<
263− string,
264− { name: string; type: string; count: number; isLoading: boolean; warningCount: number; source?: ToolSourceRecord }
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, {
262+ return sources
263+ .filter((source) => source.enabled)
264+ .map((source) => ({
281265 name: source.name,
282266 type: source.type,
283− count,
284− isLoading,
285− warningCount,
267+ count: sourceCounts[source.name] ?? 0,
268+ isLoading: loadingSources.has(source.name),
269+ warningCount: warningCountsBySource[source.name] ?? 0,
286270 source,
287− });
288− }
289−
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]);
271+ }))
272+ .sort((a, b) => a.name.localeCompare(b.name));
273+ }, [loadingSources, sourceCounts, sources, warningCountsBySource]);
295274
296275 return (
297276 <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
302281 </div>
303282
304283 <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}
305287 {groups.map((g) => {
306288 const editMeta = g.source ? sourceDialogMeta?.[g.name] : undefined;
307289 return (
executor/apps/web/src/components/tools/explorer-toolbar.tsx+0−32
@@ -53,8 +53,6 @@ interface ToolExplorerToolbarProps { · 52 unmodified lines
5353 export function ToolExplorerToolbar({
5454 search,
5555 filteredToolCount,
56− hasSearch,
57− resultCount,
5856 loadingInventory = false,
5957 viewMode,
6058 groupBy,
@@ -72,8 +70,6 @@ export function ToolExplorerToolbar({ · 11 unmodified lines
7270 addSourceAction,
7371 onSelectAll,
7472 onClearSelection,
75− onExpandAll,
76− onCollapseAll,
7773 }: ToolExplorerToolbarProps) {
7874 return (
7975 <div className="shrink-0">
@@ -286,34 +282,6 @@ export function ToolExplorerToolbar({ · 206 unmodified lines
286282 </Button>
287283 </div>
288284 )}
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>
317285 </div>
318286 );
319287 }
executor/apps/web/src/components/tools/explorer.tsx+24−52
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue, useEffect } from "react";
3+import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue } from "react";
44 import { cn } from "@/lib/utils";
55 import {
66 collectGroupKeys,
@@ -142,50 +142,16 @@ export function ToolExplorer({ · 135 unmodified lines
142142 return counts;
143143 }, [hydratedTools]);
144144
145− const [stableSources, setStableSources] = useState<ToolSourceRecord[]>(sources);
146−
147− useEffect(() => {
148− setStableSources((previous) => {
149− const nextByName = new Map<string, ToolSourceRecord>();
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− }
145+ const visibleSources = useMemo(() => {
146+ const enabledByName = new Map<string, ToolSourceRecord>();
147+ for (const source of sources) {
148+ if (source.enabled) {
149+ enabledByName.set(source.name, source);
173150 }
151+ }
174152
175− const next = Array.from(nextByName.values()).sort((a, b) => a.name.localeCompare(b.name));
176− const unchanged =
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]);
153+ return Array.from(enabledByName.values()).sort((a, b) => a.name.localeCompare(b.name));
154+ }, [sources]);
189155
190156 const searchedTools = useMemo(() => {
191157 return filterToolsBySearch(filteredTools, search);
@@ -194,16 +160,16 @@ export function ToolExplorer({ · 2 unmodified lines
194160 const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]);
195161
196162 const sidebarExistingSourceNames = useMemo(() => {
197− return existingSourceNames ?? new Set(stableSources.map((source) => source.name));
198− }, [existingSourceNames, stableSources]);
163+ return existingSourceNames ?? new Set(visibleSources.map((source) => source.name));
164+ }, [existingSourceNames, visibleSources]);
199165
200166 const treeGroups = useMemo(() => {
201167 return treeGroupsForView(searchedTools, viewMode, groupBy, {
202168 loadingSources: visibleLoadingSources,
203− sourceRecords: stableSources,
169+ sourceRecords: visibleSources,
204170 activeSource: resolvedActiveSource,
205171 });
206− }, [searchedTools, viewMode, groupBy, visibleLoadingSources, stableSources, resolvedActiveSource]);
172+ }, [searchedTools, viewMode, groupBy, visibleLoadingSources, visibleSources, resolvedActiveSource]);
207173
208174 const flatTools = useMemo(() => {
209175 return flatToolsForView(searchedTools, viewMode);
@@ -211,11 +177,11 @@ export function ToolExplorer({ · 1 unmodified line
211177
212178 const sourceByName = useMemo(() => {
213179 const map = new Map<string, ToolSourceRecord>();
214− for (const source of stableSources) {
180+ for (const source of visibleSources) {
215181 map.set(source.name, source);
216182 }
217183 return map;
218− }, [stableSources]);
184+ }, [visibleSources]);
219185
220186 const autoExpandedKeys = useMemo(() => {
221187 return autoExpandedKeysForSearch(search, filteredTools, viewMode);
@@ -289,8 +255,14 @@ export function ToolExplorer({ · 67 unmodified lines
289255 }, [selectedKeys, filteredTools]);
290256
291257 const sourceOptions = useMemo(
292− () => sourceOptionsFromTools(hydratedTools, loadingSources),
293− [hydratedTools, loadingSources],
258+ () => {
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],
294266 );
295267
296268 const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => {
@@ -383,7 +355,7 @@ export function ToolExplorer({ · 86 unmodified lines
383355 <div className="flex" onWheelCapture={handleExplorerWheel}>
384356 {showSourceSidebar ? (
385357 <SourceSidebar
386− sources={stableSources}
358+ sources={visibleSources}
387359 sourceCounts={sourceCounts}
388360 loadingSources={loadingSourceSet}
389361 warningsBySource={warningsBySource}
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+0−11
@@ -56,17 +56,6 @@ export function ToolDetail({ · 55 unmodified lines
5656
5757 {inputHint && <TypeSignature raw={inputHint} label="Arguments" />}
5858 {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}
7059 </div>
7160 );
7261 }
executor/apps/web/src/components/tools/source-favicon.tsx+2−1
@@ -4,6 +4,7 @@ import { useState } from "react"; · 3 unmodified lines
44 import { Layers, Globe, Server } from "lucide-react";
55 import Image from "next/image";
66 import type { ToolSourceRecord } from "@/lib/types";
7+import { cn } from "@/lib/utils";
78 import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers";
89
910 interface SourceFaviconProps {
@@ -53,7 +54,7 @@ export function SourceFavicon({ · 43 unmodified lines
5354 alt=""
5455 width={imageSize}
5556 height={imageSize}
56− className={imageClassName ?? "w-full h-full object-contain"}
57+ className={cn("w-full h-full object-cover rounded-full", imageClassName)}
5758 loading="lazy"
5859 unoptimized
5960 onError={() => setFailedSrc(sourceFavicon)}
executor/apps/web/src/components/tools/view.tsx+83−22
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useCallback, useMemo, useState } from "react";
3+import { useCallback, useMemo, useRef, useState } from "react";
44 import {
55 Plus,
66 } from "lucide-react";
@@ -24,6 +24,7 @@ import type { · 17 unmodified lines
2424 CredentialRecord,
2525 } from "@/lib/types";
2626 import {
27+ parseWarningSourceName,
2728 warningsBySourceName,
2829 } from "@/lib/tools/source-helpers";
2930 import { sourceLabel } from "@/lib/tool/source-utils";
@@ -35,6 +36,7 @@ import type { SourceDialogMeta } from "@/components/tools/add/source-dialog"; · 5 unmodified lines
3536 type OptimisticAdd = { kind: "add"; source: ToolSourceRecord; addedAt: number };
3637 type OptimisticRemove = { kind: "remove"; sourceName: string; removedAt: number };
3738 type OptimisticOp = OptimisticAdd | OptimisticRemove;
39+const OPTIMISTIC_ADD_TTL_MS = 45_000;
3840
3941 /** Merge server sources with pending optimistic operations. */
4042 function applyOptimisticOps(
@@ -60,11 +62,19 @@ function applyOptimisticOps( · 19 unmodified lines
6062 function pruneStaleOps(
6163 ops: OptimisticOp[],
6264 serverSources: ToolSourceRecord[],
65+ toolSourceNames: Set<string>,
6366 ): OptimisticOp[] {
6467 const serverNames = new Set(serverSources.map((s) => s.name));
68+ const now = Date.now();
6569 return ops.filter((op) => {
6670 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;
6878 }
6979 if (op.kind === "remove") {
7080 return serverNames.has(op.sourceName);
@@ -108,20 +118,49 @@ export function ToolsView({ · 37 unmodified lines
108118 convexApi.workspace.listToolSources,
109119 workspaceQueryArgs(context),
110120 );
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+ }
111135 const serverSourceItems = useMemo<ToolSourceRecord[]>(
112− () => sources ?? [],
136+ () => sources ?? sourceCacheRef.current.items,
113137 [sources],
114138 );
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+ );
116156
117157 // ── Optimistic source state ──
118158 const [rawOptimisticOps, setOptimisticOps] = useState<OptimisticOp[]>([]);
119159
120160 // Prune stale ops: if the server already reflects an add/remove, drop it.
121− // This is a pure derivation — no side effects, no refs.
122161 const optimisticOps = useMemo(
123− () => pruneStaleOps(rawOptimisticOps, serverSourceItems),
124− [rawOptimisticOps, serverSourceItems],
162+ () => pruneStaleOps(rawOptimisticOps, serverSourceItems, toolSourceNames),
163+ [rawOptimisticOps, serverSourceItems, toolSourceNames],
125164 );
126165
127166 const sourceItems = useMemo(
@@ -144,33 +183,42 @@ export function ToolsView({ · 16 unmodified lines
144183 const credentialItems: CredentialRecord[] = credentials ?? [];
145184 const credentialsLoading = !!context && credentials === undefined;
146185
147− const {
148− tools,
149− warnings,
150− sourceQuality,
151− sourceAuthProfiles,
152− loadingSources,
153− loadingTools,
154− refreshingTools,
155− loadToolDetails,
156− } = useWorkspaceTools(context ?? null, { includeDetails: false });
186+ const globalWarnings = useMemo(
187+ () => warnings.filter((warning) => !parseWarningSourceName(warning)),
188+ [warnings],
189+ );
190+ const hasGlobalInventoryWarning = useMemo(
191+ () => globalWarnings.some((warning) =>
192+ warning.includes("Tool inventory is still loading")
193+ || warning.includes("showing previous results while refreshing"),
194+ ),
195+ [globalWarnings],
196+ );
157197
158198 // Merge optimistic loading with real loading sources
159199 const mergedLoadingSources = useMemo(() => {
160200 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+
161213 for (const name of optimisticallyLoadingNames) {
162214 if (!combined.includes(name)) {
163215 combined.push(name);
164216 }
165217 }
166218 return combined;
167− }, [loadingSources, optimisticallyLoadingNames]);
219+ }, [hasGlobalInventoryWarning, loadingSources, optimisticallyLoadingNames, sourceItems, toolSourceNames]);
168220
169221 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− );
174222 const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]);
175223 const sourceDialogMeta = useMemo(() => {
176224 const bySource: Record<string, SourceDialogMeta> = {};
@@ -300,6 +348,19 @@ export function ToolsView({ · 123 unmodified lines
300348 <TabsContent value="catalog" className="mt-4 min-h-0">
301349 <Card className="bg-card border-border min-h-0 flex flex-col pt-4 gap-3">
302350 <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+
303364 <div className="min-h-0 flex-1">
304365 <ToolExplorer
305366 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
9898 return { value: headers };
9999 }
100100
101+export type HeaderOverrideEntry = {
102+ key: string;
103+ value: string;
104+};
105+
106+export 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+
129+export 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+
152+export 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+
101163 export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string {
102164 const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {};
103165 return Object.entries(headers)