mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
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:
parent
4a43d815c5
commit
429e7e4f03
7 changed files with 205 additions and 26 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue