From 9e8760e0482d1b978adbe451f81db2f5000a8ed5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 12:07:21 -0700 Subject: [PATCH] [eric] onboarding: connected state lives on the provider tabs; drop consent checkboxes + api-key link --- .../OnboardingV3/OnboardingV3Root.tsx | 127 ++++++++--------- .../OnboardingV3/beats/BeatConnect.tsx | 133 +++++------------- 2 files changed, 100 insertions(+), 160 deletions(-) diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx index e9394763..cf7801e6 100644 --- a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx +++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx @@ -1,15 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, 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 { selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useClaudeTokens, useThemeAccent } from '@/shared/styles/ThemeContext'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline'; -import { GRAIN_URL } from './beats/BeatShell'; -import Starburst from './Starburst'; +import { GRAIN_URL } from '@/shared/styles/grainTexture'; +import { ARC_BLUE_BG, ONBOARDING_SANS } from './beats/BeatShell'; +import OnboardingLogo from './OnboardingLogo'; import BeatConnect from './beats/BeatConnect'; import BeatApps from './beats/BeatApps'; import BeatTheme from './beats/BeatTheme'; @@ -19,8 +20,6 @@ type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme' | 'card'; const V2_STORAGE_KEY = 'openswarm.onboarding.v2'; const WINDOWED_BEATS: Beat[] = ['welcome', 'newos']; -// The opening is its own vivid moment (electric blue -> indigo -> violet), deliberately off-brand; the user's picked color takes over from the theme beat on. -const INTRO = { core: '#93b5ff', mid: '#5b6cff', edge: '#a855f7' }; // 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 { @@ -34,52 +33,51 @@ function useOnboardingV3Gate(): boolean { try { return localStorage.getItem(V2_STORAGE_KEY) !== null; } catch { return false; } }, []); + // Dev/QA only (stripped from production builds): localStorage 'osw_force_onboarding'='1' replays the + // v3 flow on a non-fresh install, since the gate is otherwise first-install-only. Lets us showcase + // + live-test the whole flow without a truly clean profile. + const forceShow = useMemo(() => { + if (process.env.NODE_ENV === 'production') return false; + try { return localStorage.getItem('osw_force_onboarding') === '1'; } catch { return false; } + }, []); + + // Under the replay flag we open the flow exactly ONCE (a ref, not on every v3State change) so that + // finish() setting flowActive=false actually closes the curtain instead of the effect re-opening it. + const forcedOpenRef = useRef(false); useEffect(() => { + if (forceShow) { + if (!forcedOpenRef.current) { forcedOpenRef.current = true; dispatch(setFlowActive(true)); } + return; + } if (!settingsLoaded || v3State) return; if (hasV2History) { dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' })); return; } dispatch(setFlowActive(true)); - }, [settingsLoaded, v3State, hasV2History, dispatch]); + }, [settingsLoaded, v3State, hasV2History, dispatch, forceShow]); - // Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install. + // Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install. (Skipped under the dev replay flag, whose demo dashboard legitimately has sessions.) useEffect(() => { - if (!flowActive || sessionCount === 0) return; + if (forceShow || !flowActive || sessionCount === 0) return; dispatch(setFlowActive(false)); dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' })); - }, [flowActive, sessionCount, dispatch]); + }, [flowActive, sessionCount, dispatch, forceShow]); - return flowActive && settingsLoaded && !v3State; + // forceShow bypasses the v2/v3 ENTRY block but still respects flowActive, so finish() closes it. + return forceShow ? (flowActive && settingsLoaded) : (flowActive && settingsLoaded && !v3State); } -// Full-bleed intro room: a soft accent blob drifts once behind giant type, one arrow, nothing else. +// Full-bleed intro room, Arc's "A browser for you.": giant heavy white type centered on the grained +// electric-blue gradient, one arrow, nothing else. const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: () => void }> = ({ c, line, sub, onNext }) => ( -
- -
- - - +
+
{line} @@ -88,7 +86,7 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6, delay: 0.8 }} - style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.02rem', color: c.text.inverse + '99' }} + style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.02rem', color: 'rgba(255,255,255,0.75)' }} > {sub} @@ -117,26 +115,37 @@ const OnboardingV3Root: React.FC = () => { const dispatch = useAppDispatch(); const pipeline = useOnboardingV3Pipeline(); const [beat, setBeat] = useState('welcome'); - const [scanConsent, setScanConsent] = useState(true); - const [usageConsent, setUsageConsent] = useState(true); const [picks, setPicks] = useState([]); const connectedProvider = useAppSelector((s) => selectSubscriptionConnections(s).find((cx) => cx.isActive !== false)?.provider ?? null); const [finishing, setFinishing] = useState(false); + // The curtain wears whatever the user just picked (their gradient under a soft veil), Arc's post-theme rule. + const { accent, gradient } = useThemeAccent(); + const finishStops = gradient ?? (accent ? [accent] : null); + const finishBackdrop = finishStops + ? `linear-gradient(rgba(255,255,255,0.16), rgba(255,255,255,0.16)), linear-gradient(160deg, ${finishStops.map((hex, i) => `${hex} ${finishStops.length > 1 ? (i / (finishStops.length - 1)) * 100 : 100}%`).join(', ')})` + : ARC_BLUE_BG; const { kickIdentity, kickScan, kickUsageRead, kickPrep, finish } = pipeline; + // Start ALL the background work the instant they connect: identity + scan + usage + prep + the + // audit/app jobs. They then run through the entire rest of the flow (connect -> apps -> theme -> + // card), so the reveal lands on work already well underway. Personalization is default-on (no + // opt-in checkbox), so scan + chat-read always fire here. kickPrep is idempotent and awaits the + // scan/usage promises just kicked here; picks aren't in yet and prep doesn't gate on them. const onConnected = useCallback(() => { kickIdentity(); - kickScan(scanConsent); - if (connectedProvider) kickUsageRead(connectedProvider, usageConsent); - }, [kickIdentity, kickScan, kickUsageRead, scanConsent, usageConsent, connectedProvider]); + kickScan(true); + if (connectedProvider) kickUsageRead(connectedProvider, true); + kickPrep(picks); + }, [kickIdentity, kickScan, kickUsageRead, kickPrep, connectedProvider, picks]); - // Fire prep + the background jobs at connect-exit (scan/usage/identity are all ready by now), so they run through the apps + theme + card beats (~three beats of runway) and the reveal lands on work well underway, not just-started. kickPrep is idempotent; picks arrive a beat later and feed the reveal's connect suggestions, not the already-launched jobs. + // Backstop: onConnected fires prep for subscription/api-key connects; this covers any path where it + // didn't (e.g. free trial). Idempotent, so it's a no-op when prep already started at connect. const leaveConnect = useCallback(() => { - kickScan(scanConsent); + kickScan(true); kickPrep(picks); setBeat('apps'); - }, [kickScan, kickPrep, scanConsent, picks]); + }, [kickScan, kickPrep, picks]); const leaveApps = useCallback(() => { kickPrep(picks); @@ -149,7 +158,6 @@ const OnboardingV3Root: React.FC = () => { await finish('done'); }, [dispatch, finish]); - const skipAll = useCallback(() => { void finish('skipped'); }, [finish]); const windowed = WINDOWED_BEATS.includes(beat); const vw = window.innerWidth; @@ -197,10 +205,6 @@ const OnboardingV3Root: React.FC = () => { setBeat('newos')} @@ -211,41 +215,32 @@ const OnboardingV3Root: React.FC = () => { {beat === 'card' && { void leaveCard(name); }} onBack={() => setBeat('theme')} />} - {/* Traffic lights sell the floating window during the intro; they fade as the window takes the screen. */} + {/* Traffic lights on every beat: Arc's onboarding is a real window and never hides them. */} {['#FF5F57', '#FEBC2E', '#28C840'].map((dot) => ( ))} {finishing && ( -
+
+
- + - + Setting up your canvas...
)} - {!finishing && ( - - )} )} diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx index 03259f5e..3e35db89 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx @@ -1,37 +1,31 @@ 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 { fetchSubscriptionStatus, markSubscriptionConnected, selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice'; 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 OnboardingLogo from '../OnboardingLogo'; +import { providerLogo } from '../providerLogos'; import BeatShell from './BeatShell'; -// The single ask of the whole flow, staged as Arc's import list: radio rows, no filler copy. Reuses the proven Settings connect flow verbatim; the scan disclosure is one quiet line, and the scan runs during the OAuth wait. +// The single ask of the whole flow, staged as Arc's import list: radio rows, no filler copy. Reuses the proven Settings connect flow verbatim. Personalization (local scan + one-time chat read) just happens the moment they connect; it's not an opt-in checkbox anymore. const BeatConnect: React.FC<{ c: ClaudeTokens; identity: ProviderIdentity[]; - scanConsent: boolean; - setScanConsent: (v: boolean) => void; - usageConsent: boolean; - setUsageConsent: (v: boolean) => void; onConnected: () => void; onNext: () => void; onBack: () => void; -}> = ({ c, identity, scanConsent, setScanConsent, usageConsent, setUsageConsent, onConnected, onNext, onBack }) => { +}> = ({ c, identity, 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); @@ -70,15 +64,14 @@ const BeatConnect: React.FC<{ } }, [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; + // Which provider rows are live, so the tab itself shows "Connected", not a floating label below. + const connections = useAppSelector(selectSubscriptionConnections); + const connectedIds = new Set(connections.filter((cx) => cx.isActive !== false).map((cx) => cx.provider)); + const identityFor = (pid: string): ProviderIdentity | undefined => + identity.find((id) => id.provider === pid) ?? (pid === 'antigravity' ? identity.find((id) => id.provider === 'gemini') : undefined); + // The whole flow leans on a real connection (identity, chat-history read, personalized reveal), so + // there is no skip: Continue stays locked until a provider is connected or the free trial is armed. + const canContinue = connected || freeTrial; return ( } >
{SUBSCRIPTION_PROVIDERS.map((p, i) => { const isThis = connecting === p.id; - const filled = connected && isThis; + const isConnected = connectedIds.has(p.id); + const ident = isConnected ? identityFor(p.id) : undefined; return ( - - {p.name} - {isThis && !connected && waiting for sign-in...} + + {p.name} + {isConnected + ? Connected{ident?.email ? ` as ${ident.email}` : ''} + : isThis && !connected + ? waiting for sign-in... + : null} + {/* Arc's icon tile: a full-height soft-tinted zone on the row's right edge, real brand mark inside. */} - + {providerLogo(p.id, 28)} ); })} {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, - }} - /> - + {!canContinue && ( +
+ Pick a subscription above to continue.
)}