fix(viewer): building-universe overlay can never wedge — escape hatch + streaming polish (#405, #406) - #409
Conversation
…n never wedge (#405) + streaming polish (#406) #405 (CRITICAL): the "building your universe" cover (z-9000) only cleared via the first-narration handoff or a 12-min backstop; its dismiss() callback had ZERO call sites. On a stalled/errored cold-open it wedged full-screen over the table's own recovery for up to 12 minutes. The overlay is only a COVER for the slow cold-open, so it now YIELDS to the table (live streaming + 180s narrating timeout + "Try again") on ANY of: a hard cold-open/session error (dismiss after a 3s stale-error grace), a FIXED ~120s stall ceiling (not the 12-min wall, not re-armed per beat), or a manual "Enter anyway ->" button surfaced after ~15s. dismiss() is wired in App (onEnterAnyway) and passed the bridge error. After dismiss the table is reachable + usable (cold-open action bar is enabled). Found + fixed a latent bug the new tests surfaced: the handoff flourish armed its 1400ms dismiss timer in the SAME effect that flipped `handoff`, whose cleanup cancelled the timer on the re-run -> the overlay never auto-dismissed on first narration. Split into flip + a separate handoff-keyed arm effect. #406 polish: 1. aria-modal: dropped the false role=dialog/aria-modal (no focus trap) -> aria-busy labeled container + the existing role=status announcement. 2. backstop re-arm: notePendingProgress now re-arms only the 'stuck' recovery timer, not the absolute backstop (armed once in armPending) -> a streaming-but-unresolved turn can't defer the 12-min cap forever. 3. dedup scope: #407 fixed the canonical seq-keyed path; a residual TEXT-key suppression remained for the /chat-only fallback across turns -> seenText now resets per turn (a repeated short line on a later turn renders again). 4. retried cold-open window: firstBeat now keys off resolvedTurnsRef (bumped only on /chat resolution), not streamed paragraphs -> a retried cold-open keeps the 4-min window instead of dropping to 180s. 5. headline freeze: added BUILDING_FLAVOR_LATE (calmer pool past ~42s) so the headline keeps fresh, on-arc copy for the full overlay lifetime. 6. untested lifecycle: added an effect-running harness + lifecycle tests (handoff, dismiss-on-error+grace, dismiss-on-ceiling, manual escape, table-reachable, negative disclosure) and 2 streaming-hook regression guards; rewrote the misleading min-display-floor comment.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThis PR refactors the "DM is narrating" recovery timing from per-paragraph beats to per-resolved-turn tracking, resets text deduplication per-turn to prevent suppressing repeated lines across turns, and extends the building-universe loading overlay with explicit multi-exit paths (error dismissal, stall ceiling, manual escape, handoff flourish) to prevent getting stuck. ChangesNever-Wedge Overlay and Turn-Based Recovery
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
Closes #405. Addresses #406.
#405 (CRITICAL) — the overlay could wedge full-screen for 12 minutes
The "building your universe" cover (
z-index: 9000) only cleared via the first-narration handoff or a 12-minute backstop, and itsdismiss()callback had zero call sites. On a DM cold-open that errors or stalls (no narration ever arrives), the cover sat over everything — including the table's own "Try again" recovery — for up to 12 minutes. A blind playtester quits.Product decision implemented
The overlay is only a cover for the legitimately-slow cold-open (~55s). The table already has robust handling (live streaming narration, a 180s "DM is narrating…" timeout, a "Try again" recovery), so the cover now yields to the table on ANY of:
appStatus.lastError, mirrored tonativeState.error) dismisses the overlay. Honored after a 3s grace so a stale leftover error at a fresh build's click instant (pre-reload) can't nuke a just-started build; a genuine failure (seen on a later poll, past the grace) still dismisses promptly. (Pre-reload mint failures were already torn down byscreen-launcher/screen-create; this covers the post-reload cold-open error.)dismiss()(App passesonEnterAnyway={building.dismiss}).After any dismiss the table is reachable (the z-9000 cover unmounts) and usable — on a cold-open the table arms no
pending, so its action bar is enabled and the player can act (their move then arms the table's own narrating/stuck-recovery).The 12-min
sessionStorageself-heal inread()is kept as a separate net for the reload path only (a stale flag can't re-enter the overlay) — it no longer governs the live overlay.Latent handoff bug found + fixed (by the new tests)
The handoff flourish armed its 1400ms
dismiss()timer in the same effect that flippedhandoff— an effect whose deps includehandoffand whose cleanup clears the timer. React re-runs the effect whenhandoffflips → cleanup cancels the just-armed timer → the re-run early-returns → the dismiss never fired. So even the canonical exit (first narration) never auto-dismissed the cover; it relied on nothing. This shipped green only because the unit tests stubuseEffectto a no-op. Fixed by splitting into (a) fliphandoffon first narration and (b) a separatehandoff-keyed effect that arms the dismiss timer (cleanup only on unmount/record swap). Confirmed empirically:test_first_narration_beat_hands_off_and_clearsFAILS on the single-effect version, PASSES after.#406 — streaming/overlay polish bundle
aria-modalw/ no focus managementrole=dialog/aria-modal(no focus trap exists); root is now anaria-busylabeled container; the dedicatedrole=statusannouncement is kept.notePendingProgressre-arms only the adaptivestuckrecovery timer (split outclearRecoveryTimer); the absolute backstop is armed once inarmPending, so a streaming-but-unresolved turn can't defer the 12-min cap forever./eventsdedup (#407) does NOT suppress legitimately-repeated lines (differentseq). But the TEXT-key fallback (seenText, used only for/chat-only beats) was run-long, so a repeated short line on a later/chat-only turn (a catchphrase, a repeated "Yes.") was permanently suppressed.seenTextnow resets when a turn resolves (the gap it must span is one turn's/events→/chat).firstBeatnow keys off a newresolvedTurnsRef(bumped only on/chatturn-resolution), notdmBeatCountRef(bumped per streamed paragraph). A retried cold-open whose first turn never resolved keeps the generous 4-min window.BUILDING_FLAVOR_LATE, a calmer "still composing — a rich opening is worth the wait" pool used past ~42s, so the headline keeps fresh, on-arc copy for the full overlay lifetime (still rotates → proof-of-life).How I verified the wedge is gone
test_building_universe.py) drives the realuseBuildingUniversehook with a minimal working React (realuseState/useEffect+cleanup/useRef/useCallback) + a controllable clock/timers. Asserts: handoff-on-narration clears the cover; dismiss-on-error (after grace) clears it; a stale error within grace does NOT dismiss (and a persisting one does); the ~120s ceiling dismisses (and does NOT re-arm on re-renders); the "Enter anyway" affordance arms after ~15s anddismiss()clears the flag; and a negative-disclosure test that no-beat-within-ceiling legitimately stays up.aria-modaldialog and the escape renders as a real wired<button>.test_live_narration_stream.py): a repeated/chat-only line renders on a later turn (chore(openworlds): building-universe + live-session streaming edge cases (a11y modal, backstop re-arm, dedup scope, headline freeze, untested lifecycle) #406.3); a streamed-but-unresolved cold-open keeps the first-beat window (chore(openworlds): building-universe + live-session streaming edge cases (a11y modal, backstop re-arm, dedup scope, headline freeze, untested lifecycle) #406.4). Both were confirmed to FAIL on pre-fix behavior and pass after.viewer/server.py "" 8884) and confirmed it serves the changed bundle. (Browser-eyeball wasn't possible this session — the Preview tool kept binding to a different local checkout and Claude-in-Chrome was disconnected — so the lifecycle harness is the behavioral proof.)Scope
Viewer-only:
viewer/openworlds/{building-universe,app}.jsx,styles.css, + the two test files. No DM skill /--resume/persist_beat/ engine / wire-contract changes.CI runs the viewer suite (
viewer-testsjob,pytest viewer/tests -q -p no:xdist); the new lifecycle tests run there.Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Tests