mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
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:
parent
65e2b3b868
commit
86818e7d21
27 changed files with 3114 additions and 168 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
83
apps/x/apps/renderer/src/components/onboarding/index.tsx
Normal file
83
apps/x/apps/renderer/src/components/onboarding/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
36
apps/x/apps/renderer/src/hooks/useBilling.ts
Normal file
36
apps/x/apps/renderer/src/hooks/useBilling.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue