History

Connect dialog for adding sources (#480)

- Replace inline URL input with a Connect button that opens a dialog - Unified search/URL input: filters preset list as you type, switches to detect-button mode when the input looks like a URL - Preset grid moved into the dialog with a fixed-height scroll area and a zero state when filters return nothing - Empty state on the page now points users at the Connect button - Move McpInstallCard below the sources list - Vite watch config: poll + un-ignore workspace symlinks so edits in packages/* trigger HMR through the apps/local dev server

Rhys Sullivan committed 6 months ago

Commit c5d93a31 parent af4ed7e2 files · +272 −164Browse files

Unified · Split

2 files
  1. apps/local/vite.config.ts +11 −0
  2. packages/react/src/pages/sources.tsx +261 −164
apps/local/vite.config.ts+11−0
@@ -107,6 +107,17 @@ export default defineConfig({ · 106 unmodified lines
107107 server: {
108108 port: parseInt(process.env.PORT ?? "5173", 10),
109109 host: "127.0.0.1",
110+ watch: {
111+ // Workspace packages live under packages/ and are symlinked into
112+ // node_modules. Without this, chokidar treats them as ordinary
113+ // node_modules and skips watching, so edits to e.g.
114+ // packages/react/src/pages/sources.tsx don't trigger HMR.
115+ ignored: ["!**/node_modules/@executor-js/**"],
116+ // WSL2 + symlinked workspace packages can drop inotify events;
117+ // polling is slower but reliable.
118+ usePolling: true,
119+ interval: 200,
120+ },
110121 },
111122 plugins: [
112123 tailwindcss(),
packages/react/src/pages/sources.tsx+261−164
@@ -1,7 +1,8 @@
1−import { Suspense, useState, useCallback, useMemo } from "react";
1+import { Suspense, useCallback, useMemo, useState } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import { useAtomSet } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import { PlusIcon } from "lucide-react";
56 import type { SourceDetectionResult } from "@executor-js/sdk";
67 import {
78 useSourcePlugins,
@@ -15,17 +16,22 @@ import { McpInstallCard } from "../components/mcp-install-card"; · 7 unmodified lines
1516 import { Button } from "../components/button";
1617 import { Badge } from "../components/badge";
1718 import { Input } from "../components/input";
19+import {
20+ Dialog,
21+ DialogContent,
22+ DialogDescription,
23+ DialogHeader,
24+ DialogTitle,
25+} from "../components/dialog";
1826 import {
1927 CardStack,
20− CardStackHeader,
2128 CardStackContent,
2229 CardStackEntry,
23− CardStackEntryField,
24− CardStackEntryMedia,
30+ CardStackEntryActions,
2531 CardStackEntryContent,
26− CardStackEntryTitle,
2732 CardStackEntryDescription,
28− CardStackEntryActions,
33+ CardStackEntryMedia,
34+ CardStackEntryTitle,
2935 } from "../components/card-stack";
3036 import { SourceFavicon } from "../components/source-favicon";
3137 import { Skeleton } from "../components/skeleton";
@@ -53,18 +59,104 @@ const bestDetection = ( · 21 unmodified lines
5359 // ---------------------------------------------------------------------------
5460
5561 export function SourcesPage() {
56− const sourcePlugins = useSourcePlugins();
57− const [url, setUrl] = useState("");
58− const [detecting, setDetecting] = useState(false);
59− const [error, setError] = useState<string | null>(null);
60−
6162 const scopeId = useScope();
6263 const sources = useSourcesWithPending(scopeId);
64+ const [connectOpen, setConnectOpen] = useState(false);
65+
66+ return (
67+ <div className="min-h-0 flex-1 overflow-y-auto">
68+ <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
69+ <div className="mb-8 flex items-end justify-between gap-4">
70+ <div>
71+ <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
72+ Sources
73+ </h1>
74+ <p className="mt-1.5 text-[14px] text-muted-foreground">
75+ Tool providers available in this workspace.
76+ </p>
77+ </div>
78+ <Button onClick={() => setConnectOpen(true)} size="sm" className="shrink-0 gap-1.5">
79+ <PlusIcon className="size-4" />
80+ Connect
81+ </Button>
82+ </div>
83+
84+ {AsyncResult.match(sources, {
85+ onInitial: () => <SourcesGridSkeleton />,
86+ onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>,
87+ onSuccess: ({ value }) => {
88+ const connectedSources = (
89+ value as Array<{
90+ readonly id: string;
91+ readonly name: string;
92+ readonly kind: string;
93+ readonly url?: string;
94+ readonly runtime?: boolean;
95+ }>
96+ ).filter((source) => !source.runtime);
97+
98+ if (connectedSources.length === 0) {
99+ return <EmptySources onConnect={() => setConnectOpen(true)} />;
100+ }
101+
102+ return (
103+ <div className="mb-8 space-y-3">
104+ <SourceGrid sources={connectedSources} />
105+ </div>
106+ );
107+ },
108+ })}
109+
110+ <div className="mb-8 border-t border-border/50" />
111+
112+ <div className="mb-8">
113+ <McpInstallCard />
114+ </div>
115+ </div>
116+
117+ <ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} />
118+ </div>
119+ );
120+}
121+
122+// ---------------------------------------------------------------------------
123+// Connect dialog — URL detection + manual plugin chooser + presets
124+// ---------------------------------------------------------------------------
125+
126+// Heuristic: the input either looks like a URL (auto-detect) or a free-text
127+// search query (filter the preset list). Anything with a scheme, slash, or
128+// host-with-TLD is treated as a URL; everything else is search.
129+const looksLikeUrl = (raw: string): boolean => {
130+ const v = raw.trim();
131+ if (v.length === 0) return false;
132+ if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true;
133+ if (v.includes("/")) return true;
134+ if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true;
135+ return false;
136+};
137+
138+function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
139+ const sourcePlugins = useSourcePlugins();
140+ const scopeId = useScope();
63141 const doDetect = useAtomSet(detectSource, { mode: "promise" });
64142 const navigate = useNavigate();
65143
144+ const [query, setQuery] = useState("");
145+ const [detecting, setDetecting] = useState(false);
146+ const [error, setError] = useState<string | null>(null);
147+
148+ const isUrl = looksLikeUrl(query);
149+ const presetSearch = isUrl ? "" : query;
150+
151+ const closeAndReset = useCallback(() => {
152+ setQuery("");
153+ setError(null);
154+ setDetecting(false);
155+ props.onOpenChange(false);
156+ }, [props]);
157+
66158 const handleDetect = useCallback(async () => {
67− const trimmed = url.trim();
159+ const trimmed = query.trim();
68160 if (!trimmed) return;
69161 setDetecting(true);
70162 setError(null);
@@ -86,6 +178,7 @@ export function SourcesPage() { · 15 unmodified lines
86178 }
87179 const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind];
88180 if (pluginKey) {
181+ closeAndReset();
89182 void navigate({
90183 to: "/sources/add/$pluginKey",
91184 params: { pluginKey },
@@ -93,134 +186,111 @@ export function SourcesPage() { · 1 unmodified line
93186 });
94187 } else {
95188 setError(`Detected source type "${detected.kind}" but no plugin is available for it.`);
189+ setDetecting(false);
96190 }
97191 } catch {
98192 setError("Detection failed. Try adding a source manually.");
99− } finally {
100193 setDetecting(false);
101194 }
102− }, [url, doDetect, navigate, scopeId]);
195+ }, [query, doDetect, navigate, scopeId, closeAndReset]);
103196
104197 return (
105− <div className="min-h-0 flex-1 overflow-y-auto">
106− <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
107− {/* Header */}
108− <div className="mb-8">
109− <div className="flex items-end justify-between">
110− <div>
111− <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
112− Sources
113− </h1>
114− <p className="mt-1.5 text-[14px] text-muted-foreground">
115− Tool providers available in this workspace.
116− </p>
198+ <Dialog
199+ open={props.open}
200+ onOpenChange={(open) => {
201+ if (!open) closeAndReset();
202+ else props.onOpenChange(open);
203+ }}
204+ >
205+ <DialogContent className="sm:max-w-[560px]">
206+ <DialogHeader>
207+ <DialogTitle>Connect a source</DialogTitle>
208+ <DialogDescription>
209+ Search the preset library, or paste a URL to auto-detect.
210+ </DialogDescription>
211+ </DialogHeader>
212+
213+ <div className="flex min-w-0 flex-col gap-5">
214+ <div className="flex flex-col gap-2">
215+ <div className="flex gap-2">
216+ <Input
217+ type="text"
218+ value={query}
219+ onChange={(e) => {
220+ setQuery((e.target as HTMLInputElement).value);
221+ setError(null);
222+ }}
223+ onKeyDown={(e) => {
224+ if (e.key === "Enter" && isUrl) void handleDetect();
225+ }}
226+ placeholder="Search or paste a URL…"
227+ disabled={detecting}
228+ className="flex-1"
229+ />
230+ {isUrl && (
231+ <Button
232+ onClick={() => void handleDetect()}
233+ disabled={detecting || !query.trim()}
234+ >
235+ {detecting ? "Detecting..." : "Detect"}
236+ </Button>
237+ )}
117238 </div>
239+ {error && <p className="text-xs text-destructive">{error}</p>}
118240 </div>
119241
120− {/* URL detection input */}
121− <div className="mt-5">
122− <CardStack>
123− <CardStackContent>
124− <CardStackEntryField
125− label="Paste URL"
126− description="auto-detect source type"
127− hint={error ?? undefined}
242+ <div className="flex flex-col gap-2">
243+ <p className="text-xs font-medium text-foreground/80">Or add manually</p>
244+ <div className="flex flex-wrap gap-2">
245+ {sourcePlugins.map((p) => (
246+ <Link
247+ key={p.key}
248+ to="/sources/add/$pluginKey"
249+ params={{ pluginKey: p.key }}
250+ onClick={closeAndReset}
251+ className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
128252 >
129− <div className="flex gap-2">
130− <Input
131− type="url"
132− value={url}
133− onChange={(e) => {
134− setUrl((e.target as HTMLInputElement).value);
135− setError(null);
136− }}
137− onKeyDown={(e) => {
138− if (e.key === "Enter") handleDetect();
139− }}
140− placeholder="https://..."
141− disabled={detecting}
142− className="flex-1"
143− />
144− <Button onClick={handleDetect} disabled={detecting || !url.trim()}>
145− {detecting ? "Detecting..." : "Detect"}
146− </Button>
147− </div>
148− <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
149− Or add manually:{" "}
150− {sourcePlugins.map((p) => (
151− <Link
152− key={p.key}
153− to="/sources/add/$pluginKey"
154− params={{ pluginKey: p.key }}
155− className="rounded-md border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
156− >
157− {p.label}
158− </Link>
159− ))}
160− </div>
161− </CardStackEntryField>
162− </CardStackContent>
163− </CardStack>
253+ {p.label}
254+ </Link>
255+ ))}
256+ </div>
164257 </div>
165− </div>
166258
167− <div className="mb-8">
168− <McpInstallCard />
259+ <PresetGrid
260+ plugins={sourcePlugins}
261+ onPick={closeAndReset}
262+ searchQuery={presetSearch}
263+ />
169264 </div>
265+ </DialogContent>
266+ </Dialog>
267+ );
268+}
170269
171− {AsyncResult.match(sources, {
172− onInitial: () => <SourcesGridSkeleton />,
173− onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>,
174− onSuccess: ({ value }) => {
175− const connectedSources = (
176− value as Array<{
177− readonly id: string;
178− readonly name: string;
179− readonly kind: string;
180− readonly url?: string;
181− readonly runtime?: boolean;
182− }>
183− ).filter((source) => !source.runtime);
184−
185− return value.length === 0 ? (
186− <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
187− <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
188− <svg viewBox="0 0 24 24" fill="none" className="size-5">
189− <path
190− d="M12 6v12M6 12h12"
191− stroke="currentColor"
192− strokeWidth="1.5"
193− strokeLinecap="round"
194− />
195− </svg>
196− </div>
197− <p className="text-[14px] font-medium text-foreground/70 mb-1">No sources yet</p>
198− <p className="text-[13px] text-muted-foreground/60 mb-5">
199− Add a source to get started.
200− </p>
201− </div>
202− ) : (
203− <div className="mb-8 space-y-8">
204− {connectedSources.length > 0 && (
205− <section className="space-y-3">
206− <SourceGrid sources={connectedSources} />
207− </section>
208− )}
209− </div>
210− );
211− },
212− })}
213−
214− <div className="mb-8 border-t border-border/50" />
270+// ---------------------------------------------------------------------------
271+// Empty state
272+// ---------------------------------------------------------------------------
215273
216− <PresetGrid />
274+function EmptySources(props: { onConnect: () => void }) {
275+ return (
276+ <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
277+ <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
278+ <PlusIcon className="size-5" />
217279 </div>
280+ <p className="mb-1 text-[14px] font-medium text-foreground/70">No sources yet</p>
281+ <p className="mb-5 text-[13px] text-muted-foreground/60">
282+ Connect a source to start curating tools.
283+ </p>
284+ <Button onClick={props.onConnect} size="sm" className="gap-1.5">
285+ <PlusIcon className="size-4" />
286+ Connect a source
287+ </Button>
218288 </div>
219289 );
220290 }
221291
222292 // ---------------------------------------------------------------------------
223−// Preset grid
293+// Preset grid (for inside the Connect dialog)
224294 // ---------------------------------------------------------------------------
225295
226296 type PresetEntry = {
@@ -229,11 +299,16 @@ type PresetEntry = { · 2 unmodified lines
229299 pluginLabel: string;
230300 };
231301
232−function PresetGrid() {
233− const plugins = useSourcePlugins();
302+function PresetGrid(props: {
303+ plugins: readonly SourcePlugin[];
304+ onPick: () => void;
305+ /** Controlled filter query forwarded from the dialog's unified
306+ * search/URL input. Empty string disables filtering. */
307+ searchQuery?: string;
308+}) {
234309 const allPresets = useMemo(() => {
235310 const entries: PresetEntry[] = [];
236− for (const plugin of plugins) {
311+ for (const plugin of props.plugins) {
237312 for (const preset of plugin.presets ?? []) {
238313 entries.push({
239314 preset,
@@ -243,58 +318,81 @@ function PresetGrid() { · 3 unmodified lines
243318 }
244319 }
245320 return entries;
246− }, [plugins]);
321+ }, [props.plugins]);
322+
323+ const filtered = useMemo(() => {
324+ const q = (props.searchQuery ?? "").trim().toLowerCase();
325+ if (q.length === 0) return allPresets;
326+ return allPresets.filter(({ preset, pluginLabel }) => {
327+ const corpus =
328+ `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();
329+ return corpus.includes(q);
330+ });
331+ }, [allPresets, props.searchQuery]);
247332
248333 if (allPresets.length === 0) return null;
249334
250335 return (
251− <section className="mb-8 space-y-3">
252− <CardStack searchable>
253− <CardStackHeader>Popular sources</CardStackHeader>
254− <CardStackContent>
255− {allPresets.map(({ preset, pluginKey, pluginLabel }) => {
256− const search: Record<string, string> = { preset: preset.id };
257− if (preset.url) search.url = preset.url;
258− return (
259− <CardStackEntry
260− key={`${pluginKey}-${preset.id}`}
261− asChild
262− searchText={`${preset.name} ${preset.summary ?? ""} ${pluginLabel}`}
263− >
264− <Link to="/sources/add/$pluginKey" params={{ pluginKey }} search={search}>
265− <CardStackEntryMedia>
266− {preset.icon ? (
267− <img
268− src={preset.icon}
269− alt=""
270− className="size-5 object-contain"
271− loading="lazy"
272− />
273− ) : (
274− <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
275− <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
276− </svg>
277− )}
278− </CardStackEntryMedia>
279− <CardStackEntryContent>
280− <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>
281− <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>
282− </CardStackEntryContent>
283− <CardStackEntryActions>
284− <Badge variant="secondary">{pluginLabel}</Badge>
285− </CardStackEntryActions>
286− </Link>
287− </CardStackEntry>
288− );
289− })}
336+ <div className="flex min-w-0 flex-col gap-2">
337+ <p className="text-xs font-medium text-foreground/80">Popular sources</p>
338+ <CardStack className="min-w-0">
339+ {/* Fixed height keeps the dialog stable as the user filters; the
340+ * inner area scrolls when the list overflows and shows an empty
341+ * state when no presets match. */}
342+ <CardStackContent className="h-64 overflow-y-auto">
343+ {filtered.length === 0 ? (
344+ <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center">
345+ <p className="text-sm text-muted-foreground">No matching presets</p>
346+ <p className="text-xs text-muted-foreground/70">
347+ Paste a URL above to auto-detect, or pick a source type manually.
348+ </p>
349+ </div>
350+ ) : (
351+ filtered.map(({ preset, pluginKey, pluginLabel }) => {
352+ const search: Record<string, string> = { preset: preset.id };
353+ if (preset.url) search.url = preset.url;
354+ return (
355+ <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild>
356+ <Link
357+ to="/sources/add/$pluginKey"
358+ params={{ pluginKey }}
359+ search={search}
360+ onClick={props.onPick}
361+ >
362+ <CardStackEntryMedia>
363+ {preset.icon ? (
364+ <img
365+ src={preset.icon}
366+ alt=""
367+ className="size-5 object-contain"
368+ loading="lazy"
369+ />
370+ ) : (
371+ <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
372+ <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
373+ </svg>
374+ )}
375+ </CardStackEntryMedia>
376+ <CardStackEntryContent>
377+ <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>
378+ <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>
379+ </CardStackEntryContent>
380+ <CardStackEntryActions>
381+ <Badge variant="secondary">{pluginLabel}</Badge>
382+ </CardStackEntryActions>
383+ </Link>
384+ </CardStackEntry>
385+ );
386+ })
387+ )}
290388 </CardStackContent>
291389 </CardStack>
292− </section>
390+ </div>
293391 );
294392 }
295393
296394 // ---------------------------------------------------------------------------
297−// Source grid
395+// Source grid — flat list of connected sources, click-through to detail
298396 // ---------------------------------------------------------------------------
299397
300398 function SourceGrid(props: {
@@ -315,7 +413,6 @@ function SourceGrid(props: { · 14 unmodified lines
315413
316414 return (
317415 <CardStack searchable>
318− <CardStackHeader>Connected</CardStackHeader>
319416 <CardStackContent>
320417 {props.sources.map((s) => {
321418 const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t