diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx index 9e33a00d..efe80ff9 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx @@ -52,6 +52,8 @@ const BeatCard: React.FC<{ }> = ({ c, identity, onFinish, onBack }) => { const { accent, gradient } = useThemeAccent(); const [name, setName] = useState(() => nameFromIdentity(identity)); + // finish() can legitimately wait up to PREP_WAIT_CAP_MS on prep; the button must say so, once. + const [submitting, setSubmitting] = useState(false); const seed = useMemo(() => Math.floor(Math.random() * EPITHETS.length), []); const [roll, setRoll] = useState(0); const epithet = EPITHETS[(seed + roll) % EPITHETS.length]; @@ -184,8 +186,9 @@ const BeatCard: React.FC<{ c={c} title={name ? `Welcome to OpenSwarm, ${name}` : 'Welcome to OpenSwarm'} body={"Here's your Swarm Card. Show it off to the world or keep it to yourself.\n\nAnd with that, you're ready to run your new OS."} - nextLabel="Get started" - onNext={() => onFinish(name.trim() || null)} + nextLabel={submitting ? 'Setting up...' : 'Get started'} + nextDisabled={submitting} + onNext={() => { if (submitting) return; setSubmitting(true); onFinish(name.trim() || null); }} onBack={onBack} stageDark > diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index bfdea934..0536ccc4 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -29,6 +29,7 @@ export function useOnboardingV3Pipeline() { // greeting/starters 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 finishingRef = useRef(false); const scanResultRef = useRef(null); const usageSummaryRef = useRef(''); const usageReadRef = useRef | null>(null); @@ -72,6 +73,9 @@ export function useOnboardingV3Pipeline() { }, []); const finish = useCallback(async (outcome: 'done' | 'skipped') => { + // Double-fire guard: repeated Get-started clicks during the prep wait must not stage twice. + if (finishingRef.current) return; + finishingRef.current = true; if (outcome === 'skipped') { dispatch(setFlowActive(false)); dispatch(updateSettingsPatch({ onboarding_v3: 'skipped', accent_color: accent, accent_gradient: gradient, theme: mode }));