From 1b9cdc44c2200f9f4e4cdb9a101af05fe571ede5 Mon Sep 17 00:00:00 2001 From: krypticmouse Date: Thu, 26 Mar 2026 18:08:37 +0000 Subject: [PATCH] feat: add setup wizard components (SourcePicker, ConnectFlow, IngestDashboard, ReadyScreen) SourcePicker renders a grouped card grid for selecting data sources; SourceConnectFlow provides per-source auth panels (OAuth, filesystem, local); IngestDashboard polls sync status every 2s with progress bars; ReadyScreen shows a celebration screen with context-aware starter queries. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/setup/IngestDashboard.tsx | 179 +++++++ frontend/src/components/setup/ReadyScreen.tsx | 142 ++++++ .../components/setup/SourceConnectFlow.tsx | 442 ++++++++++++++++++ .../src/components/setup/SourcePicker.tsx | 178 +++++++ 4 files changed, 941 insertions(+) create mode 100644 frontend/src/components/setup/IngestDashboard.tsx create mode 100644 frontend/src/components/setup/ReadyScreen.tsx create mode 100644 frontend/src/components/setup/SourceConnectFlow.tsx create mode 100644 frontend/src/components/setup/SourcePicker.tsx diff --git a/frontend/src/components/setup/IngestDashboard.tsx b/frontend/src/components/setup/IngestDashboard.tsx new file mode 100644 index 00000000..292bd603 --- /dev/null +++ b/frontend/src/components/setup/IngestDashboard.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { getSyncStatus } from '../../lib/connectors-api'; +import { SOURCE_CATALOG } from '../../types/connectors'; +import type { SyncStatus } from '../../types/connectors'; + +// --------------------------------------------------------------------------- +// ProgressRow +// --------------------------------------------------------------------------- + +function ProgressRow({ + displayName, + status, +}: { + displayName: string; + status: SyncStatus | null; +}) { + const isDone = status?.state === 'idle' && (status?.items_synced ?? 0) > 0; + const pct = + status && status.items_total > 0 + ? Math.min(100, Math.round((status.items_synced / status.items_total) * 100)) + : isDone + ? 100 + : 0; + + return ( +
+
+ + {displayName} + +
+ {!status || status.state === 'idle' ? ( + isDone ? ( + + ) : ( + + ) + ) : status.state === 'syncing' ? ( + + ) : status.state === 'paused' ? ( + + ) : ( + + )} + + {status?.state === 'syncing' + ? `${status.items_synced} / ${status.items_total}` + : isDone + ? `${status!.items_synced} items` + : status?.state === 'paused' + ? 'Paused' + : status?.state === 'error' + ? 'Error' + : 'Starting...'} + +
+
+
+
+
+ {status?.error && ( +

+ {status.error} +

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// IngestDashboard +// --------------------------------------------------------------------------- + +export function IngestDashboard({ + connectedIds, + onReady, +}: { + connectedIds: string[]; + onReady: () => void; +}) { + const [statuses, setStatuses] = useState>(() => + Object.fromEntries(connectedIds.map((id) => [id, null])), + ); + + const poll = useCallback(async () => { + const updates = await Promise.all( + connectedIds.map(async (id) => { + try { + const s = await getSyncStatus(id); + return [id, s] as [string, SyncStatus]; + } catch { + return [id, null] as [string, null]; + } + }), + ); + setStatuses(Object.fromEntries(updates)); + }, [connectedIds]); + + useEffect(() => { + poll(); + const interval = setInterval(poll, 2000); + return () => clearInterval(interval); + }, [poll]); + + const allDone = connectedIds.every( + (id) => statuses[id]?.state === 'error' || + (statuses[id]?.state === 'idle' && (statuses[id]?.items_synced ?? 0) > 0), + ); + + const totalSynced = Object.values(statuses).reduce( + (sum, s) => sum + (s?.items_synced ?? 0), + 0, + ); + + return ( +
+ {/* Header */} +
+

+ {allDone ? 'Sync complete' : 'Syncing your data...'} +

+

+ {allDone + ? `Indexed ${totalSynced} items across ${connectedIds.length} source${connectedIds.length !== 1 ? 's' : ''}.` + : 'This may take a few minutes depending on your data volume.'} +

+
+ + {/* Progress rows */} +
+ {connectedIds.map((id) => { + const card = SOURCE_CATALOG.find((c) => c.connector_id === id); + return ( + + ); + })} +
+ + {/* Footer */} +
+ + {!allDone && ( +

+ Sync will continue in the background +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/setup/ReadyScreen.tsx b/frontend/src/components/setup/ReadyScreen.tsx new file mode 100644 index 00000000..8d65973d --- /dev/null +++ b/frontend/src/components/setup/ReadyScreen.tsx @@ -0,0 +1,142 @@ +import { Sparkles, MessageSquare, ArrowRight } from 'lucide-react'; +import { SOURCE_CATALOG } from '../../types/connectors'; + +// --------------------------------------------------------------------------- +// Starter queries +// --------------------------------------------------------------------------- + +function getStarterQueries(connectedSources: string[]): string[] { + const queries: string[] = []; + const has = (id: string) => connectedSources.includes(id); + + if (has('gmail') || has('gmail_imap')) { + queries.push('What emails need my attention today?'); + } + if (has('gcalendar')) { + queries.push("What's on my calendar this week?"); + } + if (has('slack')) { + queries.push('Summarize important Slack messages from yesterday'); + } + if (has('gdrive') || has('notion') || has('obsidian')) { + queries.push('Find my notes about project planning'); + } + if (has('imessage')) { + queries.push('What have I been texting about lately?'); + } + if (has('gcontacts')) { + queries.push('Who are my most frequent collaborators?'); + } + if (has('granola')) { + queries.push('Summarize my recent meeting notes'); + } + + // Fallback defaults + if (queries.length === 0) { + return [ + 'What should I focus on today?', + 'Summarize my recent activity', + 'Help me draft a quick update', + ]; + } + + return queries.slice(0, 3); +} + +// --------------------------------------------------------------------------- +// StarterCard +// --------------------------------------------------------------------------- + +function StarterCard({ + query, + onSelect, +}: { + query: string; + onSelect: (q: string) => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// ReadyScreen +// --------------------------------------------------------------------------- + +export function ReadyScreen({ + connectedSources, + onStart, +}: { + connectedSources: string[]; + onStart: (query?: string) => void; +}) { + const starters = getStarterQueries(connectedSources); + + const connectedCards = connectedSources + .map((id) => SOURCE_CATALOG.find((c) => c.connector_id === id)) + .filter(Boolean); + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Headline */} +
+

+ You're all set! +

+

+ {connectedCards.length > 0 + ? `Connected ${connectedCards.length} source${connectedCards.length !== 1 ? 's' : ''}: ${connectedCards.map((c) => c!.display_name).join(', ')}.` + : 'Your personal AI is ready to help.'} + {' '}Ask anything about your work and life. +

+
+ + {/* Starter queries */} +
+

+ Try asking +

+ {starters.map((q) => ( + + ))} +
+ + {/* Open Chat button */} + +
+ ); +} diff --git a/frontend/src/components/setup/SourceConnectFlow.tsx b/frontend/src/components/setup/SourceConnectFlow.tsx new file mode 100644 index 00000000..7ca3f52d --- /dev/null +++ b/frontend/src/components/setup/SourceConnectFlow.tsx @@ -0,0 +1,442 @@ +import { useState } from 'react'; +import { + CheckCircle2, + Circle, + SkipForward, + ExternalLink, + FolderOpen, + Loader2, +} from 'lucide-react'; +import { SOURCE_CATALOG } from '../../types/connectors'; +import { connectSource } from '../../lib/connectors-api'; +import type { ConnectRequest } from '../../types/connectors'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type SourceState = 'pending' | 'connecting' | 'connected' | 'skipped' | 'error'; + +interface SourceEntry { + id: string; + state: SourceState; + error?: string; +} + +// --------------------------------------------------------------------------- +// Sidebar item +// --------------------------------------------------------------------------- + +function SidebarItem({ + label, + state, + active, + onClick, +}: { + label: string; + state: SourceState; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Auth panels +// --------------------------------------------------------------------------- + +function FilesystemPanel({ + displayName, + onConnect, + onSkip, + isConnecting, +}: { + displayName: string; + onConnect: (req: ConnectRequest) => void; + onSkip: () => void; + isConnecting: boolean; +}) { + const [path, setPath] = useState(''); + return ( +
+

+ Enter the path to your local {displayName} folder. +

+
+ setPath(e.target.value)} + placeholder="/Users/you/Documents/..." + className="flex-1 px-3 py-2 rounded-lg text-sm outline-none" + style={{ + background: 'var(--color-surface)', + border: '1px solid var(--color-border)', + color: 'var(--color-text)', + }} + /> + +
+ +
+ ); +} + +function OAuthPanel({ + displayName, + authUrl, + onConnect, + onSkip, + isConnecting, +}: { + displayName: string; + authUrl?: string; + onConnect: (req: ConnectRequest) => void; + onSkip: () => void; + isConnecting: boolean; +}) { + const [token, setToken] = useState(''); + const [phase, setPhase] = useState<'start' | 'paste'>('start'); + + const openBrowser = () => { + if (authUrl) { + window.open(authUrl, '_blank'); + } + setPhase('paste'); + }; + + return ( +
+ {phase === 'start' ? ( + <> +

+ Authorize OpenJarvis to access your {displayName} account. +

+ + + ) : ( + <> +

+ After authorizing, paste the token or code below. +

+