From 910f87f2709c297c3d155cd92af3e6e0f6c7e80d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 00:29:11 -0700 Subject: [PATCH] [eric] onboarding: v3 connect-first flow, live theme pad, personalized reveal --- frontend/src/app/Main.tsx | 15 +- .../components/Onboarding/OnboardingRoot.tsx | 7 +- .../app/components/OnboardingV3/BeatApps.tsx | 70 +++++++ .../components/OnboardingV3/BeatConnect.tsx | 170 +++++++++++++++ .../app/components/OnboardingV3/BeatShell.tsx | 85 ++++++++ .../app/components/OnboardingV3/BeatTheme.tsx | 131 ++++++++++++ .../OnboardingV3/OnboardingV3Root.tsx | 195 ++++++++++++++++++ .../OnboardingV3/onboardingV3Api.ts | 76 +++++++ .../OnboardingV3/useOnboardingV3Pipeline.ts | 64 ++++++ .../pages/AgentChat/WelcomeQuickReplies.tsx | 64 +++++- .../app/pages/AgentChat/useWelcomeGreeting.ts | 15 +- .../lifecycle/useOnboardingRevealSeed.ts | 45 ++++ .../hooks/state/useDashboardController.ts | 10 + .../src/shared/state/dashboardLayoutSlice.ts | 4 +- .../src/shared/state/onboardingV3Slice.ts | 45 ++++ frontend/src/shared/state/settingsSlice.ts | 11 + frontend/src/shared/state/store.ts | 2 + 17 files changed, 998 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/components/OnboardingV3/BeatApps.tsx create mode 100644 frontend/src/app/components/OnboardingV3/BeatConnect.tsx create mode 100644 frontend/src/app/components/OnboardingV3/BeatShell.tsx create mode 100644 frontend/src/app/components/OnboardingV3/BeatTheme.tsx create mode 100644 frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx create mode 100644 frontend/src/app/components/OnboardingV3/onboardingV3Api.ts create mode 100644 frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts create mode 100644 frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts create mode 100644 frontend/src/shared/state/onboardingV3Slice.ts diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 77f273be..e2536068 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -28,6 +28,7 @@ import ErrorBoundary from './components/feedback/ErrorBoundary'; import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice'; const Analytics = React.lazy(() => import('./pages/Analytics/Analytics')); +const OnboardingV3Root = React.lazy(() => import('./components/OnboardingV3/OnboardingV3Root')); const OnboardingRoot = React.lazy(() => import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })), ); @@ -68,7 +69,7 @@ import { useRouteTracker } from '@/shared/hooks/useRouteTracker'; import { useDeepLink } from '@/shared/hooks/useDeepLink'; import { useWindowFocus } from '@/shared/hooks/useWindowFocus'; import { useInteractionHeartbeat } from '@/shared/hooks/useInteractionHeartbeat'; -import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { ThemeProvider, useThemeMode, useThemeAccent, useClaudeTokens } from '@/shared/styles/ThemeContext'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') { @@ -202,7 +203,9 @@ const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => { const dispatch = useAppDispatch(); const { setMode: setThemeMode } = useThemeMode(); + const { setAccent } = useThemeAccent(); const theme = useAppSelector((s) => s.settings.data.theme); + const accentColor = useAppSelector((s) => s.settings.data.accent_color); const loaded = useAppSelector((s) => s.settings.loaded); const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates); useEffect(() => { @@ -255,6 +258,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = if (loaded) setThemeMode(theme as 'light' | 'dark'); }, [loaded, theme, setThemeMode]); + // Effect re-fires only when the persisted value changes, so live pad drags (context-only until finish() patches) never get snapped back by a mid-drag settings refetch. + useEffect(() => { + if (loaded) setAccent(accentColor ?? null); + }, [loaded, accentColor, setAccent]); + useEffect(() => { if (!loaded) return; (window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates); @@ -525,6 +533,11 @@ const ThemedApp: React.FC = () => { + + + + + diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 5c211e3f..e4b71fef 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -65,10 +65,12 @@ const OnboardingRoot: React.FC = () => { !(s.onboardingProgress.completedSteps ?? []).includes('launch_agent') && Object.keys(s.agents?.sessions ?? {}).length === 0, ); + // Onboarding v3 owns the window on a fresh install; the v2 cursor tour stays dormant until the flow resolves. + const v3Owns = useAppSelector((s) => s.onboardingV3.flowActive); const welcomeFiredRef = useRef(false); const welcomeTimerRef = useRef(null); useEffect(() => { - if (welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return; + if (v3Owns || welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return; if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return; welcomeFiredRef.current = true; welcomeTimerRef.current = window.setTimeout(() => { @@ -77,7 +79,7 @@ const OnboardingRoot: React.FC = () => { onboardingDirector.startStep('welcome_open', { x: window.innerWidth / 2, y: window.innerHeight / 2 }); }, 600); return () => { if (welcomeTimerRef.current) window.clearTimeout(welcomeTimerRef.current); }; - }, [progress.initialized, welcomeOpenReady]); + }, [progress.initialized, welcomeOpenReady, v3Owns]); useEffect(() => { if (progress.initialized) return; @@ -264,6 +266,7 @@ const OnboardingRoot: React.FC = () => { return () => onboardingDirector.detach(); }, [store, tokens.accent.primary]); + if (v3Owns) return null; if (!settingsLoaded) return null; if (!progress.initialized) return null; diff --git a/frontend/src/app/components/OnboardingV3/BeatApps.tsx b/frontend/src/app/components/OnboardingV3/BeatApps.tsx new file mode 100644 index 00000000..c32511e0 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/BeatApps.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Check } from 'lucide-react'; +import { INTEGRATIONS } from '@/app/pages/Tools/integrations'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import BeatShell from './BeatShell'; + +// Order matters: the first row should read as "your work lives here" for the widest audience. +const PICKER_IDS = ['google-workspace', 'notion', 'slack', 'github', 'discord', 'microsoft-365', 'reddit', 'youtube', 'x', 'airtable', 'hubspot', 'tiktok']; + +// Picks do double duty: they brief the prep call on what this person's work looks like, and they seed which integrations we suggest connecting later. Nothing installs here; the MCP gate stays untouched. +const BeatApps: React.FC<{ + c: ClaudeTokens; + picks: string[]; + setPicks: (ids: string[]) => void; + onNext: () => void; + onBack: () => void; +}> = ({ c, picks, setPicks, onNext, onBack }) => { + const entries = PICKER_IDS + .map((id) => INTEGRATIONS.find((i) => i.id === id)) + .filter((i): i is NonNullable => !!i); + + const toggle = (id: string) => { + setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]); + }; + + return ( + 0 ? 'Continue' : 'Skip for now'} + onNext={onNext} + onBack={onBack} + > +
+ {entries.map((entry, i) => { + const picked = picks.includes(entry.id); + return ( + toggle(entry.id)} + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.06 + i * 0.04 }} + style={{ + position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, + padding: '18px 10px 14px', borderRadius: c.radius.md, + border: `1.5px solid ${picked ? c.accent.primary : c.border.medium}`, + background: c.bg.surface, cursor: 'pointer', fontFamily: 'inherit', + boxShadow: picked ? `0 0 0 3px ${c.accent.primary}22` : c.shadow.sm, + transition: 'border-color 140ms ease, box-shadow 140ms ease', + }} + > + {picked && ( + + + + )} + {entry.icon} + {entry.name} + + ); + })} +
+
+ ); +}; + +export default BeatApps; diff --git a/frontend/src/app/components/OnboardingV3/BeatConnect.tsx b/frontend/src/app/components/OnboardingV3/BeatConnect.tsx new file mode 100644 index 00000000..bbb28389 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/BeatConnect.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { Check } from 'lucide-react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { API_BASE } from '@/shared/config'; +import { fetchModels } from '@/shared/state/modelsSlice'; +import { fetchSubscriptionStatus, markSubscriptionConnected } from '@/shared/state/subscriptionsSlice'; +import { updateSettingsPatch } from '@/shared/state/settingsSlice'; +import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; +import { SUBSCRIPTION_PROVIDERS } from '@/app/pages/Settings/sections/subscription/subscriptionProviders'; +import { runConnectFlow } from '@/app/pages/Settings/sections/subscription/subscriptionConnect'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import type { ProviderIdentity } from './onboardingV3Api'; +import BeatShell from './BeatShell'; + +// The single ask of the whole flow. Reuses the proven Settings connect flow verbatim; the scan disclosure lives here as one honest line with an opt-out, and the scan runs during the OAuth wait. +const BeatConnect: React.FC<{ + c: ClaudeTokens; + identity: ProviderIdentity[]; + scanConsent: boolean; + setScanConsent: (v: boolean) => void; + onConnected: () => void; + onNext: () => void; + onBack: () => void; +}> = ({ c, identity, scanConsent, setScanConsent, onConnected, onNext, onBack }) => { + const dispatch = useAppDispatch(); + const connected = useAppSelector((s) => hasModelConnected(s)); + const freeTrial = useAppSelector((s) => hasFreeTrialActive(s)); + const [connecting, setConnecting] = useState(null); + const [userCode, setUserCode] = useState(''); + const [showKeys, setShowKeys] = useState(false); + const [keyDraft, setKeyDraft] = useState(''); + const pollTimerRef = useRef | null>(null); + const connectedOnce = useRef(false); + + useEffect(() => () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }, []); + + useEffect(() => { + if (connected && !connectedOnce.current) { + connectedOnce.current = true; + onConnected(); + } + }, [connected, onConnected]); + + const handleConnect = useCallback(async (providerId: string) => { + setConnecting(providerId); + setUserCode(''); + try { + const res = await fetch(`${API_BASE}/agents/subscriptions/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(String(data?.detail ?? 'connect failed')); + runConnectFlow({ + providerId, + data, + setConnecting, + setUserCode, + setPollTimer: (t) => { pollTimerRef.current = t; }, + fetchStatus: (opts) => dispatch(fetchSubscriptionStatus(opts)).unwrap(), + refreshPickerModels: () => { dispatch(fetchModels()); }, + markConnected: (provider) => { dispatch(markSubscriptionConnected({ provider })); }, + }); + } catch { + setConnecting(null); + } + }, [dispatch]); + + const saveKey = useCallback(() => { + const v = keyDraft.trim(); + if (!v) return; + const field = v.startsWith('sk-ant-') ? 'anthropic_api_key' : v.startsWith('sk-or-') ? 'openrouter_api_key' : v.startsWith('AIza') ? 'google_api_key' : 'openai_api_key'; + dispatch(updateSettingsPatch({ [field]: v })); + setKeyDraft(''); + }, [keyDraft, dispatch]); + + const connectedIdentity = identity.length > 0 ? identity[0] : null; + + return ( + +
+ {SUBSCRIPTION_PROVIDERS.map((p, i) => ( + !connected && handleConnect(p.id)} + initial={{ opacity: 0, y: 14 }} + animate={{ opacity: 1, y: 0 }} + transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }} + style={{ + display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px', textAlign: 'left', + borderRadius: c.radius.md, border: `1px solid ${c.border.medium}`, background: c.bg.surface, + cursor: connected ? 'default' : 'pointer', fontFamily: 'inherit', + boxShadow: c.shadow.sm, + }} + > + + + {p.name} + {p.desc} + + {connecting === p.id && !connected && waiting for sign-in...} + {connected && connecting === p.id && } + + ))} + {userCode && !connected && ( +
+ Your code: {userCode} +
+ )} + {connected && ( + + Connected{connectedIdentity?.email ? ` as ${connectedIdentity.email}` : ''} + {connectedIdentity?.plan ? ` ยท ${connectedIdentity.label} ${connectedIdentity.plan}` : ''} + + )} + +
+ + {freeTrial && !connected && ( + + )} +
+ {showKeys && ( +
+ setKeyDraft(e.target.value)} + placeholder="Paste an Anthropic, OpenAI, Google, or OpenRouter key" + style={{ + flex: 1, padding: '10px 12px', borderRadius: c.radius.sm, border: `1px solid ${c.border.medium}`, + background: c.bg.surface, color: c.text.primary, fontSize: '0.85rem', fontFamily: c.font.mono, + }} + /> + +
+ )} +
+
+ ); +}; + +export default BeatConnect; diff --git a/frontend/src/app/components/OnboardingV3/BeatShell.tsx b/frontend/src/app/components/OnboardingV3/BeatShell.tsx new file mode 100644 index 00000000..f36bbf71 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/BeatShell.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { ArrowLeft } from 'lucide-react'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; + +// Split-stage layout for the interactive beats: dark copy panel left, live artifact right. One loud button, a whisper Back, no progress bar; each beat is a room, not step 3 of 9. +const BeatShell: React.FC<{ + c: ClaudeTokens; + title: string; + body: string; + nextLabel: string; + nextDisabled?: boolean; + onNext: () => void; + onBack?: () => void; + children: React.ReactNode; +}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children }) => { + // Zen steal: controls stay inert until the entrance animation lands so a double-click from the prior beat can't fire them. + const [armed, setArmed] = useState(false); + useEffect(() => { + const t = window.setTimeout(() => setArmed(true), 450); + return () => window.clearTimeout(t); + }, []); + + return ( +
+ + {onBack && ( + + )} +

+ {title} +

+

+ {body} +

+
+ +
+
+ + {children} + +
+ ); +}; + +export default BeatShell; diff --git a/frontend/src/app/components/OnboardingV3/BeatTheme.tsx b/frontend/src/app/components/OnboardingV3/BeatTheme.tsx new file mode 100644 index 00000000..2a6d14ef --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/BeatTheme.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { Moon, Sun } from 'lucide-react'; +import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext'; +import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import BeatShell from './BeatShell'; + +const PRESETS = ['#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470']; + +// The IKEA-effect beat: dragging on the pad drives the REAL app theme live through ThemeContext, so the product becomes theirs before they've entered it. Persistence happens at finish(), not here. +const BeatTheme: React.FC<{ + c: ClaudeTokens; + onNext: () => void; + onBack: () => void; +}> = ({ c, onNext, onBack }) => { + const { accent, setAccent } = useThemeAccent(); + const { mode, setMode } = useThemeMode(); + const padRef = useRef(null); + const draggingRef = useRef(false); + const lastApplyRef = useRef(0); + + const applyFromEvent = useCallback((clientX: number, clientY: number) => { + const pad = padRef.current; + if (!pad) return; + // ~30ms throttle: every apply re-derives tokens and re-renders the tree, and pointermove fires far faster than paint needs. + const now = performance.now(); + if (now - lastApplyRef.current < 30) return; + lastApplyRef.current = now; + const rect = pad.getBoundingClientRect(); + const fx = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + const fy = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)); + setAccent(hslToHex({ h: fx, s: 0.72, l: 0.62 - fy * 0.34 })); + }, [setAccent]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + draggingRef.current = true; + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + lastApplyRef.current = 0; + applyFromEvent(e.clientX, e.clientY); + }, [applyFromEvent]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (draggingRef.current) applyFromEvent(e.clientX, e.clientY); + }, [applyFromEvent]); + + const onPointerUp = useCallback(() => { draggingRef.current = false; }, []); + + const dot = accent ? hexToHsl(accent) : null; + + return ( + +
+ + {dot && ( + + )} + +
+ {PRESETS.map((hex) => ( + +
+
+ {(['light', 'dark'] as const).map((m) => ( + + ))} +
+
+
+ ); +}; + +export default BeatTheme; diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx new file mode 100644 index 00000000..e3d959a4 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx @@ -0,0 +1,195 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ArrowRight } from 'lucide-react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { updateSettingsPatch } from '@/shared/state/settingsSlice'; +import { setFlowActive } from '@/shared/state/onboardingV3Slice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline'; +import BeatConnect from './BeatConnect'; +import BeatApps from './BeatApps'; +import BeatTheme from './BeatTheme'; + +type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme'; + +const V2_STORAGE_KEY = 'openswarm.onboarding.v2'; + +// Decides whether the v3 full-screen flow owns this launch. Only genuinely fresh installs see it: anyone with the v2 tour key or existing sessions is auto-marked skipped so an update never re-onboards a veteran. +function useOnboardingV3Gate(): boolean { + const dispatch = useAppDispatch(); + const settingsLoaded = useAppSelector((s) => s.settings.loaded); + const v3State = useAppSelector((s) => s.settings.data.onboarding_v3); + const flowActive = useAppSelector((s) => s.onboardingV3.flowActive); + const sessionCount = useAppSelector((s) => Object.keys(s.agents.sessions).length); + + const hasV2History = useMemo(() => { + try { return localStorage.getItem(V2_STORAGE_KEY) !== null; } catch { return false; } + }, []); + + useEffect(() => { + if (!settingsLoaded || v3State) return; + if (hasV2History) { + dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' })); + return; + } + dispatch(setFlowActive(true)); + }, [settingsLoaded, v3State, hasV2History, dispatch]); + + // Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install. + useEffect(() => { + if (!flowActive || sessionCount === 0) return; + dispatch(setFlowActive(false)); + dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' })); + }, [flowActive, sessionCount, dispatch]); + + return flowActive && settingsLoaded && !v3State; +} + +// Full-bleed intro rooms: a soft accent blob blooms behind giant type, one arrow, nothing else. +const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: () => void }> = ({ c, line, sub, onNext }) => ( +
+ + + {line} + + {sub && ( + + {sub} + + )} + + + +
+); + +// Onboarding v3: connect-first, Arc/Zen-style staged rooms over the live app. Each beat commits its side effect on exit; the overlay dissolving IS the reveal (the seeder has already dressed the canvas behind it). +const OnboardingV3Root: React.FC = () => { + const active = useOnboardingV3Gate(); + const c = useClaudeTokens(); + const pipeline = useOnboardingV3Pipeline(); + const [beat, setBeat] = useState('welcome'); + const [scanConsent, setScanConsent] = useState(true); + const [picks, setPicks] = useState([]); + const [finishing, setFinishing] = useState(false); + + const { kickIdentity, kickScan, kickPrep, finish } = pipeline; + + const onConnected = useCallback(() => { + kickIdentity(); + kickScan(scanConsent); + }, [kickIdentity, kickScan, scanConsent]); + + const leaveConnect = useCallback(() => { + kickScan(scanConsent); + setBeat('apps'); + }, [kickScan, scanConsent]); + + const leaveApps = useCallback(() => { + kickPrep(picks); + setBeat('theme'); + }, [kickPrep, picks]); + + const leaveTheme = useCallback(async () => { + setFinishing(true); + await finish('done'); + }, [finish]); + + const skipAll = useCallback(() => { void finish('skipped'); }, [finish]); + + // AnimatePresence stays mounted so the overlay's exit fade (the curtain lift) actually plays when active flips false. + return ( + + {active && ( + + + + {beat === 'welcome' && setBeat('newos')} />} + {beat === 'newos' && setBeat('connect')} />} + {beat === 'connect' && ( + setBeat('newos')} + /> + )} + {beat === 'apps' && setBeat('connect')} />} + {beat === 'theme' && { void leaveTheme(); }} onBack={() => setBeat('apps')} />} + + + {finishing && ( +
+ + Setting up your canvas... + +
+ )} + {!finishing && ( + + )} +
+ )} +
+ ); +}; + +export default OnboardingV3Root; diff --git a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts new file mode 100644 index 00000000..03fe84f8 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts @@ -0,0 +1,76 @@ +import { API_BASE } from '@/shared/config'; +import type { PersonalizedStarter } from '@/shared/state/settingsSlice'; + +export interface ProviderIdentity { + provider: string; + label: string; + email?: string | null; + plan?: string | null; +} + +export interface FolderSummary { + name: string; + entry_count: number; + screenshot_count: number; + top_extensions: string[]; +} + +export interface ScanResult { + apps: string[]; + folders: FolderSummary[]; + git_repo_count: number; + has_gitconfig: boolean; +} + +export interface PrepResponse { + greeting: string; + starters: PersonalizedStarter[]; +} + +export async function fetchIdentity(): Promise { + try { + const res = await fetch(`${API_BASE}/onboarding/identity`); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data?.providers) ? data.providers : []; + } catch { + return []; + } +} + +export async function runScan(): Promise { + try { + const res = await fetch(`${API_BASE}/onboarding/scan`, { method: 'POST' }); + if (!res.ok) return null; + return (await res.json()) as ScanResult; + } catch { + return null; + } +} + +export async function runPrep(scan: ScanResult | null, pickedApps: string[]): Promise { + try { + const res = await fetch(`${API_BASE}/onboarding/prep`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scan, picked_apps: pickedApps }), + }); + if (!res.ok) return null; + const data = (await res.json()) as PrepResponse; + return Array.isArray(data?.starters) && data.starters.length > 0 ? data : null; + } catch { + return null; + } +} + +// One human-readable line about what the scan found, reused by the reveal note so the user can see exactly what informed their starters. +export function summarizeScan(scan: ScanResult | null): string | null { + if (!scan) return null; + const parts: string[] = []; + for (const f of scan.folders) { + if (f.screenshot_count > 20) parts.push(`${f.screenshot_count} screenshots on your ${f.name}`); + else if (f.entry_count > 300) parts.push(`${f.entry_count} items in ${f.name}`); + } + if (scan.git_repo_count > 0) parts.push(`${scan.git_repo_count} git repo${scan.git_repo_count === 1 ? '' : 's'}`); + return parts.length > 0 ? parts.slice(0, 3).join(', ') : null; +} diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts new file mode 100644 index 00000000..41e762b1 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -0,0 +1,64 @@ +import { useCallback, useRef, useState } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { updateSettingsPatch } from '@/shared/state/settingsSlice'; +import { setFlowActive, stageReveal } from '@/shared/state/onboardingV3Slice'; +import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext'; +import { + fetchIdentity, runPrep, runScan, summarizeScan, + type PrepResponse, type ProviderIdentity, type ScanResult, +} from './onboardingV3Api'; + +// The curtain machinery: scan kicks off during the OAuth wait, prep during the theme beat, so by the reveal everything personal is already sitting in memory. Every stage fails soft; the flow never blocks on any of it. +export function useOnboardingV3Pipeline() { + const dispatch = useAppDispatch(); + const { accent } = useThemeAccent(); + const { mode } = useThemeMode(); + const [identity, setIdentity] = useState([]); + const scanRef = useRef | null>(null); + const prepRef = useRef | null>(null); + const scanResultRef = useRef(null); + + const kickIdentity = useCallback(() => { + fetchIdentity().then(setIdentity).catch(() => {}); + }, []); + + const kickScan = useCallback((consented: boolean) => { + if (scanRef.current) return; + scanRef.current = consented + ? runScan().then((r) => { scanResultRef.current = r; return r; }).catch(() => null) + : Promise.resolve(null); + }, []); + + const kickPrep = useCallback((pickedApps: string[]) => { + if (prepRef.current) return; + const scanPromise = scanRef.current ?? Promise.resolve(null); + prepRef.current = scanPromise.then((scan) => runPrep(scan, pickedApps)).catch(() => null); + }, []); + + const finish = useCallback(async (outcome: 'done' | 'skipped') => { + if (outcome === 'skipped') { + dispatch(setFlowActive(false)); + dispatch(updateSettingsPatch({ onboarding_v3: 'skipped', accent_color: accent, theme: mode })); + return; + } + // Cap the wait so a slow aux call degrades to generic starters instead of a hung curtain. + const timeout = new Promise((resolve) => { window.setTimeout(() => resolve(null), 15000); }); + const prep = await Promise.race([prepRef.current ?? Promise.resolve(null), timeout]); + const greeting = prep?.greeting?.trim() || null; + const starters = prep?.starters ?? []; + // Await the PATCH so personalized_greeting/starters are IN settings before the reveal seeds the welcome chat; the greeting stream snapshots settings at mount. + try { + await dispatch(updateSettingsPatch({ + onboarding_v3: 'done', + accent_color: accent, + theme: mode, + personalized_greeting: greeting, + personalized_starters: starters, + })).unwrap(); + } catch {} + dispatch(stageReveal({ greeting, starters, scanSummary: summarizeScan(scanResultRef.current) })); + dispatch(setFlowActive(false)); + }, [dispatch, accent, mode]); + + return { identity, kickIdentity, kickScan, kickPrep, finish }; +} diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index 0840a7e3..692c62fc 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -2,7 +2,8 @@ import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Sparkles } from 'lucide-react'; +import { useAppSelector } from '@/shared/hooks'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { STARTER_CATEGORIES } from '@/shared/starterCategories'; @@ -13,6 +14,9 @@ const WelcomeQuickReplies: React.FC<{ onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { const [expanded, setExpanded] = React.useState(null); + // Onboarding v3's prep wrote starters about THIS user's machine and apps; they lead, generic categories demote to "More ideas". + const personalized = useAppSelector((s) => s.settings.data.personalized_starters ?? []); + const [showCategories, setShowCategories] = React.useState(personalized.length === 0); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; const currentPrompts = currentCategory?.prompts ?? []; @@ -30,8 +34,64 @@ const WelcomeQuickReplies: React.FC<{ style={{ padding: '4px 18px 8px 18px', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }} > - {expanded === null ? ( + {!showCategories && expanded === null ? ( + + + made for you, or just type below + + + {personalized.map((s, i) => ( + onPick(s.prompt)} + initial={{ opacity: 0, scale: 0.92 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 420, damping: 26, delay: 0.08 + i * 0.06 }} + style={{ + display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left', + padding: '10px 14px', borderRadius: 11, + border: `1px solid ${c.border.medium}`, background: c.bg.surface, + color: c.text.secondary, fontSize: '0.88rem', fontWeight: 500, + cursor: 'pointer', fontFamily: 'inherit', + }} + > + + {s.title} + + ))} + + setShowCategories(true)} + sx={{ + alignSelf: 'flex-start', mt: 0.9, px: 0.6, py: 0.3, + border: 'none', background: 'transparent', + color: c.text.ghost, fontSize: '0.82rem', + cursor: 'pointer', fontFamily: 'inherit', + '&:hover': { color: c.text.secondary }, + }} + > + More ideas + + + ) : expanded === null ? ( + {personalized.length > 0 && ( + setShowCategories(false)} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3, + border: 'none', background: 'transparent', + color: c.text.ghost, fontSize: '0.85rem', + cursor: 'pointer', fontFamily: 'inherit', + '&:hover': { color: c.text.secondary }, + }} + > + your starters + + )} pick one, or just type below diff --git a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts index 21d05f44..9cdb598e 100644 --- a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts +++ b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useAppDispatch } from '@/shared/hooks'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { streamStart, streamDelta } from '@/shared/state/streamingSlice'; import { addMessage, type AgentSession } from '@/shared/state/agentsSlice'; @@ -23,6 +23,11 @@ export function useWelcomeGreeting( const eligible = isDraft && !!session?.is_welcome_draft && (session?.messages?.length ?? 0) === 0; const sessionId = session?.id; const branchId = session?.active_branch_id || 'main'; + // Onboarding v3's prep wrote a greeting about THIS machine; when present it replaces the stock opener. + const personalized = useAppSelector((s) => s.settings.data.personalized_greeting); + const greetingText = personalized?.trim() + ? `${personalized.trim()}\n\nWhere do you want to start?` + : WELCOME_GREETING; useEffect(() => { if (!eligible || !sessionId || startedRef.current) return; @@ -30,8 +35,9 @@ export function useWelcomeGreeting( dispatch(streamStart({ sessionId, messageId: GREETING_MSG_ID, role: 'assistant' })); - // Feed word-by-word at a real-reply cadence; useSmoothText trails it for the typed look. - const tokens = WELCOME_GREETING.split(/(\s+)/); + // Snapshot the text at stream start: greetingText stays OUT of the deps because a mid-stream settings refetch changing it would tear down the interval and the one-shot ref blocks a restart (greeting freezes after two words). + const streamText = greetingText; + const tokens = streamText.split(/(\s+)/); let i = 0; const timer = window.setInterval(() => { const chunk = (tokens[i] ?? '') + (tokens[i + 1] ?? ''); @@ -45,7 +51,7 @@ export function useWelcomeGreeting( message: { id: GREETING_MSG_ID, role: 'assistant', - content: WELCOME_GREETING, + content: streamText, timestamp: new Date().toISOString(), branch_id: branchId, parent_id: null, @@ -56,6 +62,7 @@ export function useWelcomeGreeting( }, 50); return () => window.clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [eligible, sessionId, branchId, dispatch]); return { greetingDone }; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts new file mode 100644 index 00000000..6a086085 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, type RefObject } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { addNote, DEFAULT_CARD_W } from '@/shared/state/dashboardLayoutSlice'; +import { clearReveal } from '@/shared/state/onboardingV3Slice'; + +interface Args { + isActive: boolean; + canvasEmpty: boolean; + viewportRef: RefObject; + canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; + createWelcomeDraft: () => void; +} + +// The reveal: onboarding v3 finished behind the curtain, so dress the canvas BEFORE the overlay's exit fade lands. Welcome chat (with personalized greeting + chips) at center, a "while you were setting up" note docked beside it. Everything seeded is a draft or a note; nothing runs until the user clicks. +export function useOnboardingRevealSeed({ isActive, canvasEmpty, viewportRef, canvasStateRef, createWelcomeDraft }: Args): void { + const dispatch = useAppDispatch(); + const revealPending = useAppSelector((s) => s.onboardingV3.revealPending); + const greeting = useAppSelector((s) => s.onboardingV3.greeting); + const starters = useAppSelector((s) => s.onboardingV3.starters); + const scanSummary = useAppSelector((s) => s.onboardingV3.scanSummary); + const settingsLoaded = useAppSelector((s) => s.settings.loaded); + const seededRef = useRef(false); + + useEffect(() => { + if (!revealPending || seededRef.current || !isActive || !canvasEmpty || !settingsLoaded) return; + seededRef.current = true; + try { + const vp = viewportRef.current; + const cs = canvasStateRef.current; + if (vp && cs) { + const vr = vp.getBoundingClientRect(); + const cx = (vr.width / 2 - cs.panX) / cs.zoom; + const cy = (vr.height / 2 - cs.panY) / cs.zoom; + const lines: string[] = ['While you were setting up, I got some ideas ready.']; + if (scanSummary) lines.push(`Spotted on this Mac: ${scanSummary}.`); + if (starters.length > 0) lines.push(`Ready to run:\n${starters.map((s) => `- ${s.title}`).join('\n')}`); + lines.push('Pick one in the chat, or just type what you need.'); + dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + 48, y: cy - 140, color: 'yellow', content: lines.join('\n\n') })); + } + createWelcomeDraft(); + } finally { + dispatch(clearReveal()); + } + }, [revealPending, isActive, canvasEmpty, settingsLoaded, greeting, starters, scanSummary, viewportRef, canvasStateRef, createWelcomeDraft, dispatch]); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index f86f74a3..c523af08 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -17,6 +17,7 @@ import { useCardDrag } from '../interaction/useCardDrag'; import { useSubAgentLifecycle } from '../lifecycle/useSubAgentLifecycle'; import { useDashboardLifecycle } from '../lifecycle/useDashboardLifecycle'; import { useWelcomeDraft } from '../lifecycle/useWelcomeDraft'; +import { useOnboardingRevealSeed } from '../lifecycle/useOnboardingRevealSeed'; import { useDashboardThumbnail } from './useDashboardThumbnail'; import { useSiblingRestack } from '../lifecycle/useSiblingRestack'; import { useAgentSpawn } from '../lifecycle/useAgentSpawn'; @@ -163,6 +164,15 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { spawnOriginsRef, }); + // Onboarding v3 reveal: seeds the personalized note + welcome chat the instant the flow's curtain lifts. + useOnboardingRevealSeed({ + isActive, + canvasEmpty, + viewportRef: canvas.viewportRef, + canvasStateRef, + createWelcomeDraft, + }); + // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- useSubAgentLifecycle({ isActive, diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 18ff1735..772607b1 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1369,7 +1369,7 @@ const dashboardLayoutSlice = createSlice({ addNote( state, - action: PayloadAction<{ x?: number; y?: number; expandedSessionIds?: string[]; color?: NoteColor }>, + action: PayloadAction<{ x?: number; y?: number; expandedSessionIds?: string[]; color?: NoteColor; content?: string }>, ) { const id = `note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; let posX: number, posY: number; @@ -1388,7 +1388,7 @@ const dashboardLayoutSlice = createSlice({ y: posY, width: DEFAULT_NOTE_W, height: DEFAULT_NOTE_H, - content: '', + content: action.payload.content ?? '', color: action.payload.color || 'yellow', zOrder: state.nextZOrder++, }; diff --git a/frontend/src/shared/state/onboardingV3Slice.ts b/frontend/src/shared/state/onboardingV3Slice.ts new file mode 100644 index 00000000..306ca3bb --- /dev/null +++ b/frontend/src/shared/state/onboardingV3Slice.ts @@ -0,0 +1,45 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { PersonalizedStarter } from '@/shared/state/settingsSlice'; + +// Transient bridge between the v3 flow overlay and the dashboard: the overlay finishes, stashes the prep payload here, and the dashboard's reveal hook consumes it exactly once to seed the canvas. + +export interface OnboardingV3State { + /** True while the full-screen flow owns the window; suppresses onboarding v2. */ + flowActive: boolean; + /** One-shot: set on finish, cleared by the reveal seeder after cards land. */ + revealPending: boolean; + greeting: string | null; + starters: PersonalizedStarter[]; + scanSummary: string | null; +} + +const initialState: OnboardingV3State = { + flowActive: false, + revealPending: false, + greeting: null, + starters: [], + scanSummary: null, +}; + +const onboardingV3Slice = createSlice({ + name: 'onboardingV3', + initialState, + reducers: { + setFlowActive(state, action: PayloadAction) { + state.flowActive = action.payload; + }, + stageReveal(state, action: PayloadAction<{ greeting: string | null; starters: PersonalizedStarter[]; scanSummary: string | null }>) { + state.revealPending = true; + state.greeting = action.payload.greeting; + state.starters = action.payload.starters; + state.scanSummary = action.payload.scanSummary; + }, + clearReveal(state) { + state.revealPending = false; + state.scanSummary = null; + }, + }, +}); + +export const { setFlowActive, stageReveal, clearReveal } = onboardingV3Slice.actions; +export default onboardingV3Slice.reducer; diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index c9a8fec5..e4441a41 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -75,6 +75,17 @@ export interface AppSettings { signin_method?: 'google' | 'email' | 'stripe' | null; /** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */ installation_id?: string | null; + /** Onboarding v3 lifecycle: absent = never seen, 'done'/'skipped' once resolved. */ + onboarding_v3?: string | null; + /** User-picked accent hex from the onboarding theme pad; null = stock accent. */ + accent_color?: string | null; + personalized_greeting?: string | null; + personalized_starters?: PersonalizedStarter[]; +} + +export interface PersonalizedStarter { + title: string; + prompt: string; } export interface ActivateSubscriptionPayload { diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index 7cc5a199..2b8b1345 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -18,6 +18,7 @@ import subscriptionsReducer from './subscriptionsSlice'; import workflowsReducer from './workflowsSlice'; import missedRunsReducer from './missedRunsSlice'; import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice'; +import onboardingV3Reducer from '@/shared/state/onboardingV3Slice'; export const store = configureStore({ reducer: { @@ -40,6 +41,7 @@ export const store = configureStore({ workflows: workflowsReducer, missedRuns: missedRunsReducer, onboardingProgress: onboardingProgressReducer, + onboardingV3: onboardingV3Reducer, }, // Disable Redux Toolkit's dev-mode invariant middleware (serializable + immutable checks). These deep-walk the entire state on every dispatch, and our state is large enough to trigger 30-50ms pauses on hot paths (agent streaming, websocket heartbeats, settings sync). Console warns "SerializableStateInvariantMiddleware took 41ms" repeatedly under load. Production builds skip these middlewares anyway, so disabling them in dev makes dev behavior match prod, no surprises at packaging time. Trade-off: serializability bugs (e.g. accidentally putting a Map or Date directly into state) won't be caught at dev time. We've shipped many versions with stable slice shapes; that risk is now low. middleware: (getDefault) =>