diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a557107d..01644e90 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -19,6 +19,7 @@ import { RunEvent } from 'packages/shared/dist/runs.js'; import container from '@x/core/dist/di/container.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; +import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -326,12 +327,21 @@ export function setupIpcHandlers() { 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); - + // Trigger sync immediately when enabled if (args.enabled) { triggerGranolaSync(); } - + + return { success: true }; + }, + 'onboarding:getStatus': async () => { + // Show onboarding if it hasn't been completed yet + const complete = isOnboardingComplete(); + return { showOnboarding: !complete }; + }, + 'onboarding:markComplete': async () => { + markOnboardingComplete(); return { success: true }; }, }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1a959c1a..3ac0b24f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -49,6 +49,7 @@ import { TooltipProvider } from "@/components/ui/tooltip" import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' +import { OnboardingModal } from '@/components/onboarding-modal' type DirEntry = z.infer type RunEventType = z.infer @@ -422,6 +423,9 @@ function App() { // Workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') + // Onboarding state + const [showOnboarding, setShowOnboarding] = useState(false) + // Load directory tree const loadDirectory = useCallback(async () => { try { @@ -1175,6 +1179,30 @@ function App() { }) }, []) + // Check onboarding status on mount + useEffect(() => { + async function checkOnboarding() { + try { + const result = await window.ipc.invoke('onboarding:getStatus', null) + setShowOnboarding(result.showOnboarding) + } catch (err) { + console.error('Failed to check onboarding status:', err) + } + } + checkOnboarding() + }, []) + + // Handler for onboarding completion + const handleOnboardingComplete = useCallback(async () => { + try { + await window.ipc.invoke('onboarding:markComplete', null) + setShowOnboarding(false) + } catch (err) { + console.error('Failed to mark onboarding complete:', err) + setShowOnboarding(false) + } + }, []) + const knowledgeActions = React.useMemo(() => ({ createNote: async (parentPath: string = 'knowledge') => { try { @@ -1732,6 +1760,10 @@ function App() { + ) } diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx new file mode 100644 index 00000000..48119934 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -0,0 +1,446 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback } from "react" +import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +interface ProviderState { + isConnected: boolean + isLoading: boolean + isConnecting: boolean +} + +interface OnboardingModalProps { + open: boolean + onComplete: () => void +} + +type Step = 0 | 1 | 2 + +export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { + const [currentStep, setCurrentStep] = useState(0) + + // OAuth provider states + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + const [providerStates, setProviderStates] = useState>({}) + + // Granola state + const [granolaEnabled, setGranolaEnabled] = useState(false) + const [granolaLoading, setGranolaLoading] = useState(true) + + // Track connected providers for the completion step + const connectedProviders = Object.entries(providerStates) + .filter(([, state]) => state.isConnected) + .map(([provider]) => provider) + + // Load available providers on mount + useEffect(() => { + if (!open) return + + async function loadProviders() { + try { + setProvidersLoading(true) + const result = await window.ipc.invoke('oauth:list-providers', null) + setProviders(result.providers || []) + } catch (error) { + console.error('Failed to get available providers:', error) + setProviders([]) + } finally { + setProvidersLoading(false) + } + } + loadProviders() + }, [open]) + + // Load Granola config + const refreshGranolaConfig = useCallback(async () => { + try { + setGranolaLoading(true) + const result = await window.ipc.invoke('granola:getConfig', null) + setGranolaEnabled(result.enabled) + } catch (error) { + console.error('Failed to load Granola config:', error) + setGranolaEnabled(false) + } finally { + setGranolaLoading(false) + } + }, []) + + // Update Granola config + const handleGranolaToggle = useCallback(async (enabled: boolean) => { + try { + setGranolaLoading(true) + await window.ipc.invoke('granola:setConfig', { enabled }) + setGranolaEnabled(enabled) + toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') + } catch (error) { + console.error('Failed to update Granola config:', error) + toast.error('Failed to update Granola sync settings') + } finally { + setGranolaLoading(false) + } + }, []) + + // Check connection status for all providers + const refreshAllStatuses = useCallback(async () => { + // Refresh Granola + refreshGranolaConfig() + + // Refresh OAuth providers + if (providers.length === 0) return + + const newStates: Record = {} + + await Promise.all( + providers.map(async (provider) => { + try { + const result = await window.ipc.invoke('oauth:is-connected', { provider }) + newStates[provider] = { + isConnected: result.isConnected, + isLoading: false, + isConnecting: false, + } + } catch (error) { + console.error(`Failed to check connection status for ${provider}:`, error) + newStates[provider] = { + isConnected: false, + isLoading: false, + isConnecting: false, + } + } + }) + ) + + setProviderStates(newStates) + }, [providers, refreshGranolaConfig]) + + // Refresh statuses when modal opens or providers list changes + useEffect(() => { + if (open && providers.length > 0) { + refreshAllStatuses() + } + }, [open, providers, refreshAllStatuses]) + + // Listen for OAuth completion events + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + const { provider, success, error } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + + if (success) { + const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) + toast.success(`Connected to ${displayName}`) + } else { + toast.error(error || `Failed to connect to ${provider}`) + } + }) + + return cleanup + }, []) + + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: true } + })) + + try { + const result = await window.ipc.invoke('oauth:connect', { provider }) + + if (!result.success) { + toast.error(result.error || `Failed to connect to ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + } catch (error) { + console.error('Failed to connect:', error) + toast.error(`Failed to connect to ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + }, []) + + const handleNext = () => { + if (currentStep < 2) { + setCurrentStep((prev) => (prev + 1) as Step) + } + } + + const handleComplete = () => { + onComplete() + } + + // Step indicator component + const StepIndicator = () => ( +
+ {[0, 1, 2].map((step) => ( +
= step ? "bg-primary" : "bg-muted" + )} + /> + ))} +
+ ) + + // Helper to render an OAuth provider row + const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { + const state = providerStates[provider] || { + isConnected: false, + isLoading: true, + isConnecting: false, + } + + return ( +
+
+
+ {icon} +
+
+ {displayName} + {state.isLoading ? ( + Checking... + ) : ( + {description} + )} +
+
+
+ {state.isLoading ? ( + + ) : state.isConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + } + + // Render Granola row + const renderGranolaRow = () => ( +
+
+
+ +
+
+ Granola + + Local meeting notes + +
+
+
+ {granolaLoading && ( + + )} + +
+
+ ) + + // Step 0: Welcome + const WelcomeStep = () => ( +
+
+ +
+ + Your AI coworker, with memory + + Rowboat connects to your email, calendar, and meetings to help you stay on top of your work. + + +
+
+
1
+

Syncs with your email, calendar, and meetings

+
+
+
2
+

Remembers the people and context from your conversations

+
+
+
3
+

Helps you follow up and never miss what matters

+
+
+ +
+ ) + + // Step 1: Connect Accounts + const AccountConnectionStep = () => ( +
+ + Connect Your Accounts + + Connect your accounts to start syncing your data. You can always add more later. + + + +
+ {providersLoading ? ( +
+ +
+ ) : ( + <> + {/* Email & Calendar Section */} + {providers.includes('google') && ( +
+
+ Email & Calendar +
+ {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar events')} +
+ )} + + {/* Meeting Notes Section */} +
+
+ Meeting Notes +
+ {renderGranolaRow()} + {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} +
+ + )} +
+ +
+ + +
+
+ ) + + // Step 2: Completion + const CompletionStep = () => { + const hasConnections = connectedProviders.length > 0 || granolaEnabled + + return ( +
+
+ +
+ + You're All Set! + + {hasConnections ? ( + <>Your workspace will populate over the next ~30 minutes as we sync your data. + ) : ( + <>You can connect your accounts anytime from the sidebar to start syncing data. + )} + + + + {hasConnections && ( +
+
+

Connected accounts:

+
+ {connectedProviders.includes('google') && ( +
+ + Google (Email & Calendar) +
+ )} + {connectedProviders.includes('fireflies-ai') && ( +
+ + Fireflies (Meeting transcripts) +
+ )} + {granolaEnabled && ( +
+ + Granola (Local meeting notes) +
+ )} +
+
+
+ )} + + +
+ ) + } + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + {currentStep === 0 && } + {currentStep === 1 && } + {currentStep === 2 && } + + + ) +} diff --git a/apps/x/packages/core/src/config/note_creation_config.ts b/apps/x/packages/core/src/config/note_creation_config.ts index 1fd7968d..da4b3d02 100644 --- a/apps/x/packages/core/src/config/note_creation_config.ts +++ b/apps/x/packages/core/src/config/note_creation_config.ts @@ -7,6 +7,7 @@ export type NoteCreationStrictness = 'low' | 'medium' | 'high'; interface NoteCreationConfig { strictness: NoteCreationStrictness; configured: boolean; + onboardingComplete?: boolean; } const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json'); @@ -92,3 +93,44 @@ export function getNoteCreationAgentSuffix(): string { const strictness = getNoteCreationStrictness(); return `note_creation_${strictness}`; } + +/** + * Check if onboarding has been completed. + */ +export function isOnboardingComplete(): boolean { + try { + if (!fs.existsSync(CONFIG_FILE)) { + return false; + } + const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); + const config = JSON.parse(raw); + return config.onboardingComplete === true; + } catch { + return false; + } +} + +/** + * Mark onboarding as complete. + */ +export function markOnboardingComplete(): void { + const configDir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + let config: NoteCreationConfig; + try { + if (fs.existsSync(CONFIG_FILE)) { + const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); + config = JSON.parse(raw); + } else { + config = { strictness: DEFAULT_STRICTNESS, configured: false }; + } + } catch { + config = { strictness: DEFAULT_STRICTNESS, configured: false }; + } + + config.onboardingComplete = true; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 8e3d2c1f..93b797a9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -231,6 +231,18 @@ const ipcSchemas = { success: z.literal(true), }), }, + 'onboarding:getStatus': { + req: z.null(), + res: z.object({ + showOnboarding: z.boolean(), + }), + }, + 'onboarding:markComplete': { + req: z.null(), + res: z.object({ + success: z.literal(true), + }), + }, } as const; // ============================================================================