Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,11 @@ function useLiveSession(state) {

const [chatBeats, setChatBeats] = React.useState([]);
const [log, setLog] = React.useState([]); // local optimistic player echoes
const [pending, setPending] = React.useState(null); // { text, since, stuck? } | null
const [pending, setPending] = React.useState(null); // { text, since, stuck?, firstBeat?, streaming? } | null
// `streaming` (set in notePendingProgress) means live /events prose has begun arriving for THIS
// in-flight turn — the narrating affordance uses it to confirm the scene is being written above,
// instead of showing the generic "weaving the next beat" wait. armPending starts a fresh pending
// object without it, so each new turn re-derives `streaming` from its own /events arrivals.
const chatCursor = React.useRef(0);
const eventsCursor = React.useRef(0); // #393: per-file cursor for the live /events tail
const dmBeatCountRef = React.useRef(0);
Expand Down Expand Up @@ -404,8 +408,14 @@ function useLiveSession(state) {
recoveryTimer.current = window.setTimeout(() => {
setPendingState((q) => (q ? { ...q, stuck: true } : q));
}, recoveryMs);
// Clear any prior 'stuck' flag — fresh prose just arrived, so the turn is plainly not stuck.
if (p.stuck) setPendingState((q) => (q ? { ...q, stuck: false } : q));
// #G3-UX: fresh prose just streamed via /events for THIS in-flight turn → mark the pending turn
// as `streaming`. The narrating affordance reads this to flip its copy from the generic "weaving
// the next beat" wait to "the scene is arriving above" — so the spinner is no longer disconnected
// from the live narration tail filling in right above it (the player WATCHES the beat being
// written instead of staring at a static spinner). Clear any prior 'stuck' flag in the same
// update — fresh prose just arrived, so the turn is plainly not stuck. Folding both into one
// updater keeps `streaming`/`stuck` mutually consistent and avoids a second state churn.
setPendingState((q) => (q ? (q.streaming && !q.stuck ? q : { ...q, streaming: true, stuck: false }) : q));
}, [clearRecoveryTimer, setPendingState]);

// #399: idempotent player echo for STUCK retries only. The #344 'Try again' recovery re-POSTs
Expand Down
98 changes: 75 additions & 23 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
)) : <div className="body-sm muted">No moves yet</div>}
{pendingActive && (
<div ref={pendingBeatRef} data-worldos-testid="chronicle-pending-beat" style={{ scrollMarginBlock: 12 }}>
<DmNarratingBeat since={pending.since} firstBeat={pending.firstBeat} />
{/* #G3-UX: `streaming` (set by useLiveSession's notePendingProgress the moment live
/events prose lands for this in-flight turn) flips the affordance from a generic
"weaving the next beat" wait to confirming the scene is being written ABOVE — so
the spinner is wired to the live narration tail the player is watching fill in,
not a dead static line. `onNavigate` is passed so the wait can point the player at
read-only screens (sheet/map/journal) that stay open during compose. */}
<DmNarratingBeat since={pending.since} firstBeat={pending.firstBeat} streaming={Boolean(pending.streaming)} onNavigate={onNavigate} />
</div>
)}
{pendingStuck && (
Expand Down Expand Up @@ -1318,7 +1324,7 @@ const DM_COLD_OPEN_FLAVOR = [
"The ink is still drying on your opening…",
];

function DmNarratingBeat({ since, firstBeat }) {
function DmNarratingBeat({ since, firstBeat, streaming, onNavigate }) {
const start = typeof since === "number" ? since : Date.now();
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
Expand All @@ -1331,14 +1337,34 @@ function DmNarratingBeat({ since, firstBeat }) {
const elapsedLabel = `${mm}:${ss}`;
// #385: the headline reads as an ACTIVE process, not a passive status. The cold-open rotates a
// flavor line every ~4s (so the text itself visibly changes); later beats keep the steady label.
// #G3-UX: once `streaming` is true, the DM's prose is visibly filling into the chronicle directly
// ABOVE this affordance — so the later-beat copy switches from the anticipatory "weaving" wait to
// a present-tense confirmation that the scene is arriving NOW. This connects the spinner to the
// live /events narration tail the player is watching (the give-up the veteran hit was a dead
// static spinner; once prose is flowing the spinner should say so), without changing the cold-open
// path (which has its own minutes-long rotating flavor + window).
const label = firstBeat
? DM_COLD_OPEN_FLAVOR[Math.floor(secs / 4) % DM_COLD_OPEN_FLAVOR.length]
: "The Dungeon Master is narrating";
: streaming
? "The scene is unfolding above"
: "The Dungeon Master is narrating";
const waitHint = firstBeat
? "The first beat of a session can take a few minutes — hang tight, your story is on its way."
// #399: a content-rich beat can run up to ~two minutes (the window is 180s); say "a minute or
// two" so a 90–120s wait reads as expected, not as the app having stalled.
: "Weaving the next beat — this can take a minute or two.";
: streaming
// The live prose is appending into the chronicle above this line as the DM writes it — say so,
// so the wait reads as visible progress (the scene is arriving) rather than a static spinner.
? "The Dungeon Master is writing this beat — it's appearing above as it's composed."
// #399: a content-rich beat can run up to ~two minutes (the window is 180s); say "a minute or
// two" so a 90–120s wait reads as expected, not as the app having stalled.
: "Weaving the next beat — this can take a minute or two.";
// #G3-UX FIX 2: read-only navigation (the character sheet, the map/Travel, the Quest Journal, the
// Quick Stash) is already UN-gated during compose — only move/write controls gate on pendingActive.
// But a player staring at the wait doesn't KNOW that, so the long beat reads as "frozen, can't do
// anything". This one-liner advertises the affordance: the buttons are real `onNavigate` calls (so
// it's a working invitation, not just prose), and they route to read-only surfaces that stay open
// while the DM composes. Rendered for the normal later-beat wait (the ~120–200s beats the veteran
// rage-quit on); the cold-open path keeps its own focused "your story is on its way" reassurance.
const showNavAffordance = !firstBeat && typeof onNavigate === "function";
// #385: a11y model for the cold-open. The frozen-app illusion came from the live region being the
// ONLY accessible text AND it never changing (the dots/shimmer/elapsed were all aria-hidden). Fix:
// • The visible label + elapsed are NO LONGER aria-hidden for the first beat, so they appear in
Expand Down Expand Up @@ -1384,26 +1410,52 @@ function DmNarratingBeat({ since, firstBeat }) {
);
}
return (
<div role="status" aria-live="polite" style={{ margin: "14px 0", display: "flex", gap: 12, opacity: 0.92 }}>
<div style={{ margin: "14px 0", display: "flex", gap: 12, opacity: 0.92 }}>
<div style={{ width: 4, alignSelf: "stretch", background: "linear-gradient(180deg, var(--crimson), transparent)" }} />
<div className="body" style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span className="dm-narrating-label eyebrow" style={{ color: "var(--crimson)" }}>{label}</span>
<span className="dm-narrating-dots" aria-hidden="true" style={{ display: "inline-flex", gap: 4 }}>
{[0, 1, 2].map((i) => (
<span key={i} style={{
width: 6, height: 6, borderRadius: "50%", background: "var(--b-400)",
animation: "dmNarratePulse 1200ms ease-in-out infinite", animationDelay: `${i * 200}ms`,
}} />
))}
</span>
<span aria-hidden="true" style={{ fontFamily: "var(--f-mono)", fontSize: 12, color: "var(--ink-600)", fontVariantNumeric: "tabular-nums" }}>
{elapsedLabel}
</span>
</div>
<div className="hand muted" style={{ fontSize: 12, marginTop: 4 }}>
{waitHint}
{/* The status line (label + wait hint) is the announced region. Scoping aria-live HERE — not
on the whole affordance — means the #G3-UX nav buttons below are NOT re-announced every
time the label flips (e.g. when `streaming` turns on), avoiding screen-reader spam while
still announcing the single meaningful "the scene is arriving" change. The per-second
elapsed counter stays aria-hidden so the polite region isn't re-fired every tick. */}
<div role="status" aria-live="polite">
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span className="dm-narrating-label eyebrow" style={{ color: "var(--crimson)" }}>{label}</span>
<span className="dm-narrating-dots" aria-hidden="true" style={{ display: "inline-flex", gap: 4 }}>
{[0, 1, 2].map((i) => (
<span key={i} style={{
width: 6, height: 6, borderRadius: "50%", background: "var(--b-400)",
animation: "dmNarratePulse 1200ms ease-in-out infinite", animationDelay: `${i * 200}ms`,
}} />
))}
</span>
<span aria-hidden="true" style={{ fontFamily: "var(--f-mono)", fontSize: 12, color: "var(--ink-600)", fontVariantNumeric: "tabular-nums" }}>
{elapsedLabel}
</span>
</div>
<div className="hand muted" style={{ fontSize: 12, marginTop: 4 }}>
{waitHint}
</div>
</div>
{/* #G3-UX FIX 2: tell the player the wait is NOT a freeze — read-only surfaces stay open while
the DM composes. The verbs are live onNavigate calls (a working invitation, not just copy)
to surfaces that don't gate on pendingActive: the character sheet, the map, the journal.
Lives OUTSIDE the aria-live region above so it isn't re-announced on every status change.
data-worldos-testid lets the static/jsdom harness assert the affordance renders. */}
{showNavAffordance && (
<div
data-worldos-testid="narrating-nav-affordance"
className="hand muted"
style={{ fontSize: 12, marginTop: 6, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}
>
<span>While you wait, review your</span>
<button type="button" className="btn ghost sm" style={{ padding: "0 6px" }} onClick={() => onNavigate("character")}>character sheet</button>
<span>·</span>
<button type="button" className="btn ghost sm" style={{ padding: "0 6px" }} onClick={() => onNavigate("map")}>the map</button>
<span>·</span>
<button type="button" className="btn ghost sm" style={{ padding: "0 6px" }} onClick={() => onNavigate("journal")}>your journal</button>
</div>
)}
</div>
</div>
);
Expand Down
44 changes: 44 additions & 0 deletions viewer/tests/test_live_narration_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,50 @@ def test_streamed_beat_shows_prose_but_keeps_turn_gated(self):
self.assertFalse(out["stuck"],
"fresh streamed prose proves the turn is alive — it must not read as stuck")

# --- #G3-UX FIX 1: a streamed beat marks the pending turn `streaming` -----------------------
# The narrating affordance (DmNarratingBeat) reads `pending.streaming` to flip its copy from the
# generic "weaving the next beat" wait to a present-tense confirmation that the scene is arriving
# above — wiring the spinner to the live /events tail the player is watching. This asserts the
# data wiring: the moment live prose streams for an in-flight turn, notePendingProgress stamps
# `streaming: true` (while KEEPING the turn gated — pending present, not stuck).
def test_streamed_beat_marks_pending_streaming(self):
out = self._run(
"h.arm('I push open the door');"
"var before = h.pending();"
"h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The hinges shriek.' }], next: 1 });"
"await h.tick();"
"var p = h.pending();"
"return ({ before_streaming: !!(before && before.streaming), after_streaming: !!(p && p.streaming), pending_present: !!p, stuck: !!(p && p.stuck) });"
)
self.assertFalse(out["before_streaming"],
"a freshly-armed turn has not streamed yet — `streaming` must start falsy")
self.assertTrue(out["after_streaming"],
"once live /events prose lands for the in-flight turn, the pending turn must be marked `streaming`")
self.assertTrue(out["pending_present"],
"marking `streaming` must NOT resolve the turn — the bar stays gated until /chat resolves it")
self.assertFalse(out["stuck"],
"fresh streamed prose proves the turn is alive — it must not be stuck")

# A fresh turn must NOT inherit the prior turn's `streaming` flag — armPending starts a clean
# pending object, so the affordance re-derives "the scene is arriving" from THIS turn's own
# /events arrivals (otherwise every later turn would falsely claim it's already streaming).
def test_new_turn_resets_streaming_flag(self):
out = self._run(
"h.arm('first move');"
"h.enqueue('/events', { entries: [{ kind: 'narration', text: 'A first beat.' }], next: 1 });"
"await h.tick();"
"var first = h.pending();"
# resolve the first turn, then arm a second — its pending must start un-streamed.
"h.enqueue('/chat', { items: [{ role: 'dm', text: 'A first beat.' }], next: 1 });"
"await h.tick();"
"h.arm('second move');"
"var second = h.pending();"
"return ({ first_streaming: !!(first && first.streaming), second_streaming: !!(second && second.streaming) });"
)
self.assertTrue(out["first_streaming"], "the first turn streamed → it was marked streaming")
self.assertFalse(out["second_streaming"],
"a newly-armed turn must reset `streaming` to falsy (no prose has streamed for it yet)")

# --- #393: the turn-END /chat line RESOLVES a turn whose prose already streamed --------------
def test_chat_resolves_a_streamed_turn(self):
out = self._run(
Expand Down
44 changes: 44 additions & 0 deletions viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,50 @@ def test_openworlds_table_bounds_and_anchors_the_chronicle(self):
# The action bar is explicitly anchored (never pushed out by a growing chronicle).
self.assertIn('flex: "0 0 auto"', source)

def test_openworlds_narrating_beat_reflects_live_stream_and_offers_nav(self):
# #G3-UX: the ~120–200s/beat wait was a give-up because the play-screen spinner was a DEAD
# static line ("Weaving the next beat…") with no connection to the live /events narration
# that #393 already streams into the chronicle above it, and nothing told the player that
# read-only screens stay open during compose. Two LOW-RISK fixes, asserted statically here:
#
# FIX 1 — the spinner is WIRED to the in-flight /events tail. The pending turn carries a
# `streaming` flag (set in app.jsx's notePendingProgress the moment live prose lands), passed
# into DmNarratingBeat, which flips its copy to confirm the scene is arriving ABOVE — so the
# player watches the beat being written instead of a frozen spinner.
#
# FIX 2 — a visible nav affordance near the narrating beat invites the player to the
# read-only character sheet / map / journal (all un-gated during compose) via real
# onNavigate calls, so the long wait no longer reads as "frozen, can't do anything".
status, ctype, body = self._get("/openworlds/screen-table.jsx")
self.assertEqual(status, 200)
self.assertIn("text/babel", ctype)
source = body.decode("utf-8")

# The pending beat passes the live-stream + nav wiring into DmNarratingBeat.
self.assertIn("streaming={Boolean(pending.streaming)}", source)
self.assertIn("onNavigate={onNavigate}", source)
# DmNarratingBeat accepts the new props.
self.assertIn("function DmNarratingBeat({ since, firstBeat, streaming, onNavigate })", source)

# FIX 1: the later-beat copy is streaming-aware — when prose is arriving it confirms the
# scene is being written above, instead of the generic anticipatory "weaving" wait. Both
# the streaming and the not-yet-streaming strings must exist (the flip is conditional on
# `streaming`), so the spinner is demonstrably connected to the /events tail it sits below.
self.assertIn("The scene is unfolding above", source)
self.assertIn("appearing above as it's composed", source)
# The original anticipatory wait is still the copy BEFORE prose starts arriving.
self.assertIn("Weaving the next beat — this can take a minute or two.", source)

# FIX 2: a testable nav affordance with real onNavigate calls to read-only surfaces.
self.assertIn('data-worldos-testid="narrating-nav-affordance"', source)
self.assertIn("showNavAffordance", source)
self.assertIn('onNavigate("character")', source)
self.assertIn('onNavigate("map")', source)
self.assertIn('onNavigate("journal")', source)
# The affordance is gated to the later-beat path + an actual handler (not the cold-open,
# which keeps its own focused reassurance), so it only shows where the player can really act.
self.assertIn('const showNavAffordance = !firstBeat && typeof onNavigate === "function";', source)

def test_openworlds_table_promotes_action_palette_into_main_column(self):
# #G3: the action palette must be PROMINENT in the main play flow, not buried in the
# 320px right rail. It is rendered in the CENTER column (LEFT — Party / CENTER — Scene
Expand Down
Loading