[eric] onboarding: lift the reveal curtain instantly (no 'setting up' spinner); greeting waits-then-streams

This commit is contained in:
ciregenz
2026-07-19 17:31:19 -07:00
parent 0c473e995c
commit 6ebe5e78df
3 changed files with 47 additions and 53 deletions
@@ -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<Beat>('welcome');
const [picks, setPicks] = useState<string[]>([]);
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 = () => {
<span key={dot} style={{ width: 11, height: 11, borderRadius: 999, background: dot, opacity: 0.9 }} />
))}
</motion.div>
{finishing && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', gap: 18, alignItems: 'center', justifyContent: 'center', background: finishBackdrop, fontFamily: ONBOARDING_SANS }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.3, pointerEvents: 'none' }} />
<motion.div
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: [0.55, 1, 0.55], scale: [0.9, 1.06, 0.9] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
style={{ position: 'relative' }}
>
<OnboardingLogo size={54} />
</motion.div>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ position: 'relative', fontSize: '1.02rem', fontWeight: 600, color: '#fff', textShadow: '0 1px 8px rgba(0,0,0,0.25)' }}>
Setting up your canvas...
</motion.div>
</div>
)}
</motion.div>
</motion.div>
)}
@@ -50,6 +50,10 @@ export function useOnboardingV3Pipeline() {
const identityRef = useRef<ProviderIdentity[]>([]);
const scanRef = useRef<Promise<ScanResult | null> | null>(null);
const prepRef = useRef<Promise<PrepResponse | null> | 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<PrepResponse | null>(null);
const scanResultRef = useRef<ScanResult | null>(null);
const usageSummaryRef = useRef<string>('');
const usageReadRef = useRef<Promise<void> | 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<null>((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 };
@@ -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 };
}