From 45eae4dcbe0e482acbb8941eababb2acea586719 Mon Sep 17 00:00:00 2001 From: AINative Admin Date: Thu, 8 Oct 2026 09:00:22 -0700 Subject: [PATCH] fix(build): hold autoplay while a Cody nudge is unactioned (#1019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodyNudge ("Cody suggests · {primitive}") was keyed off state.view in CodyFeed, but useAutoplay advanced state.view HANDOFF_MS (550ms) after an artifact finished generating, with no regard for whether its nudge card had been read or acted on. Live-verified against a real autoplay run (Playwright, /build deep-link + real generation): the actual visible window is max(HANDOFF_MS, generation latency) and is never floor-protected for reaction time — on a fast/cached generation it can still collapse to the raw 550ms floor, exactly the reported symptom. Hold autoplay's view-advance while the CURRENT view has an unactioned, done nudge and autoplay is actually about to move past it. Accepting or dismissing the nudge (nudgeState, now in the effect's dependency array) releases the hold and autoplay resumes exactly where it left off — confirmed live for both the accept and dismiss paths, no permanent stall. --- __tests__/lib/build/useAutoplay-hook.test.ts | 123 +++++++++++++++++++ lib/build/useAutoplay.ts | 28 +++++ 2 files changed, 151 insertions(+) diff --git a/__tests__/lib/build/useAutoplay-hook.test.ts b/__tests__/lib/build/useAutoplay-hook.test.ts index c4b91d6d..79019162 100644 --- a/__tests__/lib/build/useAutoplay-hook.test.ts +++ b/__tests__/lib/build/useAutoplay-hook.test.ts @@ -364,6 +364,129 @@ describe('useAutoplay — wedge draft fetch (hook body, #668)', () => { }) }) +// ── Nudge hold (#1019) ──────────────────────────────────────────────────────── +// CodyNudge ("Cody suggests · {primitive}") used to vanish ~550ms (HANDOFF_MS) +// after the artifact it's attached to finished generating — autoplay advanced +// state.view unconditionally, and showNudge (CodyFeed.tsx) is keyed off +// state.view, so the card for the view just left disappears with it. Live +// verification (real /build autoplay run, Playwright) confirmed the real +// window is max(HANDOFF_MS, generation latency) — observed ~4.3s for one +// artifact, but on a fast/cached generation it can still collapse to the raw +// 550ms floor, exactly the reported symptom. Fix: hold autoplay (don't even +// search for `next`) while the CURRENT view has an unactioned nudge and has +// finished generating; NUDGE (accept/dismiss) releases the hold. + +describe('useAutoplay — nudge hold (hook body, #1019)', () => { + it('does NOT advance state.view while the current view has an unactioned, done nudge', () => { + // 'brief' has a real nudge (AI Kit Safety, see lib/build/primitives.ts) and + // is already done generating — autoplay's natural next step would advance + // past it, but the nudge is still unactioned (nudgeState.brief === undefined). + const state = wsState({ + done: { design: 'done', brief: 'done' }, + view: 'brief', + track: 'app', + askedPrivacy: true, + designStepDone: true, + nudgeState: {}, + }) + const dispatch = vi.fn() + useAutoplay(state, dispatch) + ;(globalThis as any).__triggerEffect?.(0) + // No GOTO_VIEW, no SET_OVERLAY, no fetch-driving dispatch at all — autoplay + // is fully held, not just skipping the transition. + expect(dispatch).not.toHaveBeenCalled() + }) + + it('does NOT hold when the view has no nudge (prd has nudge: null)', () => { + const state = wsState({ + done: { design: 'done', brief: 'done' }, + view: 'prd', + track: 'app', + askedPrivacy: true, + designStepDone: true, + nudgeState: {}, + }) + const dispatch = vi.fn() + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) + useAutoplay(state, dispatch) + ;(globalThis as any).__triggerEffect?.(0) + // prd has no nudge (PRIMITIVE_MAP.prd.nudge === null) — autoplay proceeds + // normally and dispatches the forming overlay for the next undone view. + expect(dispatch).toHaveBeenCalled() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('does NOT hold once the nudge has been accepted (nudgeState set)', () => { + const state = wsState({ + done: { design: 'done', brief: 'done' }, + view: 'brief', + track: 'app', + askedPrivacy: true, + designStepDone: true, + nudgeState: { brief: 'accepted' }, + }) + const dispatch = vi.fn() + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) + useAutoplay(state, dispatch) + ;(globalThis as any).__triggerEffect?.(0) + // Nudge answered — autoplay resumes searching for the next undone view + // (prd) and dispatches GOTO_VIEW to it. + const types = dispatch.mock.calls.map((c) => c[0].type) + expect(types).toContain('GOTO_VIEW') + const gotoCall = dispatch.mock.calls.find((c) => c[0].type === 'GOTO_VIEW') + expect(gotoCall![0].view).toBe('prd') + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('does NOT hold once the nudge has been dismissed (nudgeState set)', () => { + const state = wsState({ + done: { design: 'done', brief: 'done' }, + view: 'brief', + track: 'app', + askedPrivacy: true, + designStepDone: true, + nudgeState: { brief: 'dismissed' }, + }) + const dispatch = vi.fn() + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) + useAutoplay(state, dispatch) + ;(globalThis as any).__triggerEffect?.(0) + const types = dispatch.mock.calls.map((c) => c[0].type) + expect(types).toContain('GOTO_VIEW') + const gotoCall = dispatch.mock.calls.find((c) => c[0].type === 'GOTO_VIEW') + expect(gotoCall![0].view).toBe('prd') + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('does NOT hold while the nudge-bearing view is still generating (not done yet)', () => { + // brief has a nudge but isn't done yet — the card isn't showing (CodyFeed's + // showNudge only depends on state.view/nudgeState, not `done`, but there is + // nothing to hold FOR here: next is still brief itself, so the generation + // fetch must be allowed to proceed normally). + const state = wsState({ + done: { design: 'done' }, + view: 'brief', + track: 'app', + askedPrivacy: true, + designStepDone: true, + nudgeState: {}, + }) + const dispatch = vi.fn() + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) + useAutoplay(state, dispatch) + ;(globalThis as any).__triggerEffect?.(0) + const formingCall = dispatch.mock.calls.find( + (c) => c[0].type === 'SET_OVERLAY' && c[0].overlay?.kind === 'forming', + ) + expect(formingCall).toBeDefined() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) +}) + // ── GOTO_VIEW when view does not match next ─────────────────────────────────── describe('useAutoplay — GOTO_VIEW navigation (hook body)', () => { diff --git a/lib/build/useAutoplay.ts b/lib/build/useAutoplay.ts index 99a2a7aa..55ede9ac 100644 --- a/lib/build/useAutoplay.ts +++ b/lib/build/useAutoplay.ts @@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react' import { trackViews, type BuildState, type BuildAction, type ArtifactView } from '@/lib/build/state' import { GENERATED_VIEWS } from '@/lib/build/artifact-prompts' +import { PRIMITIVE_MAP } from '@/lib/build/primitives' type Dispatch = React.Dispatch @@ -96,6 +97,30 @@ export function useAutoplay(state: BuildState, dispatch: Dispatch) { return } + // #1019: a nudge card (PRIMITIVE_MAP[view].nudge) gives the founder a real + // decision to make — "add" a primitive to the company — but used to vanish + // the instant autoplay advanced state.view, which happens HANDOFF_MS (550ms) + // after generation finishes. On a fast/cached generation that left well + // under a second to notice the card, read it, and click — live-verified: + // the real window is max(HANDOFF_MS, generation latency), which can still + // collapse to the 550ms floor. Hold autoplay here — don't let it move + // state.view away — while the CURRENT view has an unactioned nudge, is + // already done generating, and autoplay is actually about to advance past + // it (state.view !== next; if they're equal there's nothing to protect — + // e.g. this is the last view and the track-complete branch above already + // returned). NUDGE (accept/dismiss) is in the dependency array below, so + // answering the nudge re-runs this effect and lets autoplay resume exactly + // where it left off — this is not a permanent stall. + const currentNudge = PRIMITIVE_MAP[state.view]?.nudge + if ( + currentNudge && + state.nudgeState[state.view] === undefined && + state.done[state.view] && + state.view !== next + ) { + return + } + // ── Privacy decision (App track, once, right before the Data Model) ────── if (state.track === 'app' && next === 'dataModel' && !state.askedPrivacy) { dispatch({ type: 'SET_OVERLAY', overlay: { kind: 'none' } }) @@ -272,6 +297,9 @@ export function useAutoplay(state: BuildState, dispatch: Dispatch) { state.screen, state.auto, state.paused, state.idea, state.track, state.view, state.done, state.generated, state.genError, state.askedPrivacy, state.builtMVP, state.wedgePicked, state.designStepDone, state.wedgeDraft, state.wedgeDraftError, + // #1019: NUDGE (accept/dismiss) must re-run this effect so the nudge-hold + // guard above releases and autoplay resumes advancing. + state.nudgeState, ]) // Clear pending timers on unmount so a torn-down workspace doesn't dispatch.