From 86818e7d217836646149162f5330380f1cd4d2aa Mon Sep 17 00:00:00 2001 From: Tushar <47842976+tusharmagar@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:04:57 +0530 Subject: [PATCH] Onboarding rebased (#426) * Enhance onboarding modal to support multiple paths (Rowboat and BYOK). v1 * new onboarding flow * Resolve stash merge conflicts: keep both inline-task and billing features Co-Authored-By: Claude Opus 4.6 * Refactor billing information structure and API integration * onboarding ui refactor * Update import path for getAccessToken in billing.ts to reflect new directory structure * Implement Gmail integration with Composio, enhancing onboarding flow to support Gmail connection status and API key management. Update ConnectorsPopover and SettingsDialog components to reflect new functionality, including dynamic tab visibility based on Rowboat connection status. * use composio for calendar * Enhance onboarding flow to support Google Calendar integration with Composio. Add state management for Google Calendar connection status, loading states, and connection handling. Update UI components to reflect Google Calendar connectivity in onboarding steps. * Integrate Google Calendar sync functionality with Composio, enhancing the connection handling in composio-handler and oauth-handler. Update onboarding modal and connectors-popover to manage connection states and provide user feedback during the sync process. Implement Composio-based event syncing in sync_calendar.ts. * Maximize window on ready-to-show event in main.ts to improve user experience by preventing blank screen on launch. * Enhance WelcomeStep component in onboarding flow with new feature highlights and animations. Introduce icons for memory, connectivity, and privacy features. Update logo display with ambient glow effect and improve user feedback during connection states. * Refactor voice availability check in App component to trigger on OAuth state changes. Update SidebarContentPanel to enhance billing display with improved styling and clearer upgrade call-to-action. * Enhance OAuth event handling by notifying the renderer on provider disconnection. Update ConnectorsPopover to listen for OAuth state changes, and refine WelcomeStep component by removing feature highlights and adjusting layout for improved user experience. * Implement Rowboat model settings in the settings dialog, including loading and saving model configurations based on Rowboat connection status. Enhance chat input component to manage Rowboat connection state and update model loading logic accordingly. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com> --- apps/x/.claude/launch.json | 11 + apps/x/apps/main/src/composio-handler.ts | 15 + apps/x/apps/main/src/ipc.ts | 8 + apps/x/apps/main/src/main.ts | 1 + apps/x/apps/main/src/oauth-handler.ts | 6 + apps/x/apps/renderer/src/App.css | 62 ++ apps/x/apps/renderer/src/App.tsx | 14 +- .../components/chat-input-with-mentions.tsx | 163 ++-- .../src/components/connectors-popover.tsx | 189 ++++- .../src/components/google-client-id-modal.tsx | 65 +- .../src/components/onboarding-modal.tsx | 374 +++++++-- .../src/components/onboarding/index.tsx | 83 ++ .../components/onboarding/provider-icons.tsx | 107 +++ .../components/onboarding/step-indicator.tsx | 68 ++ .../onboarding/steps/completion-step.tsx | 154 ++++ .../steps/connect-accounts-step.tsx | 294 +++++++ .../onboarding/steps/llm-setup-step.tsx | 300 ++++++++ .../onboarding/steps/welcome-step.tsx | 124 +++ .../onboarding/use-onboarding-state.ts | 720 ++++++++++++++++++ .../src/components/settings-dialog.tsx | 146 +++- .../src/components/sidebar-content.tsx | 18 + apps/x/apps/renderer/src/hooks/useBilling.ts | 36 + apps/x/packages/core/src/billing/billing.ts | 39 + apps/x/packages/core/src/composio/client.ts | 10 + .../core/src/knowledge/sync_calendar.ts | 253 +++++- .../packages/core/src/knowledge/sync_gmail.ts | 6 +- apps/x/packages/shared/src/ipc.ts | 16 + 27 files changed, 3114 insertions(+), 168 deletions(-) create mode 100644 apps/x/.claude/launch.json create mode 100644 apps/x/apps/renderer/src/components/onboarding/index.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts create mode 100644 apps/x/apps/renderer/src/hooks/useBilling.ts create mode 100644 apps/x/packages/core/src/billing/billing.ts diff --git a/apps/x/.claude/launch.json b/apps/x/.claude/launch.json new file mode 100644 index 00000000..3ba43066 --- /dev/null +++ b/apps/x/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "renderer-dev", + "runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite", + "runtimeArgs": ["--port", "5173"], + "port": 5173 + } + ] +} diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index cace8c74..36470d3e 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -5,6 +5,7 @@ import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js'; import { z } from 'zod'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; +import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -145,7 +146,11 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ // Set up callback server let cleanupTimeout: NodeJS.Timeout; + let callbackHandled = false; const { server } = await createAuthServer(8081, async (_code, _state) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; // OAuth callback received - sync the account status try { const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); @@ -156,6 +161,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ if (toolkitSlug === 'gmail') { triggerGmailSync(); } + if (toolkitSlug === 'googlecalendar') { + triggerCalendarSync(); + } } else { emitComposioEvent({ toolkitSlug, @@ -277,6 +285,13 @@ export async function useComposioForGoogle(): Promise<{ enabled: boolean }> { return { enabled: await composioClient.useComposioForGoogle() }; } +/** + * Check if Composio should be used for Google Calendar + */ +export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> { + return { enabled: await composioClient.useComposioForGoogleCalendar() }; +} + /** * Execute a Composio action */ diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 35856e08..2b43983f 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -40,6 +40,7 @@ import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedu import { search } from '@x/core/dist/search/search.js'; import { versionHistory, voice } from '@x/core'; import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; +import { getBillingInfo } from '@x/core/dist/billing/billing.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -546,6 +547,9 @@ export function setupIpcHandlers() { 'composio:use-composio-for-google': async () => { return composioHandler.useComposioForGoogle(); }, + 'composio:use-composio-for-google-calendar': async () => { + return composioHandler.useComposioForGoogleCalendar(); + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); @@ -710,5 +714,9 @@ export function setupIpcHandlers() { 'voice:getDeepgramToken': async () => { return voice.getDeepgramToken(); }, + // Billing handler + 'billing:getInfo': async () => { + return await getBillingInfo(); + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 4dbe6117..579fdbfa 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -103,6 +103,7 @@ function createWindow() { // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { + win.maximize(); win.show(); }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 2efc77c2..b38a42d2 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -186,7 +186,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom }); // Create callback server + let callbackHandled = false; const { server } = await createAuthServer(8080, async (code, receivedState) => { + // Guard against duplicate callbacks (browser may send multiple requests) + if (callbackHandled) return; + callbackHandled = true; // Validate state if (receivedState !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); @@ -282,6 +286,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.delete(provider); + // Notify renderer so sidebar, voice, and billing re-check state + emitOAuthEvent({ provider, success: false }); return { success: true }; } catch (error) { console.error('OAuth disconnect failed:', error); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 991236ea..5c1eabb2 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -49,6 +49,15 @@ color: #888; } +/* Onboarding dot grid background */ +.onboarding-dot-grid { + background-image: radial-gradient(circle, oklch(0.5 0 0 / 0.08) 1px, transparent 1px); + background-size: 24px 24px; +} +.dark .onboarding-dot-grid { + background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -293,3 +302,56 @@ pointer-events: none; user-select: none; } + +/* Upgrade button: grainy gradient sweep on hover */ +.upgrade-btn { + position: relative; + overflow: hidden; + isolation: isolate; +} + +.upgrade-btn::before { + content: ''; + position: absolute; + inset: 0; + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), + linear-gradient( + 90deg, + transparent 0%, + rgba(168, 85, 247, 0.35) 20%, + rgba(236, 72, 153, 0.4) 40%, + rgba(251, 146, 60, 0.35) 60%, + rgba(168, 85, 247, 0.3) 80%, + transparent 100% + ); + background-size: 100px 100px, 100% 100%; + transform: translateX(-120%); + opacity: 0; + z-index: 1; + pointer-events: none; + border-radius: inherit; +} + +.upgrade-btn:hover::before { + animation: grain-sweep 2.4s ease-in-out infinite; +} + +@keyframes grain-sweep { + 0% { + opacity: 1; + transform: translateX(-120%); + } + 45% { + opacity: 1; + transform: translateX(120%); + } + 55% { + opacity: 1; + transform: translateX(120%); + } + 100% { + opacity: 1; + transform: translateX(-120%); + } +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fa929b8c..ae8d60c9 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -49,7 +49,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' -import { OnboardingModal } from '@/components/onboarding-modal' +import { OnboardingModal } from '@/components/onboarding' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { VersionHistoryPanel } from '@/components/version-history-panel' @@ -619,8 +619,8 @@ function App() { const voiceRef = useRef(voice) voiceRef.current = voice - // Check if voice is available on mount - useEffect(() => { + // Check if voice is available on mount and when OAuth state changes + const refreshVoiceAvailability = useCallback(() => { Promise.all([ window.ipc.invoke('voice:getConfig', null), window.ipc.invoke('oauth:getState', null), @@ -634,6 +634,14 @@ function App() { }) }, []) + useEffect(() => { + refreshVoiceAvailability() + const cleanup = window.ipc.on('oauth:didConnect', () => { + refreshVoiceAvailability() + }) + return cleanup + }, [refreshVoiceAvailability]) + const handleStartRecording = useCallback(() => { setIsRecording(true) isRecordingRef.current = true diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 057550b2..74a0b2ed 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -66,6 +66,7 @@ const providerDisplayNames: Record = { openrouter: 'OpenRouter', aigateway: 'AI Gateway', 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', } interface ConfiguredModel { @@ -156,51 +157,103 @@ function ChatInputInner({ const [activeModelKey, setActiveModelKey] = useState('') const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) + const [isRowboatConnected, setIsRowboatConnected] = useState(false) - // Load model config from disk (on mount and whenever tab becomes active) + // Check Rowboat sign-in state + useEffect(() => { + window.ipc.invoke('oauth:getState', null).then((result) => { + setIsRowboatConnected(result.config?.rowboat?.connected ?? false) + }).catch(() => setIsRowboatConnected(false)) + }, [isActive]) + + // Update sign-in state when OAuth events fire + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', () => { + window.ipc.invoke('oauth:getState', null).then((result) => { + setIsRowboatConnected(result.config?.rowboat?.connected ?? false) + }).catch(() => setIsRowboatConnected(false)) + }) + return cleanup + }, []) + + // Load model config (gateway when signed in, local config when BYOK) const loadModelConfig = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - const models: ConfiguredModel[] = [] - if (parsed?.providers) { - for (const [flavor, entry] of Object.entries(parsed.providers)) { - const e = entry as Record - const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] - const singleModel = typeof e.model === 'string' ? e.model : '' - const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] - for (const model of allModels) { - if (model) { - models.push({ - flavor, - model, - apiKey: (e.apiKey as string) || undefined, - baseURL: (e.baseURL as string) || undefined, - headers: (e.headers as Record) || undefined, - knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, - }) + if (isRowboatConnected) { + // Fetch gateway models + const listResult = await window.ipc.invoke('models:list', null) + const rowboatProvider = listResult.providers?.find( + (p: { id: string }) => p.id === 'rowboat' + ) + const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( + (m: { id: string }) => ({ flavor: 'rowboat', model: m.id }) + ) + + // Read current default from config + let defaultModel = '' + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + defaultModel = parsed?.model || '' + } catch { /* no config yet */ } + + if (defaultModel) { + models.sort((a, b) => { + if (a.model === defaultModel) return -1 + if (b.model === defaultModel) return 1 + return 0 + }) + } + + setConfiguredModels(models) + const activeKey = defaultModel + ? `rowboat/${defaultModel}` + : models[0] ? `rowboat/${models[0].model}` : '' + if (activeKey) setActiveModelKey(activeKey) + } else { + // BYOK: read from local models.json + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + const models: ConfiguredModel[] = [] + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers)) { + const e = entry as Record + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] + const singleModel = typeof e.model === 'string' ? e.model : '' + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] + for (const model of allModels) { + if (model) { + models.push({ + flavor, + model, + apiKey: (e.apiKey as string) || undefined, + baseURL: (e.baseURL as string) || undefined, + headers: (e.headers as Record) || undefined, + knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, + }) + } } } } - } - const defaultKey = parsed?.provider?.flavor && parsed?.model - ? `${parsed.provider.flavor}/${parsed.model}` - : '' - models.sort((a, b) => { - const aKey = `${a.flavor}/${a.model}` - const bKey = `${b.flavor}/${b.model}` - if (aKey === defaultKey) return -1 - if (bKey === defaultKey) return 1 - return 0 - }) - setConfiguredModels(models) - if (defaultKey) { - setActiveModelKey(defaultKey) + const defaultKey = parsed?.provider?.flavor && parsed?.model + ? `${parsed.provider.flavor}/${parsed.model}` + : '' + models.sort((a, b) => { + const aKey = `${a.flavor}/${a.model}` + const bKey = `${b.flavor}/${b.model}` + if (aKey === defaultKey) return -1 + if (bKey === defaultKey) return 1 + return 0 + }) + setConfiguredModels(models) + if (defaultKey) { + setActiveModelKey(defaultKey) + } } } catch { // No config yet } - }, []) + }, [isRowboatConnected]) useEffect(() => { loadModelConfig() @@ -238,22 +291,32 @@ function ChatInputInner({ const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) if (!entry) return setActiveModelKey(key) - // Collect all models for this provider so the full list is preserved - const providerModels = configuredModels - .filter((m) => m.flavor === entry.flavor) - .map((m) => m.model) + try { - await window.ipc.invoke('models:saveConfig', { - provider: { - flavor: entry.flavor, - apiKey: entry.apiKey, - baseURL: entry.baseURL, - headers: entry.headers, - }, - model: entry.model, - models: providerModels, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) + if (entry.flavor === 'rowboat') { + // Gateway model — save with valid Zod flavor, no credentials + await window.ipc.invoke('models:saveConfig', { + provider: { flavor: 'openrouter' as const }, + model: entry.model, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } else { + // BYOK — preserve full provider config + const providerModels = configuredModels + .filter((m) => m.flavor === entry.flavor) + .map((m) => m.model) + await window.ipc.invoke('models:saveConfig', { + provider: { + flavor: entry.flavor, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }, + model: entry.model, + models: providerModels, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } } catch { toast.error('Failed to switch model') } diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 268fcfe7..254a4331 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { AlertTriangle, Loader2, Mic, Mail, MessageSquare, User } from "lucide-react" +import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react" import { Popover, @@ -75,7 +75,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) - // Load available providers and composio-for-google flag on mount + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + + // Load available providers on mount useEffect(() => { async function loadProviders() { try { @@ -89,6 +95,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha setProvidersLoading(false) } } + loadProviders() + }, []) + + // Re-check composio-for-google flag every time the popover opens + useEffect(() => { + if (!open) return async function loadComposioForGoogleFlag() { try { const result = await window.ipc.invoke('composio:use-composio-for-google', null) @@ -97,9 +109,17 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha console.error('Failed to check composio-for-google flag:', error) } } - loadProviders() + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } loadComposioForGoogleFlag() - }, []) + loadComposioForGoogleCalendarFlag() + }, [open]) // Load Granola config const refreshGranolaConfig = useCallback(async () => { @@ -184,6 +204,20 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, []) + // Load Google Calendar connection status + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + // Connect to Gmail via Composio const startGmailConnect = useCallback(async () => { try { @@ -212,6 +246,52 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha await startGmailConnect() }, [startGmailConnect]) + // Connect to Google Calendar via Composio + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + // Handle Google Calendar connect button click + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + + // Disconnect from Google Calendar + const handleDisconnectGoogleCalendar = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' }) + if (result.success) { + setGoogleCalendarConnected(false) + toast.success('Disconnected from Google Calendar') + } else { + toast.error('Failed to disconnect from Google Calendar') + } + } catch (error) { + console.error('Failed to disconnect from Google Calendar:', error) + toast.error('Failed to disconnect from Google Calendar') + } finally { + setGoogleCalendarLoading(false) + } + }, []) + // Disconnect from Gmail const handleDisconnectGmail = useCallback(async () => { try { @@ -292,6 +372,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha refreshGmailStatus() } + // Refresh Google Calendar Composio status if enabled + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + // Refresh OAuth providers if (providers.length === 0) return @@ -328,7 +413,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -337,11 +422,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, [open, providers, refreshAllStatuses]) - // Listen for OAuth completion events + // Listen for OAuth state change events (connect + disconnect) useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - const { provider, success, error } = event - + const cleanup = window.ipc.on('oauth:didConnect', async (event) => { + const { provider, success } = event + setProviderStates(prev => ({ ...prev, [provider]: { @@ -362,17 +447,32 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } else { toast.success(`Connected to ${displayName}`) } + + // When Rowboat account connects, re-check composio flags so Gmail/Calendar use the right flow + if (provider === 'rowboat') { + try { + const [googleResult, calendarResult] = await Promise.all([ + window.ipc.invoke('composio:use-composio-for-google', null), + window.ipc.invoke('composio:use-composio-for-google-calendar', null), + ]) + setUseComposioForGoogle(googleResult.enabled) + setUseComposioForGoogleCalendar(calendarResult.enabled) + } catch (err) { + console.error('Failed to re-check composio flags:', err) + } + } + // Refresh status to ensure consistency refreshAllStatuses() - } else { - toast.error(error || `Failed to connect to ${provider}`) } + // Note: error toasts for failed connections are handled by startConnect/handleConnect. + // Disconnect events (success: false) are handled by handleDisconnect which shows its own toast. }) return cleanup }, [refreshAllStatuses]) - // Listen for Composio connection events (Gmail) + // Listen for Composio connection events (Gmail, Google Calendar) useEffect(() => { const cleanup = window.ipc.on('composio:didConnect', (event) => { const { toolkitSlug, success, error } = event @@ -390,6 +490,20 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha toast.error(error || 'Failed to connect to Gmail') } } + + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) + + if (success) { + toast.success('Connected to Google Calendar', { + description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Google Calendar') + } + } }) return cleanup @@ -640,11 +754,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha )} {/* Email & Calendar Section */} - {(useComposioForGoogle || providers.includes('google')) && ( + {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( <>
- {useComposioForGoogle ? 'Email' : 'Email & Calendar'} + Email & Calendar
{useComposioForGoogle ? ( @@ -696,6 +810,53 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) : ( renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') )} + {useComposioForGoogleCalendar && ( +
+
+
+ +
+
+ Google Calendar + {googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
+
+
+ {googleCalendarLoading ? ( + + ) : googleCalendarConnected ? ( + + ) : ( + + )} +
+
+ )} )} diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx index c4df07a2..3ef536d9 100644 --- a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -47,19 +47,37 @@ export function GoogleClientIdModal({ return ( - - - Enter Google Client ID - - {description ?? "Enter the client ID for your Google OAuth app to continue."} - - -
- -
- Need help setting this up?{" "} + +
+ + Google Client ID + + {description ?? "Enter the client ID for your Google OAuth app to connect."} + + +
+
+
+ + setClientId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + className="font-mono text-xs" + autoFocus + /> +
+

+ Need help?{" "} Read the setup guide - . -

- setClientId(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - handleSubmit() - } - }} - autoFocus - /> +

-
+
-
diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 9060a669..d687d81a 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, Calendar, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react" import { Dialog, @@ -38,7 +38,9 @@ interface OnboardingModalProps { onComplete: () => void } -type Step = 0 | 1 | 2 +type Step = 0 | 1 | 2 | 3 | 4 + +type OnboardingPath = 'rowboat' | 'byok' | null type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" @@ -50,6 +52,7 @@ interface LlmModelOption { export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [currentStep, setCurrentStep] = useState(0) + const [onboardingPath, setOnboardingPath] = useState(null) // LLM setup state const [llmProvider, setLlmProvider] = useState("openai") @@ -99,6 +102,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ @@ -150,8 +159,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { console.error('Failed to check composio-for-google flag:', error) } } + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } loadProviders() loadComposioForGoogleFlag() + loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -288,6 +306,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Google Calendar connection status + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + // Connect to Gmail via Composio const startGmailConnect = useCallback(async () => { try { @@ -315,6 +347,33 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { await startGmailConnect() }, [startGmailConnect]) + // Connect to Google Calendar via Composio + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + // Handle Google Calendar connect button click + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + // Handle Composio API key submission const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { try { @@ -364,11 +423,29 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, []) const handleNext = () => { - if (currentStep < 2) { + if (currentStep < 4) { setCurrentStep((prev) => (prev + 1) as Step) } } + const handleBack = () => { + if (currentStep === 1) { + // BYOK upsell → back to sign-in page + setOnboardingPath(null) + setCurrentStep(0 as Step) + } else if (currentStep === 2) { + // LLM setup → back to BYOK upsell + setCurrentStep(1 as Step) + } else if (currentStep === 3) { + // Connect accounts → back depends on path + if (onboardingPath === 'rowboat') { + setCurrentStep(0 as Step) + } else { + setCurrentStep(2 as Step) + } + } + } + const handleComplete = () => { onComplete() } @@ -420,6 +497,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { refreshGmailStatus() } + // Refresh Google Calendar Composio status if enabled + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + // Refresh OAuth providers if (providers.length === 0) return @@ -447,7 +529,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle]) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -456,10 +538,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, [open, providers, refreshAllStatuses]) - // Listen for OAuth completion events + // Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover) useEffect(() => { const cleanup = window.ipc.on('oauth:didConnect', (event) => { - const { provider, success, error } = event + const { provider, success } = event setProviderStates(prev => ({ ...prev, @@ -469,35 +551,37 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { 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 }, []) - // Listen for Composio connection events (Gmail) + // Auto-advance from Rowboat sign-in step when OAuth completes + useEffect(() => { + if (onboardingPath !== 'rowboat' || currentStep !== 0) return + + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' && event.success) { + setCurrentStep(3 as Step) + } + }) + + return cleanup + }, [onboardingPath, currentStep]) + + // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover) useEffect(() => { const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event + const { toolkitSlug, success } = event if (toolkitSlug === 'gmail') { setGmailConnected(success) setGmailConnecting(false) + } - if (success) { - toast.success('Connected to Gmail', { - description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Gmail') - } + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) } }) @@ -552,20 +636,30 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { startConnect('google', clientId) }, [startConnect]) - // Step indicator - const renderStepIndicator = () => ( -
- {[0, 1, 2].map((step) => ( -
= step ? "bg-primary" : "bg-muted" - )} - /> - ))} -
- ) + // Step indicator - dynamic based on path + const renderStepIndicator = () => { + // Rowboat path: Sign In (0), Connect (3), Done (4) = 3 dots + // BYOK path: Sign In (0), Upsell (1), Model (2), Connect (3), Done (4) = 5 dots + // Before path is chosen: show 3 dots (minimal) + const rowboatSteps = [0, 3, 4] + const byokSteps = [0, 1, 2, 3, 4] + const steps = onboardingPath === 'byok' ? byokSteps : rowboatSteps + const currentIndex = steps.indexOf(currentStep) + + return ( +
+ {steps.map((_, i) => ( +
= i ? "bg-primary" : "bg-muted" + )} + /> + ))} +
+ ) + } // Helper to render an OAuth provider row const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { @@ -691,6 +785,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) + // Render Google Calendar Composio row + const renderGoogleCalendarRow = () => ( +
+
+
+ +
+
+ Google Calendar + {googleCalendarLoading ? ( + Checking... + ) : ( + + Sync calendar events + + )} +
+
+
+ {googleCalendarLoading ? ( + + ) : googleCalendarConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Render Slack row const renderSlackRow = () => (
@@ -772,7 +910,123 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 0: LLM Setup + // Step 0: Sign in to Rowboat (with BYOK option) + const renderSignInStep = () => { + const rowboatState = providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false } + + return ( +
+
+ Your AI coworker, with memory +
+ + Sign in to Rowboat + + Connect your Rowboat account for instant access to all models through our gateway — no API keys needed. + + + + {rowboatState.isConnected ? ( +
+
+ + Connected to Rowboat +
+ +
+ ) : ( +
+ + {rowboatState.isConnecting && ( +

+ Complete sign in in your browser, then return here. +

+ )} +
+ )} + +
+ +
+
+ ) + } + + // Step 1: BYOK upsell — explain benefits of Rowboat before continuing with BYOK + const renderByokUpsellStep = () => ( +
+ + Before you continue + + With a Rowboat account, you get: + + + +
+
+ +
+
Instant access to all models
+
GPT, Claude, Gemini, and more — no separate API keys needed
+
+
+
+ +
+
Simplified billing
+
One account for everything — no juggling multiple provider subscriptions
+
+
+
+ +
+
Automatic updates
+
New models are available as soon as they launch, with no configuration changes
+
+
+
+ +

+ By continuing, you'll set up your own API keys instead of using Rowboat's managed gateway. +

+ +
+ + +
+
+ ) + + // Step 2 (BYOK path): LLM Setup const renderLlmSetupStep = () => { const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ { id: "openai", name: "OpenAI", description: "Use your OpenAI API key" }, @@ -948,10 +1202,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)} -
+
+ - +
+ + +
) - // Step 2: Completion + // Step 4: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected return (
@@ -1059,6 +1323,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Gmail (Email)
)} + {googleCalendarConnected && ( +
+ + Google Calendar +
+ )} {connectedProviders.includes('google') && (
@@ -1117,9 +1387,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onEscapeKeyDown={(e) => e.preventDefault()} > {renderStepIndicator()} - {currentStep === 0 && renderLlmSetupStep()} - {currentStep === 1 && renderAccountConnectionStep()} - {currentStep === 2 && renderCompletionStep()} + {currentStep === 0 && renderSignInStep()} + {currentStep === 1 && renderByokUpsellStep()} + {currentStep === 2 && renderLlmSetupStep()} + {currentStep === 3 && renderAccountConnectionStep()} + {currentStep === 4 && renderCompletionStep()}
diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx new file mode 100644 index 00000000..d37cdf0f --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -0,0 +1,83 @@ +"use client" + +import * as React from "react" +import { AnimatePresence, motion } from "motion/react" + +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { useOnboardingState } from "./use-onboarding-state" +import { StepIndicator } from "./step-indicator" +import { WelcomeStep } from "./steps/welcome-step" +import { LlmSetupStep } from "./steps/llm-setup-step" +import { ConnectAccountsStep } from "./steps/connect-accounts-step" +import { CompletionStep } from "./steps/completion-step" + +interface OnboardingModalProps { + open: boolean + onComplete: () => void +} + +export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { + const state = useOnboardingState(open, onComplete) + + const stepContent = React.useMemo(() => { + switch (state.currentStep) { + case 0: + return + case 1: + return + case 2: + return + case 3: + return + } + }, [state.currentStep, state]) + + return ( + <> + + + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > +
+ + + + {stepContent} + + +
+
+
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx new file mode 100644 index 00000000..c58c7bf0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx @@ -0,0 +1,107 @@ +import { cn } from "@/lib/utils" + +interface IconProps { + className?: string +} + +export function OpenAIIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function AnthropicIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function GoogleIcon({ className }: IconProps) { + return ( + + + + + + + ) +} + +export function OllamaIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function OpenRouterIcon({ className }: IconProps) { + return ( + + + + + ) +} + +export function VercelIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function GmailIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function SlackIcon({ className }: IconProps) { + return ( + + + + + + + ) +} + +export function FirefliesIcon({ className }: IconProps) { + return ( + + + + + + + + + + + ) +} + +export function GranolaIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function GenericApiIcon({ className }: IconProps) { + return ( + + + + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx new file mode 100644 index 00000000..6fae6dbb --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx @@ -0,0 +1,68 @@ +import * as React from "react" +import { CheckCircle2 } from "lucide-react" +import { cn } from "@/lib/utils" +import type { Step, OnboardingPath } from "./use-onboarding-state" + +const ROWBOAT_STEPS = [ + { step: 0 as Step, label: "Welcome" }, + { step: 2 as Step, label: "Connect" }, + { step: 3 as Step, label: "Done" }, +] + +const BYOK_STEPS = [ + { step: 0 as Step, label: "Welcome" }, + { step: 1 as Step, label: "Model" }, + { step: 2 as Step, label: "Connect" }, + { step: 3 as Step, label: "Done" }, +] + +interface StepIndicatorProps { + currentStep: Step + path: OnboardingPath +} + +export function StepIndicator({ currentStep, path }: StepIndicatorProps) { + const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS + const currentIndex = steps.findIndex(s => s.step === currentStep) + + return ( +
+ {steps.map((s, i) => ( + + {i > 0 && ( +
+ )} +
+
currentIndex && "bg-muted text-muted-foreground" + )} + > + {i < currentIndex ? ( + + ) : ( + i + 1 + )} +
+ + {s.label} + +
+ + ))} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx new file mode 100644 index 00000000..c01e42ea --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx @@ -0,0 +1,154 @@ +import { CheckCircle2 } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import type { OnboardingState } from "../use-onboarding-state" + +interface CompletionStepProps { + state: OnboardingState +} + +export function CompletionStep({ state }: CompletionStepProps) { + const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, googleCalendarConnected, handleComplete } = state + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected + + return ( +
+ {/* Animated checkmark */} + + {/* Pulsing ring */} + +
+ +
+
+ + {/* Title */} + + You're All Set! + + + + {hasConnections ? ( + <>Give me 30 minutes to build your context graph. I can still help with other things on your computer. + ) : ( + <>You can connect your accounts anytime from the sidebar to start syncing data. + )} + + + {/* Connected accounts summary */} + {hasConnections && ( + +

Connected

+
+ {gmailConnected && ( + + + Gmail (Email) + + )} + {googleCalendarConnected && ( + + + Google Calendar + + )} + {connectedProviders.includes('google') && ( + + + Google (Email & Calendar) + + )} + {connectedProviders.includes('fireflies-ai') && ( + + + Fireflies (Meeting transcripts) + + )} + {granolaEnabled && ( + + + Granola (Local meeting notes) + + )} + {slackEnabled && ( + + + Slack (Team communication) + + )} +
+
+ )} + + {/* CTA */} + + + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx new file mode 100644 index 00000000..b152d567 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx @@ -0,0 +1,294 @@ +import { Loader2, CheckCircle2, ArrowLeft, Calendar } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { GmailIcon, SlackIcon, FirefliesIcon, GranolaIcon } from "../provider-icons" +import type { OnboardingState, ProviderState } from "../use-onboarding-state" + +interface ConnectAccountsStepProps { + state: OnboardingState +} + +function ProviderCard({ + name, + description, + icon, + iconBg, + iconColor, + providerState, + onConnect, + rightSlot, + index, +}: { + name: string + description: string + icon: React.ReactNode + iconBg: string + iconColor: string + providerState?: ProviderState + onConnect?: () => void + rightSlot?: React.ReactNode + index: number +}) { + const isConnected = providerState?.isConnected ?? false + + return ( + +
+
+ {icon} +
+
+
{name}
+
{description}
+
+
+
+ {rightSlot ?? ( + providerState?.isLoading ? ( + + ) : isConnected ? ( +
+ + Connected +
+ ) : ( + + ) + )} +
+
+ ) +} + +export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) { + const { + providers, providersLoading, providerStates, handleConnect, + granolaEnabled, granolaLoading, handleGranolaToggle, + slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces, + slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen, + slackDiscovering, slackDiscoverError, + handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable, + useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail, + useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar, + handleNext, handleBack, + } = state + + let cardIndex = 0 + + return ( +
+ {/* Title */} +

+ Connect Your Accounts +

+

+ Connect your accounts to give Rowboat context about your work. You can always add more later. +

+ + {providersLoading ? ( +
+ +
+ ) : ( +
+ {/* Email & Calendar */} + {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( +
+ + Email & Calendar + + {useComposioForGoogle ? ( + } + iconBg="bg-red-500/10" + iconColor="text-red-500" + providerState={{ isConnected: gmailConnected, isLoading: gmailLoading, isConnecting: gmailConnecting }} + onConnect={handleConnectGmail} + index={cardIndex++} + /> + ) : ( + } + iconBg="bg-red-500/10" + iconColor="text-red-500" + providerState={providerStates['google']} + onConnect={() => handleConnect('google')} + index={cardIndex++} + /> + )} + {useComposioForGoogleCalendar && ( + } + iconBg="bg-blue-500/10" + iconColor="text-blue-500" + providerState={{ isConnected: googleCalendarConnected, isLoading: googleCalendarLoading, isConnecting: googleCalendarConnecting }} + onConnect={handleConnectGoogleCalendar} + index={cardIndex++} + /> + )} +
+ )} + + {/* Meeting Notes */} +
+ + Meeting Notes + + } + iconBg="bg-purple-500/10" + iconColor="text-purple-500" + providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }} + rightSlot={ +
+ {granolaLoading && } + +
+ } + index={cardIndex++} + /> + {providers.includes('fireflies-ai') && ( + } + iconBg="bg-amber-500/10" + iconColor="text-amber-500" + providerState={providerStates['fireflies-ai']} + onConnect={() => handleConnect('fireflies-ai')} + index={cardIndex++} + /> + )} +
+ + {/* Team Communication */} +
+ + Team Communication + +
+ 0 + ? slackWorkspaces.map(w => w.name).join(', ') + : "Enable Rowboat to understand your team conversations and provide relevant context" + } + icon={} + iconBg="bg-emerald-500/10" + iconColor="text-emerald-500" + providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }} + rightSlot={ +
+ {(slackLoading || slackDiscovering) && } + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> + ) : ( + + )} +
+ } + index={cardIndex++} + /> + {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )} +
+
+
+ )} + + {/* Footer */} +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx new file mode 100644 index 00000000..31a7308a --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -0,0 +1,300 @@ +import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { + OpenAIIcon, + AnthropicIcon, + GoogleIcon, + OllamaIcon, + OpenRouterIcon, + VercelIcon, + GenericApiIcon, +} from "../provider-icons" +import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state" + +interface LlmSetupStepProps { + state: OnboardingState +} + +const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [ + { id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: }, + { id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: }, + { id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: }, + { id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: }, +] + +const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [ + { id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: }, + { id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: }, + { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: }, +] + +export function LlmSetupStep({ state }: LlmSetupStepProps) { + const { + llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError, + activeConfig, testState, setTestState, showApiKey, requiresApiKey, + showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders, + updateProviderConfig, handleTestAndSaveLlmConfig, handleBack, + upsellDismissed, setUpsellDismissed, handleSwitchToRowboat, + } = state + + const isMoreProvider = moreProviders.some(p => p.id === llmProvider) + const modelsForProvider = modelsCatalog[llmProvider] || [] + const showModelInput = isLocalProvider || modelsForProvider.length === 0 + + const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => { + const isSelected = llmProvider === provider.id + return ( + { + setLlmProvider(provider.id) + setTestState({ status: "idle" }) + }} + className={cn( + "rounded-xl border-2 p-4 text-left transition-all", + isSelected + ? "border-primary bg-primary/5 shadow-sm" + : "border-transparent bg-muted/50 hover:bg-muted" + )} + > +
+
+ {provider.icon} +
+
+
{provider.name}
+
{provider.description}
+
+
+
+ ) + } + + return ( +
+ {/* Title */} +

+ Choose your model +

+

+ Select a provider and configure your API key +

+ + {/* Inline Rowboat upsell callout */} + {!upsellDismissed && ( + + +
+

+ Tip: Sign in with Rowboat for instant access to all models — no API keys needed. +

+ +
+ +
+ )} + + {/* Provider selection */} +
+ Provider +
+ {primaryProviders.map((p, i) => renderProviderCard(p, i))} +
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map((p, i) => renderProviderCard(p, i + 4))} +
+ ) : ( + + )} +
+ + {/* Separator */} +
+ + {/* Model configuration */} +
+

Model Configuration

+ +
+
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
+
+ + {showApiKey && ( +
+ + updateProviderConfig(llmProvider, { apiKey: e.target.value })} + placeholder="Paste your API key" + className="font-mono" + /> +
+ )} + + {showBaseURL && ( +
+ + updateProviderConfig(llmProvider, { baseURL: e.target.value })} + placeholder={ + llmProvider === "ollama" + ? "http://localhost:11434" + : llmProvider === "openai-compatible" + ? "http://localhost:1234/v1" + : "https://ai-gateway.vercel.sh/v1" + } + className="font-mono" + /> +
+ )} +
+ + {/* Footer */} +
+ + +
+ {testState.status === "success" && ( + + + Connected + + )} + {testState.status === "error" && ( + + {testState.error} + + )} + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx new file mode 100644 index 00000000..9a660507 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx @@ -0,0 +1,124 @@ +import { Loader2, CheckCircle2 } from "lucide-react" +import { motion } from "motion/react" +import { Button } from "@/components/ui/button" +import type { OnboardingState } from "../use-onboarding-state" + +interface WelcomeStepProps { + state: OnboardingState +} + +export function WelcomeStep({ state }: WelcomeStepProps) { + const rowboatState = state.providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false } + + return ( +
+ {/* Logo with ambient glow */} + +
+ Rowboat + + + {/* Tagline badge */} + + + Your AI coworker, with memory + + + {/* Main heading */} + + Welcome to Rowboat + + + Rowboat connects to your work, builds a knowledge graph, and uses that context to help you get things done. Private and on your machine. + + + {/* Sign in / connected state */} + + {rowboatState.isConnected ? ( +
+
+ + Connected to Rowboat +
+ +
+ ) : ( +
+ + {rowboatState.isConnecting && ( +

+ Complete sign in in your browser, then return here. +

+ )} +
+ )} +
+ + {/* BYOK link */} + + + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts new file mode 100644 index 00000000..7cc50a90 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -0,0 +1,720 @@ +import { useState, useEffect, useCallback } from "react" +import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" +import { toast } from "sonner" + +export interface ProviderState { + isConnected: boolean + isLoading: boolean + isConnecting: boolean +} + +export type Step = 0 | 1 | 2 | 3 + +export type OnboardingPath = 'rowboat' | 'byok' | null + +export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" + +export interface LlmModelOption { + id: string + name?: string + release_date?: string +} + +export function useOnboardingState(open: boolean, onComplete: () => void) { + const [currentStep, setCurrentStep] = useState(0) + const [onboardingPath, setOnboardingPath] = useState(null) + + // LLM setup state + const [llmProvider, setLlmProvider] = useState("openai") + const [modelsCatalog, setModelsCatalog] = useState>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + }) + const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ + status: "idle", + }) + const [showMoreProviders, setShowMoreProviders] = useState(false) + + // OAuth provider states + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) + + // Granola state + const [granolaEnabled, setGranolaEnabled] = useState(false) + const [granolaLoading, setGranolaLoading] = useState(true) + + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) + + // Inline upsell callout dismissed + const [upsellDismissed, setUpsellDismissed] = useState(false) + + // Composio/Gmail state (used when signed in with Rowboat account) + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + + const updateProviderConfig = useCallback( + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { + setProviderConfigs(prev => ({ + ...prev, + [provider]: { ...prev[provider], ...updates }, + })) + setTestState({ status: "idle" }) + }, + [] + ) + + const activeConfig = providerConfigs[llmProvider] + const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible" + const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" + const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" + const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway" + const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible" + const canTest = + activeConfig.model.trim().length > 0 && + (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && + (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) + + // Track connected providers for the completion step + const connectedProviders = Object.entries(providerStates) + .filter(([, state]) => state.isConnected) + .map(([provider]) => provider) + + // Load available providers and composio-for-google flag 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) + } + } + async function loadComposioForGoogleFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google', null) + setUseComposioForGoogle(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google flag:', error) + } + } + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } + loadProviders() + loadComposioForGoogleFlag() + loadComposioForGoogleCalendarFlag() + }, [open]) + + // Load LLM models catalog on open + useEffect(() => { + if (!open) return + + async function loadModels() { + try { + setModelsLoading(true) + setModelsError(null) + const result = await window.ipc.invoke("models:list", null) + const catalog: Record = {} + for (const provider of result.providers || []) { + catalog[provider.id] = provider.models || [] + } + setModelsCatalog(catalog) + } catch (error) { + console.error("Failed to load models catalog:", error) + setModelsError("Failed to load models list") + setModelsCatalog({}) + } finally { + setModelsLoading(false) + } + } + + loadModels() + }, [open]) + + // Preferred default models for each provider + const preferredDefaults: Partial> = { + openai: "gpt-5.2", + anthropic: "claude-opus-4-6-20260202", + } + + // Initialize default models from catalog + useEffect(() => { + if (Object.keys(modelsCatalog).length === 0) return + setProviderConfigs(prev => { + const next = { ...prev } + const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + for (const provider of cloudProviders) { + const models = modelsCatalog[provider] + if (models?.length && !next[provider].model) { + const preferredModel = preferredDefaults[provider] + const hasPreferred = preferredModel && models.some(m => m.id === preferredModel) + next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") } + } + } + return next + }) + }, [modelsCatalog]) + + // 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) + } + }, []) + + // Load Slack config + const refreshSlackConfig = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) + } catch (error) { + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) + } finally { + setSlackLoading(false) + } + }, []) + + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) + try { + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) + } + } catch (error) { + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) + } + }, []) + + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) + + // Load Gmail connection status (Composio) + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + // Connect to Gmail via Composio + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + // Handle Gmail connect button click (checks Composio config first) + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + // Handle Composio API key submission + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + await startGmailConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startGmailConnect]) + + // Load Google Calendar connection status (Composio) + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + + // Connect to Google Calendar via Composio + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + // Handle Google Calendar connect button click + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + + // New step flow: + // Rowboat path: 0 (welcome) → 2 (connect) → 3 (done) + // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done) + const handleNext = useCallback(() => { + if (currentStep === 0) { + if (onboardingPath === 'byok') { + setCurrentStep(1) + } else { + setCurrentStep(2) + } + } else if (currentStep === 1) { + setCurrentStep(2) + } else if (currentStep === 2) { + setCurrentStep(3) + } + }, [currentStep, onboardingPath]) + + const handleBack = useCallback(() => { + if (currentStep === 1) { + setCurrentStep(0) + setOnboardingPath(null) + } else if (currentStep === 2) { + if (onboardingPath === 'rowboat') { + setCurrentStep(0) + } else { + setCurrentStep(1) + } + } + }, [currentStep, onboardingPath]) + + const handleComplete = useCallback(() => { + onComplete() + }, [onComplete]) + + const handleTestAndSaveLlmConfig = useCallback(async () => { + if (!canTest) return + setTestState({ status: "testing" }) + try { + const apiKey = activeConfig.apiKey.trim() || undefined + const baseURL = activeConfig.baseURL.trim() || undefined + const model = activeConfig.model.trim() + const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined + const providerConfig = { + provider: { + flavor: llmProvider, + apiKey, + baseURL, + }, + model, + knowledgeGraphModel, + } + const result = await window.ipc.invoke("models:test", providerConfig) + if (result.success) { + setTestState({ status: "success" }) + await window.ipc.invoke("models:saveConfig", providerConfig) + window.dispatchEvent(new Event('models-config-changed')) + handleNext() + } else { + setTestState({ status: "error", error: result.error }) + toast.error(result.error || "Connection test failed") + } + } catch (error) { + console.error("Connection test failed:", error) + setTestState({ status: "error", error: "Connection test failed" }) + toast.error("Connection test failed") + } + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext]) + + // Check connection status for all providers + const refreshAllStatuses = useCallback(async () => { + refreshGranolaConfig() + refreshSlackConfig() + + // Refresh Gmail Composio status if enabled + if (useComposioForGoogle) { + refreshGmailStatus() + } + + // Refresh Google Calendar Composio status if enabled + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + + if (providers.length === 0) return + + const newStates: Record = {} + + try { + const result = await window.ipc.invoke('oauth:getState', null) + const config = result.config || {} + for (const provider of providers) { + newStates[provider] = { + isConnected: config[provider]?.connected ?? false, + isLoading: false, + isConnecting: false, + } + } + } catch (error) { + console.error('Failed to check connection status for providers:', error) + for (const provider of providers) { + newStates[provider] = { + isConnected: false, + isLoading: false, + isConnecting: false, + } + } + } + + setProviderStates(newStates) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) + + // Refresh statuses when modal opens or providers list changes + useEffect(() => { + if (open && providers.length > 0) { + refreshAllStatuses() + } + }, [open, providers, refreshAllStatuses]) + + // Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover) + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + const { provider, success } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + }) + + return cleanup + }, []) + + // Auto-advance from Rowboat sign-in step when OAuth completes + useEffect(() => { + if (onboardingPath !== 'rowboat' || currentStep !== 0) return + + const cleanup = window.ipc.on('oauth:didConnect', async (event) => { + if (event.provider === 'rowboat' && event.success) { + // Re-check composio flags now that the account is connected + try { + const [googleResult, calendarResult] = await Promise.all([ + window.ipc.invoke('composio:use-composio-for-google', null), + window.ipc.invoke('composio:use-composio-for-google-calendar', null), + ]) + setUseComposioForGoogle(googleResult.enabled) + setUseComposioForGoogleCalendar(calendarResult.enabled) + } catch (error) { + console.error('Failed to re-check composio flags:', error) + } + setCurrentStep(2) // Go to Connect Accounts + } + }) + + return cleanup + }, [onboardingPath, currentStep]) + + // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover) + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success } = event + + if (toolkitSlug === 'slack') { + setSlackEnabled(success) + } + + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + } + + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) + } + }) + + return cleanup + }, []) + + const startConnect = useCallback(async (provider: string, clientId?: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: true } + })) + + try { + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + + 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 } + })) + } + }, []) + + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) + + // Switch to rowboat path from BYOK inline callout + const handleSwitchToRowboat = useCallback(() => { + setOnboardingPath('rowboat') + setCurrentStep(0) + }, []) + + return { + // Step state + currentStep, + setCurrentStep, + onboardingPath, + setOnboardingPath, + + // LLM state + llmProvider, + setLlmProvider, + modelsCatalog, + modelsLoading, + modelsError, + providerConfigs, + activeConfig, + testState, + setTestState, + showApiKey, + requiresApiKey, + requiresBaseURL, + showBaseURL, + isLocalProvider, + canTest, + showMoreProviders, + setShowMoreProviders, + updateProviderConfig, + handleTestAndSaveLlmConfig, + + // OAuth state + providers, + providersLoading, + providerStates, + googleClientIdOpen, + setGoogleClientIdOpen, + connectedProviders, + handleConnect, + handleGoogleClientIdSubmit, + startConnect, + + // Granola state + granolaEnabled, + granolaLoading, + handleGranolaToggle, + + // Slack state + slackEnabled, + slackLoading, + slackWorkspaces, + slackAvailableWorkspaces, + slackSelectedUrls, + setSlackSelectedUrls, + slackPickerOpen, + slackDiscovering, + slackDiscoverError, + handleSlackEnable, + handleSlackSaveWorkspaces, + handleSlackDisable, + + // Upsell + upsellDismissed, + setUpsellDismissed, + + // Composio/Gmail state + useComposioForGoogle, + gmailConnected, + gmailLoading, + gmailConnecting, + composioApiKeyOpen, + setComposioApiKeyOpen, + composioApiKeyTarget, + handleConnectGmail, + handleComposioApiKeySubmit, + + // Composio/Google Calendar state + useComposioForGoogleCalendar, + googleCalendarConnected, + googleCalendarLoading, + googleCalendarConnecting, + handleConnectGoogleCalendar, + + // Navigation + handleNext, + handleBack, + handleComplete, + handleSwitchToRowboat, + } +} + +export type OnboardingState = ReturnType diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 6d6dfbfc..65c5b424 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -693,6 +693,126 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Rowboat Model Settings (when signed in via Rowboat) --- + +function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [gatewayModels, setGatewayModels] = useState([]) + const [selectedModel, setSelectedModel] = useState("") + const [selectedKgModel, setSelectedKgModel] = useState("") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!dialogOpen) return + + async function load() { + setLoading(true) + try { + // Fetch gateway models + const listResult = await window.ipc.invoke("models:list", null) + const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat") + const models = rowboatProvider?.models || [] + setGatewayModels(models) + + // Read current selection from config + try { + const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) + const parsed = JSON.parse(configResult.data) + if (parsed?.model) setSelectedModel(parsed.model) + if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel) + } catch { + // No config yet — pick first model as default + if (models.length > 0) setSelectedModel(models[0].id) + } + } catch { + toast.error("Failed to load models") + } finally { + setLoading(false) + } + } + + load() + }, [dialogOpen]) + + const handleSave = useCallback(async () => { + if (!selectedModel) return + setSaving(true) + try { + await window.ipc.invoke("models:saveConfig", { + provider: { flavor: "openrouter" as const }, + model: selectedModel, + knowledgeGraphModel: selectedKgModel || undefined, + }) + window.dispatchEvent(new Event("models-config-changed")) + toast.success("Model configuration saved") + } catch { + toast.error("Failed to save model configuration") + } finally { + setSaving(false) + } + }, [selectedModel, selectedKgModel]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+

+ Select the models Rowboat uses. These are provided through your Rowboat account. +

+ + {/* Assistant model */} +
+ + +
+ + {/* Knowledge graph model */} +
+ + +
+ + {/* Save */} + +
+ ) +} + // --- Note Tagging Settings --- interface TagDef { @@ -1112,8 +1232,22 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [rowboatConnected, setRowboatConnected] = useState(false) - const activeTabConfig = tabs.find((t) => t.id === activeTab)! + // Check if user is signed in to Rowboat + useEffect(() => { + if (!open) return + window.ipc.invoke('oauth:getState', null).then((result) => { + const connected = result.config?.rowboat?.connected ?? false + setRowboatConnected(connected) + }).catch(() => { + setRowboatConnected(false) + }) + }, [open]) + + const visibleTabs = useMemo(() => tabs, []) + + const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0] const isJsonTab = activeTab === "mcp" || activeTab === "security" const formatJson = (jsonString: string): string => { @@ -1202,7 +1336,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

Settings

+
+ )} {/* Bottom actions */}
diff --git a/apps/x/apps/renderer/src/hooks/useBilling.ts b/apps/x/apps/renderer/src/hooks/useBilling.ts new file mode 100644 index 00000000..49459fdb --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useBilling.ts @@ -0,0 +1,36 @@ +import { useState, useEffect, useCallback } from 'react' + +interface BillingInfo { + subscriptionPlan: string + subscriptionStatus: string + sanctionedCredits: number + availableCredits: number +} + +export function useBilling(isRowboatConnected: boolean) { + const [billing, setBilling] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchBilling = useCallback(async () => { + if (!isRowboatConnected) { + setBilling(null) + return + } + try { + setIsLoading(true) + const result = await window.ipc.invoke('billing:getInfo', null) + setBilling(result) + } catch (error) { + console.error('Failed to fetch billing info:', error) + setBilling(null) + } finally { + setIsLoading(false) + } + }, [isRowboatConnected]) + + useEffect(() => { + fetchBilling() + }, [fetchBilling]) + + return { billing, isLoading, refresh: fetchBilling } +} diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts new file mode 100644 index 00000000..69cf6083 --- /dev/null +++ b/apps/x/packages/core/src/billing/billing.ts @@ -0,0 +1,39 @@ +import { getAccessToken } from '../auth/tokens.js'; +import { API_URL } from '../config/env.js'; + +export interface BillingInfo { + subscriptionPlan: string | null; + subscriptionStatus: string | null; + sanctionedCredits: number; + availableCredits: number; +} + +export async function getBillingInfo(): Promise { + const accessToken = await getAccessToken(); + const response = await fetch(`${API_URL}/v1/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error(`Billing API failed: ${response.status}`); + } + const body = await response.json() as { + user: { + id: string; + email: string; + }; + billing: { + plan: string | null; + status: string | null; + usage: { + sanctionedCredits: number; + availableCredits: number; + }; + }; + }; + return { + subscriptionPlan: body.billing.plan, + subscriptionStatus: body.billing.status, + sanctionedCredits: body.billing.usage.sanctionedCredits, + availableCredits: body.billing.usage.availableCredits, + }; +} diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index f1aeee7e..3602bef6 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -49,6 +49,7 @@ async function getAuthHeaders(): Promise> { const ZComposioConfig = z.object({ apiKey: z.string().optional(), use_composio_for_google: z.boolean().optional(), + use_composio_for_google_calendar: z.boolean().optional(), }); type ComposioConfig = z.infer; @@ -113,6 +114,15 @@ export async function useComposioForGoogle(): Promise { return config.use_composio_for_google === true; } +/** + * Check if Composio should be used for Google Calendar + */ +export async function useComposioForGoogleCalendar(): Promise { + if (await isSignedIn()) return true; + const config = loadConfig(); + return config.use_composio_for_google_calendar === true; +} + /** * Make an API call to Composio */ diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index cf2f6387..70b1ccce 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -5,13 +5,16 @@ import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { serviceLogger } from '../services/service_logger.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; +import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js'; +import { composioAccountsRepo } from '../composio/repo.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 14; +const COMPOSIO_LOOKBACK_DAYS = 14; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/drive.readonly' @@ -56,7 +59,7 @@ function cleanUpOldFiles(currentEventIds: Set, syncDir: string): string[ const files = fs.readdirSync(syncDir); const deleted: string[] = []; for (const filename of files) { - if (filename === 'sync_state.json') continue; + if (filename === 'sync_state.json' || filename === 'composio_state.json') continue; // We expect files like: // {eventId}.json @@ -133,10 +136,10 @@ async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, s const filename = `${eventId}_doc_${safeTitle}.md`; const filePath = path.join(syncDir, filename); - // Simple cache check: if file exists, skip. + // Simple cache check: if file exists, skip. // Ideally we check modifiedTime, but that requires an extra API call per file. // Given the loop interval, we can just check existence to save quota. - // If user updates notes, they might want them re-synced. + // If user updates notes, they might want them re-synced. // For now, let's just check existence. To be smarter, we'd need a state file or check API. if (fs.existsSync(filePath)) continue; @@ -343,20 +346,248 @@ async function performSync(syncDir: string, lookbackDays: number) { } } +// --- Composio-based Sync --- + +interface ComposioCalendarState { + last_sync: string; // ISO string +} + +function loadComposioState(stateFile: string): ComposioCalendarState | null { + if (fs.existsSync(stateFile)) { + try { + const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (data.last_sync) { + return { last_sync: data.last_sync }; + } + } catch (e) { + console.error('[Calendar] Failed to load composio state:', e); + } + } + return null; +} + +function saveComposioState(stateFile: string, lastSync: string): void { + fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); +} + +/** + * Save a Composio calendar event as JSON (same format used by Google OAuth path). + * The event data from Composio is already structured similarly to Google Calendar API. + */ +function saveComposioEvent(eventData: Record, syncDir: string): { changed: boolean; isNew: boolean; title: string } { + const eventId = eventData.id as string | undefined; + if (!eventId) return { changed: false, isNew: false, title: 'Unknown' }; + + const filePath = path.join(syncDir, `${eventId}.json`); + const content = JSON.stringify(eventData, null, 2); + const exists = fs.existsSync(filePath); + + try { + if (exists) { + const existing = fs.readFileSync(filePath, 'utf-8'); + if (existing === content) { + return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; + } + } + + fs.writeFileSync(filePath, content); + return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId }; + } catch (e) { + console.error(`[Calendar] Error saving event ${eventId}:`, e); + return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; + } +} + +async function performSyncComposio() { + const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json'); + + if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); + + const account = composioAccountsRepo.getAccount('googlecalendar'); + if (!account || account.status !== 'ACTIVE') { + console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.'); + return; + } + + const connectedAccountId = account.id; + + // Calculate time window: lookback + 14 days forward + const now = new Date(); + const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000; + const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; + + const timeMin = new Date(now.getTime() - lookbackMs).toISOString(); + const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString(); + + console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`); + + let run: ServiceRunContext | null = null; + const ensureRun = async (): Promise => { + if (!run) { + run = await serviceLogger.startRun({ + service: 'calendar', + message: 'Syncing calendar (Composio)', + trigger: 'timer', + }); + } + return run; + }; + + try { + const result = await executeAction( + 'GOOGLECALENDAR_FIND_EVENT', + { + connected_account_id: connectedAccountId, + user_id: 'rowboat-user', + version: 'latest', + arguments: { + calendar_id: 'primary', + time_min: timeMin, + time_max: timeMax, + single_events: true, + order_by: 'startTime', + }, + } + ); + + if (!result.successful || !result.data) { + console.error('[Calendar] Failed to list events via Composio:', result.error); + return; + } + + const data = result.data as Record; + // Composio may return events in different structures + let events: Array> = []; + + if (Array.isArray(data.items)) { + events = data.items as Array>; + } else if (Array.isArray(data.events)) { + events = data.events as Array>; + } else if (Array.isArray(data)) { + events = data as unknown as Array>; + } + + const currentEventIds = new Set(); + let newCount = 0; + let updatedCount = 0; + const changedTitles: string[] = []; + + if (events.length === 0) { + console.log('[Calendar] No events found in this window.'); + } else { + console.log(`[Calendar] Found ${events.length} events.`); + for (const event of events) { + const eventId = event.id as string | undefined; + if (eventId) { + const saveResult = saveComposioEvent(event, SYNC_DIR); + currentEventIds.add(eventId); + + if (saveResult.changed) { + await ensureRun(); + changedTitles.push(saveResult.title); + if (saveResult.isNew) { + newCount++; + } else { + updatedCount++; + } + } + } + } + } + + // Clean up events no longer in the window + const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR); + let deletedCount = 0; + if (deletedFiles.length > 0) { + await ensureRun(); + deletedCount = deletedFiles.length; + } + + // Log results if any changes were detected (run was started by ensureRun) + if (run) { + const r = run as ServiceRunContext; + const totalChanges = newCount + updatedCount + deletedCount; + const limitedTitles = limitEventItems(changedTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: r.service, + runId: r.runId, + level: 'info', + message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + counts: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); + await serviceLogger.log({ + type: 'run_complete', + service: r.service, + runId: r.runId, + level: 'info', + message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + durationMs: Date.now() - r.startedAt, + outcome: 'ok', + summary: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + }, + }); + } + + // Save state + saveComposioState(STATE_FILE, new Date().toISOString()); + console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`); + } catch (error) { + console.error('[Calendar] Error during Composio sync:', error); + const errRun = await ensureRun(); + await serviceLogger.log({ + type: 'error', + service: errRun.service, + runId: errRun.runId, + level: 'error', + message: 'Calendar sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: errRun.service, + runId: errRun.runId, + level: 'error', + message: 'Calendar sync failed', + durationMs: Date.now() - errRun.startedAt, + outcome: 'error', + }); + } +} + export async function init() { console.log("Starting Google Calendar & Notes Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); + const composioMode = await useComposioForGoogleCalendar(); + if (composioMode) { + const isConnected = composioAccountsRepo.isConnected('googlecalendar'); + if (!isConnected) { + console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...'); + } else { + await performSyncComposio(); + } } else { - // Perform one sync - await performSync(SYNC_DIR, LOOKBACK_DAYS); + // Check if credentials are available with required scopes + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); + + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); + } else { + // Perform one sync + await performSync(SYNC_DIR, LOOKBACK_DAYS); + } } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index c74de77a..9dffdc6d 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -786,13 +786,9 @@ export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); - const composioMode = await useComposioForGoogle(); - if (composioMode) { - console.log('[Gmail] Using Composio backend for Gmail sync.'); - } - while (true) { try { + const composioMode = await useComposioForGoogle(); if (composioMode) { const isConnected = composioAccountsRepo.isConnected('gmail'); if (!isConnected) { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 557df845..b6abbf29 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -386,6 +386,12 @@ const ipcSchemas = { enabled: z.boolean(), }), }, + 'composio:use-composio-for-google-calendar': { + req: z.null(), + res: z.object({ + enabled: z.boolean(), + }), + }, 'composio:didConnect': { req: z.object({ toolkitSlug: z.string(), @@ -516,6 +522,16 @@ const ipcSchemas = { ]).nullable(), }), }, + // Billing channels + 'billing:getInfo': { + req: z.null(), + res: z.object({ + subscriptionPlan: z.string().nullable(), + subscriptionStatus: z.string().nullable(), + sanctionedCredits: z.number(), + availableCredits: z.number(), + }), + }, } as const; // ============================================================================