+ {/* Action bar — #402: flex 0 0 auto so it is ALWAYS anchored at the bottom of the panel and
+ never pushed out of view by an ever-growing chronicle above it. */}
+
Active
diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py
index 61ff3963..329f3ea3 100644
--- a/viewer/tests/test_live_narration_stream.py
+++ b/viewer/tests/test_live_narration_stream.py
@@ -227,6 +227,10 @@
log: () => (reactHost.api().log || []).map((e) => ({ kind: e.kind, who: e.who, text: e.text })),
// #399: the recovery-window selector by turn position (firstBeat ⇒ cold-open window, else later).
recoveryWindowMs: (firstBeat) => sandbox.window.recoveryWindowMs(firstBeat),
+ // #402: the live-tail caps (so a test asserts against the SAME numbers the hook trims to) and the
+ // raw chatBeats length (the bounded DM-narration/dialogue tail).
+ caps: () => sandbox.window.__LIVE_TAIL_CAPS__,
+ beatCount: () => (reactHost.api().chatBeats || []).length,
drain,
};
@@ -470,6 +474,48 @@ def test_player_echo_keeps_distinct_actions(self):
self.assertEqual(len(out["log"]), 2,
"two distinct actions must both appear (idempotence only suppresses a back-to-back exact repeat)")
+ # --- #402: the live chatBeats tail is BOUNDED so a long session can't grow the DOM/a11y tree --
+ # The bug: chatBeats accumulated every streamed/turn-end narration for the WHOLE session with no
+ # cap, so the chronicle rendered an ever-growing list — burying the latest beat + the action box,
+ # and truncating an a11y reader before it reached the newest content. Stream far MORE than the
+ # cap of unique narration paragraphs and assert the tail is trimmed to the cap.
+ def test_live_beats_tail_is_bounded(self):
+ out = self._run(
+ "await h.drain();"
+ "var cap = h.caps().maxBeats;"
+ "var total = cap + 25;" # stream well past the cap
+ "for (var i = 0; i < total; i++) {"
+ " h.enqueue('/events', { entries: [{ kind: 'narration', text: 'paragraph number ' + i }], next: i + 1 });"
+ " await h.tick();"
+ "}"
+ "return ({ cap: cap, total: total, count: h.beatCount() });"
+ )
+ self.assertEqual(out["count"], out["cap"],
+ "the live narration tail must be trimmed to MAX_LIVE_BEATS no matter how long the session runs (#402)")
+ self.assertLess(out["count"], out["total"],
+ "the tail must drop the oldest beats once it exceeds the cap (it must not grow unbounded)")
+
+ # --- #402: trimming keeps the NEWEST beats (the latest DM narration must always survive) ------
+ # A naive trim that kept the FIRST N would hide exactly the content the player needs (the reply
+ # to their latest move). Assert the most-recent paragraph is present and the oldest is gone.
+ def test_bounded_tail_keeps_the_newest_beats(self):
+ out = self._run(
+ "await h.drain();"
+ "var cap = h.caps().maxBeats;"
+ "var total = cap + 10;"
+ "for (var i = 0; i < total; i++) {"
+ " h.enqueue('/events', { entries: [{ kind: 'narration', text: 'beat ' + i }], next: i + 1 });"
+ " await h.tick();"
+ "}"
+ "var texts = h.narrationTexts();"
+ "return ({ first: texts[0], last: texts[texts.length - 1], total: total });"
+ )
+ # The newest beat (index total-1) must still be in the tail; the very oldest (beat 0) must not.
+ self.assertEqual(out["last"], f"beat {out['total'] - 1}",
+ "the most-recent DM narration must always survive the trim (the player's latest reply)")
+ self.assertNotEqual(out["first"], "beat 0",
+ "the oldest beats must be dropped once the cap is exceeded (the tail slides forward)")
+
if __name__ == "__main__":
unittest.main()
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py
index e7d7d856..7053c8dd 100644
--- a/viewer/tests/test_openworlds_static.py
+++ b/viewer/tests/test_openworlds_static.py
@@ -186,6 +186,70 @@ def test_openworlds_table_posts_only_enabled_session_actions(self):
self.assertNotIn("snapshot.json", source)
self.assertNotIn("writeSnapshot", source)
+ def test_openworlds_table_bounds_and_anchors_the_chronicle(self):
+ # #402: the chronicle must stay navigable across a long session — the rendered row count is
+ # CAPPED (DOM + a11y tree bounded so the latest beat isn't truncated), the scroll region is
+ # labelled role="log" and tracks user scroll, auto-follow respects a reader scrolled up
+ # (stick-to-bottom) while a new move snaps to latest, and the action bar is anchored.
+ status, ctype, body = self._get("/openworlds/screen-table.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ # Rendered window cap (bounds the DOM + accessibility tree).
+ self.assertIn("CHRONICLE_RENDER_CAP", source)
+ self.assertIn("renderedLog", source)
+ self.assertIn("hiddenLogCount", source)
+ # The scroll region is a labelled log and reports scroll position for the auto-follow guard.
+ self.assertIn('role="log"', source)
+ self.assertIn("onLogScroll", source)
+ # Auto-follow respects a reader scrolled up, and a new move re-pins to the latest.
+ self.assertIn("stickToBottomRef", source)
+ self.assertIn("snapNextRef", source)
+ # The auto-scroll effect follows the pending/narrating indicator into view too (not just log).
+ self.assertIn("}, [renderedLog, pending]);", source)
+ # The action bar is explicitly anchored (never pushed out by a growing chronicle).
+ self.assertIn('flex: "0 0 auto"', source)
+
+ def test_openworlds_app_bounds_the_live_session_tail(self):
+ # #402: the live tail (chatBeats + player echoes) is bounded in useLiveSession so a long
+ # session doesn't accumulate state without limit (the upstream half of the DOM-growth fix).
+ status, ctype, body = self._get("/openworlds/app.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ self.assertIn("MAX_LIVE_BEATS", source)
+ self.assertIn("MAX_LIVE_ECHOES", source)
+ self.assertIn("boundTail(", source)
+ # The cap is applied at the chatBeats append sites and the player-echo append site.
+ self.assertIn("boundTail([...prev, ...beats], MAX_LIVE_BEATS)", source)
+ self.assertIn("MAX_LIVE_ECHOES", source)
+
+ def test_openworlds_camp_rest_gives_feedback_when_dm_is_busy(self):
+ # #402: the Camp "Begin Resting" CTA must give clear feedback when the DM is mid-turn (the
+ # bug was a silent no-op — can_act stays true so the click POSTed a move that just queued).
+ # ScreenMap threads the DM-busy state from the live session into CampSidebar, which disables
+ # the CTA + explains why (and the click handler toasts on the keyboard/edge path).
+ _s_map, _c_map, map_body = self._get("/openworlds/screen-map.jsx")
+ map_source = map_body.decode("utf-8")
+ self.assertIn("liveSession", map_source)
+ self.assertIn("dmBusy", map_source)
+ self.assertIn("dmBusy={dmBusy}", map_source)
+
+ _s_camp, _c_camp, camp_body = self._get("/openworlds/camp-sidebar.jsx")
+ camp_source = camp_body.decode("utf-8")
+ self.assertIn("dmBusy", camp_source)
+ # The button is disabled while busy, and the early-return path toasts instead of no-op'ing.
+ self.assertIn("!canAct || dmBusy", camp_source)
+ self.assertIn("still narrating", camp_source)
+
+ # And the app actually passes liveSession to the map screen (so dmBusy is real, not always false).
+ _s_app, _c_app, app_body = self._get("/openworlds/app.jsx")
+ app_source = app_body.decode("utf-8")
+ self.assertIn("ScreenMap", app_source)
+ self.assertRegex(app_source, r"case \"map\":\s*return ]*liveSession=\{liveSession\}")
+
def test_openworlds_acts_screen_binds_viewer_acts_surface(self):
status, ctype, body = self._get("/openworlds/screen-acts.jsx")