mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
onboarding ui refactor
This commit is contained in:
parent
3674eb77ad
commit
2806a496a6
12 changed files with 1695 additions and 32 deletions
|
|
@ -49,6 +49,15 @@
|
|||
color: #888;
|
||||
}
|
||||
|
||||
/* Onboarding dot grid background */
|
||||
.onboarding-dot-grid {
|
||||
background-image: radial-gradient(circle, oklch(0.5 0 0 / 0.08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.dark .onboarding-dot-grid {
|
||||
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
|
|
@ -293,3 +302,56 @@
|
|||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Upgrade button: grainy gradient sweep on hover */
|
||||
.upgrade-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.upgrade-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(168, 85, 247, 0.35) 20%,
|
||||
rgba(236, 72, 153, 0.4) 40%,
|
||||
rgba(251, 146, 60, 0.35) 60%,
|
||||
rgba(168, 85, 247, 0.3) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100px 100px, 100% 100%;
|
||||
transform: translateX(-120%);
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.upgrade-btn:hover::before {
|
||||
animation: grain-sweep 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes grain-sweep {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
transform: translateX(120%);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateX(120%);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
|
|
|
|||
|
|
@ -47,19 +47,37 @@ export function GoogleClientIdModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enter Google Client ID</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description ?? "Enter the client ID for your Google OAuth app to continue."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
|
||||
Client ID
|
||||
</label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Need help setting this up?{" "}
|
||||
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
|
||||
<div className="p-6 pb-0">
|
||||
<DialogHeader className="space-y-1.5">
|
||||
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
{description ?? "Enter the client ID for your Google OAuth app to connect."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-id">
|
||||
Client ID
|
||||
</label>
|
||||
<Input
|
||||
id="google-client-id"
|
||||
placeholder="xxxxxxxxxxxx-xxxx.apps.googleusercontent.com"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Need help?{" "}
|
||||
<a
|
||||
className="text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
href={GOOGLE_CLIENT_ID_SETUP_GUIDE_URL}
|
||||
|
|
@ -68,31 +86,18 @@ export function GoogleClientIdModal({
|
|||
>
|
||||
Read the setup guide
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<Input
|
||||
id="google-client-id"
|
||||
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
76
apps/x/apps/renderer/src/components/onboarding/index.tsx
Normal file
76
apps/x/apps/renderer/src/components/onboarding/index.tsx
Normal file
|
|
@ -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 <WelcomeStep state={state} />
|
||||
case 1:
|
||||
return <LlmSetupStep state={state} />
|
||||
case 2:
|
||||
return <ConnectAccountsStep state={state} />
|
||||
case 3:
|
||||
return <CompletionStep state={state} />
|
||||
}
|
||||
}, [state.currentStep, state])
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleClientIdModal
|
||||
open={state.googleClientIdOpen}
|
||||
onOpenChange={state.setGoogleClientIdOpen}
|
||||
onSubmit={state.handleGoogleClientIdSubmit}
|
||||
isSubmitting={state.providerStates.google?.isConnecting ?? false}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="w-[90vw] max-w-2xl max-h-[85vh] p-0 overflow-hidden"
|
||||
showCloseButton={false}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
|
||||
<StepIndicator
|
||||
currentStep={state.currentStep}
|
||||
path={state.onboardingPath}
|
||||
/>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={state.currentStep}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
{stepContent}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface IconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function OpenAIIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnthropicIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M17.304 3.541h-3.483l6.15 16.918h3.483zm-10.61 0L.545 20.459H4.15l1.278-3.554h6.539l1.278 3.554h3.604L10.698 3.541zm.49 10.537 2.065-5.728h.054l2.065 5.728z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OllamaIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-2-11a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm4 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-5.07 5.14a.5.5 0 0 1 .71-.07A4.97 4.97 0 0 0 12 15.5c.93 0 1.8-.26 2.53-.7a.5.5 0 1 1 .51.86A5.97 5.97 0 0 1 12 16.5a5.97 5.97 0 0 1-3.14-.88.5.5 0 0 1 .07-.48z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenRouterIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M4 4h7v7H4zm9 0h7v7h-7zm-9 9h7v7H4zm9 0h7v7h-7z" opacity="0.8" />
|
||||
<path d="M6 6h3v3H6zm9 0h3v3h-3zM6 15h3v3H6zm9 0h3v3h-3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VercelIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 1L24 22H0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GmailIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlackIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("size-5", className)}>
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A" />
|
||||
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0" />
|
||||
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.271 0a2.527 2.527 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.164 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" fill="#2EB67D" />
|
||||
<path d="M15.164 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.164 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.314A2.528 2.528 0 0 1 24 15.164a2.528 2.528 0 0 1-2.522 2.521h-6.314z" fill="#ECB22E" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FirefliesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<circle cx="12" cy="6" r="2" opacity="0.9" />
|
||||
<circle cx="7" cy="9" r="1.5" opacity="0.7" />
|
||||
<circle cx="17" cy="9" r="1.5" opacity="0.7" />
|
||||
<circle cx="5" cy="13" r="1" opacity="0.5" />
|
||||
<circle cx="19" cy="13" r="1" opacity="0.5" />
|
||||
<circle cx="8" cy="16" r="1.5" opacity="0.6" />
|
||||
<circle cx="16" cy="16" r="1.5" opacity="0.6" />
|
||||
<circle cx="12" cy="19" r="2" opacity="0.8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GranolaIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-1v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h1V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2zm0 2h-2v1h4V4h-2zm5 3H7v2h10V7zM4 11v6h16v-6H4zm3 10h10v-2H7v2z" opacity="0.85" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenericApiIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={cn("size-5", className)}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 13h8v2H8v-2zm0 4h5v2H8v-2z" opacity="0.8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from "react"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Step, OnboardingPath } from "./use-onboarding-state"
|
||||
|
||||
const ROWBOAT_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: Step
|
||||
path: OnboardingPath
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
||||
const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
|
||||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-8 px-4">
|
||||
{steps.map((s, i) => (
|
||||
<React.Fragment key={s.step}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-px flex-1 transition-colors duration-500",
|
||||
i <= currentIndex ? "bg-primary" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-xs font-medium transition-all duration-300",
|
||||
i < currentIndex && "bg-primary text-primary-foreground",
|
||||
i === currentIndex && "bg-primary text-primary-foreground ring-4 ring-primary/20",
|
||||
i > currentIndex && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{i < currentIndex ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium transition-colors duration-300",
|
||||
i <= currentIndex ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,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 (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Animated checkmark */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
{/* Pulsing ring */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0.6 }}
|
||||
animate={{ scale: 1.5, opacity: 0 }}
|
||||
transition={{ duration: 1.2, repeat: 2, ease: "easeOut" }}
|
||||
className="absolute inset-0 rounded-full bg-green-500/20"
|
||||
/>
|
||||
<div className="relative size-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle2 className="size-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="text-3xl font-bold tracking-tight mb-3"
|
||||
>
|
||||
You're All Set!
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8"
|
||||
>
|
||||
{hasConnections ? (
|
||||
<>Give me 30 minutes to build your context graph. I can still help with other things on your computer.</>
|
||||
) : (
|
||||
<>You can connect your accounts anytime from the sidebar to start syncing data.</>
|
||||
)}
|
||||
</motion.p>
|
||||
|
||||
{/* Connected accounts summary */}
|
||||
{hasConnections && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
className="w-full max-w-sm rounded-xl border bg-muted/30 p-4 mb-8"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-3 text-left">Connected</p>
|
||||
<div className="space-y-2">
|
||||
{connectedProviders.includes('google') && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Google (Email & Calendar)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{connectedProviders.includes('fireflies-ai') && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Fireflies (Meeting transcripts)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{granolaEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Granola (Local meeting notes)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{slackEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.65 }}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400" />
|
||||
<span>Slack (Team communication)</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
size="lg"
|
||||
className="w-full max-w-xs h-12 text-base font-medium"
|
||||
>
|
||||
Start Using Rowboat
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06 }}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 rounded-xl border p-4 transition-colors",
|
||||
isConnected
|
||||
? "border-green-200 bg-green-50/50 dark:border-green-800/50 dark:bg-green-900/10"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", iconBg)}>
|
||||
<span className={iconColor}>{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">{name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{rightSlot ?? (
|
||||
providerState?.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : isConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onConnect}
|
||||
disabled={providerState?.isConnecting}
|
||||
>
|
||||
{providerState?.isConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectAccountsStep({ state }: ConnectAccountsStepProps) {
|
||||
const {
|
||||
providers, providersLoading, providerStates, handleConnect,
|
||||
granolaEnabled, granolaLoading, handleGranolaToggle,
|
||||
slackEnabled, slackLoading, slackWorkspaces, slackAvailableWorkspaces,
|
||||
slackSelectedUrls, setSlackSelectedUrls, slackPickerOpen,
|
||||
slackDiscovering, slackDiscoverError,
|
||||
handleSlackEnable, handleSlackSaveWorkspaces, handleSlackDisable,
|
||||
handleNext, handleBack,
|
||||
} = state
|
||||
|
||||
let cardIndex = 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Connect Your Accounts
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center leading-relaxed mb-8">
|
||||
Connect your accounts to give Rowboat context about your work. You can always add more later.
|
||||
</p>
|
||||
|
||||
{providersLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Email & Calendar */}
|
||||
{providers.includes('google') && (
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Email & Calendar
|
||||
</span>
|
||||
<ProviderCard
|
||||
name="Google"
|
||||
description="Rowboat uses your email and calendar to provide personalized, context-aware assistance"
|
||||
icon={<GmailIcon />}
|
||||
iconBg="bg-red-500/10"
|
||||
iconColor="text-red-500"
|
||||
providerState={providerStates['google']}
|
||||
onConnect={() => handleConnect('google')}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meeting Notes */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Meeting Notes
|
||||
</span>
|
||||
<ProviderCard
|
||||
name="Granola"
|
||||
description="Sync your local meeting notes for richer context"
|
||||
icon={<GranolaIcon />}
|
||||
iconBg="bg-purple-500/10"
|
||||
iconColor="text-purple-500"
|
||||
providerState={{ isConnected: granolaEnabled, isLoading: false, isConnecting: false }}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
{granolaLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={handleGranolaToggle}
|
||||
disabled={granolaLoading}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
{providers.includes('fireflies-ai') && (
|
||||
<ProviderCard
|
||||
name="Fireflies"
|
||||
description="Import AI-powered meeting transcripts automatically"
|
||||
icon={<FirefliesIcon />}
|
||||
iconBg="bg-amber-500/10"
|
||||
iconColor="text-amber-500"
|
||||
providerState={providerStates['fireflies-ai']}
|
||||
onConnect={() => handleConnect('fireflies-ai')}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Communication */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Team Communication
|
||||
</span>
|
||||
<div>
|
||||
<ProviderCard
|
||||
name="Slack"
|
||||
description={
|
||||
slackEnabled && slackWorkspaces.length > 0
|
||||
? slackWorkspaces.map(w => w.name).join(', ')
|
||||
: "Enable Rowboat to understand your team conversations and provide relevant context"
|
||||
}
|
||||
icon={<SlackIcon />}
|
||||
iconBg="bg-emerald-500/10"
|
||||
iconColor="text-emerald-500"
|
||||
providerState={{ isConnected: slackEnabled, isLoading: false, isConnecting: false }}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-2">
|
||||
{(slackLoading || slackDiscovering) && <Loader2 className="size-3 animate-spin" />}
|
||||
{slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => handleSlackDisable()}
|
||||
disabled={slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackEnable}
|
||||
disabled={slackLoading || slackDiscovering}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
index={cardIndex++}
|
||||
/>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-[3.25rem] space-y-2 pl-4 border-l-2 border-muted">
|
||||
{slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackSaveWorkspaces}
|
||||
disabled={slackSelectedUrls.size === 0 || slackLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
|
||||
<Button onClick={handleNext} size="lg" className="h-12 text-base font-medium">
|
||||
Continue
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
OpenAIIcon,
|
||||
AnthropicIcon,
|
||||
GoogleIcon,
|
||||
OllamaIcon,
|
||||
OpenRouterIcon,
|
||||
VercelIcon,
|
||||
GenericApiIcon,
|
||||
} from "../provider-icons"
|
||||
import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
|
||||
|
||||
interface LlmSetupStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: <OpenAIIcon /> },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
|
||||
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: <OpenRouterIcon /> },
|
||||
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
|
||||
]
|
||||
|
||||
export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||
const {
|
||||
llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError,
|
||||
activeConfig, testState, setTestState, showApiKey, requiresApiKey,
|
||||
showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleBack,
|
||||
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
|
||||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
const modelsForProvider = modelsCatalog[llmProvider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
|
||||
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
|
||||
const isSelected = llmProvider === provider.id
|
||||
return (
|
||||
<motion.button
|
||||
key={provider.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => {
|
||||
setLlmProvider(provider.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent bg-muted/50 hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
|
||||
{provider.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{provider.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Choose your model
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center mb-6">
|
||||
Select a provider and configure your API key
|
||||
</p>
|
||||
|
||||
{/* Inline Rowboat upsell callout */}
|
||||
{!upsellDismissed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
|
||||
>
|
||||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to all models — no API keys needed.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
|
||||
>
|
||||
Sign in instead
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUpsellDismissed(true)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Provider selection */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
|
||||
</div>
|
||||
{(showMoreProviders || isMoreProvider) ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2 mt-2">
|
||||
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowMoreProviders(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
More providers...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
{/* Model configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Model Configuration</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Assistant Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Knowledge Graph Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
API Key {!state.requiresApiKey && "(optional)"}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
|
||||
placeholder="Paste your API key"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
|
||||
placeholder={
|
||||
llmProvider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: llmProvider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
<Button variant="ghost" onClick={handleBack} className="gap-1">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{testState.status === "success" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected
|
||||
</motion.div>
|
||||
)}
|
||||
{testState.status === "error" && (
|
||||
<span className="text-sm text-destructive max-w-[200px] truncate">
|
||||
{testState.error}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleTestAndSaveLlmConfig}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
|
||||
) : (
|
||||
"Test & Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,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 (
|
||||
<div className="flex flex-col items-center justify-center text-center flex-1">
|
||||
{/* Logo */}
|
||||
<img src="/logo-only.png" alt="Rowboat" className="size-14 mb-6" />
|
||||
|
||||
{/* Tagline badge */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-muted/50 px-3.5 py-1.5 text-xs font-medium text-muted-foreground mb-6">
|
||||
<span className="size-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Your AI coworker, with memory
|
||||
</div>
|
||||
|
||||
{/* Main heading */}
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-3">
|
||||
Welcome to Rowboat
|
||||
</h1>
|
||||
<p className="text-base text-muted-foreground leading-relaxed max-w-sm mb-8">
|
||||
Connect your Rowboat account for instant access to all models through our gateway — no API keys needed.
|
||||
</p>
|
||||
|
||||
{/* Product preview placeholder */}
|
||||
<div className="w-full max-w-sm rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/30 aspect-video flex items-center justify-center mb-8">
|
||||
<span className="text-sm text-muted-foreground/50">Product Preview</span>
|
||||
</div>
|
||||
|
||||
{/* Sign in / connected state */}
|
||||
{rowboatState.isConnected ? (
|
||||
<div className="flex flex-col items-center gap-4 w-full max-w-xs">
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="size-5" />
|
||||
<span className="text-sm font-medium">Connected to Rowboat</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => state.setCurrentStep(2)}
|
||||
size="lg"
|
||||
className="w-full h-12 text-base font-medium"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 w-full max-w-xs">
|
||||
<Button
|
||||
onClick={() => {
|
||||
state.setOnboardingPath('rowboat')
|
||||
state.startConnect('rowboat')
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full h-12 text-base font-medium"
|
||||
disabled={rowboatState.isConnecting}
|
||||
>
|
||||
{rowboatState.isConnecting ? (
|
||||
<><Loader2 className="size-5 animate-spin mr-2" />Waiting for sign in...</>
|
||||
) : (
|
||||
"Sign in with Rowboat"
|
||||
)}
|
||||
</Button>
|
||||
{rowboatState.isConnecting && (
|
||||
<p className="text-xs text-muted-foreground animate-pulse">
|
||||
Complete sign in in your browser, then return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BYOK link */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
state.setOnboardingPath('byok')
|
||||
state.setCurrentStep(1)
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4 decoration-muted-foreground/30 hover:decoration-foreground/50"
|
||||
>
|
||||
I want to bring my own API key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<Step>(0)
|
||||
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
|
||||
|
||||
// LLM setup state
|
||||
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
})
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// OAuth provider states
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
|
||||
// Slack state (agent-slack CLI)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
|
||||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Inline upsell callout dismissed
|
||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||
|
||||
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<string, LlmModelOption[]> = {}
|
||||
for (const provider of result.providers || []) {
|
||||
catalog[provider.id] = provider.models || []
|
||||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch (error) {
|
||||
console.error("Failed to load models catalog:", error)
|
||||
setModelsError("Failed to load models list")
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
}, [open])
|
||||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.2",
|
||||
anthropic: "claude-opus-4-6-20260202",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
useEffect(() => {
|
||||
if (Object.keys(modelsCatalog).length === 0) return
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev }
|
||||
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
for (const provider of cloudProviders) {
|
||||
const models = modelsCatalog[provider]
|
||||
if (models?.length && !next[provider].model) {
|
||||
const preferredModel = preferredDefaults[provider]
|
||||
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
|
||||
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [modelsCatalog])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
const result = await window.ipc.invoke('granola:getConfig', null)
|
||||
setGranolaEnabled(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Granola config:', error)
|
||||
setGranolaEnabled(false)
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update Granola config
|
||||
const handleGranolaToggle = useCallback(async (enabled: boolean) => {
|
||||
try {
|
||||
setGranolaLoading(true)
|
||||
await window.ipc.invoke('granola:setConfig', { enabled })
|
||||
setGranolaEnabled(enabled)
|
||||
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Granola config:', error)
|
||||
toast.error('Failed to update Granola sync settings')
|
||||
} finally {
|
||||
setGranolaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack config
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('slack:getConfig', null)
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackWorkspaces(result.workspaces || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Enable Slack: discover workspaces
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save selected Slack workspaces
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
|
||||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Slack config:', error)
|
||||
toast.error('Failed to save Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [slackAvailableWorkspaces, slackSelectedUrls])
|
||||
|
||||
// Disable Slack
|
||||
const handleSlackDisable = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
toast.error('Failed to update Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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<string, ProviderState> = {}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:getState', null)
|
||||
const config = result.config || {}
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: config[provider]?.connected ?? false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status for providers:', error)
|
||||
for (const provider of providers) {
|
||||
newStates[provider] = {
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
isConnecting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
||||
|
||||
// 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<typeof useOnboardingState>
|
||||
|
|
@ -500,7 +500,7 @@ export function SidebarContentPanel({
|
|||
8 days left
|
||||
</span>
|
||||
</div>
|
||||
<button className="rounded-md bg-sidebar-accent px-2.5 py-1 text-[10px] font-medium text-sidebar-foreground hover:bg-sidebar-accent/80 transition-colors">
|
||||
<button className="upgrade-btn rounded-md bg-sidebar-accent px-2.5 py-1 text-[10px] font-medium text-sidebar-foreground transition-colors">
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue