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.

This commit is contained in:
tusharmagar 2026-03-16 16:17:33 +05:30
parent 4a43d815c5
commit 429e7e4f03
7 changed files with 205 additions and 26 deletions

View file

@ -75,7 +75,7 @@ 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
// Load available providers on mount
useEffect(() => {
async function loadProviders() {
try {
@ -89,6 +89,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 +103,8 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
console.error('Failed to check composio-for-google flag:', error)
}
}
loadProviders()
loadComposioForGoogleFlag()
}, [])
}, [open])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
@ -339,9 +344,9 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
// Listen for OAuth completion events
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
const { provider, success, error } = event
setProviderStates(prev => ({
...prev,
[provider]: {
@ -362,6 +367,17 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
} else {
toast.success(`Connected to ${displayName}`)
}
// When Rowboat account connects, re-check composio flag so Gmail uses the right flow
if (provider === 'rowboat') {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
setUseComposioForGoogle(result.enabled)
} catch (err) {
console.error('Failed to re-check composio-for-google flag:', err)
}
}
// Refresh status to ensure consistency
refreshAllStatuses()
} else {

View file

@ -8,6 +8,7 @@ import {
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"
@ -44,6 +45,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
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"

View file

@ -8,8 +8,8 @@ interface CompletionStepProps {
}
export function CompletionStep({ state }: CompletionStepProps) {
const { connectedProviders, granolaEnabled, slackEnabled, handleComplete } = state
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled
const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, handleComplete } = state
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected
return (
<div className="flex flex-col items-center justify-center text-center flex-1">
@ -65,6 +65,17 @@ export function CompletionStep({ state }: CompletionStepProps) {
>
<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>
)}
{connectedProviders.includes('google') && (
<motion.div
initial={{ opacity: 0, x: -8 }}

View file

@ -90,6 +90,7 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen,
slackDiscovering, slackDiscoverError,
handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable,
useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail,
handleNext, handleBack,
} = state
@ -111,22 +112,35 @@ export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
</div>
) : (
<div className="space-y-6">
{/* Email & Calendar */}
{providers.includes('google') && (
{/* Email / Email & Calendar */}
{(useComposioForGoogle || providers.includes('google')) && (
<div className="space-y-3">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Email & Calendar
{useComposioForGoogle ? 'Email' : 'Email & Calendar'}
</span>
<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++}
/>
{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++}
/>
)}
</div>
)}

View file

@ -41,7 +41,10 @@ export function WelcomeStep({ state }: WelcomeStepProps) {
<span className="text-sm font-medium">Connected to Rowboat</span>
</div>
<Button
onClick={() => state.setCurrentStep(2)}
onClick={() => {
state.setOnboardingPath('rowboat')
state.setCurrentStep(2)
}}
size="lg"
className="w-full h-12 text-base font-medium"
>

View file

@ -66,6 +66,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// 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')
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
@ -93,7 +101,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
.filter(([, state]) => state.isConnected)
.map(([provider]) => provider)
// Load available providers on mount
// Load available providers and composio-for-google flag on mount
useEffect(() => {
if (!open) return
@ -109,7 +117,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
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)
}
}
loadProviders()
loadComposioForGoogleFlag()
}, [open])
// Load LLM models catalog on open
@ -266,6 +283,60 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
}
}, [])
// 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])
// New step flow:
// Rowboat path: 0 (welcome) → 2 (connect) → 3 (done)
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done)
@ -338,6 +409,11 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
refreshGranolaConfig()
refreshSlackConfig()
// Refresh Gmail Composio status if enabled
if (useComposioForGoogle) {
refreshGmailStatus()
}
if (providers.length === 0) return
const newStates: Record<string, ProviderState> = {}
@ -364,7 +440,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackConfig])
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle])
// Refresh statuses when modal opens or providers list changes
useEffect(() => {
@ -402,8 +478,15 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
useEffect(() => {
if (onboardingPath !== 'rowboat' || currentStep !== 0) return
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
if (event.provider === 'rowboat' && event.success) {
// Re-check the composio-for-google flag now that the account is connected
try {
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
setUseComposioForGoogle(result.enabled)
} catch (error) {
console.error('Failed to re-check composio-for-google flag:', error)
}
setCurrentStep(2) // Go to Connect Accounts
}
})
@ -425,6 +508,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
toast.error(error || 'Failed to connect to Slack')
}
}
if (toolkitSlug === 'gmail') {
setGmailConnected(success)
setGmailConnecting(false)
if (success) {
toast.success('Connected to Gmail')
} else {
toast.error(error || 'Failed to connect to Gmail')
}
}
})
return cleanup
@ -545,6 +639,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
upsellDismissed,
setUpsellDismissed,
// Composio/Gmail state
useComposioForGoogle,
gmailConnected,
gmailLoading,
gmailConnecting,
composioApiKeyOpen,
setComposioApiKeyOpen,
composioApiKeyTarget,
handleConnectGmail,
handleComposioApiKeySubmit,
// Navigation
handleNext,
handleBack,

View file

@ -1112,8 +1112,31 @@ 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 (hides Models tab)
useEffect(() => {
if (!open) return
window.ipc.invoke('oauth:getState', null).then((result) => {
const connected = result.config?.rowboat?.connected ?? false
setRowboatConnected(connected)
// If currently on models tab and signed in, switch to next tab
if (connected && activeTab === 'models') {
setActiveTab('mcp')
}
}).catch(() => {
setRowboatConnected(false)
})
}, [open])
const visibleTabs = useMemo(() => {
if (rowboatConnected) {
return tabs.filter(t => t.id !== 'models')
}
return tabs
}, [rowboatConnected])
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
const isJsonTab = activeTab === "mcp" || activeTab === "security"
const formatJson = (jsonString: string): string => {
@ -1202,7 +1225,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)}