Unified · Split
| @@ -2,7 +2,17 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { Suspense, useState } from "react"; |
| 4 | 4 | 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"; | |
| 6 | 16 | import { Button } from "@/components/ui/button"; |
| 7 | 17 | import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"; |
| 8 | 18 | import { useSession } from "@/lib/session-context"; |
| @@ -21,6 +31,8 @@ const NAV_ITEMS = [ · 12 unmodified lines | ||
| 21 | 31 | ]; |
| 22 | 32 | |
| 23 | 33 | 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"; | |
| 24 | 36 | |
| 25 | 37 | function NavLinks({ onClick }: { onClick?: () => void }) { |
| 26 | 38 | const location = useLocation(); |
| @@ -66,7 +78,24 @@ function RepoStarLink() { · 39 unmodified lines | ||
| 66 | 78 | <Github className="h-3.5 w-3.5" /> |
| 67 | 79 | Star Executor |
| 68 | 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 | + | |
| 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" /> | |
| 70 | 99 | </a> |
| 71 | 100 | ); |
| 72 | 101 | } |
| @@ -85,6 +114,9 @@ function Sidebar() { · 12 unmodified lines | ||
| 85 | 114 | <SessionInfo /> |
| 86 | 115 | </Suspense> |
| 87 | 116 | </div> |
| 117 | + <div className="px-3 pb-1"> | |
| 118 | + <FeedbackLink /> | |
| 119 | + </div> | |
| 88 | 120 | <div className="px-3 pb-1"> |
| 89 | 121 | <RepoStarLink /> |
| 90 | 122 | </div> |
| @@ -122,6 +154,9 @@ function MobileHeader() { · 31 unmodified lines | ||
| 122 | 154 | <SessionInfo /> |
| 123 | 155 | </Suspense> |
| 124 | 156 | </div> |
| 157 | + <div className="px-3 pb-1"> | |
| 158 | + <FeedbackLink /> | |
| 159 | + </div> | |
| 125 | 160 | <div className="px-3 pb-1"> |
| 126 | 161 | <RepoStarLink /> |
| 127 | 162 | </div> |
| @@ -178,8 +178,10 @@ export function CodeEditor({ · 177 unmodified lines | ||
| 178 | 178 | onMount={handleMount} |
| 179 | 179 | options={CODE_EDITOR_OPTIONS} |
| 180 | 180 | 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> | |
| 183 | 185 | </div> |
| 184 | 186 | } |
| 185 | 187 | /> |
| @@ -89,14 +89,14 @@ export function CatalogViewSection({ · 88 unmodified lines | ||
| 89 | 89 | <img |
| 90 | 90 | src={item.logoUrl} |
| 91 | 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 | 95 | <SourceFavicon |
| 96 | 96 | sourceUrl={item.originUrl || item.specUrl} |
| 97 | 97 | fallbackType={item.sourceType ?? "openapi"} |
| 98 | 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 | 102 | <div className="flex-1 min-w-0 w-0 overflow-hidden"> |
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useState } from "react"; | |
| 3 | +import { type ChangeEvent, useEffect, useState } from "react"; | |
| 4 | 4 | import { useAction } from "convex/react"; |
| 5 | 5 | import { toast } from "sonner"; |
| 6 | 6 | import { Button } from "@/components/ui/button"; |
| 7 | 7 | import { Input } from "@/components/ui/input"; |
| 8 | 8 | import { Label } from "@/components/ui/label"; |
| 9 | −import { Textarea } from "@/components/ui/textarea"; | |
| 10 | 9 | import { Badge } from "@/components/ui/badge"; |
| 10 | +import { Plus, Trash2 } from "lucide-react"; | |
| 11 | 11 | import { |
| 12 | 12 | Select, |
| 13 | 13 | SelectContent, |
| @@ -30,7 +30,10 @@ import type { · 16 unmodified lines | ||
| 30 | 30 | } from "@/lib/types"; |
| 31 | 31 | import { |
| 32 | 32 | connectionDisplayName, |
| 33 | − parseHeaderOverrides, | |
| 33 | + parseHeaderOverrideEntries, | |
| 34 | + parseHeaderOverrideRows, | |
| 35 | + serializeHeaderOverrideRows, | |
| 36 | + type HeaderOverrideEntry, | |
| 34 | 37 | } from "@/lib/credentials/source-helpers"; |
| 35 | 38 | import { sourceForCredentialKey } from "@/lib/tools/source-helpers"; |
| 36 | 39 | import { ConnectionAuthFields } from "./auth-fields"; |
| @@ -64,6 +67,7 @@ export function ConnectionFormDialog({ · 27 unmodified lines | ||
| 64 | 67 | const { context, clientConfig } = useSession(); |
| 65 | 68 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 66 | 69 | const [saving, setSaving] = useState(false); |
| 70 | + const [headerRows, setHeaderRows] = useState<HeaderOverrideEntry[]>(() => parseHeaderOverrideEntries("")); | |
| 67 | 71 | const { |
| 68 | 72 | sourceKey, |
| 69 | 73 | scopeType, |
| @@ -116,6 +120,14 @@ export function ConnectionFormDialog({ · 46 unmodified lines | ||
| 116 | 120 | && (selectedSourceName ? loadingSourceNames.includes(selectedSourceName) : false); |
| 117 | 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 | 131 | const handleSave = async () => { |
| 120 | 132 | if (!context) { |
| 121 | 133 | return; |
| @@ -129,12 +141,15 @@ export function ConnectionFormDialog({ · 7 unmodified lines | ||
| 129 | 141 | return; |
| 130 | 142 | } |
| 131 | 143 | |
| 132 | − const parsedHeaders = parseHeaderOverrides(customHeadersText); | |
| 144 | + const parsedHeaders = parseHeaderOverrideRows(headerRows); | |
| 133 | 145 | if (!parsedHeaders.value) { |
| 134 | 146 | toast.error(parsedHeaders.error ?? "Invalid header overrides"); |
| 135 | 147 | return; |
| 136 | 148 | } |
| 137 | 149 | |
| 150 | + const serializedHeaders = serializeHeaderOverrideRows(headerRows); | |
| 151 | + setCustomHeadersText(serializedHeaders); | |
| 152 | + | |
| 138 | 153 | const linkExisting = !editing && connectionMode === "existing"; |
| 139 | 154 | if (linkExisting && !existingConnectionKey) { |
| 140 | 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 | 248 | return ( |
| 209 | 249 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 210 | 250 | <DialogContent className="bg-card border-border sm:max-w-lg"> |
| @@ -345,15 +385,49 @@ export function ConnectionFormDialog({ · 134 unmodified lines | ||
| 345 | 385 | onBasicPasswordChange={setBasicPassword} |
| 346 | 386 | /> |
| 347 | 387 | |
| 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> | |
| 357 | 431 | </div> |
| 358 | 432 | |
| 359 | 433 | <Button onClick={handleSave} disabled={saving || authDetectionPending} className="w-full h-9" size="sm"> |
| @@ -115,13 +115,13 @@ export function GroupNode({ · 114 unmodified lines | ||
| 115 | 115 | |
| 116 | 116 | {isSource && ( |
| 117 | 117 | <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 | + ) : ( | |
| 125 | 125 | sourceTypeFallback === "mcp" |
| 126 | 126 | ? <Server className="h-3 w-3 text-muted-foreground" /> |
| 127 | 127 | : sourceTypeFallback === "graphql" |
| @@ -259,39 +259,18 @@ export function SourceSidebar({ · 131 unmodified lines | ||
| 259 | 259 | ); |
| 260 | 260 | |
| 261 | 261 | 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) => ({ | |
| 281 | 265 | name: source.name, |
| 282 | 266 | 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, | |
| 286 | 270 | 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]); | |
| 295 | 274 | |
| 296 | 275 | return ( |
| 297 | 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 | 281 | </div> |
| 303 | 282 | |
| 304 | 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 | 287 | {groups.map((g) => { |
| 306 | 288 | const editMeta = g.source ? sourceDialogMeta?.[g.name] : undefined; |
| 307 | 289 | return ( |
| @@ -53,8 +53,6 @@ interface ToolExplorerToolbarProps { · 52 unmodified lines | ||
| 53 | 53 | export function ToolExplorerToolbar({ |
| 54 | 54 | search, |
| 55 | 55 | filteredToolCount, |
| 56 | − hasSearch, | |
| 57 | − resultCount, | |
| 58 | 56 | loadingInventory = false, |
| 59 | 57 | viewMode, |
| 60 | 58 | groupBy, |
| @@ -72,8 +70,6 @@ export function ToolExplorerToolbar({ · 11 unmodified lines | ||
| 72 | 70 | addSourceAction, |
| 73 | 71 | onSelectAll, |
| 74 | 72 | onClearSelection, |
| 75 | − onExpandAll, | |
| 76 | − onCollapseAll, | |
| 77 | 73 | }: ToolExplorerToolbarProps) { |
| 78 | 74 | return ( |
| 79 | 75 | <div className="shrink-0"> |
| @@ -286,34 +282,6 @@ export function ToolExplorerToolbar({ · 206 unmodified lines | ||
| 286 | 282 | </Button> |
| 287 | 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 | 285 | </div> |
| 318 | 286 | ); |
| 319 | 287 | } |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue, useEffect } from "react"; | |
| 3 | +import { type ReactNode, useState, useMemo, useCallback, useRef, useDeferredValue } from "react"; | |
| 4 | 4 | import { cn } from "@/lib/utils"; |
| 5 | 5 | import { |
| 6 | 6 | collectGroupKeys, |
| @@ -142,50 +142,16 @@ export function ToolExplorer({ · 135 unmodified lines | ||
| 142 | 142 | return counts; |
| 143 | 143 | }, [hydratedTools]); |
| 144 | 144 | |
| 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); | |
| 173 | 150 | } |
| 151 | + } | |
| 174 | 152 | |
| 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]); | |
| 189 | 155 | |
| 190 | 156 | const searchedTools = useMemo(() => { |
| 191 | 157 | return filterToolsBySearch(filteredTools, search); |
| @@ -194,16 +160,16 @@ export function ToolExplorer({ · 2 unmodified lines | ||
| 194 | 160 | const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]); |
| 195 | 161 | |
| 196 | 162 | 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]); | |
| 199 | 165 | |
| 200 | 166 | const treeGroups = useMemo(() => { |
| 201 | 167 | return treeGroupsForView(searchedTools, viewMode, groupBy, { |
| 202 | 168 | loadingSources: visibleLoadingSources, |
| 203 | − sourceRecords: stableSources, | |
| 169 | + sourceRecords: visibleSources, | |
| 204 | 170 | activeSource: resolvedActiveSource, |
| 205 | 171 | }); |
| 206 | − }, [searchedTools, viewMode, groupBy, visibleLoadingSources, stableSources, resolvedActiveSource]); | |
| 172 | + }, [searchedTools, viewMode, groupBy, visibleLoadingSources, visibleSources, resolvedActiveSource]); | |
| 207 | 173 | |
| 208 | 174 | const flatTools = useMemo(() => { |
| 209 | 175 | return flatToolsForView(searchedTools, viewMode); |
| @@ -211,11 +177,11 @@ export function ToolExplorer({ · 1 unmodified line | ||
| 211 | 177 | |
| 212 | 178 | const sourceByName = useMemo(() => { |
| 213 | 179 | const map = new Map<string, ToolSourceRecord>(); |
| 214 | − for (const source of stableSources) { | |
| 180 | + for (const source of visibleSources) { | |
| 215 | 181 | map.set(source.name, source); |
| 216 | 182 | } |
| 217 | 183 | return map; |
| 218 | − }, [stableSources]); | |
| 184 | + }, [visibleSources]); | |
| 219 | 185 | |
| 220 | 186 | const autoExpandedKeys = useMemo(() => { |
| 221 | 187 | return autoExpandedKeysForSearch(search, filteredTools, viewMode); |
| @@ -289,8 +255,14 @@ export function ToolExplorer({ · 67 unmodified lines | ||
| 289 | 255 | }, [selectedKeys, filteredTools]); |
| 290 | 256 | |
| 291 | 257 | 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], | |
| 294 | 266 | ); |
| 295 | 267 | |
| 296 | 268 | const maybeLoadToolDetails = useCallback(async (tool: ToolDescriptor, expanded: boolean) => { |
| @@ -383,7 +355,7 @@ export function ToolExplorer({ · 86 unmodified lines | ||
| 383 | 355 | <div className="flex" onWheelCapture={handleExplorerWheel}> |
| 384 | 356 | {showSourceSidebar ? ( |
| 385 | 357 | <SourceSidebar |
| 386 | − sources={stableSources} | |
| 358 | + sources={visibleSources} | |
| 387 | 359 | sourceCounts={sourceCounts} |
| 388 | 360 | loadingSources={loadingSourceSet} |
| 389 | 361 | warningsBySource={warningsBySource} |
| @@ -56,17 +56,6 @@ export function ToolDetail({ · 55 unmodified lines | ||
| 56 | 56 | |
| 57 | 57 | {inputHint && <TypeSignature raw={inputHint} label="Arguments" />} |
| 58 | 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 | 59 | </div> |
| 71 | 60 | ); |
| 72 | 61 | } |
| @@ -4,6 +4,7 @@ import { useState } from "react"; · 3 unmodified lines | ||
| 4 | 4 | import { Layers, Globe, Server } from "lucide-react"; |
| 5 | 5 | import Image from "next/image"; |
| 6 | 6 | import type { ToolSourceRecord } from "@/lib/types"; |
| 7 | +import { cn } from "@/lib/utils"; | |
| 7 | 8 | import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers"; |
| 8 | 9 | |
| 9 | 10 | interface SourceFaviconProps { |
| @@ -53,7 +54,7 @@ export function SourceFavicon({ · 43 unmodified lines | ||
| 53 | 54 | alt="" |
| 54 | 55 | width={imageSize} |
| 55 | 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 | 58 | loading="lazy" |
| 58 | 59 | unoptimized |
| 59 | 60 | onError={() => setFailedSrc(sourceFavicon)} |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useCallback, useMemo, useState } from "react"; | |
| 3 | +import { useCallback, useMemo, useRef, useState } from "react"; | |
| 4 | 4 | import { |
| 5 | 5 | Plus, |
| 6 | 6 | } from "lucide-react"; |
| @@ -24,6 +24,7 @@ import type { · 17 unmodified lines | ||
| 24 | 24 | CredentialRecord, |
| 25 | 25 | } from "@/lib/types"; |
| 26 | 26 | import { |
| 27 | + parseWarningSourceName, | |
| 27 | 28 | warningsBySourceName, |
| 28 | 29 | } from "@/lib/tools/source-helpers"; |
| 29 | 30 | import { sourceLabel } from "@/lib/tool/source-utils"; |
| @@ -35,6 +36,7 @@ import type { SourceDialogMeta } from "@/components/tools/add/source-dialog"; · 5 unmodified lines | ||
| 35 | 36 | type OptimisticAdd = { kind: "add"; source: ToolSourceRecord; addedAt: number }; |
| 36 | 37 | type OptimisticRemove = { kind: "remove"; sourceName: string; removedAt: number }; |
| 37 | 38 | type OptimisticOp = OptimisticAdd | OptimisticRemove; |
| 39 | +const OPTIMISTIC_ADD_TTL_MS = 45_000; | |
| 38 | 40 | |
| 39 | 41 | /** Merge server sources with pending optimistic operations. */ |
| 40 | 42 | function applyOptimisticOps( |
| @@ -60,11 +62,19 @@ function applyOptimisticOps( · 19 unmodified lines | ||
| 60 | 62 | function pruneStaleOps( |
| 61 | 63 | ops: OptimisticOp[], |
| 62 | 64 | serverSources: ToolSourceRecord[], |
| 65 | + toolSourceNames: Set<string>, | |
| 63 | 66 | ): OptimisticOp[] { |
| 64 | 67 | const serverNames = new Set(serverSources.map((s) => s.name)); |
| 68 | + const now = Date.now(); | |
| 65 | 69 | return ops.filter((op) => { |
| 66 | 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 | 79 | if (op.kind === "remove") { |
| 70 | 80 | return serverNames.has(op.sourceName); |
| @@ -108,20 +118,49 @@ export function ToolsView({ · 37 unmodified lines | ||
| 108 | 118 | convexApi.workspace.listToolSources, |
| 109 | 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 | 135 | const serverSourceItems = useMemo<ToolSourceRecord[]>( |
| 112 | − () => sources ?? [], | |
| 136 | + () => sources ?? sourceCacheRef.current.items, | |
| 113 | 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 | 157 | // ── Optimistic source state ── |
| 118 | 158 | const [rawOptimisticOps, setOptimisticOps] = useState<OptimisticOp[]>([]); |
| 119 | 159 | |
| 120 | 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 | 161 | const optimisticOps = useMemo( |
| 123 | − () => pruneStaleOps(rawOptimisticOps, serverSourceItems), | |
| 124 | − [rawOptimisticOps, serverSourceItems], | |
| 162 | + () => pruneStaleOps(rawOptimisticOps, serverSourceItems, toolSourceNames), | |
| 163 | + [rawOptimisticOps, serverSourceItems, toolSourceNames], | |
| 125 | 164 | ); |
| 126 | 165 | |
| 127 | 166 | const sourceItems = useMemo( |
| @@ -144,33 +183,42 @@ export function ToolsView({ · 16 unmodified lines | ||
| 144 | 183 | const credentialItems: CredentialRecord[] = credentials ?? []; |
| 145 | 184 | const credentialsLoading = !!context && credentials === undefined; |
| 146 | 185 | |
| 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 | + ); | |
| 157 | 197 | |
| 158 | 198 | // Merge optimistic loading with real loading sources |
| 159 | 199 | const mergedLoadingSources = useMemo(() => { |
| 160 | 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 | 213 | for (const name of optimisticallyLoadingNames) { |
| 162 | 214 | if (!combined.includes(name)) { |
| 163 | 215 | combined.push(name); |
| 164 | 216 | } |
| 165 | 217 | } |
| 166 | 218 | return combined; |
| 167 | − }, [loadingSources, optimisticallyLoadingNames]); | |
| 219 | + }, [hasGlobalInventoryWarning, loadingSources, optimisticallyLoadingNames, sourceItems, toolSourceNames]); | |
| 168 | 220 | |
| 169 | 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 | 222 | const warningsBySource = useMemo(() => warningsBySourceName(warnings), [warnings]); |
| 175 | 223 | const sourceDialogMeta = useMemo(() => { |
| 176 | 224 | const bySource: Record<string, SourceDialogMeta> = {}; |
| @@ -300,6 +348,19 @@ export function ToolsView({ · 123 unmodified lines | ||
| 300 | 348 | <TabsContent value="catalog" className="mt-4 min-h-0"> |
| 301 | 349 | <Card className="bg-card border-border min-h-0 flex flex-col pt-4 gap-3"> |
| 302 | 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 | 364 | <div className="min-h-0 flex-1"> |
| 304 | 365 | <ToolExplorer |
| 305 | 366 | tools={tools} |
| @@ -98,6 +98,68 @@ export function parseHeaderOverrides(text: string): { value?: Record<string, str · 97 unmodified lines | ||
| 98 | 98 | return { value: headers }; |
| 99 | 99 | } |
| 100 | 100 | |
| 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 | + | |
| 101 | 163 | export function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { |
| 102 | 164 | const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; |
| 103 | 165 | return Object.entries(headers) |