@gmickel/gno 0.28.2 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/app/constants.ts +4 -2
  4. package/src/cli/commands/mcp/install.ts +4 -4
  5. package/src/cli/commands/mcp/status.ts +7 -7
  6. package/src/cli/commands/skill/install.ts +5 -5
  7. package/src/cli/program.ts +2 -2
  8. package/src/collection/add.ts +10 -0
  9. package/src/collection/types.ts +1 -0
  10. package/src/config/types.ts +12 -2
  11. package/src/core/depth-policy.ts +1 -1
  12. package/src/core/file-ops.ts +203 -1
  13. package/src/llm/registry.ts +20 -4
  14. package/src/serve/AGENTS.md +16 -16
  15. package/src/serve/CLAUDE.md +16 -16
  16. package/src/serve/config-sync.ts +32 -1
  17. package/src/serve/connectors.ts +243 -0
  18. package/src/serve/context.ts +9 -0
  19. package/src/serve/doc-events.ts +31 -1
  20. package/src/serve/embed-scheduler.ts +12 -0
  21. package/src/serve/import-preview.ts +173 -0
  22. package/src/serve/public/app.tsx +101 -7
  23. package/src/serve/public/components/AIModelSelector.tsx +383 -145
  24. package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
  25. package/src/serve/public/components/BootstrapStatus.tsx +133 -0
  26. package/src/serve/public/components/CaptureModal.tsx +5 -2
  27. package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
  28. package/src/serve/public/components/FirstRunWizard.tsx +622 -0
  29. package/src/serve/public/components/HealthCenter.tsx +128 -0
  30. package/src/serve/public/components/IndexingProgress.tsx +21 -2
  31. package/src/serve/public/components/QuickSwitcher.tsx +62 -36
  32. package/src/serve/public/components/TagInput.tsx +5 -1
  33. package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
  34. package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
  35. package/src/serve/public/hooks/use-doc-events.ts +48 -4
  36. package/src/serve/public/lib/local-history.ts +40 -7
  37. package/src/serve/public/lib/navigation-state.ts +156 -0
  38. package/src/serve/public/lib/workspace-tabs.ts +235 -0
  39. package/src/serve/public/pages/Ask.tsx +11 -1
  40. package/src/serve/public/pages/Browse.tsx +73 -0
  41. package/src/serve/public/pages/Collections.tsx +29 -13
  42. package/src/serve/public/pages/Connectors.tsx +178 -0
  43. package/src/serve/public/pages/Dashboard.tsx +493 -67
  44. package/src/serve/public/pages/DocView.tsx +192 -34
  45. package/src/serve/public/pages/DocumentEditor.tsx +127 -5
  46. package/src/serve/public/pages/Search.tsx +12 -1
  47. package/src/serve/routes/api.ts +541 -62
  48. package/src/serve/server.ts +79 -2
  49. package/src/serve/status-model.ts +149 -0
  50. package/src/serve/status.ts +706 -0
  51. package/src/serve/watch-service.ts +73 -8
  52. package/src/types/electrobun-shell.d.ts +43 -0
@@ -1,7 +1,10 @@
1
1
  import {
2
2
  BookOpen,
3
3
  CheckCircle2Icon,
4
+ CpuIcon,
4
5
  Database,
6
+ DownloadIcon,
7
+ FolderHeartIcon,
5
8
  FolderIcon,
6
9
  GitForkIcon,
7
10
  Layers,
@@ -10,11 +13,19 @@ import {
10
13
  PenIcon,
11
14
  RefreshCwIcon,
12
15
  Search,
16
+ StarIcon,
13
17
  } from "lucide-react";
14
18
  import { useCallback, useEffect, useState } from "react";
15
19
 
20
+ import type { AppStatusResponse, HealthActionKind } from "../../status-model";
21
+
22
+ import { AddCollectionDialog } from "../components/AddCollectionDialog";
23
+ import { AIModelSelector } from "../components/AIModelSelector";
24
+ import { BootstrapStatus } from "../components/BootstrapStatus";
16
25
  import { CaptureButton } from "../components/CaptureButton";
26
+ import { FirstRunWizard } from "../components/FirstRunWizard";
17
27
  import { GnoLogo } from "../components/GnoLogo";
28
+ import { HealthCenter } from "../components/HealthCenter";
18
29
  import { IndexingProgress } from "../components/IndexingProgress";
19
30
  import { Button } from "../components/ui/button";
20
31
  import {
@@ -23,55 +34,157 @@ import {
23
34
  CardDescription,
24
35
  CardHeader,
25
36
  } from "../components/ui/card";
37
+ import { Progress } from "../components/ui/progress";
26
38
  import { apiFetch } from "../hooks/use-api";
27
39
  import { useCaptureModal } from "../hooks/useCaptureModal";
40
+ import {
41
+ loadFavoriteCollections,
42
+ loadFavoriteDocuments,
43
+ loadRecentDocuments,
44
+ toggleFavoriteCollection,
45
+ type FavoriteCollection,
46
+ type FavoriteDoc,
47
+ type RecentDoc,
48
+ } from "../lib/navigation-state";
28
49
 
29
50
  interface SyncResponse {
30
51
  jobId: string;
31
52
  }
32
53
 
33
- interface PageProps {
34
- navigate: (to: string | number) => void;
54
+ interface EmbedResponse {
55
+ embedded?: number;
56
+ errors?: number;
57
+ running?: boolean;
58
+ pendingCount?: number;
59
+ note?: string;
35
60
  }
36
61
 
37
- interface StatusData {
38
- indexName: string;
39
- totalDocuments: number;
40
- totalChunks: number;
41
- embeddingBacklog: number;
42
- healthy: boolean;
62
+ interface SyncResultSummary {
43
63
  collections: Array<{
44
- name: string;
45
- path: string;
46
- documentCount: number;
47
- chunkCount: number;
48
- embeddedCount: number;
64
+ collection: string;
65
+ filesProcessed: number;
66
+ filesAdded: number;
67
+ filesUpdated: number;
68
+ filesUnchanged: number;
69
+ filesErrored: number;
70
+ durationMs: number;
49
71
  }>;
72
+ totalDurationMs: number;
73
+ totalFilesProcessed: number;
74
+ totalFilesAdded: number;
75
+ totalFilesUpdated: number;
76
+ totalFilesErrored: number;
77
+ totalFilesSkipped: number;
78
+ }
79
+
80
+ interface DownloadProgressState {
81
+ downloadedBytes: number;
82
+ totalBytes: number;
83
+ percent: number;
84
+ }
85
+
86
+ interface ModelDownloadStatus {
87
+ active: boolean;
88
+ currentType: string | null;
89
+ progress: DownloadProgressState | null;
90
+ completed: string[];
91
+ failed: Array<{ type: string; error: string }>;
92
+ startedAt: number | null;
93
+ }
94
+
95
+ interface PageProps {
96
+ navigate: (to: string | number) => void;
50
97
  }
51
98
 
52
99
  export default function Dashboard({ navigate }: PageProps) {
53
- const [status, setStatus] = useState<StatusData | null>(null);
100
+ const [status, setStatus] = useState<AppStatusResponse | null>(null);
54
101
  const [error, setError] = useState<string | null>(null);
55
102
  const [syncing, setSyncing] = useState(false);
56
103
  const [syncJobId, setSyncJobId] = useState<string | null>(null);
104
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
105
+ const [initialCollectionPath, setInitialCollectionPath] = useState<
106
+ string | undefined
107
+ >(undefined);
108
+ const [busyAction, setBusyAction] = useState<HealthActionKind | null>(null);
109
+ const [recentDocs, setRecentDocs] = useState<RecentDoc[]>([]);
110
+ const [favoriteDocs, setFavoriteDocs] = useState<FavoriteDoc[]>([]);
111
+ const [favoriteCollections, setFavoriteCollections] = useState<
112
+ FavoriteCollection[]
113
+ >([]);
114
+ const [modelDownloadStatus, setModelDownloadStatus] =
115
+ useState<ModelDownloadStatus | null>(null);
57
116
  const { openCapture } = useCaptureModal();
117
+
58
118
  const openCollections = () => navigate("/collections");
59
119
 
60
120
  const loadStatus = useCallback(async () => {
61
- const { data, error: err } = await apiFetch<StatusData>("/api/status");
121
+ const { data, error: err } =
122
+ await apiFetch<AppStatusResponse>("/api/status");
62
123
  if (err) {
63
124
  setError(err);
64
- } else {
65
- setStatus(data);
66
- setError(null);
125
+ return;
67
126
  }
127
+
128
+ setStatus(data);
129
+ setError(null);
68
130
  }, []);
69
131
 
70
132
  useEffect(() => {
71
133
  void loadStatus();
72
134
  }, [loadStatus]);
73
135
 
74
- const handleSync = async () => {
136
+ useEffect(() => {
137
+ setRecentDocs(loadRecentDocuments());
138
+ setFavoriteDocs(loadFavoriteDocuments());
139
+ setFavoriteCollections(loadFavoriteCollections());
140
+ }, []);
141
+
142
+ useEffect(() => {
143
+ let cancelled = false;
144
+ let intervalId: ReturnType<typeof setInterval> | null = null;
145
+
146
+ async function pollModelStatus(): Promise<void> {
147
+ const { data } =
148
+ await apiFetch<ModelDownloadStatus>("/api/models/status");
149
+ if (cancelled || !data) {
150
+ return;
151
+ }
152
+
153
+ setModelDownloadStatus(data);
154
+
155
+ if (!data.active && intervalId) {
156
+ clearInterval(intervalId);
157
+ intervalId = null;
158
+ void loadStatus();
159
+ }
160
+ }
161
+
162
+ void pollModelStatus();
163
+
164
+ if (
165
+ busyAction === "download-models" ||
166
+ status?.bootstrap.models.downloading ||
167
+ modelDownloadStatus?.active
168
+ ) {
169
+ intervalId = setInterval(() => {
170
+ void pollModelStatus();
171
+ }, 1000);
172
+ }
173
+
174
+ return () => {
175
+ cancelled = true;
176
+ if (intervalId) {
177
+ clearInterval(intervalId);
178
+ }
179
+ };
180
+ }, [
181
+ busyAction,
182
+ loadStatus,
183
+ modelDownloadStatus?.active,
184
+ status?.bootstrap.models.downloading,
185
+ ]);
186
+
187
+ const handleSync = useCallback(async () => {
75
188
  setSyncing(true);
76
189
  setSyncJobId(null);
77
190
 
@@ -88,47 +201,213 @@ export default function Dashboard({ navigate }: PageProps) {
88
201
  if (data?.jobId) {
89
202
  setSyncJobId(data.jobId);
90
203
  }
91
- };
204
+ }, []);
92
205
 
93
- const handleSyncComplete = () => {
206
+ const handleSyncComplete = (result?: SyncResultSummary) => {
94
207
  setSyncing(false);
95
- setSyncJobId(null);
96
208
  void loadStatus();
209
+
210
+ const isNoop =
211
+ !!result &&
212
+ result.totalFilesProcessed === 0 &&
213
+ result.totalFilesAdded === 0 &&
214
+ result.totalFilesUpdated === 0 &&
215
+ result.totalFilesErrored === 0;
216
+
217
+ window.setTimeout(
218
+ () => {
219
+ setSyncJobId(null);
220
+ },
221
+ isNoop ? 2500 : 1200
222
+ );
223
+ };
224
+
225
+ const handleOpenAddCollection = (path?: string) => {
226
+ setInitialCollectionPath(path);
227
+ setAddDialogOpen(true);
228
+ };
229
+
230
+ const handleDownloadModels = useCallback(async () => {
231
+ setBusyAction("download-models");
232
+ const { error: err } = await apiFetch("/api/models/pull", {
233
+ method: "POST",
234
+ });
235
+ setBusyAction(null);
236
+
237
+ if (err) {
238
+ setError(err);
239
+ return;
240
+ }
241
+
242
+ const { data } = await apiFetch<ModelDownloadStatus>("/api/models/status");
243
+ if (data) {
244
+ setModelDownloadStatus(data);
245
+ }
246
+ void loadStatus();
247
+ }, [loadStatus]);
248
+
249
+ const handleEmbedNow = useCallback(async () => {
250
+ setBusyAction("embed");
251
+ const { data, error: err } = await apiFetch<EmbedResponse>("/api/embed", {
252
+ method: "POST",
253
+ });
254
+ setBusyAction(null);
255
+
256
+ if (err) {
257
+ setError(err);
258
+ return;
259
+ }
260
+
261
+ if (data?.errors && data.errors > 0) {
262
+ setError(`Embedding failed for ${data.errors} chunks.`);
263
+ }
264
+
265
+ void loadStatus();
266
+ }, [loadStatus]);
267
+
268
+ const isModelDownloadBlocking =
269
+ busyAction === "download-models" || modelDownloadStatus?.active;
270
+
271
+ const handleHealthAction = (action: HealthActionKind) => {
272
+ if (action === "add-collection") {
273
+ handleOpenAddCollection();
274
+ return;
275
+ }
276
+
277
+ if (action === "open-collections") {
278
+ openCollections();
279
+ return;
280
+ }
281
+
282
+ if (action === "sync") {
283
+ void handleSync();
284
+ return;
285
+ }
286
+
287
+ if (action === "embed") {
288
+ void handleEmbedNow();
289
+ return;
290
+ }
291
+
292
+ if (action === "download-models") {
293
+ void handleDownloadModels();
294
+ }
295
+ };
296
+
297
+ const handleToggleFavoriteCollection = (
298
+ collection: AppStatusResponse["collections"][number]
299
+ ) => {
300
+ setFavoriteCollections(
301
+ toggleFavoriteCollection({
302
+ name: collection.name,
303
+ href: `/browse?collection=${encodeURIComponent(collection.name)}`,
304
+ label: collection.name,
305
+ })
306
+ );
97
307
  };
98
308
 
99
309
  return (
100
310
  <div className="min-h-screen">
101
- {/* Header with aurora glow */}
311
+ {isModelDownloadBlocking && (
312
+ <div className="fixed inset-0 z-[90] bg-background/85 backdrop-blur-sm">
313
+ <div className="flex min-h-screen items-center justify-center px-6">
314
+ <Card className="w-full max-w-2xl border-primary/30 bg-card/95 shadow-2xl">
315
+ <CardHeader className="space-y-3">
316
+ <div className="flex items-center gap-3">
317
+ <div className="flex size-11 items-center justify-center rounded-full bg-primary/12 text-primary">
318
+ <DownloadIcon className="size-5" />
319
+ </div>
320
+ <div>
321
+ <div className="font-semibold text-xl">
322
+ Downloading local models
323
+ </div>
324
+ <CardDescription className="mt-1 text-sm">
325
+ GNO is preparing the active preset. Keep this page open
326
+ until the download finishes.
327
+ </CardDescription>
328
+ </div>
329
+ </div>
330
+ </CardHeader>
331
+ <CardContent className="space-y-5">
332
+ <div className="rounded-xl border border-border/60 bg-background/70 p-4">
333
+ <div className="mb-2 flex items-center justify-between gap-3">
334
+ <span className="font-medium text-sm">
335
+ {modelDownloadStatus?.currentType
336
+ ? `Current step: ${modelDownloadStatus.currentType}`
337
+ : "Preparing download"}
338
+ </span>
339
+ <span className="font-mono text-muted-foreground text-xs">
340
+ {modelDownloadStatus?.progress
341
+ ? `${modelDownloadStatus.progress.percent.toFixed(0)}%`
342
+ : "Starting..."}
343
+ </span>
344
+ </div>
345
+ <Progress
346
+ value={modelDownloadStatus?.progress?.percent ?? 5}
347
+ />
348
+ </div>
349
+
350
+ <div className="grid gap-4 md:grid-cols-2">
351
+ <div className="rounded-xl border border-border/60 bg-background/70 p-4">
352
+ <div className="mb-2 font-medium text-sm">Completed</div>
353
+ <p className="text-muted-foreground text-sm">
354
+ {modelDownloadStatus?.completed.length
355
+ ? modelDownloadStatus.completed.join(", ")
356
+ : "Nothing completed yet."}
357
+ </p>
358
+ </div>
359
+ <div className="rounded-xl border border-border/60 bg-background/70 p-4">
360
+ <div className="mb-2 font-medium text-sm">
361
+ Why this blocks
362
+ </div>
363
+ <p className="text-muted-foreground text-sm">
364
+ Search can partially work while files stream in, but
365
+ results and first-run status are clearer once the preset
366
+ download finishes.
367
+ </p>
368
+ </div>
369
+ </div>
370
+ </CardContent>
371
+ </Card>
372
+ </div>
373
+ </div>
374
+ )}
375
+
102
376
  <header className="relative border-border/50 border-b bg-card/50 backdrop-blur-sm">
103
377
  <div className="aurora-glow absolute inset-0 opacity-30" />
104
378
  <div className="relative px-8 py-12">
105
- <div className="mb-2 flex items-center justify-between">
106
- <div className="flex items-center gap-3">
107
- <GnoLogo className="size-8 text-primary" />
108
- <h1 className="font-bold text-4xl text-primary tracking-tight">
109
- GNO
110
- </h1>
379
+ <div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
380
+ <div className="min-w-0">
381
+ <div className="flex items-center gap-3">
382
+ <GnoLogo className="size-8 shrink-0 text-primary" />
383
+ <h1 className="font-bold text-4xl text-primary tracking-tight">
384
+ GNO
385
+ </h1>
386
+ </div>
387
+ <p className="mt-4 text-lg text-muted-foreground">
388
+ Your Local Knowledge Index
389
+ </p>
390
+ </div>
391
+
392
+ <div className="flex flex-wrap items-center gap-3 md:justify-end">
393
+ <AIModelSelector showLabel={false} />
394
+ <Button
395
+ disabled={syncing}
396
+ onClick={() => void handleSync()}
397
+ size="sm"
398
+ variant="outline"
399
+ >
400
+ {syncing ? (
401
+ <Loader2Icon className="mr-1.5 size-4 animate-spin" />
402
+ ) : (
403
+ <RefreshCwIcon className="mr-1.5 size-4" />
404
+ )}
405
+ {syncing ? "Syncing..." : "Update All"}
406
+ </Button>
111
407
  </div>
112
- <Button
113
- disabled={syncing}
114
- onClick={handleSync}
115
- size="sm"
116
- variant="outline"
117
- >
118
- {syncing ? (
119
- <Loader2Icon className="mr-1.5 size-4 animate-spin" />
120
- ) : (
121
- <RefreshCwIcon className="mr-1.5 size-4" />
122
- )}
123
- {syncing ? "Syncing..." : "Update All"}
124
- </Button>
125
408
  </div>
126
- <p className="text-lg text-muted-foreground">
127
- Your Local Knowledge Index
128
- </p>
129
409
 
130
- {/* Sync progress */}
131
- {syncJobId && (
410
+ {syncJobId && (!status || status.onboarding.ready) && (
132
411
  <div className="mt-4 rounded-lg border border-border/50 bg-background/50 p-4">
133
412
  <IndexingProgress
134
413
  jobId={syncJobId}
@@ -144,7 +423,22 @@ export default function Dashboard({ navigate }: PageProps) {
144
423
  </header>
145
424
 
146
425
  <main className="mx-auto max-w-6xl p-8">
147
- {/* Navigation */}
426
+ {status && !status.onboarding.ready && (
427
+ <section className="mb-10">
428
+ <FirstRunWizard
429
+ onboarding={status.onboarding}
430
+ onAddCollection={handleOpenAddCollection}
431
+ onDownloadModels={() => void handleDownloadModels()}
432
+ onEmbed={() => void handleEmbedNow()}
433
+ onSync={() => void handleSync()}
434
+ onSyncComplete={handleSyncComplete}
435
+ embedding={busyAction === "embed"}
436
+ syncJobId={syncJobId}
437
+ syncing={syncing}
438
+ />
439
+ </section>
440
+ )}
441
+
148
442
  <nav className="mb-10 flex flex-wrap gap-4">
149
443
  <Button
150
444
  className="gap-2"
@@ -181,6 +475,15 @@ export default function Dashboard({ navigate }: PageProps) {
181
475
  <FolderIcon className="size-4" />
182
476
  Collections
183
477
  </Button>
478
+ <Button
479
+ className="gap-2"
480
+ onClick={() => navigate("/connectors")}
481
+ size="lg"
482
+ variant="outline"
483
+ >
484
+ <CpuIcon className="size-4" />
485
+ Connectors
486
+ </Button>
184
487
  <Button
185
488
  className="gap-2"
186
489
  onClick={() => navigate("/graph")}
@@ -192,17 +495,118 @@ export default function Dashboard({ navigate }: PageProps) {
192
495
  </Button>
193
496
  </nav>
194
497
 
195
- {/* Error state */}
196
498
  {error && (
197
499
  <Card className="mb-6 border-destructive bg-destructive/10">
198
500
  <CardContent className="py-4 text-destructive">{error}</CardContent>
199
501
  </Card>
200
502
  )}
201
503
 
202
- {/* Stats Grid */}
504
+ {status && (
505
+ <div className="mb-10">
506
+ <HealthCenter
507
+ busyAction={busyAction}
508
+ health={status.health}
509
+ onAction={handleHealthAction}
510
+ />
511
+ </div>
512
+ )}
513
+
514
+ {status && (
515
+ <div className="mb-10">
516
+ <BootstrapStatus
517
+ bootstrap={status.bootstrap}
518
+ onDownloadModels={() => void handleDownloadModels()}
519
+ />
520
+ </div>
521
+ )}
522
+
523
+ {(recentDocs.length > 0 ||
524
+ favoriteDocs.length > 0 ||
525
+ favoriteCollections.length > 0) && (
526
+ <section className="mb-10 grid gap-4 xl:grid-cols-3">
527
+ <Card className="border-border/60 bg-card/70">
528
+ <CardHeader className="pb-3">
529
+ <div className="flex items-center gap-2">
530
+ <StarIcon className="size-4 text-primary" />
531
+ <CardDescription>Favorite Documents</CardDescription>
532
+ </div>
533
+ </CardHeader>
534
+ <CardContent className="space-y-2">
535
+ {favoriteDocs.length === 0 ? (
536
+ <p className="text-muted-foreground text-sm">
537
+ Favorite a document from Browse to keep it here.
538
+ </p>
539
+ ) : (
540
+ favoriteDocs.slice(0, 5).map((doc) => (
541
+ <Button
542
+ className="w-full justify-start"
543
+ key={doc.href}
544
+ onClick={() => navigate(doc.href)}
545
+ variant="ghost"
546
+ >
547
+ {doc.label}
548
+ </Button>
549
+ ))
550
+ )}
551
+ </CardContent>
552
+ </Card>
553
+ <Card className="border-border/60 bg-card/70">
554
+ <CardHeader className="pb-3">
555
+ <div className="flex items-center gap-2">
556
+ <FolderHeartIcon className="size-4 text-primary" />
557
+ <CardDescription>Pinned Collections</CardDescription>
558
+ </div>
559
+ </CardHeader>
560
+ <CardContent className="space-y-2">
561
+ {favoriteCollections.length === 0 ? (
562
+ <p className="text-muted-foreground text-sm">
563
+ Pin collections from the dashboard cards.
564
+ </p>
565
+ ) : (
566
+ favoriteCollections.slice(0, 5).map((collection) => (
567
+ <Button
568
+ className="w-full justify-start"
569
+ key={collection.name}
570
+ onClick={() => navigate(collection.href)}
571
+ variant="ghost"
572
+ >
573
+ {collection.label}
574
+ </Button>
575
+ ))
576
+ )}
577
+ </CardContent>
578
+ </Card>
579
+ <Card className="border-border/60 bg-card/70">
580
+ <CardHeader className="pb-3">
581
+ <div className="flex items-center gap-2">
582
+ <Search className="size-4 text-primary" />
583
+ <CardDescription>Recent Documents</CardDescription>
584
+ </div>
585
+ </CardHeader>
586
+ <CardContent className="space-y-2">
587
+ {recentDocs.length === 0 ? (
588
+ <p className="text-muted-foreground text-sm">
589
+ Open a few notes and they will show up here.
590
+ </p>
591
+ ) : (
592
+ recentDocs.slice(0, 5).map((doc) => (
593
+ <Button
594
+ className="w-full justify-start"
595
+ key={doc.href}
596
+ onClick={() => navigate(doc.href)}
597
+ variant="ghost"
598
+ >
599
+ {doc.label}
600
+ </Button>
601
+ ))
602
+ )}
603
+ </CardContent>
604
+ </Card>
605
+ </section>
606
+ )}
607
+
203
608
  {status && (
204
609
  <div className="mb-10 grid animate-fade-in gap-6 opacity-0 md:grid-cols-4">
205
- {/* Hero Documents Card */}
206
610
  <Card className="group relative overflow-hidden border-primary/30 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent transition-all duration-300 hover:border-primary/50 hover:shadow-[0_0_30px_-10px_hsl(var(--primary)/0.3)]">
207
611
  <div className="pointer-events-none absolute -top-12 -right-12 size-32 rounded-full bg-primary/10 blur-2xl" />
208
612
  <CardHeader className="relative pb-2">
@@ -268,7 +672,6 @@ export default function Dashboard({ navigate }: PageProps) {
268
672
  </CardContent>
269
673
  </Card>
270
674
 
271
- {/* Quick Capture Card */}
272
675
  <Card
273
676
  className="group stagger-3 animate-fade-in cursor-pointer opacity-0 transition-all duration-200 hover:-translate-y-0.5 hover:border-secondary/50 hover:bg-secondary/5 hover:shadow-lg"
274
677
  onClick={() => openCapture()}
@@ -293,7 +696,6 @@ export default function Dashboard({ navigate }: PageProps) {
293
696
  </div>
294
697
  )}
295
698
 
296
- {/* Collections */}
297
699
  {status && status.collections.length > 0 && (
298
700
  <section className="stagger-3 animate-fade-in opacity-0">
299
701
  <div className="mb-6 flex items-center justify-between gap-4 border-border/50 border-b pb-3">
@@ -303,42 +705,61 @@ export default function Dashboard({ navigate }: PageProps) {
303
705
  </Button>
304
706
  </div>
305
707
  <div className="space-y-3">
306
- {status.collections.map((c, i) => (
708
+ {status.collections.map((collection, index) => (
307
709
  <Card
308
710
  className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
309
- key={c.name}
711
+ key={collection.name}
310
712
  onClick={() =>
311
- navigate(`/browse?collection=${encodeURIComponent(c.name)}`)
713
+ navigate(
714
+ `/browse?collection=${encodeURIComponent(collection.name)}`
715
+ )
312
716
  }
313
- style={{ animationDelay: `${0.4 + i * 0.1}s` }}
717
+ style={{ animationDelay: `${0.4 + index * 0.1}s` }}
314
718
  >
315
719
  <CardContent className="flex items-center justify-between py-4">
316
720
  <div className="flex items-center gap-3">
317
- {/* Health indicator */}
318
721
  {syncing ? (
319
722
  <Loader2Icon className="size-4 animate-spin text-amber-500" />
320
- ) : c.embeddedCount >= c.chunkCount ? (
723
+ ) : collection.embeddedCount >= collection.chunkCount ? (
321
724
  <CheckCircle2Icon className="size-4 text-green-500" />
322
725
  ) : (
323
726
  <div className="size-4 rounded-full border-2 border-amber-500" />
324
727
  )}
325
728
  <div>
326
729
  <div className="font-medium text-lg transition-colors group-hover:text-primary">
327
- {c.name}
730
+ {collection.name}
328
731
  </div>
329
732
  <div className="font-mono text-muted-foreground text-sm">
330
- {c.path}
733
+ {collection.path}
331
734
  </div>
332
735
  </div>
333
736
  </div>
334
- <div className="text-right">
737
+ <div className="flex items-center gap-3 text-right">
738
+ <Button
739
+ onClick={(event) => {
740
+ event.stopPropagation();
741
+ handleToggleFavoriteCollection(collection);
742
+ }}
743
+ size="icon-sm"
744
+ variant="ghost"
745
+ >
746
+ <StarIcon
747
+ className={`size-4 ${
748
+ favoriteCollections.some(
749
+ (entry) => entry.name === collection.name
750
+ )
751
+ ? "fill-current text-secondary"
752
+ : "text-muted-foreground"
753
+ }`}
754
+ />
755
+ </Button>
335
756
  <div className="font-medium">
336
- {c.documentCount.toLocaleString()} docs
757
+ {collection.documentCount.toLocaleString()} docs
337
758
  </div>
338
759
  <div className="text-muted-foreground text-sm">
339
- {c.embeddedCount === c.chunkCount
340
- ? `${c.chunkCount.toLocaleString()} chunks`
341
- : `${c.embeddedCount}/${c.chunkCount} embedded`}
760
+ {collection.embeddedCount === collection.chunkCount
761
+ ? `${collection.chunkCount.toLocaleString()} chunks`
762
+ : `${collection.embeddedCount}/${collection.chunkCount} embedded`}
342
763
  </div>
343
764
  </div>
344
765
  </CardContent>
@@ -349,8 +770,13 @@ export default function Dashboard({ navigate }: PageProps) {
349
770
  )}
350
771
  </main>
351
772
 
352
- {/* Floating Action Button */}
353
773
  <CaptureButton onClick={() => openCapture()} />
774
+ <AddCollectionDialog
775
+ initialPath={initialCollectionPath}
776
+ onOpenChange={setAddDialogOpen}
777
+ onSuccess={() => void loadStatus()}
778
+ open={addDialogOpen}
779
+ />
354
780
  </div>
355
781
  );
356
782
  }