+ )}
+ {googleCalendarConnected && (
+
+ )}
{connectedProviders.includes('google') && (
)}
- {slackConnected && (
+ {slackEnabled && (
Slack (Team communication)
@@ -908,7 +1377,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
- isSubmitting={slackConnecting}
+ isSubmitting={gmailConnecting}
/>
>
diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx
new file mode 100644
index 00000000..d37cdf0f
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import * as React from "react"
+import { AnimatePresence, motion } from "motion/react"
+
+import {
+ Dialog,
+ DialogContent,
+} from "@/components/ui/dialog"
+import { GoogleClientIdModal } from "@/components/google-client-id-modal"
+import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
+import { useOnboardingState } from "./use-onboarding-state"
+import { StepIndicator } from "./step-indicator"
+import { WelcomeStep } from "./steps/welcome-step"
+import { LlmSetupStep } from "./steps/llm-setup-step"
+import { ConnectAccountsStep } from "./steps/connect-accounts-step"
+import { CompletionStep } from "./steps/completion-step"
+
+interface OnboardingModalProps {
+ open: boolean
+ onComplete: () => void
+}
+
+export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
+ const state = useOnboardingState(open, onComplete)
+
+ const stepContent = React.useMemo(() => {
+ switch (state.currentStep) {
+ case 0:
+ return
+ case 1:
+ return
+ case 2:
+ return
+ case 3:
+ return
+ }
+ }, [state.currentStep, state])
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx
new file mode 100644
index 00000000..c58c7bf0
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx
@@ -0,0 +1,107 @@
+import { cn } from "@/lib/utils"
+
+interface IconProps {
+ className?: string
+}
+
+export function OpenAIIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function AnthropicIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function GoogleIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function OllamaIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function OpenRouterIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function VercelIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function GmailIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function SlackIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function FirefliesIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function GranolaIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+
+export function GenericApiIcon({ className }: IconProps) {
+ return (
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx
new file mode 100644
index 00000000..6fae6dbb
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+import { CheckCircle2 } from "lucide-react"
+import { cn } from "@/lib/utils"
+import type { Step, OnboardingPath } from "./use-onboarding-state"
+
+const ROWBOAT_STEPS = [
+ { step: 0 as Step, label: "Welcome" },
+ { step: 2 as Step, label: "Connect" },
+ { step: 3 as Step, label: "Done" },
+]
+
+const BYOK_STEPS = [
+ { step: 0 as Step, label: "Welcome" },
+ { step: 1 as Step, label: "Model" },
+ { step: 2 as Step, label: "Connect" },
+ { step: 3 as Step, label: "Done" },
+]
+
+interface StepIndicatorProps {
+ currentStep: Step
+ path: OnboardingPath
+}
+
+export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
+ const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
+ const currentIndex = steps.findIndex(s => s.step === currentStep)
+
+ return (
+
+ {steps.map((s, i) => (
+
+ {i > 0 && (
+
+ )}
+
+
currentIndex && "bg-muted text-muted-foreground"
+ )}
+ >
+ {i < currentIndex ? (
+
+ ) : (
+ i + 1
+ )}
+
+
+ {s.label}
+
+
+
+ ))}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx
new file mode 100644
index 00000000..c01e42ea
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx
@@ -0,0 +1,154 @@
+import { CheckCircle2 } from "lucide-react"
+import { motion } from "motion/react"
+import { Button } from "@/components/ui/button"
+import type { OnboardingState } from "../use-onboarding-state"
+
+interface CompletionStepProps {
+ state: OnboardingState
+}
+
+export function CompletionStep({ state }: CompletionStepProps) {
+ const { connectedProviders, granolaEnabled, slackEnabled, gmailConnected, googleCalendarConnected, handleComplete } = state
+ const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected || googleCalendarConnected
+
+ return (
+
+ {/* Animated checkmark */}
+
+ {/* Pulsing ring */}
+
+
+
+
+
+
+ {/* Title */}
+
+ You're All Set!
+
+
+
+ {hasConnections ? (
+ <>Give me 30 minutes to build your context graph. I can still help with other things on your computer.>
+ ) : (
+ <>You can connect your accounts anytime from the sidebar to start syncing data.>
+ )}
+
+
+ {/* Connected accounts summary */}
+ {hasConnections && (
+
+ Connected
+
+ {gmailConnected && (
+
+
+ Gmail (Email)
+
+ )}
+ {googleCalendarConnected && (
+
+
+ Google Calendar
+
+ )}
+ {connectedProviders.includes('google') && (
+
+
+ Google (Email & Calendar)
+
+ )}
+ {connectedProviders.includes('fireflies-ai') && (
+
+
+ Fireflies (Meeting transcripts)
+
+ )}
+ {granolaEnabled && (
+
+
+ Granola (Local meeting notes)
+
+ )}
+ {slackEnabled && (
+
+
+ Slack (Team communication)
+
+ )}
+
+
+ )}
+
+ {/* CTA */}
+
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx
new file mode 100644
index 00000000..b152d567
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx
@@ -0,0 +1,294 @@
+import { Loader2, CheckCircle2, ArrowLeft, Calendar } from "lucide-react"
+import { motion } from "motion/react"
+import { Button } from "@/components/ui/button"
+import { Switch } from "@/components/ui/switch"
+import { cn } from "@/lib/utils"
+import { GmailIcon, SlackIcon, FirefliesIcon, GranolaIcon } from "../provider-icons"
+import type { OnboardingState, ProviderState } from "../use-onboarding-state"
+
+interface ConnectAccountsStepProps {
+ state: OnboardingState
+}
+
+function ProviderCard({
+ name,
+ description,
+ icon,
+ iconBg,
+ iconColor,
+ providerState,
+ onConnect,
+ rightSlot,
+ index,
+}: {
+ name: string
+ description: string
+ icon: React.ReactNode
+ iconBg: string
+ iconColor: string
+ providerState?: ProviderState
+ onConnect?: () => void
+ rightSlot?: React.ReactNode
+ index: number
+}) {
+ const isConnected = providerState?.isConnected ?? false
+
+ return (
+
+
+
+ {icon}
+
+
+
{name}
+
{description}
+
+
+
+ {rightSlot ?? (
+ providerState?.isLoading ? (
+
+ ) : isConnected ? (
+
+
+ Connected
+
+ ) : (
+
+ )
+ )}
+
+
+ )
+}
+
+export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
+ const {
+ providers, providersLoading, providerStates, handleConnect,
+ granolaEnabled, granolaLoading, handleGranolaToggle,
+ slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces,
+ slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen,
+ slackDiscovering, slackDiscoverError,
+ handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable,
+ useComposioForGoogle, gmailConnected, gmailLoading, gmailConnecting, handleConnectGmail,
+ useComposioForGoogleCalendar, googleCalendarConnected, googleCalendarLoading, googleCalendarConnecting, handleConnectGoogleCalendar,
+ handleNext, handleBack,
+ } = state
+
+ let cardIndex = 0
+
+ return (
+
+ {/* Title */}
+
+ Connect Your Accounts
+
+
+ Connect your accounts to give Rowboat context about your work. You can always add more later.
+
+
+ {providersLoading ? (
+
+
+
+ ) : (
+
+ {/* Email & Calendar */}
+ {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && (
+
+
+ Email & Calendar
+
+ {useComposioForGoogle ? (
+
}
+ iconBg="bg-red-500/10"
+ iconColor="text-red-500"
+ providerState={{ isConnected: gmailConnected, isLoading: gmailLoading, isConnecting: gmailConnecting }}
+ onConnect={handleConnectGmail}
+ index={cardIndex++}
+ />
+ ) : (
+
}
+ iconBg="bg-red-500/10"
+ iconColor="text-red-500"
+ providerState={providerStates['google']}
+ onConnect={() => handleConnect('google')}
+ index={cardIndex++}
+ />
+ )}
+ {useComposioForGoogleCalendar && (
+
}
+ iconBg="bg-blue-500/10"
+ iconColor="text-blue-500"
+ providerState={{ isConnected: googleCalendarConnected, isLoading: googleCalendarLoading, isConnecting: googleCalendarConnecting }}
+ onConnect={handleConnectGoogleCalendar}
+ index={cardIndex++}
+ />
+ )}
+
+ )}
+
+ {/* Meeting Notes */}
+
+
+ Meeting Notes
+
+
}
+ iconBg="bg-purple-500/10"
+ iconColor="text-purple-500"
+ providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }}
+ rightSlot={
+
+ {granolaLoading && }
+
+
+ }
+ index={cardIndex++}
+ />
+ {providers.includes('fireflies-ai') && (
+
}
+ iconBg="bg-amber-500/10"
+ iconColor="text-amber-500"
+ providerState={providerStates['fireflies-ai']}
+ onConnect={() => handleConnect('fireflies-ai')}
+ index={cardIndex++}
+ />
+ )}
+
+
+ {/* Team Communication */}
+
+
+ Team Communication
+
+
+
0
+ ? slackWorkspaces.map(w => w.name).join(', ')
+ : "Enable Rowboat to understand your team conversations and provide relevant context"
+ }
+ icon={}
+ iconBg="bg-emerald-500/10"
+ iconColor="text-emerald-500"
+ providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }}
+ rightSlot={
+
+ {(slackLoading || slackDiscovering) && }
+ {slackEnabled ? (
+ handleSlackDisable()}
+ disabled={slackLoading}
+ />
+ ) : (
+
+ )}
+
+ }
+ index={cardIndex++}
+ />
+ {slackPickerOpen && (
+
+ {slackDiscoverError ? (
+
{slackDiscoverError}
+ ) : (
+ <>
+ {slackAvailableWorkspaces.map(w => (
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
+
+ )}
+
+ {/* Footer */}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx
new file mode 100644
index 00000000..534a67a8
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx
@@ -0,0 +1,300 @@
+import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
+import { motion } from "motion/react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import {
+ OpenAIIcon,
+ AnthropicIcon,
+ GoogleIcon,
+ OllamaIcon,
+ OpenRouterIcon,
+ VercelIcon,
+ GenericApiIcon,
+} from "../provider-icons"
+import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
+
+interface LlmSetupStepProps {
+ state: OnboardingState
+}
+
+const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
+ { id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon:
},
+ { id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon:
},
+ { id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon:
},
+ { id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon:
},
+]
+
+const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
+ { id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon:
},
+ { id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon:
},
+ { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon:
},
+]
+
+export function LlmSetupStep({ state }: LlmSetupStepProps) {
+ const {
+ llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError,
+ activeConfig, testState, setTestState, showApiKey,
+ showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders,
+ updateProviderConfig, handleTestAndSaveLlmConfig, handleBack,
+ upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
+ } = state
+
+ const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
+ const modelsForProvider = modelsCatalog[llmProvider] || []
+ const showModelInput = isLocalProvider || modelsForProvider.length === 0
+
+ const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
+ const isSelected = llmProvider === provider.id
+ return (
+
{
+ setLlmProvider(provider.id)
+ setTestState({ status: "idle" })
+ }}
+ className={cn(
+ "rounded-xl border-2 p-4 text-left transition-all",
+ isSelected
+ ? "border-primary bg-primary/5 shadow-sm"
+ : "border-transparent bg-muted/50 hover:bg-muted"
+ )}
+ >
+
+
+ {provider.icon}
+
+
+
{provider.name}
+
{provider.description}
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Title */}
+
+ Choose your model
+
+
+ Select a provider and configure your API key
+
+
+ {/* Inline Rowboat upsell callout */}
+ {!upsellDismissed && (
+
+
+
+
+ Tip: Sign in with Rowboat for instant access to all models — no API keys needed.
+
+
+
+
+
+ )}
+
+ {/* Provider selection */}
+
+
Provider
+
+ {primaryProviders.map((p, i) => renderProviderCard(p, i))}
+
+ {(showMoreProviders || isMoreProvider) ? (
+
+ {moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
+
+ ) : (
+
+ )}
+
+
+ {/* Separator */}
+
+
+ {/* Model configuration */}
+
+
Model Configuration
+
+
+
+
+ {modelsLoading ? (
+
+
+ Loading...
+
+ ) : showModelInput ? (
+
updateProviderConfig(llmProvider, { model: e.target.value })}
+ placeholder="Enter model"
+ />
+ ) : (
+
+ )}
+ {modelsError && (
+
{modelsError}
+ )}
+
+
+
+
+ {modelsLoading ? (
+
+
+ Loading...
+
+ ) : showModelInput ? (
+
updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
+ placeholder={activeConfig.model || "Enter model"}
+ />
+ ) : (
+
+ )}
+
+
+
+ {showApiKey && (
+
+
+ updateProviderConfig(llmProvider, { apiKey: e.target.value })}
+ placeholder="Paste your API key"
+ className="font-mono"
+ />
+
+ )}
+
+ {showBaseURL && (
+
+
+ updateProviderConfig(llmProvider, { baseURL: e.target.value })}
+ placeholder={
+ llmProvider === "ollama"
+ ? "http://localhost:11434"
+ : llmProvider === "openai-compatible"
+ ? "http://localhost:1234/v1"
+ : "https://ai-gateway.vercel.sh/v1"
+ }
+ className="font-mono"
+ />
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+ {testState.status === "success" && (
+
+
+ Connected
+
+ )}
+ {testState.status === "error" && (
+
+ {testState.error}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx
new file mode 100644
index 00000000..9a660507
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx
@@ -0,0 +1,124 @@
+import { Loader2, CheckCircle2 } from "lucide-react"
+import { motion } from "motion/react"
+import { Button } from "@/components/ui/button"
+import type { OnboardingState } from "../use-onboarding-state"
+
+interface WelcomeStepProps {
+ state: OnboardingState
+}
+
+export function WelcomeStep({ state }: WelcomeStepProps) {
+ const rowboatState = state.providerStates['rowboat'] || { isConnected: false, isLoading: false, isConnecting: false }
+
+ return (
+
+ {/* Logo with ambient glow */}
+
+
+
+
+
+ {/* Tagline badge */}
+
+
+ Your AI coworker, with memory
+
+
+ {/* Main heading */}
+
+ Welcome to Rowboat
+
+
+ Rowboat connects to your work, builds a knowledge graph, and uses that context to help you get things done. Private and on your machine.
+
+
+ {/* Sign in / connected state */}
+
+ {rowboatState.isConnected ? (
+
+
+
+ Connected to Rowboat
+
+
+
+ ) : (
+
+
+ {rowboatState.isConnecting && (
+
+ Complete sign in in your browser, then return here.
+
+ )}
+
+ )}
+
+
+ {/* BYOK link */}
+
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts
new file mode 100644
index 00000000..7cc50a90
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts
@@ -0,0 +1,720 @@
+import { useState, useEffect, useCallback } from "react"
+import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
+import { toast } from "sonner"
+
+export interface ProviderState {
+ isConnected: boolean
+ isLoading: boolean
+ isConnecting: boolean
+}
+
+export type Step = 0 | 1 | 2 | 3
+
+export type OnboardingPath = 'rowboat' | 'byok' | null
+
+export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
+
+export interface LlmModelOption {
+ id: string
+ name?: string
+ release_date?: string
+}
+
+export function useOnboardingState(open: boolean, onComplete: () => void) {
+ const [currentStep, setCurrentStep] = useState
(0)
+ const [onboardingPath, setOnboardingPath] = useState(null)
+
+ // LLM setup state
+ const [llmProvider, setLlmProvider] = useState("openai")
+ const [modelsCatalog, setModelsCatalog] = useState>({})
+ const [modelsLoading, setModelsLoading] = useState(false)
+ const [modelsError, setModelsError] = useState(null)
+ const [providerConfigs, setProviderConfigs] = useState>({
+ openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
+ "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
+ })
+ const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
+ status: "idle",
+ })
+ const [showMoreProviders, setShowMoreProviders] = useState(false)
+
+ // OAuth provider states
+ const [providers, setProviders] = useState([])
+ const [providersLoading, setProvidersLoading] = useState(true)
+ const [providerStates, setProviderStates] = useState>({})
+ const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
+
+ // Granola state
+ const [granolaEnabled, setGranolaEnabled] = useState(false)
+ const [granolaLoading, setGranolaLoading] = useState(true)
+
+ // Slack state (agent-slack CLI)
+ const [slackEnabled, setSlackEnabled] = useState(false)
+ const [slackLoading, setSlackLoading] = useState(true)
+ const [slackWorkspaces, setSlackWorkspaces] = useState>([])
+ const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([])
+ const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set())
+ const [slackPickerOpen, setSlackPickerOpen] = useState(false)
+ const [slackDiscovering, setSlackDiscovering] = useState(false)
+ const [slackDiscoverError, setSlackDiscoverError] = useState(null)
+
+ // Inline upsell callout dismissed
+ const [upsellDismissed, setUpsellDismissed] = useState(false)
+
+ // Composio/Gmail state (used when signed in with Rowboat account)
+ const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
+ const [gmailConnected, setGmailConnected] = useState(false)
+ const [gmailLoading, setGmailLoading] = useState(true)
+ const [gmailConnecting, setGmailConnecting] = useState(false)
+ const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
+ const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
+
+ // Composio/Google Calendar state
+ const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
+ const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
+ const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
+ const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
+
+ const updateProviderConfig = useCallback(
+ (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
+ setProviderConfigs(prev => ({
+ ...prev,
+ [provider]: { ...prev[provider], ...updates },
+ }))
+ setTestState({ status: "idle" })
+ },
+ []
+ )
+
+ const activeConfig = providerConfigs[llmProvider]
+ const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
+ const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
+ const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
+ const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
+ const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
+ const canTest =
+ activeConfig.model.trim().length > 0 &&
+ (!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
+ (!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
+
+ // Track connected providers for the completion step
+ const connectedProviders = Object.entries(providerStates)
+ .filter(([, state]) => state.isConnected)
+ .map(([provider]) => provider)
+
+ // Load available providers and composio-for-google flag on mount
+ useEffect(() => {
+ if (!open) return
+
+ async function loadProviders() {
+ try {
+ setProvidersLoading(true)
+ const result = await window.ipc.invoke('oauth:list-providers', null)
+ setProviders(result.providers || [])
+ } catch (error) {
+ console.error('Failed to get available providers:', error)
+ setProviders([])
+ } finally {
+ setProvidersLoading(false)
+ }
+ }
+ async function loadComposioForGoogleFlag() {
+ try {
+ const result = await window.ipc.invoke('composio:use-composio-for-google', null)
+ setUseComposioForGoogle(result.enabled)
+ } catch (error) {
+ console.error('Failed to check composio-for-google flag:', error)
+ }
+ }
+ async function loadComposioForGoogleCalendarFlag() {
+ try {
+ const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
+ setUseComposioForGoogleCalendar(result.enabled)
+ } catch (error) {
+ console.error('Failed to check composio-for-google-calendar flag:', error)
+ }
+ }
+ loadProviders()
+ loadComposioForGoogleFlag()
+ loadComposioForGoogleCalendarFlag()
+ }, [open])
+
+ // Load LLM models catalog on open
+ useEffect(() => {
+ if (!open) return
+
+ async function loadModels() {
+ try {
+ setModelsLoading(true)
+ setModelsError(null)
+ const result = await window.ipc.invoke("models:list", null)
+ const catalog: Record = {}
+ for (const provider of result.providers || []) {
+ catalog[provider.id] = provider.models || []
+ }
+ setModelsCatalog(catalog)
+ } catch (error) {
+ console.error("Failed to load models catalog:", error)
+ setModelsError("Failed to load models list")
+ setModelsCatalog({})
+ } finally {
+ setModelsLoading(false)
+ }
+ }
+
+ loadModels()
+ }, [open])
+
+ // Preferred default models for each provider
+ const preferredDefaults: Partial> = {
+ openai: "gpt-5.2",
+ anthropic: "claude-opus-4-6-20260202",
+ }
+
+ // Initialize default models from catalog
+ useEffect(() => {
+ if (Object.keys(modelsCatalog).length === 0) return
+ setProviderConfigs(prev => {
+ const next = { ...prev }
+ const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
+ for (const provider of cloudProviders) {
+ const models = modelsCatalog[provider]
+ if (models?.length && !next[provider].model) {
+ const preferredModel = preferredDefaults[provider]
+ const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
+ next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
+ }
+ }
+ return next
+ })
+ }, [modelsCatalog])
+
+ // Load Granola config
+ const refreshGranolaConfig = useCallback(async () => {
+ try {
+ setGranolaLoading(true)
+ const result = await window.ipc.invoke('granola:getConfig', null)
+ setGranolaEnabled(result.enabled)
+ } catch (error) {
+ console.error('Failed to load Granola config:', error)
+ setGranolaEnabled(false)
+ } finally {
+ setGranolaLoading(false)
+ }
+ }, [])
+
+ // Update Granola config
+ const handleGranolaToggle = useCallback(async (enabled: boolean) => {
+ try {
+ setGranolaLoading(true)
+ await window.ipc.invoke('granola:setConfig', { enabled })
+ setGranolaEnabled(enabled)
+ toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
+ } catch (error) {
+ console.error('Failed to update Granola config:', error)
+ toast.error('Failed to update Granola sync settings')
+ } finally {
+ setGranolaLoading(false)
+ }
+ }, [])
+
+ // Load Slack config
+ const refreshSlackConfig = useCallback(async () => {
+ try {
+ setSlackLoading(true)
+ const result = await window.ipc.invoke('slack:getConfig', null)
+ setSlackEnabled(result.enabled)
+ setSlackWorkspaces(result.workspaces || [])
+ } catch (error) {
+ console.error('Failed to load Slack config:', error)
+ setSlackEnabled(false)
+ setSlackWorkspaces([])
+ } finally {
+ setSlackLoading(false)
+ }
+ }, [])
+
+ // Enable Slack: discover workspaces
+ const handleSlackEnable = useCallback(async () => {
+ setSlackDiscovering(true)
+ setSlackDiscoverError(null)
+ try {
+ const result = await window.ipc.invoke('slack:listWorkspaces', null)
+ if (result.error || result.workspaces.length === 0) {
+ setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
+ setSlackAvailableWorkspaces([])
+ setSlackPickerOpen(true)
+ } else {
+ setSlackAvailableWorkspaces(result.workspaces)
+ setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
+ setSlackPickerOpen(true)
+ }
+ } catch (error) {
+ console.error('Failed to discover Slack workspaces:', error)
+ setSlackDiscoverError('Failed to discover Slack workspaces')
+ setSlackPickerOpen(true)
+ } finally {
+ setSlackDiscovering(false)
+ }
+ }, [])
+
+ // Save selected Slack workspaces
+ const handleSlackSaveWorkspaces = useCallback(async () => {
+ const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
+ try {
+ setSlackLoading(true)
+ await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
+ setSlackEnabled(true)
+ setSlackWorkspaces(selected)
+ setSlackPickerOpen(false)
+ toast.success('Slack enabled')
+ } catch (error) {
+ console.error('Failed to save Slack config:', error)
+ toast.error('Failed to save Slack settings')
+ } finally {
+ setSlackLoading(false)
+ }
+ }, [slackAvailableWorkspaces, slackSelectedUrls])
+
+ // Disable Slack
+ const handleSlackDisable = useCallback(async () => {
+ try {
+ setSlackLoading(true)
+ await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
+ setSlackEnabled(false)
+ setSlackWorkspaces([])
+ setSlackPickerOpen(false)
+ toast.success('Slack disabled')
+ } catch (error) {
+ console.error('Failed to update Slack config:', error)
+ toast.error('Failed to update Slack settings')
+ } finally {
+ setSlackLoading(false)
+ }
+ }, [])
+
+ // Load Gmail connection status (Composio)
+ const refreshGmailStatus = useCallback(async () => {
+ try {
+ setGmailLoading(true)
+ const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
+ setGmailConnected(result.isConnected)
+ } catch (error) {
+ console.error('Failed to load Gmail status:', error)
+ setGmailConnected(false)
+ } finally {
+ setGmailLoading(false)
+ }
+ }, [])
+
+ // Connect to Gmail via Composio
+ const startGmailConnect = useCallback(async () => {
+ try {
+ setGmailConnecting(true)
+ const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
+ if (!result.success) {
+ toast.error(result.error || 'Failed to connect to Gmail')
+ setGmailConnecting(false)
+ }
+ } catch (error) {
+ console.error('Failed to connect to Gmail:', error)
+ toast.error('Failed to connect to Gmail')
+ setGmailConnecting(false)
+ }
+ }, [])
+
+ // Handle Gmail connect button click (checks Composio config first)
+ const handleConnectGmail = useCallback(async () => {
+ const configResult = await window.ipc.invoke('composio:is-configured', null)
+ if (!configResult.configured) {
+ setComposioApiKeyTarget('gmail')
+ setComposioApiKeyOpen(true)
+ return
+ }
+ await startGmailConnect()
+ }, [startGmailConnect])
+
+ // Handle Composio API key submission
+ const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
+ try {
+ await window.ipc.invoke('composio:set-api-key', { apiKey })
+ setComposioApiKeyOpen(false)
+ toast.success('Composio API key saved')
+ await startGmailConnect()
+ } catch (error) {
+ console.error('Failed to save Composio API key:', error)
+ toast.error('Failed to save API key')
+ }
+ }, [startGmailConnect])
+
+ // Load Google Calendar connection status (Composio)
+ const refreshGoogleCalendarStatus = useCallback(async () => {
+ try {
+ setGoogleCalendarLoading(true)
+ const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
+ setGoogleCalendarConnected(result.isConnected)
+ } catch (error) {
+ console.error('Failed to load Google Calendar status:', error)
+ setGoogleCalendarConnected(false)
+ } finally {
+ setGoogleCalendarLoading(false)
+ }
+ }, [])
+
+ // Connect to Google Calendar via Composio
+ const startGoogleCalendarConnect = useCallback(async () => {
+ try {
+ setGoogleCalendarConnecting(true)
+ const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' })
+ if (!result.success) {
+ toast.error(result.error || 'Failed to connect to Google Calendar')
+ setGoogleCalendarConnecting(false)
+ }
+ } catch (error) {
+ console.error('Failed to connect to Google Calendar:', error)
+ toast.error('Failed to connect to Google Calendar')
+ setGoogleCalendarConnecting(false)
+ }
+ }, [])
+
+ // Handle Google Calendar connect button click
+ const handleConnectGoogleCalendar = useCallback(async () => {
+ const configResult = await window.ipc.invoke('composio:is-configured', null)
+ if (!configResult.configured) {
+ setComposioApiKeyTarget('gmail')
+ setComposioApiKeyOpen(true)
+ return
+ }
+ await startGoogleCalendarConnect()
+ }, [startGoogleCalendarConnect])
+
+ // New step flow:
+ // Rowboat path: 0 (welcome) → 2 (connect) → 3 (done)
+ // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done)
+ const handleNext = useCallback(() => {
+ if (currentStep === 0) {
+ if (onboardingPath === 'byok') {
+ setCurrentStep(1)
+ } else {
+ setCurrentStep(2)
+ }
+ } else if (currentStep === 1) {
+ setCurrentStep(2)
+ } else if (currentStep === 2) {
+ setCurrentStep(3)
+ }
+ }, [currentStep, onboardingPath])
+
+ const handleBack = useCallback(() => {
+ if (currentStep === 1) {
+ setCurrentStep(0)
+ setOnboardingPath(null)
+ } else if (currentStep === 2) {
+ if (onboardingPath === 'rowboat') {
+ setCurrentStep(0)
+ } else {
+ setCurrentStep(1)
+ }
+ }
+ }, [currentStep, onboardingPath])
+
+ const handleComplete = useCallback(() => {
+ onComplete()
+ }, [onComplete])
+
+ const handleTestAndSaveLlmConfig = useCallback(async () => {
+ if (!canTest) return
+ setTestState({ status: "testing" })
+ try {
+ const apiKey = activeConfig.apiKey.trim() || undefined
+ const baseURL = activeConfig.baseURL.trim() || undefined
+ const model = activeConfig.model.trim()
+ const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
+ const providerConfig = {
+ provider: {
+ flavor: llmProvider,
+ apiKey,
+ baseURL,
+ },
+ model,
+ knowledgeGraphModel,
+ }
+ const result = await window.ipc.invoke("models:test", providerConfig)
+ if (result.success) {
+ setTestState({ status: "success" })
+ await window.ipc.invoke("models:saveConfig", providerConfig)
+ window.dispatchEvent(new Event('models-config-changed'))
+ handleNext()
+ } else {
+ setTestState({ status: "error", error: result.error })
+ toast.error(result.error || "Connection test failed")
+ }
+ } catch (error) {
+ console.error("Connection test failed:", error)
+ setTestState({ status: "error", error: "Connection test failed" })
+ toast.error("Connection test failed")
+ }
+ }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
+
+ // Check connection status for all providers
+ const refreshAllStatuses = useCallback(async () => {
+ refreshGranolaConfig()
+ refreshSlackConfig()
+
+ // Refresh Gmail Composio status if enabled
+ if (useComposioForGoogle) {
+ refreshGmailStatus()
+ }
+
+ // Refresh Google Calendar Composio status if enabled
+ if (useComposioForGoogleCalendar) {
+ refreshGoogleCalendarStatus()
+ }
+
+ if (providers.length === 0) return
+
+ const newStates: Record = {}
+
+ try {
+ const result = await window.ipc.invoke('oauth:getState', null)
+ const config = result.config || {}
+ for (const provider of providers) {
+ newStates[provider] = {
+ isConnected: config[provider]?.connected ?? false,
+ isLoading: false,
+ isConnecting: false,
+ }
+ }
+ } catch (error) {
+ console.error('Failed to check connection status for providers:', error)
+ for (const provider of providers) {
+ newStates[provider] = {
+ isConnected: false,
+ isLoading: false,
+ isConnecting: false,
+ }
+ }
+ }
+
+ setProviderStates(newStates)
+ }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
+
+ // Refresh statuses when modal opens or providers list changes
+ useEffect(() => {
+ if (open && providers.length > 0) {
+ refreshAllStatuses()
+ }
+ }, [open, providers, refreshAllStatuses])
+
+ // Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover)
+ useEffect(() => {
+ const cleanup = window.ipc.on('oauth:didConnect', (event) => {
+ const { provider, success } = event
+
+ setProviderStates(prev => ({
+ ...prev,
+ [provider]: {
+ isConnected: success,
+ isLoading: false,
+ isConnecting: false,
+ }
+ }))
+ })
+
+ return cleanup
+ }, [])
+
+ // Auto-advance from Rowboat sign-in step when OAuth completes
+ useEffect(() => {
+ if (onboardingPath !== 'rowboat' || currentStep !== 0) return
+
+ const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
+ if (event.provider === 'rowboat' && event.success) {
+ // Re-check composio flags now that the account is connected
+ try {
+ const [googleResult, calendarResult] = await Promise.all([
+ window.ipc.invoke('composio:use-composio-for-google', null),
+ window.ipc.invoke('composio:use-composio-for-google-calendar', null),
+ ])
+ setUseComposioForGoogle(googleResult.enabled)
+ setUseComposioForGoogleCalendar(calendarResult.enabled)
+ } catch (error) {
+ console.error('Failed to re-check composio flags:', error)
+ }
+ setCurrentStep(2) // Go to Connect Accounts
+ }
+ })
+
+ return cleanup
+ }, [onboardingPath, currentStep])
+
+ // Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover)
+ useEffect(() => {
+ const cleanup = window.ipc.on('composio:didConnect', (event) => {
+ const { toolkitSlug, success } = event
+
+ if (toolkitSlug === 'slack') {
+ setSlackEnabled(success)
+ }
+
+ if (toolkitSlug === 'gmail') {
+ setGmailConnected(success)
+ setGmailConnecting(false)
+ }
+
+ if (toolkitSlug === 'googlecalendar') {
+ setGoogleCalendarConnected(success)
+ setGoogleCalendarConnecting(false)
+ }
+ })
+
+ return cleanup
+ }, [])
+
+ const startConnect = useCallback(async (provider: string, clientId?: string) => {
+ setProviderStates(prev => ({
+ ...prev,
+ [provider]: { ...prev[provider], isConnecting: true }
+ }))
+
+ try {
+ const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
+
+ if (!result.success) {
+ toast.error(result.error || `Failed to connect to ${provider}`)
+ setProviderStates(prev => ({
+ ...prev,
+ [provider]: { ...prev[provider], isConnecting: false }
+ }))
+ }
+ } catch (error) {
+ console.error('Failed to connect:', error)
+ toast.error(`Failed to connect to ${provider}`)
+ setProviderStates(prev => ({
+ ...prev,
+ [provider]: { ...prev[provider], isConnecting: false }
+ }))
+ }
+ }, [])
+
+ // Connect to a provider
+ const handleConnect = useCallback(async (provider: string) => {
+ if (provider === 'google') {
+ const existingClientId = getGoogleClientId()
+ if (!existingClientId) {
+ setGoogleClientIdOpen(true)
+ return
+ }
+ await startConnect(provider, existingClientId)
+ return
+ }
+
+ await startConnect(provider)
+ }, [startConnect])
+
+ const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
+ setGoogleClientId(clientId)
+ setGoogleClientIdOpen(false)
+ startConnect('google', clientId)
+ }, [startConnect])
+
+ // Switch to rowboat path from BYOK inline callout
+ const handleSwitchToRowboat = useCallback(() => {
+ setOnboardingPath('rowboat')
+ setCurrentStep(0)
+ }, [])
+
+ return {
+ // Step state
+ currentStep,
+ setCurrentStep,
+ onboardingPath,
+ setOnboardingPath,
+
+ // LLM state
+ llmProvider,
+ setLlmProvider,
+ modelsCatalog,
+ modelsLoading,
+ modelsError,
+ providerConfigs,
+ activeConfig,
+ testState,
+ setTestState,
+ showApiKey,
+ requiresApiKey,
+ requiresBaseURL,
+ showBaseURL,
+ isLocalProvider,
+ canTest,
+ showMoreProviders,
+ setShowMoreProviders,
+ updateProviderConfig,
+ handleTestAndSaveLlmConfig,
+
+ // OAuth state
+ providers,
+ providersLoading,
+ providerStates,
+ googleClientIdOpen,
+ setGoogleClientIdOpen,
+ connectedProviders,
+ handleConnect,
+ handleGoogleClientIdSubmit,
+ startConnect,
+
+ // Granola state
+ granolaEnabled,
+ granolaLoading,
+ handleGranolaToggle,
+
+ // Slack state
+ slackEnabled,
+ slackLoading,
+ slackWorkspaces,
+ slackAvailableWorkspaces,
+ slackSelectedUrls,
+ setSlackSelectedUrls,
+ slackPickerOpen,
+ slackDiscovering,
+ slackDiscoverError,
+ handleSlackEnable,
+ handleSlackSaveWorkspaces,
+ handleSlackDisable,
+
+ // Upsell
+ upsellDismissed,
+ setUpsellDismissed,
+
+ // Composio/Gmail state
+ useComposioForGoogle,
+ gmailConnected,
+ gmailLoading,
+ gmailConnecting,
+ composioApiKeyOpen,
+ setComposioApiKeyOpen,
+ composioApiKeyTarget,
+ handleConnectGmail,
+ handleComposioApiKeySubmit,
+
+ // Composio/Google Calendar state
+ useComposioForGoogleCalendar,
+ googleCalendarConnected,
+ googleCalendarLoading,
+ googleCalendarConnecting,
+ handleConnectGoogleCalendar,
+
+ // Navigation
+ handleNext,
+ handleBack,
+ handleComplete,
+ handleSwitchToRowboat,
+ }
+}
+
+export type OnboardingState = ReturnType
diff --git a/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
new file mode 100644
index 00000000..a5a63bc7
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
@@ -0,0 +1,109 @@
+import { useState, useRef, useEffect } from 'react'
+import { Loader2 } from 'lucide-react'
+
+interface RowboatMentionPopoverProps {
+ open: boolean
+ anchor: { top: number; left: number; width: number } | null
+ initialText?: string
+ onAdd: (instruction: string) => void | Promise
+ onRemove?: () => void
+ onClose: () => void
+}
+
+export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
+ const [text, setText] = useState('')
+ const [loading, setLoading] = useState(false)
+ const textareaRef = useRef(null)
+ const containerRef = useRef(null)
+
+ useEffect(() => {
+ if (open) {
+ setText(initialText)
+ setLoading(false)
+ requestAnimationFrame(() => {
+ textareaRef.current?.focus()
+ })
+ }
+ }, [open, initialText])
+
+ // Close on outside click
+ useEffect(() => {
+ if (!open) return
+ const handleMouseDown = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ onClose()
+ }
+ }
+ document.addEventListener('mousedown', handleMouseDown)
+ return () => document.removeEventListener('mousedown', handleMouseDown)
+ }, [open, onClose])
+
+ if (!open || !anchor) return null
+
+ const handleSubmit = async () => {
+ const trimmed = text.trim()
+ if (!trimmed || loading) return
+ setLoading(true)
+ try {
+ await onAdd(trimmed)
+ } finally {
+ setLoading(false)
+ }
+ setText('')
+ }
+
+ return (
+
+
+
+ @rowboat
+
+
+ {onRemove && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx
index c328db35..3e37cb6d 100644
--- a/apps/x/apps/renderer/src/components/settings-dialog.tsx
+++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx
@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
-import { useState, useEffect, useCallback } from "react"
-import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink } from "lucide-react"
+import { useState, useEffect, useCallback, useMemo } from "react"
+import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import {
Dialog,
@@ -18,11 +18,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
+import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
+import { AccountSettings } from "@/components/settings/account-settings"
+import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
-type ConfigTab = "models" | "mcp" | "security" | "appearance" | "tools"
+type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
interface TabConfig {
id: ConfigTab
@@ -33,6 +36,18 @@ interface TabConfig {
}
const tabs: TabConfig[] = [
+ {
+ id: "account",
+ label: "Account",
+ icon: User,
+ description: "Manage your Rowboat account",
+ },
+ {
+ id: "connected-accounts",
+ label: "Connected Accounts",
+ icon: Plug,
+ description: "Manage connected services",
+ },
{
id: "models",
label: "Models",
@@ -66,6 +81,13 @@ const tabs: TabConfig[] = [
icon: Wrench,
description: "Browse and enable Composio toolkits",
},
+ {
+ id: "note-tagging",
+ label: "Note Tagging",
+ icon: Tags,
+ path: "config/tags.json",
+ description: "Configure tags for notes and emails",
+ },
]
interface SettingsDialogProps {
@@ -1225,18 +1247,561 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
+// --- Rowboat Model Settings (when signed in via Rowboat) ---
+
+function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
+ const [gatewayModels, setGatewayModels] = useState([])
+ const [selectedModel, setSelectedModel] = useState("")
+ const [selectedKgModel, setSelectedKgModel] = useState("")
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (!dialogOpen) return
+
+ async function load() {
+ setLoading(true)
+ try {
+ // Fetch gateway models
+ const listResult = await window.ipc.invoke("models:list", null)
+ const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat")
+ const models = rowboatProvider?.models || []
+ setGatewayModels(models)
+
+ // Read current selection from config
+ try {
+ const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
+ const parsed = JSON.parse(configResult.data)
+ if (parsed?.model) setSelectedModel(parsed.model)
+ if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel)
+ } catch {
+ // No config yet — pick first model as default
+ if (models.length > 0) setSelectedModel(models[0].id)
+ }
+ } catch {
+ toast.error("Failed to load models")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ load()
+ }, [dialogOpen])
+
+ const handleSave = useCallback(async () => {
+ if (!selectedModel) return
+ setSaving(true)
+ try {
+ await window.ipc.invoke("models:saveConfig", {
+ provider: { flavor: "openrouter" as const },
+ model: selectedModel,
+ knowledgeGraphModel: selectedKgModel || undefined,
+ })
+ window.dispatchEvent(new Event("models-config-changed"))
+ toast.success("Model configuration saved")
+ } catch {
+ toast.error("Failed to save model configuration")
+ } finally {
+ setSaving(false)
+ }
+ }, [selectedModel, selectedKgModel])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ Select the models Rowboat uses. These are provided through your Rowboat account.
+
+
+ {/* Assistant model */}
+
+
+
+
+
+ {/* Knowledge graph model */}
+
+
+
+
+
+ {/* Save */}
+
+
+ )
+}
+
+// --- Note Tagging Settings ---
+
+interface TagDef {
+ tag: string
+ type: string
+ applicability: "email" | "notes" | "both"
+ description: string
+ example?: string
+ noteEffect?: "create" | "skip" | "none"
+}
+
+const NOTE_TAG_TYPE_ORDER = [
+ "relationship", "relationship-sub", "topic", "action", "status", "source",
+]
+
+const EMAIL_TAG_TYPE_ORDER = [
+ "relationship", "topic", "email-type", "filter", "action", "status",
+]
+
+const TAG_TYPE_LABELS: Record = {
+ "relationship": "Relationship",
+ "relationship-sub": "Relationship Sub-Tags",
+ "topic": "Topic",
+ "email-type": "Email Type",
+ "filter": "Filter",
+ "action": "Action",
+ "status": "Status",
+ "source": "Source",
+}
+
+const DEFAULT_TAGS: TagDef[] = [
+ { tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
+ { tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
+ { tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
+ { tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
+ { tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
+ { tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
+ { tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
+ { tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
+ { tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
+ { tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
+ { tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
+ { tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
+ { tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
+ { tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
+ { tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
+ { tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
+ { tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
+ { tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
+ { tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
+ { tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
+ { tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
+ { tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
+ { tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
+ { tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
+ { tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
+ { tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
+ { tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
+ { tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
+ { tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
+ { tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
+ { tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
+ { tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
+ { tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
+ { tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
+ { tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
+ { tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
+ { tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
+ { tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
+ { tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
+ { tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
+ { tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
+ { tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
+ { tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
+ { tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
+ { tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
+ { tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
+ { tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" },
+ { tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
+ { tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
+ { tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
+ { tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
+ { tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
+ { tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
+ { tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
+ { tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
+ { tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
+ { tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
+ { tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
+ { tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
+]
+
+function TagGroupTable({
+ group,
+ tags: _tags,
+ collapsed,
+ onToggle,
+ onAdd,
+ onUpdate,
+ onRemove,
+ getGlobalIndex,
+ isEmail,
+}: {
+ group: { type: string; label: string; tags: TagDef[] }
+ tags: TagDef[]
+ collapsed: boolean
+ onToggle: () => void
+ onAdd: () => void
+ onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
+ onRemove: (index: number) => void
+ getGlobalIndex: (type: string, localIndex: number) => number
+ isEmail: boolean
+}) {
+ return (
+
+
+
+
+
+ {!collapsed && group.tags.length > 0 && (
+
+
+
Label
+
Description
+
Example
+ {isEmail &&
Skip notes
}
+
+
+ {group.tags.map((tag, localIdx) => {
+ const globalIdx = getGlobalIndex(group.type, localIdx)
+ return (
+
+ )
+ })}
+
+ )}
+ {!collapsed && group.tags.length === 0 && (
+
No tags in this group
+ )}
+
+ )
+}
+
+function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
+ const [tags, setTags] = useState([])
+ const [originalTags, setOriginalTags] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set())
+ const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
+
+ const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
+
+ useEffect(() => {
+ if (!dialogOpen) return
+ async function load() {
+ setLoading(true)
+ try {
+ const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
+ const parsed = JSON.parse(result.data)
+ setTags(parsed)
+ setOriginalTags(parsed)
+ } catch {
+ setTags([...DEFAULT_TAGS])
+ setOriginalTags([...DEFAULT_TAGS])
+ } finally {
+ setLoading(false)
+ }
+ }
+ load()
+ }, [dialogOpen])
+
+ const noteGroups = useMemo(() => {
+ const map = new Map()
+ for (const tag of tags) {
+ if (tag.applicability === "email") continue
+ const list = map.get(tag.type) ?? []
+ list.push(tag)
+ map.set(tag.type, list)
+ }
+ return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
+ type,
+ label: TAG_TYPE_LABELS[type],
+ tags: map.get(type) ?? [],
+ }))
+ }, [tags])
+
+ const emailGroups = useMemo(() => {
+ const map = new Map()
+ for (const tag of tags) {
+ if (tag.applicability === "notes") continue
+ const list = map.get(tag.type) ?? []
+ list.push(tag)
+ map.set(tag.type, list)
+ }
+ return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
+ type,
+ label: TAG_TYPE_LABELS[type],
+ tags: map.get(type) ?? [],
+ }))
+ }, [tags])
+
+ const getGlobalIndex = useCallback((type: string, localIndex: number) => {
+ let count = 0
+ for (let i = 0; i < tags.length; i++) {
+ if (tags[i].type === type) {
+ if (count === localIndex) return i
+ count++
+ }
+ }
+ return -1
+ }, [tags])
+
+ const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
+ setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
+ }, [])
+
+ const removeTag = useCallback((index: number) => {
+ setTags(prev => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const addTag = useCallback((type: string) => {
+ const isEmailSection = activeSection === "email"
+ const applicability = isEmailSection ? "email" as const : "notes" as const
+ // For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
+ const emailOnlyTypes = ["email-type", "filter"]
+ const notesOnlyTypes = ["relationship-sub", "source"]
+ let finalApplicability: "email" | "notes" | "both" = "both"
+ if (emailOnlyTypes.includes(type)) finalApplicability = "email"
+ else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
+ else finalApplicability = isEmailSection ? "email" : applicability
+
+ const newTag: TagDef = {
+ tag: "",
+ type,
+ applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
+ description: "",
+ noteEffect: isEmailSection ? "create" : "none",
+ }
+ const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
+ if (lastIndex === -1) {
+ setTags(prev => [...prev, newTag])
+ } else {
+ setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
+ }
+ }, [tags, activeSection])
+
+ const handleSave = useCallback(async () => {
+ setSaving(true)
+ try {
+ await window.ipc.invoke("workspace:writeFile", {
+ path: "config/tags.json",
+ data: JSON.stringify(tags, null, 2),
+ })
+ setOriginalTags([...tags])
+ toast.success("Tag configuration saved")
+ } catch {
+ toast.error("Failed to save tag configuration")
+ } finally {
+ setSaving(false)
+ }
+ }, [tags])
+
+ const handleReset = useCallback(() => {
+ if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
+ setTags([...DEFAULT_TAGS])
+ }, [])
+
+ const toggleGroup = useCallback((type: string) => {
+ setCollapsedGroups(prev => {
+ const next = new Set(prev)
+ if (next.has(type)) next.delete(type)
+ else next.add(type)
+ return next
+ })
+ }, [])
+
+ if (loading) {
+ return (
+
+
+ Loading...
+
+ )
+ }
+
+ const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
+
+ return (
+
+
+
+
+
+
+ {currentGroups.map(group => (
+ toggleGroup(group.type)}
+ onAdd={() => addTag(group.type)}
+ onUpdate={updateTag}
+ onRemove={removeTag}
+ getGlobalIndex={getGlobalIndex}
+ isEmail={activeSection === "email"}
+ />
+ ))}
+
+
+
+ {hasChanges && (
+ Unsaved changes
+ )}
+
+
+
+
+
+
+
+ )
+}
+
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
- const [activeTab, setActiveTab] = useState("models")
+ const [activeTab, setActiveTab] = useState("account")
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
+ const [rowboatConnected, setRowboatConnected] = useState(false)
- const activeTabConfig = tabs.find((t) => t.id === activeTab)!
+ // Check if user is signed in to Rowboat
+ useEffect(() => {
+ if (!open) return
+ window.ipc.invoke('oauth:getState', null).then((result) => {
+ const connected = result.config?.rowboat?.connected ?? false
+ setRowboatConnected(connected)
+ }).catch(() => {
+ setRowboatConnected(false)
+ })
+ }, [open])
+
+ const visibleTabs = useMemo(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected])
+
+ const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
const isJsonTab = activeTab === "mcp" || activeTab === "security"
const formatJson = (jsonString: string): string => {
@@ -1248,7 +1813,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
- if (tab === "appearance" || tab === "models") return
+ if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@@ -1325,7 +1890,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
Settings