mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 04:09:44 +02:00
[eric] onboarding: lift the reveal curtain instantly (no 'setting up' spinner); greeting waits-then-streams
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user