From 2806a496a60ef3942b0803f19f2f12e453895271 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 16 Mar 2026 11:08:02 +0530 Subject: [PATCH] onboarding ui refactor --- apps/x/apps/renderer/src/App.css | 62 ++ apps/x/apps/renderer/src/App.tsx | 2 +- .../src/components/google-client-id-modal.tsx | 65 +- .../src/components/onboarding/index.tsx | 76 +++ .../components/onboarding/provider-icons.tsx | 107 ++++ .../components/onboarding/step-indicator.tsx | 68 +++ .../onboarding/steps/completion-step.tsx | 132 +++++ .../steps/connect-accounts-step.tsx | 267 +++++++++ .../onboarding/steps/llm-setup-step.tsx | 300 ++++++++++ .../onboarding/steps/welcome-step.tsx | 90 +++ .../onboarding/use-onboarding-state.ts | 556 ++++++++++++++++++ .../src/components/sidebar-content.tsx | 2 +- 12 files changed, 1695 insertions(+), 32 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/onboarding/index.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/provider-icons.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx create mode 100644 apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 991236ea..5c1eabb2 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -49,6 +49,15 @@ color: #888; } +/* Onboarding dot grid background */ +.onboarding-dot-grid { + background-image: radial-gradient(circle, oklch(0.5 0 0 / 0.08) 1px, transparent 1px); + background-size: 24px 24px; +} +.dark .onboarding-dot-grid { + background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -293,3 +302,56 @@ pointer-events: none; user-select: none; } + +/* Upgrade button: grainy gradient sweep on hover */ +.upgrade-btn { + position: relative; + overflow: hidden; + isolation: isolate; +} + +.upgrade-btn::before { + content: ''; + position: absolute; + inset: 0; + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), + linear-gradient( + 90deg, + transparent 0%, + rgba(168, 85, 247, 0.35) 20%, + rgba(236, 72, 153, 0.4) 40%, + rgba(251, 146, 60, 0.35) 60%, + rgba(168, 85, 247, 0.3) 80%, + transparent 100% + ); + background-size: 100px 100px, 100% 100%; + transform: translateX(-120%); + opacity: 0; + z-index: 1; + pointer-events: none; + border-radius: inherit; +} + +.upgrade-btn:hover::before { + animation: grain-sweep 2.4s ease-in-out infinite; +} + +@keyframes grain-sweep { + 0% { + opacity: 1; + transform: translateX(-120%); + } + 45% { + opacity: 1; + transform: translateX(120%); + } + 55% { + opacity: 1; + transform: translateX(120%); + } + 100% { + opacity: 1; + transform: translateX(-120%); + } +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 45044f5f..01ad2b52 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -49,7 +49,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' -import { OnboardingModal } from '@/components/onboarding-modal' +import { OnboardingModal } from '@/components/onboarding' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { VersionHistoryPanel } from '@/components/version-history-panel' diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx index c4df07a2..3ef536d9 100644 --- a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -47,19 +47,37 @@ export function GoogleClientIdModal({ return ( - - - Enter Google Client ID - - {description ?? "Enter the client ID for your Google OAuth app to continue."} - - -
- -
- Need help setting this up?{" "} + +
+ + Google Client ID + + {description ?? "Enter the client ID for your Google OAuth app to connect."} + + +
+
+
+ + setClientId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + className="font-mono text-xs" + autoFocus + /> +
+

+ Need help?{" "} Read the setup guide - . -

- setClientId(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - handleSubmit() - } - }} - autoFocus - /> +

-
+
-
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..8cb10700 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -0,0 +1,76 @@ +"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 { 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 ( + <> + + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > +
+ + + + {stepContent} + + +
+
+
+ + ) +} 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..73d9e73b --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/completion-step.tsx @@ -0,0 +1,132 @@ +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, handleComplete } = state + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled + + 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

+
+ {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..0f34fdf2 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/connect-accounts-step.tsx @@ -0,0 +1,267 @@ +import { Loader2, CheckCircle2, ArrowLeft } 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, + 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 */} + {providers.includes('google') && ( +
+ + Email & Calendar + + } + iconBg="bg-red-500/10" + iconColor="text-red-500" + providerState={providerStates['google']} + onConnect={() => handleConnect('google')} + 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..31a7308a --- /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, requiresApiKey, + showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders, + updateProviderConfig, handleTestAndSaveLlmConfig, handleBack, + upsellDismissed, setUpsellDismissed, handleSwitchToRowboat, + } = state + + const isMoreProvider = moreProviders.some(p => p.id === llmProvider) + const modelsForProvider = modelsCatalog[llmProvider] || [] + const showModelInput = isLocalProvider || modelsForProvider.length === 0 + + const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => { + const isSelected = llmProvider === provider.id + return ( + { + 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..08a0f9cb --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/welcome-step.tsx @@ -0,0 +1,90 @@ +import { Loader2, CheckCircle2 } from "lucide-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 */} + Rowboat + + {/* Tagline badge */} +
+ + Your AI coworker, with memory +
+ + {/* Main heading */} +

+ Welcome to Rowboat +

+

+ Connect your Rowboat account for instant access to all models through our gateway — no API keys needed. +

+ + {/* Product preview placeholder */} +
+ Product Preview +
+ + {/* 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..0a7e65eb --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -0,0 +1,556 @@ +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) + + 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 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) + } + } + loadProviders() + }, [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) + } + }, []) + + // 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) + 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() + + 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]) + + // Refresh statuses when modal opens or providers list changes + useEffect(() => { + if (open && providers.length > 0) { + refreshAllStatuses() + } + }, [open, providers, refreshAllStatuses]) + + // Listen for OAuth completion events + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + const { provider, success, error } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + + if (success) { + const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) + toast.success(`Connected to ${displayName}`) + } else { + toast.error(error || `Failed to connect to ${provider}`) + } + }) + + return cleanup + }, []) + + // Auto-advance from Rowboat sign-in step when OAuth completes + useEffect(() => { + if (onboardingPath !== 'rowboat' || currentStep !== 0) return + + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' && event.success) { + setCurrentStep(2) // Go to Connect Accounts + } + }) + + return cleanup + }, [onboardingPath, currentStep]) + + // Listen for Composio connection events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'slack') { + setSlackEnabled(success) + + if (success) { + toast.success('Connected to Slack') + } else { + toast.error(error || 'Failed to connect to Slack') + } + } + }) + + 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, + + // Navigation + handleNext, + handleBack, + handleComplete, + handleSwitchToRowboat, + } +} + +export type OnboardingState = ReturnType diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 40061dbf..db18a8df 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -500,7 +500,7 @@ export function SidebarContentPanel({ 8 days left
-