From 6ebe5e78df780cb51a9eda80bb90989932ced338 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 17:31:19 -0700 Subject: [PATCH] [eric] onboarding: lift the reveal curtain instantly (no 'setting up' spinner); greeting waits-then-streams --- .../OnboardingV3/OnboardingV3Root.tsx | 30 ++--------- .../OnboardingV3/useOnboardingV3Pipeline.ts | 50 ++++++++++--------- .../app/pages/AgentChat/useWelcomeGreeting.ts | 20 ++++++-- 3 files changed, 47 insertions(+), 53 deletions(-) diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx index cf7801e6..b3c28052 100644 --- a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx +++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx @@ -10,7 +10,6 @@ import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline'; 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'; @@ -117,13 +116,7 @@ const OnboardingV3Root: React.FC = () => { const [beat, setBeat] = useState('welcome'); 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; @@ -152,10 +145,11 @@ const OnboardingV3Root: React.FC = () => { setBeat('theme'); }, [kickPrep, picks]); - const leaveCard = useCallback(async (name: string | null) => { + const leaveCard = useCallback((name: string | null) => { if (name) dispatch(updateSettingsPatch({ user_name: name })); - setFinishing(true); - await finish('done'); + // finish() is now non-blocking: it stages the reveal + drops flowActive immediately, so the overlay + // fades straight onto the live canvas (jobs already in motion). No "Setting up your canvas" spinner. + void finish('done'); }, [dispatch, finish]); @@ -225,22 +219,6 @@ const OnboardingV3Root: React.FC = () => { ))} - {finishing && ( -
-
- - - - - Setting up your canvas... - -
- )} )} diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index 0308fb3e..40d39e06 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -50,6 +50,10 @@ export function useOnboardingV3Pipeline() { const identityRef = useRef([]); const scanRef = useRef | null>(null); const prepRef = useRef | null>(null); + // The resolved prep, readable SYNCHRONOUSLY at finish() time: lets the reveal seed with the real + // jobs/greeting the instant they're ready (the common case, prep finishes during the beats) without + // awaiting, so the curtain never blocks behind a spinner. + const prepReadyRef = useRef(null); const scanResultRef = useRef(null); const usageSummaryRef = useRef(''); const usageReadRef = useRef | null>(null); @@ -133,6 +137,7 @@ export function useOnboardingV3Pipeline() { .catch(() => null); // Launch the prepped work MID-FLOW (theme/card beats cover the latency): audit + app build, gated to a real connected model so the fragile free trial never carries it. void prepRef.current.then((prep) => { + prepReadyRef.current = prep; if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return; launchedRef.current = true; if (prep.starters.length > 0) launchJob(prep.starters[0].title, prep.starters[0].prompt, 'audit', prep.starters[0].reason ?? ''); @@ -153,30 +158,29 @@ export function useOnboardingV3Pipeline() { dispatch(updateSettingsPatch({ onboarding_v3: 'skipped', accent_color: accent, accent_gradient: gradient, theme: mode })); return; } - // Cap the wait so a slow aux call degrades to generic starters instead of a hung curtain. Sized - // above the real harvest+prep (~17-22s) so a user who rushes the beats still gets the personalized - // reveal, not generic; the backend's own 45s aux timeout is the hard backstop behind this. - const timeout = new Promise((resolve) => { window.setTimeout(() => resolve(null), 24000); }); - const prep = await Promise.race([prepRef.current ?? Promise.resolve(null), timeout]); - const greeting = prep?.greeting?.trim() || null; - const starters = prep?.starters ?? []; - const automations = prep?.automations ?? []; - // Jobs already launched mid-flow at prep-resolve; the reveal only composes the canvas. - const autoPrompt = null; - // Await the PATCH so personalized_greeting/starters/automations 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, - accent_gradient: gradient, - theme: mode, - personalized_greeting: greeting, - personalized_starters: starters, - personalized_automations: automations, - })).unwrap(); - } catch {} - dispatch(stageReveal({ greeting, starters, scanSummary: summarizeScan(scanResultRef.current), autoPrompt })); + // NEVER block the curtain behind a spinner. The connect head-start means prep has usually resolved + // during the beats, so seed with the real jobs/greeting synchronously; if a fast user beat prep to + // this point, seed with what we have and let the late-jobs effect + the async patch below fill in. + // The whole point of gating on connect is this head start, so the reveal must show it in motion. + const ready = prepReadyRef.current; + dispatch(updateSettingsPatch({ onboarding_v3: 'done', accent_color: accent, accent_gradient: gradient, theme: mode })); + dispatch(stageReveal({ + greeting: ready?.greeting?.trim() || null, + starters: ready?.starters ?? [], + scanSummary: summarizeScan(scanResultRef.current), + autoPrompt: null, + })); dispatch(setFlowActive(false)); + // Personalized fields land the instant prep resolves (often already have): the welcome greeting + // waits for personalized_greeting then streams it in, and the starter chips read it live. + void (prepRef.current ?? Promise.resolve(null)).then((prep) => { + if (!prep) return; + dispatch(updateSettingsPatch({ + personalized_greeting: prep.greeting?.trim() || null, + personalized_starters: prep.starters ?? [], + personalized_automations: prep.automations ?? [], + })); + }); }, [dispatch, accent, gradient, mode]); return { identity, kickIdentity, kickScan, kickUsageRead, kickPrep, finish }; diff --git a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts index 9cdb598e..822bf2b3 100644 --- a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts +++ b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts @@ -25,12 +25,24 @@ export function useWelcomeGreeting( 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?` + const hasPersonalized = !!personalized?.trim(); + const greetingText = hasPersonalized + ? `${personalized!.trim()}\n\nWhere do you want to start?` : WELCOME_GREETING; + // The curtain now lifts BEFORE prep necessarily finishes, so the personalized greeting may land a + // beat later. Hold the stream until it arrives (so we never snapshot the stock opener and freeze the + // personal one out), then fall back to stock after a grace so it can't hang if prep failed. + const [graceElapsed, setGraceElapsed] = useState(false); useEffect(() => { - if (!eligible || !sessionId || startedRef.current) return; + if (!eligible) return; + const t = window.setTimeout(() => setGraceElapsed(true), 18000); + return () => window.clearTimeout(t); + }, [eligible]); + const greetingReady = hasPersonalized || graceElapsed; + + useEffect(() => { + if (!eligible || !sessionId || !greetingReady || startedRef.current) return; startedRef.current = true; dispatch(streamStart({ sessionId, messageId: GREETING_MSG_ID, role: 'assistant' })); @@ -63,7 +75,7 @@ export function useWelcomeGreeting( return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eligible, sessionId, branchId, dispatch]); + }, [eligible, sessionId, greetingReady, branchId, dispatch]); return { greetingDone }; }