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
107 server: {107 server: {
108 port: parseInt(process.env.PORT ?? "5173", 10),108 port: parseInt(process.env.PORT ?? "5173", 10),
109 host: "127.0.0.1",109 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 },
110 },121 },
111 plugins: [122 plugins: [
112 tailwindcss(),123 tailwindcss(),
packages/react/src/pages/sources.tsx+261−164
@@ -1,7 +1,8 @@
1import { Suspense, useState, useCallback, useMemo } from "react";1import { Suspense, useCallback, useMemo, useState } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomSet } from "@effect/atom-react";3import { useAtomSet } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { PlusIcon } from "lucide-react";
5import type { SourceDetectionResult } from "@executor-js/sdk";6import type { SourceDetectionResult } from "@executor-js/sdk";
6import {7import {
7 useSourcePlugins,8 useSourcePlugins,
@@ -15,17 +16,22 @@ import { McpInstallCard } from "../components/mcp-install-card"; · 7 unmodified lines
15import { Button } from "../components/button";16import { Button } from "../components/button";
16import { Badge } from "../components/badge";17import { Badge } from "../components/badge";
17import { Input } from "../components/input";18import { Input } from "../components/input";
19import {
20 Dialog,
21 DialogContent,
22 DialogDescription,
23 DialogHeader,
24 DialogTitle,
25} from "../components/dialog";
18import {26import {
19 CardStack,27 CardStack,
20 CardStackHeader,
21 CardStackContent,28 CardStackContent,
22 CardStackEntry,29 CardStackEntry,
23 CardStackEntryField,30 CardStackEntryActions,
24 CardStackEntryMedia,
25 CardStackEntryContent,31 CardStackEntryContent,
26 CardStackEntryTitle,
27 CardStackEntryDescription,32 CardStackEntryDescription,
28 CardStackEntryActions,33 CardStackEntryMedia,
34 CardStackEntryTitle,
29} from "../components/card-stack";35} from "../components/card-stack";
30import { SourceFavicon } from "../components/source-favicon";36import { SourceFavicon } from "../components/source-favicon";
31import { Skeleton } from "../components/skeleton";37import { Skeleton } from "../components/skeleton";
@@ -53,18 +59,104 @@ const bestDetection = ( · 21 unmodified lines
53// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
54 60
55export function SourcesPage() {61export 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
61 const scopeId = useScope();62 const scopeId = useScope();
62 const sources = useSourcesWithPending(scopeId);63 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.
129const 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
138function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
139 const sourcePlugins = useSourcePlugins();
140 const scopeId = useScope();
63 const doDetect = useAtomSet(detectSource, { mode: "promise" });141 const doDetect = useAtomSet(detectSource, { mode: "promise" });
64 const navigate = useNavigate();142 const navigate = useNavigate();
65 143
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
66 const handleDetect = useCallback(async () => {158 const handleDetect = useCallback(async () => {
67 const trimmed = url.trim();159 const trimmed = query.trim();
68 if (!trimmed) return;160 if (!trimmed) return;
69 setDetecting(true);161 setDetecting(true);
70 setError(null);162 setError(null);
@@ -86,6 +178,7 @@ export function SourcesPage() { · 15 unmodified lines
86 }178 }
87 const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind];179 const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind];
88 if (pluginKey) {180 if (pluginKey) {
181 closeAndReset();
89 void navigate({182 void navigate({
90 to: "/sources/add/$pluginKey",183 to: "/sources/add/$pluginKey",
91 params: { pluginKey },184 params: { pluginKey },
@@ -93,134 +186,111 @@ export function SourcesPage() { · 1 unmodified line
93 });186 });
94 } else {187 } else {
95 setError(`Detected source type "${detected.kind}" but no plugin is available for it.`);188 setError(`Detected source type "${detected.kind}" but no plugin is available for it.`);
189 setDetecting(false);
96 }190 }
97 } catch {191 } catch {
98 setError("Detection failed. Try adding a source manually.");192 setError("Detection failed. Try adding a source manually.");
99 } finally {
100 setDetecting(false);193 setDetecting(false);
101 }194 }
102 }, [url, doDetect, navigate, scopeId]);195 }, [query, doDetect, navigate, scopeId, closeAndReset]);
103 196
104 return (197 return (
105 <div className="min-h-0 flex-1 overflow-y-auto">198 <Dialog
106 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">199 open={props.open}
107 {/* Header */}200 onOpenChange={(open) => {
108 <div className="mb-8">201 if (!open) closeAndReset();
109 <div className="flex items-end justify-between">202 else props.onOpenChange(open);
110 <div>203 }}
111 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">204 >
112 Sources205 <DialogContent className="sm:max-w-[560px]">
113 </h1>206 <DialogHeader>
114 <p className="mt-1.5 text-[14px] text-muted-foreground">207 <DialogTitle>Connect a source</DialogTitle>
115 Tool providers available in this workspace.208 <DialogDescription>
116 </p>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 )}
117 </div>238 </div>
239 {error && <p className="text-xs text-destructive">{error}</p>}
118 </div>240 </div>
119 241
120 {/* URL detection input */}242 <div className="flex flex-col gap-2">
121 <div className="mt-5">243 <p className="text-xs font-medium text-foreground/80">Or add manually</p>
122 <CardStack>244 <div className="flex flex-wrap gap-2">
123 <CardStackContent>245 {sourcePlugins.map((p) => (
124 <CardStackEntryField246 <Link
125 label="Paste URL"247 key={p.key}
126 description="auto-detect source type"248 to="/sources/add/$pluginKey"
127 hint={error ?? undefined}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"
128 >252 >
129 <div className="flex gap-2">253 {p.label}
130 <Input254 </Link>
131 type="url"255 ))}
132 value={url}256 </div>
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>
164 </div>257 </div>
165 </div>
166 258
167 <div className="mb-8">259 <PresetGrid
168 <McpInstallCard />260 plugins={sourcePlugins}
261 onPick={closeAndReset}
262 searchQuery={presetSearch}
263 />
169 </div>264 </div>
265 </DialogContent>
266 </Dialog>
267 );
268}
170 269
171 {AsyncResult.match(sources, {270// ---------------------------------------------------------------------------
172 onInitial: () => <SourcesGridSkeleton />,271// Empty state
173 onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>,272// ---------------------------------------------------------------------------
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" />
215 273
216 <PresetGrid />274function 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" />
217 </div>279 </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>
218 </div>288 </div>
219 );289 );
220}290}
221 291
222// ---------------------------------------------------------------------------292// ---------------------------------------------------------------------------
223// Preset grid293// Preset grid (for inside the Connect dialog)
224// ---------------------------------------------------------------------------294// ---------------------------------------------------------------------------
225 295
226type PresetEntry = {296type PresetEntry = {
@@ -229,11 +299,16 @@ type PresetEntry = { · 2 unmodified lines
229 pluginLabel: string;299 pluginLabel: string;
230};300};
231 301
232function PresetGrid() {302function PresetGrid(props: {
233 const plugins = useSourcePlugins();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}) {
234 const allPresets = useMemo(() => {309 const allPresets = useMemo(() => {
235 const entries: PresetEntry[] = [];310 const entries: PresetEntry[] = [];
236 for (const plugin of plugins) {311 for (const plugin of props.plugins) {
237 for (const preset of plugin.presets ?? []) {312 for (const preset of plugin.presets ?? []) {
238 entries.push({313 entries.push({
239 preset,314 preset,
@@ -243,58 +318,81 @@ function PresetGrid() { · 3 unmodified lines
243 }318 }
244 }319 }
245 return entries;320 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]);
247 332
248 if (allPresets.length === 0) return null;333 if (allPresets.length === 0) return null;
249 334
250 return (335 return (
251 <section className="mb-8 space-y-3">336 <div className="flex min-w-0 flex-col gap-2">
252 <CardStack searchable>337 <p className="text-xs font-medium text-foreground/80">Popular sources</p>
253 <CardStackHeader>Popular sources</CardStackHeader>338 <CardStack className="min-w-0">
254 <CardStackContent>339 {/* Fixed height keeps the dialog stable as the user filters; the
255 {allPresets.map(({ preset, pluginKey, pluginLabel }) => {340 * inner area scrolls when the list overflows and shows an empty
256 const search: Record<string, string> = { preset: preset.id };341 * state when no presets match. */}
257 if (preset.url) search.url = preset.url;342 <CardStackContent className="h-64 overflow-y-auto">
258 return (343 {filtered.length === 0 ? (
259 <CardStackEntry344 <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center">
260 key={`${pluginKey}-${preset.id}`}345 <p className="text-sm text-muted-foreground">No matching presets</p>
261 asChild346 <p className="text-xs text-muted-foreground/70">
262 searchText={`${preset.name} ${preset.summary ?? ""} ${pluginLabel}`}347 Paste a URL above to auto-detect, or pick a source type manually.
263 >348 </p>
264 <Link to="/sources/add/$pluginKey" params={{ pluginKey }} search={search}>349 </div>
265 <CardStackEntryMedia>350 ) : (
266 {preset.icon ? (351 filtered.map(({ preset, pluginKey, pluginLabel }) => {
267 <img352 const search: Record<string, string> = { preset: preset.id };
268 src={preset.icon}353 if (preset.url) search.url = preset.url;
269 alt=""354 return (
270 className="size-5 object-contain"355 <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild>
271 loading="lazy"356 <Link
272 />357 to="/sources/add/$pluginKey"
273 ) : (358 params={{ pluginKey }}
274 <svg viewBox="0 0 16 16" className="size-3.5" fill="none">359 search={search}
275 <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />360 onClick={props.onPick}
276 </svg>361 >
277 )}362 <CardStackEntryMedia>
278 </CardStackEntryMedia>363 {preset.icon ? (
279 <CardStackEntryContent>364 <img
280 <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>365 src={preset.icon}
281 <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>366 alt=""
282 </CardStackEntryContent>367 className="size-5 object-contain"
283 <CardStackEntryActions>368 loading="lazy"
284 <Badge variant="secondary">{pluginLabel}</Badge>369 />
285 </CardStackEntryActions>370 ) : (
286 </Link>371 <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
287 </CardStackEntry>372 <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
288 );373 </svg>
289 })}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 )}
290 </CardStackContent>388 </CardStackContent>
291 </CardStack>389 </CardStack>
292 </section>390 </div>
293 );391 );
294}392}
295 393
296// ---------------------------------------------------------------------------394// ---------------------------------------------------------------------------
297// Source grid395// Source grid — flat list of connected sources, click-through to detail
298// ---------------------------------------------------------------------------396// ---------------------------------------------------------------------------
299 397
300function SourceGrid(props: {398function SourceGrid(props: {
@@ -315,7 +413,6 @@ function SourceGrid(props: { · 14 unmodified lines
315 413
316 return (414 return (
317 <CardStack searchable>415 <CardStack searchable>
318 <CardStackHeader>Connected</CardStackHeader>
319 <CardStackContent>416 <CardStackContent>
320 {props.sources.map((s) => {417 {props.sources.map((s) => {
321 const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind;418 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