@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
@@ -0,0 +1,622 @@
1
+ import {
2
+ CheckCircle2Icon,
3
+ ChevronLeftIcon,
4
+ ChevronRightIcon,
5
+ DownloadIcon,
6
+ FolderPlusIcon,
7
+ RefreshCwIcon,
8
+ SparklesIcon,
9
+ } from "lucide-react";
10
+ import { type ReactNode, useEffect, useState } from "react";
11
+
12
+ import type { AppStatusResponse, OnboardingStep } from "../../status-model";
13
+
14
+ import { cn } from "../lib/utils";
15
+ import { AIModelSelector } from "./AIModelSelector";
16
+ import { IndexingProgress } from "./IndexingProgress";
17
+ import { Badge } from "./ui/badge";
18
+ import { Button } from "./ui/button";
19
+ import {
20
+ Card,
21
+ CardContent,
22
+ CardDescription,
23
+ CardFooter,
24
+ CardHeader,
25
+ CardTitle,
26
+ } from "./ui/card";
27
+ import { Progress } from "./ui/progress";
28
+ import { ScrollArea } from "./ui/scroll-area";
29
+ import { Separator } from "./ui/separator";
30
+
31
+ interface FirstRunWizardProps {
32
+ onboarding: AppStatusResponse["onboarding"];
33
+ onAddCollection: (path?: string) => void;
34
+ onDownloadModels: () => void;
35
+ onEmbed: () => void;
36
+ onSync: () => void;
37
+ onSyncComplete?: () => void;
38
+ embedding?: boolean;
39
+ syncJobId?: string | null;
40
+ syncing?: boolean;
41
+ }
42
+
43
+ function WizardPanel({
44
+ children,
45
+ className,
46
+ }: {
47
+ children: ReactNode;
48
+ className?: string;
49
+ }) {
50
+ return (
51
+ <div
52
+ className={cn(
53
+ "rounded-2xl border border-border/70 bg-card/80 shadow-sm",
54
+ className
55
+ )}
56
+ >
57
+ {children}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function WizardPanelHeader({
63
+ badge,
64
+ children,
65
+ title,
66
+ }: {
67
+ title: string;
68
+ children?: ReactNode;
69
+ badge?: ReactNode;
70
+ }) {
71
+ return (
72
+ <div className="border-border/50 border-b" style={{ padding: "24px 28px" }}>
73
+ <div className="min-w-0 flex-1">
74
+ <div className="mb-2 font-medium text-base">{title}</div>
75
+ {children}
76
+ </div>
77
+ {badge}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function WizardMiniCard({ body, title }: { title: string; body: string }) {
83
+ return (
84
+ <div
85
+ className="rounded-xl border border-border/60 bg-background/80 shadow-none"
86
+ style={{ padding: "20px" }}
87
+ >
88
+ <div className="mb-2 font-medium text-sm">{title}</div>
89
+ <p className="text-muted-foreground text-sm leading-6">{body}</p>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ function WizardActionPanel({
95
+ action,
96
+ body,
97
+ chrome,
98
+ title,
99
+ }: {
100
+ title: string;
101
+ body: string;
102
+ action: ReactNode;
103
+ chrome?: ReactNode;
104
+ }) {
105
+ return (
106
+ <div
107
+ className="rounded-2xl border border-secondary/20 bg-secondary/5 shadow-sm"
108
+ style={{ padding: "24px 28px" }}
109
+ >
110
+ <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
111
+ <div className="min-w-0">
112
+ <div className="mb-2 font-medium text-sm">{title}</div>
113
+ <p className="text-muted-foreground text-sm leading-6">{body}</p>
114
+ </div>
115
+ <div className="flex flex-wrap items-center gap-3 pt-2 lg:justify-self-end lg:pt-0">
116
+ {action}
117
+ {chrome}
118
+ </div>
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function getRecommendedStepId(
125
+ onboarding: AppStatusResponse["onboarding"]
126
+ ): string {
127
+ const current =
128
+ onboarding.steps.find((step) => step.status === "current") ??
129
+ onboarding.steps.find((step) => step.status !== "complete") ??
130
+ onboarding.steps[0];
131
+
132
+ return current?.id ?? "folders";
133
+ }
134
+
135
+ function getStepProgress(onboarding: AppStatusResponse["onboarding"]): number {
136
+ if (onboarding.steps.length === 0) {
137
+ return 0;
138
+ }
139
+
140
+ const completeCount = onboarding.steps.filter(
141
+ (step) => step.status === "complete"
142
+ ).length;
143
+ const hasCurrent = onboarding.steps.some((step) => step.status === "current");
144
+
145
+ return Math.round(
146
+ ((completeCount + (hasCurrent ? 0.5 : 0)) / onboarding.steps.length) * 100
147
+ );
148
+ }
149
+
150
+ function getStepTone(step: OnboardingStep): {
151
+ badge: string;
152
+ className: string;
153
+ } {
154
+ if (step.status === "complete") {
155
+ return {
156
+ badge: "Done",
157
+ className:
158
+ "border-emerald-500/25 bg-emerald-500/10 text-emerald-500 hover:border-emerald-500/40",
159
+ };
160
+ }
161
+
162
+ if (step.status === "current") {
163
+ return {
164
+ badge: "Now",
165
+ className:
166
+ "border-primary/35 bg-primary/10 text-primary hover:border-primary/50",
167
+ };
168
+ }
169
+
170
+ return {
171
+ badge: "Later",
172
+ className:
173
+ "border-border/60 bg-background/70 text-muted-foreground hover:border-border hover:text-foreground",
174
+ };
175
+ }
176
+
177
+ function renderStepIcon(stepId: string) {
178
+ if (stepId === "folders") {
179
+ return <FolderPlusIcon className="size-4" />;
180
+ }
181
+ if (stepId === "preset") {
182
+ return <SparklesIcon className="size-4" />;
183
+ }
184
+ if (stepId === "models") {
185
+ return <DownloadIcon className="size-4" />;
186
+ }
187
+ return <RefreshCwIcon className="size-4" />;
188
+ }
189
+
190
+ function getStepActionLabel(stepId: string): string {
191
+ if (stepId === "folders") {
192
+ return "Add folder";
193
+ }
194
+ if (stepId === "models") {
195
+ return "Download models";
196
+ }
197
+ if (stepId === "indexing") {
198
+ return "Run sync";
199
+ }
200
+ return "Review preset";
201
+ }
202
+
203
+ export function FirstRunWizard({
204
+ onboarding,
205
+ onAddCollection,
206
+ onDownloadModels,
207
+ onEmbed,
208
+ onSync,
209
+ onSyncComplete,
210
+ embedding = false,
211
+ syncJobId = null,
212
+ syncing = false,
213
+ }: FirstRunWizardProps) {
214
+ const recommendedStepId = getRecommendedStepId(onboarding);
215
+ const [activeStepId, setActiveStepId] = useState(recommendedStepId);
216
+
217
+ useEffect(() => {
218
+ setActiveStepId((current) => {
219
+ if (onboarding.steps.some((step) => step.id === current)) {
220
+ return current;
221
+ }
222
+ return recommendedStepId;
223
+ });
224
+ }, [onboarding.steps, recommendedStepId]);
225
+
226
+ const steps = onboarding.steps;
227
+ const foldersStep = steps.find((step) => step.id === "folders");
228
+ const foldersConnected = foldersStep?.status === "complete";
229
+ const activeStep =
230
+ steps.find((step) => step.id === activeStepId) ?? steps[0] ?? null;
231
+ const activeIndex = activeStep
232
+ ? steps.findIndex((step) => step.id === activeStep.id)
233
+ : 0;
234
+ const previousStep = activeIndex > 0 ? steps[activeIndex - 1] : null;
235
+ const nextStep =
236
+ activeIndex >= 0 && activeIndex < steps.length - 1
237
+ ? steps[activeIndex + 1]
238
+ : null;
239
+ const progressValue = getStepProgress(onboarding);
240
+ const isShowingRecommended = activeStep?.id === recommendedStepId;
241
+ const indexingNeedsEmbeddings =
242
+ onboarding.stage === "indexing" &&
243
+ onboarding.detail.toLowerCase().includes("embedding");
244
+
245
+ const runRecommendedAction = () => {
246
+ if (!activeStep) {
247
+ return;
248
+ }
249
+ if (activeStep.id === "folders") {
250
+ onAddCollection();
251
+ return;
252
+ }
253
+ if (activeStep.id === "models") {
254
+ onDownloadModels();
255
+ return;
256
+ }
257
+ if (activeStep.id === "indexing" && indexingNeedsEmbeddings && !embedding) {
258
+ onEmbed();
259
+ return;
260
+ }
261
+ if (activeStep.id === "indexing" && !syncing) {
262
+ onSync();
263
+ }
264
+ };
265
+
266
+ const renderStepBody = () => {
267
+ if (!activeStep) {
268
+ return null;
269
+ }
270
+
271
+ if (activeStep.id === "folders") {
272
+ return (
273
+ <div className="space-y-5">
274
+ <div className="flex flex-wrap gap-3">
275
+ <Button onClick={() => onAddCollection()} size="lg">
276
+ <FolderPlusIcon className="mr-2 size-4" />
277
+ {foldersConnected ? "Add another folder" : "Add first folder"}
278
+ </Button>
279
+ <Button
280
+ onClick={() => onAddCollection()}
281
+ size="lg"
282
+ variant="outline"
283
+ >
284
+ {foldersConnected
285
+ ? "Browse for more folders"
286
+ : "Browse for another folder"}
287
+ </Button>
288
+ </div>
289
+
290
+ {onboarding.suggestedCollections.length > 0 && (
291
+ <div className="space-y-3">
292
+ <div className="flex items-center justify-between gap-3">
293
+ <div>
294
+ <h3 className="font-medium">
295
+ {foldersConnected
296
+ ? "Suggested next folders"
297
+ : "Recommended starting points"}
298
+ </h3>
299
+ <p className="text-muted-foreground text-sm">
300
+ {foldersConnected
301
+ ? "Add another source quickly from the common local spots below."
302
+ : "Pick one to prefill the add-folder dialog."}
303
+ </p>
304
+ </div>
305
+ <Badge variant="outline">Quick picks</Badge>
306
+ </div>
307
+ <div className="grid gap-3 md:grid-cols-2">
308
+ {onboarding.suggestedCollections.map((suggestion) => (
309
+ <button
310
+ className="rounded-xl border border-border/70 bg-background/80 p-4 text-left shadow-sm transition-all hover:border-primary/35 hover:bg-background hover:shadow-md"
311
+ key={suggestion.path}
312
+ onClick={() => onAddCollection(suggestion.path)}
313
+ type="button"
314
+ >
315
+ <div className="mb-2 flex items-center justify-between gap-3">
316
+ <span className="font-medium">{suggestion.label}</span>
317
+ <ChevronRightIcon className="size-4 text-muted-foreground" />
318
+ </div>
319
+ <p className="font-mono text-muted-foreground text-xs">
320
+ {suggestion.path}
321
+ </p>
322
+ <p className="mt-3 text-muted-foreground text-sm">
323
+ {suggestion.reason}
324
+ </p>
325
+ </button>
326
+ ))}
327
+ </div>
328
+ </div>
329
+ )}
330
+ </div>
331
+ );
332
+ }
333
+
334
+ if (activeStep.id === "preset") {
335
+ return (
336
+ <div className="space-y-5">
337
+ <WizardPanel className="border-secondary/25 bg-secondary/5">
338
+ <WizardPanelHeader
339
+ badge={<Badge variant="outline">Safe to switch</Badge>}
340
+ title="Preset selector"
341
+ >
342
+ <p className="max-w-3xl text-muted-foreground text-sm leading-6">
343
+ Pick the preset here. The active card below explains the
344
+ trade-off, model footprint, and whether a tuned preset is
345
+ available.
346
+ </p>
347
+ </WizardPanelHeader>
348
+ <div style={{ padding: "24px 28px" }}>
349
+ <AIModelSelector showDetails showDownloadAction={false} />
350
+ </div>
351
+ </WizardPanel>
352
+ </div>
353
+ );
354
+ }
355
+
356
+ if (activeStep.id === "models") {
357
+ return (
358
+ <div className="space-y-5">
359
+ <WizardPanel>
360
+ <WizardPanelHeader
361
+ badge={<Badge variant="outline">Background download</Badge>}
362
+ title="Local model readiness"
363
+ >
364
+ <p className="max-w-2xl text-muted-foreground text-sm leading-6">
365
+ {activeStep.detail}
366
+ </p>
367
+ </WizardPanelHeader>
368
+ <div style={{ padding: "24px 28px" }}>
369
+ <div className="grid gap-4 md:grid-cols-2">
370
+ <WizardMiniCard
371
+ body="Core search is already usable. You can keep exploring while the heavier local model roles download."
372
+ title="What you have now"
373
+ />
374
+ <WizardMiniCard
375
+ body="Better reranking, stronger local answers, and a cleaner first run experience once the active preset is fully cached."
376
+ title="What improves next"
377
+ />
378
+ </div>
379
+ </div>
380
+ </WizardPanel>
381
+
382
+ <WizardActionPanel
383
+ action={
384
+ <Button onClick={onDownloadModels} size="lg">
385
+ <DownloadIcon className="mr-2 size-4" />
386
+ Download local models
387
+ </Button>
388
+ }
389
+ body="Safe to start now. You do not need to stay on this step."
390
+ chrome={
391
+ <Badge className="px-3 py-1" variant="outline">
392
+ Runs in background
393
+ </Badge>
394
+ }
395
+ title="Download active preset"
396
+ />
397
+ </div>
398
+ );
399
+ }
400
+
401
+ return (
402
+ <div className="space-y-5">
403
+ <WizardPanel>
404
+ <WizardPanelHeader
405
+ badge={<Badge variant="outline">Safe to rerun</Badge>}
406
+ title="Finish first indexing"
407
+ >
408
+ <p className="max-w-2xl text-muted-foreground text-sm leading-6">
409
+ {activeStep.detail}
410
+ </p>
411
+ </WizardPanelHeader>
412
+ <div style={{ padding: "24px 28px" }}>
413
+ <WizardMiniCard
414
+ body="Pulls the current folder state into the index and reconciles the first-run workspace so search, browse, and health data line up."
415
+ title="What this does"
416
+ />
417
+ </div>
418
+ </WizardPanel>
419
+
420
+ <WizardActionPanel
421
+ action={
422
+ <Button
423
+ disabled={indexingNeedsEmbeddings ? embedding : syncing}
424
+ onClick={indexingNeedsEmbeddings ? onEmbed : onSync}
425
+ size="lg"
426
+ >
427
+ <RefreshCwIcon className="mr-2 size-4" />
428
+ {indexingNeedsEmbeddings
429
+ ? embedding
430
+ ? "Embedding..."
431
+ : "Finish embeddings"
432
+ : syncing
433
+ ? "Syncing..."
434
+ : "Run first sync"}
435
+ </Button>
436
+ }
437
+ body={
438
+ indexingNeedsEmbeddings
439
+ ? "Your files are indexed. One more embedding pass will unlock semantic search and local answers."
440
+ : "Good last step before you move into normal use."
441
+ }
442
+ title={
443
+ indexingNeedsEmbeddings
444
+ ? "Finish semantic indexing"
445
+ : "Index the current workspace"
446
+ }
447
+ />
448
+
449
+ {syncJobId && (
450
+ <WizardPanel>
451
+ <WizardPanelHeader title="Sync progress">
452
+ <p className="max-w-2xl text-muted-foreground text-sm leading-6">
453
+ Your first indexing run is in progress. The wizard will advance
454
+ once the job finishes and embeddings catch up.
455
+ </p>
456
+ </WizardPanelHeader>
457
+ <div style={{ padding: "24px 28px" }}>
458
+ <IndexingProgress jobId={syncJobId} onComplete={onSyncComplete} />
459
+ </div>
460
+ </WizardPanel>
461
+ )}
462
+ </div>
463
+ );
464
+ };
465
+
466
+ return (
467
+ <section className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
468
+ <Card className="border-primary/15 bg-gradient-to-b from-card via-card to-primary/5">
469
+ <CardHeader className="space-y-4">
470
+ <div className="flex items-center justify-between gap-3">
471
+ <div className="flex items-center gap-2 text-primary">
472
+ <SparklesIcon className="size-4" />
473
+ <span className="font-medium text-sm tracking-wide uppercase">
474
+ Setup wizard
475
+ </span>
476
+ </div>
477
+ <Badge variant="outline">{progressValue}%</Badge>
478
+ </div>
479
+ <div className="space-y-2">
480
+ <CardTitle className="text-2xl tracking-tight">
481
+ {onboarding.headline}
482
+ </CardTitle>
483
+ <CardDescription>{onboarding.detail}</CardDescription>
484
+ </div>
485
+ <Progress value={progressValue} />
486
+ </CardHeader>
487
+ <CardContent className="pt-0">
488
+ <ScrollArea className="max-h-[420px] pr-3">
489
+ <div className="space-y-3">
490
+ {steps.map((step, index) => {
491
+ const tone = getStepTone(step);
492
+ const isActive = step.id === activeStep?.id;
493
+
494
+ return (
495
+ <button
496
+ className={cn(
497
+ "w-full rounded-2xl border p-4 text-left shadow-sm transition-all",
498
+ tone.className,
499
+ isActive &&
500
+ "ring-2 ring-primary/20 ring-offset-2 ring-offset-background"
501
+ )}
502
+ key={step.id}
503
+ onClick={() => setActiveStepId(step.id)}
504
+ type="button"
505
+ >
506
+ <div className="mb-3 flex items-start justify-between gap-3">
507
+ <div className="flex items-center gap-3">
508
+ <div
509
+ className={cn(
510
+ "flex size-9 shrink-0 items-center justify-center rounded-full border text-sm",
511
+ step.status === "complete"
512
+ ? "border-emerald-500/30 bg-emerald-500/15 text-emerald-500"
513
+ : step.status === "current"
514
+ ? "border-primary/30 bg-primary/10 text-primary"
515
+ : "border-border/70 bg-background/70 text-muted-foreground"
516
+ )}
517
+ >
518
+ {step.status === "complete" ? (
519
+ <CheckCircle2Icon className="size-4" />
520
+ ) : (
521
+ index + 1
522
+ )}
523
+ </div>
524
+ <div>
525
+ <div className="font-medium">{step.title}</div>
526
+ <div className="mt-1 text-xs text-muted-foreground">
527
+ Step {index + 1}
528
+ </div>
529
+ </div>
530
+ </div>
531
+ <Badge variant="outline">{tone.badge}</Badge>
532
+ </div>
533
+ <p className="text-sm text-muted-foreground">
534
+ {step.detail}
535
+ </p>
536
+ </button>
537
+ );
538
+ })}
539
+ </div>
540
+ </ScrollArea>
541
+ </CardContent>
542
+ </Card>
543
+
544
+ <Card className="border-primary/20 bg-gradient-to-br from-primary/8 via-card to-card">
545
+ <CardHeader className="space-y-4 border-border/50 border-b bg-background/40">
546
+ <div className="flex flex-wrap items-center justify-between gap-3">
547
+ <div className="flex items-center gap-3">
548
+ <div className="flex size-10 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-primary">
549
+ {activeStep ? renderStepIcon(activeStep.id) : null}
550
+ </div>
551
+ <div>
552
+ <div className="mb-1 flex items-center gap-2">
553
+ <Badge variant="outline">
554
+ Step {Math.max(activeIndex + 1, 1)} of {steps.length || 1}
555
+ </Badge>
556
+ {isShowingRecommended && (
557
+ <Badge className="border-primary/30 bg-primary/10 text-primary hover:bg-primary/10">
558
+ Recommended now
559
+ </Badge>
560
+ )}
561
+ </div>
562
+ <CardTitle className="text-3xl tracking-tight">
563
+ {activeStep?.id === "preset"
564
+ ? "Choose how GNO should feel"
565
+ : (activeStep?.title ?? onboarding.headline)}
566
+ </CardTitle>
567
+ </div>
568
+ </div>
569
+ {activeStep && activeStep.id !== "preset" && (
570
+ <Button
571
+ disabled={
572
+ activeStep.id === "indexing"
573
+ ? indexingNeedsEmbeddings
574
+ ? embedding
575
+ : syncing
576
+ : false
577
+ }
578
+ onClick={runRecommendedAction}
579
+ size="sm"
580
+ variant={isShowingRecommended ? "default" : "outline"}
581
+ >
582
+ {activeStep.id === "indexing" && indexingNeedsEmbeddings
583
+ ? embedding
584
+ ? "Embedding..."
585
+ : "Finish embeddings"
586
+ : activeStep.id === "indexing" && syncing
587
+ ? "Syncing..."
588
+ : getStepActionLabel(activeStep.id)}
589
+ </Button>
590
+ )}
591
+ </div>
592
+ <CardDescription className="max-w-3xl text-base">
593
+ {activeStep?.detail ?? onboarding.detail}
594
+ </CardDescription>
595
+ </CardHeader>
596
+ <CardContent className="space-y-6 p-6">{renderStepBody()}</CardContent>
597
+ <Separator />
598
+ <CardFooter className="flex flex-wrap items-center justify-between gap-3 p-6">
599
+ <Button
600
+ disabled={!previousStep}
601
+ onClick={() => previousStep && setActiveStepId(previousStep.id)}
602
+ variant="outline"
603
+ >
604
+ <ChevronLeftIcon className="mr-2 size-4" />
605
+ Previous
606
+ </Button>
607
+ <div className="text-muted-foreground text-sm">
608
+ You can jump between steps without losing progress.
609
+ </div>
610
+ <Button
611
+ disabled={!nextStep}
612
+ onClick={() => nextStep && setActiveStepId(nextStep.id)}
613
+ variant="outline"
614
+ >
615
+ Next
616
+ <ChevronRightIcon className="ml-2 size-4" />
617
+ </Button>
618
+ </CardFooter>
619
+ </Card>
620
+ </section>
621
+ );
622
+ }