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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
Tushar 2026-03-17 12:04:57 +05:30 committed by GitHub
parent 65e2b3b868
commit 86818e7d21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 3114 additions and 168 deletions

View file

@ -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
*/

View file

@ -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<IAgentScheduleRepo>('agentScheduleRepo');
@ -710,5 +714,9 @@ export function setupIpcHandlers() {
'voice:getDeepgramToken': async () => {
return voice.getDeepgramToken();
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();
},
});
}

View file

@ -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();
});

View file

@ -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);

View file

@ -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%);
}
}

View file

@ -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

View file

@ -66,6 +66,7 @@ const providerDisplayNames: Record<string, string> = {
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<string, unknown>
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<string, string>) || 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<string, unknown>
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<string, string>) || 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')
}

View file

@ -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')) && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
{useComposioForGoogle ? 'Email' : 'Email & Calendar'}
Email & Calendar
</span>
</div>
{useComposioForGoogle ? (
@ -696,6 +810,53 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
) : (
renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{googleCalendarLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Sync calendar events
</span>
)}
</div>
</div>
<div className="shrink-0">
{googleCalendarLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : googleCalendarConnected ? (
<Button
variant="outline"
size="sm"
onClick={handleDisconnectGoogleCalendar}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectGoogleCalendar}
disabled={googleCalendarConnecting}
className="h-7 px-2 text-xs"
>
{googleCalendarConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)}
<Separator className="my-2" />
</>
)}

View file

@ -47,19 +47,37 @@ export function GoogleClientIdModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enter Google Client ID</DialogTitle>
<DialogDescription>
{description ?? "Enter the client ID for your Google OAuth app to continue."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
Client ID
</label>
<div className="text-xs text-muted-foreground">
Need help setting this up?{" "}
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
<div className="p-6 pb-0">
<DialogHeader className="space-y-1.5">
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
<DialogDescription className="text-sm">
{description ?? "Enter the client ID for your Google OAuth app to connect."}
</DialogDescription>
</DialogHeader>
</div>
<div className="px-6 py-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-id">
Client ID
</label>
<Input
id="google-client-id"
placeholder="xxxxxxxxxxxx-xxxx.apps.googleusercontent.com"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
className="font-mono text-xs"
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground">
Need help?{" "}
<a
className="text-primary underline underline-offset-4 hover:text-primary/80"
href={GOOGLE_CLIENT_ID_SETUP_GUIDE_URL}
@ -68,31 +86,18 @@ export function GoogleClientIdModal({
>
Read the setup guide
</a>
.
</div>
<Input
id="google-client-id"
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
autoFocus
/>
</p>
</div>
<div className="mt-4 flex justify-end gap-2">
<div className="flex justify-end gap-2 px-6 py-4 border-t bg-muted/30">
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
<Button size="sm" onClick={handleSubmit} disabled={!isValid || isSubmitting}>
Continue
</Button>
</div>

View file

@ -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<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("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 = () => (
<div className="flex gap-2 justify-center mb-6">
{[0, 1, 2].map((step) => (
<div
key={step}
className={cn(
"w-2 h-2 rounded-full transition-colors",
currentStep >= step ? "bg-primary" : "bg-muted"
)}
/>
))}
</div>
)
// 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 (
<div className="flex gap-2 justify-center mb-6">
{steps.map((_, i) => (
<div
key={i}
className={cn(
"w-2 h-2 rounded-full transition-colors",
currentIndex >= i ? "bg-primary" : "bg-muted"
)}
/>
))}
</div>
)
}
// 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) {
</div>
)
// Render Google Calendar Composio row
const renderGoogleCalendarRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<Calendar className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{googleCalendarLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Sync calendar events
</span>
)}
</div>
</div>
<div className="shrink-0">
{googleCalendarLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : googleCalendarConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
</div>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectGoogleCalendar}
disabled={googleCalendarConnecting}
>
{googleCalendarConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
// Render Slack row
const renderSlackRow = () => (
<div className="rounded-md px-3 py-3 hover:bg-accent">
@ -772,7 +910,123 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// 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 (
<div className="flex flex-col items-center text-center">
<div className="flex items-center justify-center gap-3 mb-3">
<span className="text-lg font-medium text-muted-foreground">Your AI coworker, with memory</span>
</div>
<DialogHeader className="space-y-3 mb-8">
<DialogTitle className="text-2xl">Sign in to Rowboat</DialogTitle>
<DialogDescription className="text-base max-w-md mx-auto">
Connect your Rowboat account for instant access to all models through our gateway no API keys needed.
</DialogDescription>
</DialogHeader>
{rowboatState.isConnected ? (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="size-5" />
<span className="text-sm font-medium">Connected to Rowboat</span>
</div>
<Button onClick={() => setCurrentStep(3 as Step)} size="lg" className="w-full max-w-xs">
Continue
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4 w-full max-w-xs">
<Button
onClick={() => {
setOnboardingPath('rowboat')
startConnect('rowboat')
}}
size="lg"
className="w-full"
disabled={rowboatState.isConnecting}
>
{rowboatState.isConnecting ? (
<><Loader2 className="size-4 animate-spin mr-2" />Waiting for sign in...</>
) : (
"Sign in with Rowboat"
)}
</Button>
{rowboatState.isConnecting && (
<p className="text-xs text-muted-foreground">
Complete sign in in your browser, then return here.
</p>
)}
</div>
)}
<div className="w-full flex justify-end mt-8">
<button
onClick={() => {
setOnboardingPath('byok')
setCurrentStep(1 as Step)
}}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Bring your own key
</button>
</div>
</div>
)
}
// Step 1: BYOK upsell — explain benefits of Rowboat before continuing with BYOK
const renderByokUpsellStep = () => (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
<DialogTitle className="text-2xl">Before you continue</DialogTitle>
<DialogDescription className="text-base max-w-md mx-auto">
With a Rowboat account, you get:
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mb-8">
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium">Instant access to all models</div>
<div className="text-xs text-muted-foreground">GPT, Claude, Gemini, and more no separate API keys needed</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium">Simplified billing</div>
<div className="text-xs text-muted-foreground">One account for everything no juggling multiple provider subscriptions</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border px-4 py-3">
<CheckCircle2 className="size-5 text-green-600 mt-0.5 shrink-0" />
<div>
<div className="text-sm font-medium">Automatic updates</div>
<div className="text-xs text-muted-foreground">New models are available as soon as they launch, with no configuration changes</div>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground text-center mb-6">
By continuing, you'll set up your own API keys instead of using Rowboat's managed gateway.
</p>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button onClick={handleNext}>
I understand
</Button>
</div>
</div>
)
// 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) {
</div>
)}
<div className="flex flex-col gap-3 mt-4">
<div className="flex items-center justify-between mt-4">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button
onClick={handleTestAndSaveLlmConfig}
size="lg"
disabled={!canTest || testState.status === "testing"}
>
{testState.status === "testing" ? (
@ -965,7 +1222,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
}
// Step 1: Connect Accounts
// Step 3: Connect Accounts
const renderAccountConnectionStep = () => (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
@ -983,17 +1240,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) : (
<>
{/* Email / Email & Calendar Section */}
{(useComposioForGoogle || providers.includes('google')) && (
{(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{useComposioForGoogle ? 'Email' : 'Email & Calendar'}
{(useComposioForGoogle || useComposioForGoogleCalendar) ? 'Email & Calendar' : 'Email & Calendar'}
</span>
</div>
{useComposioForGoogle
? renderGmailRow()
: renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')
}
{useComposioForGoogleCalendar && renderGoogleCalendarRow()}
</div>
)}
@ -1021,16 +1279,22 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<Button onClick={handleNext} size="lg">
Continue
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
</div>
</div>
</div>
)
// 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 (
<div className="flex flex-col items-center text-center">
@ -1059,6 +1323,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<span>Gmail (Email)</span>
</div>
)}
{googleCalendarConnected && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Google Calendar</span>
</div>
)}
{connectedProviders.includes('google') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
@ -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()}
</DialogContent>
</Dialog>
</>

View file

@ -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 <WelcomeStep state={state} />
case 1:
return <LlmSetupStep state={state} />
case 2:
return <ConnectAccountsStep state={state} />
case 3:
return <CompletionStep state={state} />
}
}, [state.currentStep, state])
return (
<>
<GoogleClientIdModal
open={state.googleClientIdOpen}
onOpenChange={state.setGoogleClientIdOpen}
onSubmit={state.handleGoogleClientIdSubmit}
isSubmitting={state.providerStates.google?.isConnecting ?? false}
/>
<ComposioApiKeyModal
open={state.composioApiKeyOpen}
onOpenChange={state.setComposioApiKeyOpen}
onSubmit={state.handleComposioApiKeySubmit}
isSubmitting={state.gmailConnecting}
/>
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="w-[90vw] max-w-2xl max-h-[85vh] p-0 overflow-hidden"
showCloseButton={false}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
<StepIndicator
currentStep={state.currentStep}
path={state.onboardingPath}
/>
<AnimatePresence mode="wait">
<motion.div
key={state.currentStep}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="flex-1 flex flex-col"
>
{stepContent}
</motion.div>
</AnimatePresence>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,107 @@
import { cn } from "@/lib/utils"
interface IconProps {
className?: string
}
export function OpenAIIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
</svg>
)
}
export function AnthropicIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M17.304 3.541h-3.483l6.15 16.918h3.483zm-10.61 0L.545 20.459H4.15l1.278-3.554h6.539l1.278 3.554h3.604L10.698 3.541zm.49 10.537 2.065-5.728h.054l2.065 5.728z" />
</svg>
)
}
export function GoogleIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
)
}
export function OllamaIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-2-11a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm4 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-5.07 5.14a.5.5 0 0 1 .71-.07A4.97 4.97 0 0 0 12 15.5c.93 0 1.8-.26 2.53-.7a.5.5 0 1 1 .51.86A5.97 5.97 0 0 1 12 16.5a5.97 5.97 0 0 1-3.14-.88.5.5 0 0 1 .07-.48z" />
</svg>
)
}
export function OpenRouterIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M4 4h7v7H4zm9 0h7v7h-7zm-9 9h7v7H4zm9 0h7v7h-7z" opacity="0.8" />
<path d="M6 6h3v3H6zm9 0h3v3h-3zM6 15h3v3H6zm9 0h3v3h-3z" />
</svg>
)
}
export function VercelIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 1L24 22H0z" />
</svg>
)
}
export function GmailIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335" />
</svg>
)
}
export function SlackIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A" />
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0" />
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.271 0a2.527 2.527 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.164 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" fill="#2EB67D" />
<path d="M15.164 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.164 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.314A2.528 2.528 0 0 1 24 15.164a2.528 2.528 0 0 1-2.522 2.521h-6.314z" fill="#ECB22E" />
</svg>
)
}
export function FirefliesIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<circle cx="12" cy="6" r="2" opacity="0.9" />
<circle cx="7" cy="9" r="1.5" opacity="0.7" />
<circle cx="17" cy="9" r="1.5" opacity="0.7" />
<circle cx="5" cy="13" r="1" opacity="0.5" />
<circle cx="19" cy="13" r="1" opacity="0.5" />
<circle cx="8" cy="16" r="1.5" opacity="0.6" />
<circle cx="16" cy="16" r="1.5" opacity="0.6" />
<circle cx="12" cy="19" r="2" opacity="0.8" />
</svg>
)
}
export function GranolaIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-1v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h1V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2zm0 2h-2v1h4V4h-2zm5 3H7v2h10V7zM4 11v6h16v-6H4zm3 10h10v-2H7v2z" opacity="0.85" />
</svg>
)
}
export function GenericApiIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 13h8v2H8v-2zm0 4h5v2H8v-2z" opacity="0.8" />
</svg>
)
}

View file

@ -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 (
<div className="flex items-center gap-2 mb-8 px-4">
{steps.map((s, i) => (
<React.Fragment key={s.step}>
{i > 0 && (
<div
className={cn(
"h-px flex-1 transition-colors duration-500",
i <= currentIndex ? "bg-primary" : "bg-border"
)}
/>
)}
<div className="flex flex-col items-center gap-1.5">
<div
className={cn(
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
i < currentIndex && "bg-primary text-primary-foreground",
i === currentIndex && "bg-primary text-primary-foreground ring-4 ring-primary/20",
i > currentIndex && "bg-muted text-muted-foreground"
)}
>
{i < currentIndex ? (
<CheckCircle2 className="size-4" />
) : (
i + 1
)}
</div>
<span
className={cn(
"text-[11px] font-medium transition-colors duration-300",
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
)}
>
{s.label}
</span>
</div>
</React.Fragment>
))}
</div>
)
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center text-center flex-1">
{/* Animated checkmark */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
className="relative mb-8"
>
{/* Pulsing ring */}
<motion.div
initial={{ scale: 0.8, opacity: 0.6 }}
animate={{ scale: 1.5, opacity: 0 }}
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
className="absolute inset-0 rounded-full bg-green-500/20"
/>
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
</div>
</motion.div>
{/* Title */}
<motion.h2
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="text-3xl font-bold tracking-tight mb-3"
>
You're All Set!
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.35 }}
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8"
>
{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.</>
)}
</motion.p>
{/* Connected accounts summary */}
{hasConnections && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45 }}
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-8"
>
<p className="text-sm font-semibold mb-3 text-left">Connected</p>
<div className="space-y-2">
{gmailConnected && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Gmail (Email)</span>
</motion.div>
)}
{googleCalendarConnected && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.52 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Google Calendar</span>
</motion.div>
)}
{connectedProviders.includes('google') && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Google (Email & Calendar)</span>
</motion.div>
)}
{connectedProviders.includes('fireflies-ai') && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.55 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Fireflies (Meeting transcripts)</span>
</motion.div>
)}
{granolaEnabled && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Granola (Local meeting notes)</span>
</motion.div>
)}
{slackEnabled && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.65 }}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
<span>Slack (Team communication)</span>
</motion.div>
)}
</div>
</motion.div>
)}
{/* CTA */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
>
<Button
onClick={handleComplete}
size="lg"
className="w-full max-w-xs h-12 text-base font-medium"
>
Start Using Rowboat
</Button>
</motion.div>
</div>
)
}

View file

@ -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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06 }}
className={cn(
"flex items-center justify-between gap-4 rounded-xl border p-4 transition-colors",
isConnected
? "border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10"
: "hover:bg-muted/50"
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", iconBg)}>
<span className={iconColor}>{icon}</span>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold">{name}</div>
<div className="text-xs text-muted-foreground truncate">{description}</div>
</div>
</div>
<div className="shrink-0">
{rightSlot ?? (
providerState?.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : isConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Connected</span>
</div>
) : (
<Button
size="sm"
onClick={onConnect}
disabled={providerState?.isConnecting}
>
{providerState?.isConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)
)}
</div>
</motion.div>
)
}
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 (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Connect Your Accounts
</h2>
<p className="text-base text-muted-foreground text-center leading-relaxed mb-8">
Connect your accounts to give Rowboat context about your work. You can always add more later.
</p>
{providersLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6">
{/* Email & Calendar */}
{(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
{useComposioForGoogle ? (
<ProviderCard
name="Gmail"
description="Sync your email for context-aware assistance"
icon={<GmailIcon />}
iconBg="bg-red-500/10"
iconColor="text-red-500"
providerState={{ isConnected: gmailConnected, isLoading: gmailLoading, isConnecting: gmailConnecting }}
onConnect={handleConnectGmail}
index={cardIndex++}
/>
) : (
<ProviderCard
name="Google"
description="Rowboat uses your email and calendar to provide personalized, context-aware assistance"
icon={<GmailIcon />}
iconBg="bg-red-500/10"
iconColor="text-red-500"
providerState={providerStates['google']}
onConnect={() => handleConnect('google')}
index={cardIndex++}
/>
)}
{useComposioForGoogleCalendar && (
<ProviderCard
name="Google Calendar"
description="Sync calendar events for scheduling awareness"
icon={<Calendar className="size-5" />}
iconBg="bg-blue-500/10"
iconColor="text-blue-500"
providerState={{ isConnected: googleCalendarConnected, isLoading: googleCalendarLoading, isConnecting: googleCalendarConnecting }}
onConnect={handleConnectGoogleCalendar}
index={cardIndex++}
/>
)}
</div>
)}
{/* Meeting Notes */}
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
<ProviderCard
name="Granola"
description="Sync your local meeting notes for richer context"
icon={<GranolaIcon />}
iconBg="bg-purple-500/10"
iconColor="text-purple-500"
providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }}
rightSlot={
<div className="flex items-center gap-2">
{granolaLoading && <Loader2 className="size-3 animate-spin" />}
<Switch
checked={granolaEnabled}
onCheckedChange={handleGranolaToggle}
disabled={granolaLoading}
/>
</div>
}
index={cardIndex++}
/>
{providers.includes('fireflies-ai') && (
<ProviderCard
name="Fireflies"
description="Import AI-powered meeting transcripts automatically"
icon={<FirefliesIcon />}
iconBg="bg-amber-500/10"
iconColor="text-amber-500"
providerState={providerStates['fireflies-ai']}
onConnect={() => handleConnect('fireflies-ai')}
index={cardIndex++}
/>
)}
</div>
{/* Team Communication */}
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
<div>
<ProviderCard
name="Slack"
description={
slackEnabled && slackWorkspaces.length > 0
? slackWorkspaces.map(w => w.name).join(', ')
: "Enable Rowboat to understand your team conversations and provide relevant context"
}
icon={<SlackIcon />}
iconBg="bg-emerald-500/10"
iconColor="text-emerald-500"
providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }}
rightSlot={
<div className="flex items-center gap-2">
{(slackLoading || slackDiscovering) && <Loader2 className="size-3 animate-spin" />}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<Button
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
>
Enable
</Button>
)}
</div>
}
index={cardIndex++}
/>
{slackPickerOpen && (
<div className="mt-2 ml-[3.25rem] space-y-2 pl-4 border-l-2 border-muted">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
>
Save
</Button>
</>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Footer */}
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
<Button onClick={handleNext} size="lg" className="h-12 text-base font-medium">
Continue
</Button>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
</div>
</div>
</div>
)
}

View file

@ -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: <OpenAIIcon /> },
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
]
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: <OpenRouterIcon /> },
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
]
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 (
<motion.button
key={provider.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => {
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"
)}
>
<div className="flex items-center gap-3">
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
{provider.icon}
</div>
<div>
<div className="text-sm font-semibold">{provider.name}</div>
<div className="text-xs text-muted-foreground">{provider.description}</div>
</div>
</div>
</motion.button>
)
}
return (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Choose your model
</h2>
<p className="text-base text-muted-foreground text-center mb-6">
Select a provider and configure your API key
</p>
{/* Inline Rowboat upsell callout */}
{!upsellDismissed && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
>
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to all models no API keys needed.
</p>
<button
onClick={handleSwitchToRowboat}
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
>
Sign in instead
</button>
</div>
<button
onClick={() => setUpsellDismissed(true)}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="size-4" />
</button>
</motion.div>
)}
{/* Provider selection */}
<div className="space-y-3 mb-4">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 sm:grid-cols-2">
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 sm:grid-cols-2 mt-2">
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Separator */}
<div className="h-px bg-border my-4" />
{/* Model configuration */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Model Configuration</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Assistant Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Enter model"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
<div className="space-y-2 min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Knowledge Graph Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{showApiKey && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
API Key {!state.requiresApiKey && "(optional)"}
</label>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
placeholder="Paste your API key"
className="font-mono"
/>
</div>
)}
{showBaseURL && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Base URL
</label>
<Input
value={activeConfig.baseURL}
onChange={(e) => 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"
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex items-center gap-3">
{testState.status === "success" && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
>
<CheckCircle2 className="size-4" />
Connected
</motion.div>
)}
{testState.status === "error" && (
<span className="text-sm text-destructive max-w-[200px] truncate">
{testState.error}
</span>
)}
<Button
onClick={handleTestAndSaveLlmConfig}
disabled={!canTest || testState.status === "testing"}
className="min-w-[140px]"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
) : (
"Test & Continue"
)}
</Button>
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center text-center flex-1">
{/* Logo with ambient glow */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="relative mb-8"
>
<div className="absolute inset-0 size-16 rounded-2xl bg-primary/10 blur-xl scale-[2.5]" />
<img src="/logo-only.png" alt="Rowboat" className="relative size-16" />
</motion.div>
{/* Tagline badge */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6"
>
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
Your AI coworker, with memory
</motion.div>
{/* Main heading */}
<motion.h1
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-3xl font-bold tracking-tight mb-3"
>
Welcome to Rowboat
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-10"
>
Rowboat connects to your work, builds a knowledge graph, and uses that context to help you get things done. Private and on your machine.
</motion.p>
{/* Sign in / connected state */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="w-full max-w-xs"
>
{rowboatState.isConnected ? (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle2 className="size-5" />
<span className="text-sm font-medium">Connected to Rowboat</span>
</div>
<Button
onClick={() => {
state.setOnboardingPath('rowboat')
state.setCurrentStep(2)
}}
size="lg"
className="w-full h-12 text-base font-medium"
>
Continue
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Button
onClick={() => {
state.setOnboardingPath('rowboat')
state.startConnect('rowboat')
}}
size="lg"
className="w-full h-12 text-base font-medium"
disabled={rowboatState.isConnecting}
>
{rowboatState.isConnecting ? (
<><Loader2 className="size-5 animate-spin mr-2" />Waiting for sign in...</>
) : (
"Sign in with Rowboat"
)}
</Button>
{rowboatState.isConnecting && (
<p className="text-xs text-muted-foreground animate-pulse">
Complete sign in in your browser, then return here.
</p>
)}
</div>
)}
</motion.div>
{/* BYOK link */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-8"
>
<button
onClick={() => {
state.setOnboardingPath('byok')
state.setCurrentStep(1)
}}
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4 decoration-muted-foreground/30 hover:decoration-foreground/50"
>
I want to bring my own API key
</button>
</motion.div>
</div>
)
}

View file

@ -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<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
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<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
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<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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<string, LlmModelOption[]> = {}
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<Record<LlmProviderFlavor, string>> = {
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<string, ProviderState> = {}
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<typeof useOnboardingState>

View file

@ -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<LlmModelOption[]>([])
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 (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Select the models Rowboat uses. These are provided through your Rowboat account.
</p>
{/* Assistant model */}
<div className="space-y-2">
<label className="text-sm font-medium">Assistant model</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Knowledge graph model */}
<div className="space-y-2">
<label className="text-sm font-medium">Knowledge graph model</label>
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Same as assistant" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Save */}
<Button onClick={handleSave} disabled={!selectedModel || saving}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
"Save"
)}
</Button>
</div>
)
}
// --- 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<string | null>(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) {
<h2 className="font-semibold text-sm">Settings</h2>
</div>
<nav className="flex flex-col gap-1">
{tabs.map((tab) => (
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
@ -1226,14 +1360,18 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{activeTabConfig.description}
{activeTab === "models" && rowboatConnected
? "Select your default models"
: activeTabConfig.description}
</p>
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "models" ? (
<ModelSettings dialogOpen={open} />
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
: <ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (

View file

@ -87,6 +87,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover"
import { HelpPopover } from "@/components/help-popover"
import { SettingsDialog } from "@/components/settings-dialog"
import { toast } from "@/lib/toast"
import { useBilling } from "@/hooks/useBilling"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod"
@ -401,6 +402,8 @@ export function SidebarContentPanel({
const [connectorsOpen, setConnectorsOpen] = useState(false)
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const { billing } = useBilling(isRowboatConnected)
useEffect(() => {
let mounted = true
@ -412,6 +415,7 @@ export function SidebarContentPanel({
const hasError = Object.values(config).some((entry) => Boolean(entry?.error))
if (mounted) {
setHasOauthError(hasError)
setIsRowboatConnected(config['rowboat']?.connected ?? false)
if (!hasError) {
setShowOauthAlert(true)
}
@ -420,6 +424,7 @@ export function SidebarContentPanel({
console.error('Failed to fetch OAuth state:', error)
if (mounted) {
setHasOauthError(false)
setIsRowboatConnected(false)
setShowOauthAlert(true)
}
}
@ -483,6 +488,19 @@ export function SidebarContentPanel({
/>
)}
</SidebarContent>
{/* Billing / upgrade CTA */}
{isRowboatConnected && billing && (
<div className="px-3 py-2">
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{billing.subscriptionPlan ?? 'Free'} plan
</span>
<button className="rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20">
Upgrade
</button>
</div>
</div>
)}
{/* Bottom actions */}
<div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1">

View file

@ -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<BillingInfo | null>(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 }
}