From 87fe8df3d3ec6b078bc100dac238393607ff77ad Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 10:14:09 +0700 Subject: [PATCH 1/2] Keep long chronicle beats readable --- viewer/openworlds/screen-table.jsx | 65 ++++++++++++++++++++------ viewer/tests/test_openworlds_static.py | 16 ++++++- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index aafcd6b7..a3ef25d4 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -359,14 +359,17 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const session = liveSession || { chatBeats: [], log: [], pending: null, armPending: () => {}, clearPending: () => {}, recordPlayerEcho: () => {} }; const { chatBeats, log, pending } = session; const logRef = React.useRef(null); + const latestBeatRef = React.useRef(null); + const pendingBeatRef = React.useRef(null); const inputRef = React.useRef(null); - // #402: auto-follow state. `stickToBottomRef` is true while the player is at/near the bottom of the - // chronicle (the default) and false once they scroll UP to read history — so the auto-scroll effect - // follows new narration to the bottom WITHOUT yanking a reader back down mid-read. `snapNextRef` is - // a one-shot "force to bottom on the next content change" flag set when the player submits a move - // (a new turn) — so declaring an action always re-pins to the latest, even if they'd scrolled up. + // #402: auto-follow state. `stickToBottomRef` is true while the player is at/near the live + // end of the chronicle (the default) and false once they scroll UP to read history — so the + // auto-scroll effect follows new narration WITHOUT yanking a reader away mid-read. `snapNextRef` is + // a one-shot "force to latest" flag set when the player submits a move (a new turn) — so declaring + // an action always re-pins to the new beat, even if they'd scrolled up. const stickToBottomRef = React.useRef(true); const snapNextRef = React.useRef(false); + const programmaticScrollRef = React.useRef(false); const toast = window.useToast ? window.useToast() : (() => {}); const fallbackParty = []; const party = Array.isArray(surface?.party) && surface.party.length ? surface.party : fallbackParty; @@ -513,7 +516,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { } }, [party, activeHero]); - // #402: auto-follow the newest narration to the bottom — but RESPECT a reader who scrolled up. + // #402: auto-follow the newest narration — but RESPECT a reader who scrolled up. // The old effect pinned scrollTop to scrollHeight on EVERY content change unconditionally, which // (a) yanked a player back down the instant a streamed paragraph or 5s surface poll arrived while // they were reading history, and (b) never fired when ONLY the pending/narrating indicator toggled @@ -521,15 +524,32 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { // to bottom only when the player is already at/near the bottom (stickToBottomRef) OR a new move was // just submitted (snapNextRef, a one-shot re-pin on a new turn). Depending on `pending` too means // the narrating indicator (and a freshly-streamed beat) is followed into view the same way. + // A long Codex beat can be taller than the Chronicle viewport. Pinning to scrollHeight showed + // the END of the new response and hid its first line, making the fresh player's story read + // mid-sentence. Completed beats now align to their START; in-flight pending/stuck beats still align + // to their END so the player sees the live "DM is narrating…" feedback. React.useEffect(() => { const el = logRef.current; if (!el) return; if (snapNextRef.current || stickToBottomRef.current) { - el.scrollTop = el.scrollHeight; + const pendingTarget = pendingActive || pendingStuck ? pendingBeatRef.current : null; + const latestTarget = pendingTarget || latestBeatRef.current; + programmaticScrollRef.current = true; + if (latestTarget && typeof latestTarget.scrollIntoView === "function") { + latestTarget.scrollIntoView({ + block: pendingTarget ? "end" : "start", + inline: "nearest", + }); + } else { + el.scrollTop = el.scrollHeight; + } + window.setTimeout(() => { + programmaticScrollRef.current = false; + }, 120); snapNextRef.current = false; - stickToBottomRef.current = true; // a programmatic snap leaves us pinned to the bottom + stickToBottomRef.current = true; // a programmatic snap leaves us following the live end } - }, [renderedLog, pending]); + }, [renderedLog, pendingActive, pendingStuck]); // #402: track whether the player is reading history (scrolled up) vs. parked at the bottom. A // generous threshold (~64px) keeps "near the bottom" sticky through small layout shifts (the @@ -538,6 +558,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const onLogScroll = React.useCallback(() => { const el = logRef.current; if (!el) return; + if (programmaticScrollRef.current) { + stickToBottomRef.current = true; + return; + } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; stickToBottomRef.current = distanceFromBottom <= 64; }, []); @@ -810,7 +834,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { ref={logRef} tabIndex={0} role="log" - aria-label="Chronicle — most recent narration at the bottom" + aria-label="Chronicle — latest narration starts in view" data-worldos-testid="narration-log" onScroll={onLogScroll} style={{ flex: "1 1 auto", overflow: "auto", paddingRight: 12 }} @@ -826,10 +850,25 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { )} {renderedLog.length ? renderedLog.map((entry, i) => ( - +
+ +
)) :
No moves yet
} - {pendingActive && } - {pendingStuck && } + {pendingActive && ( +
+ +
+ )} + {pendingStuck && ( +
+ +
+ )} {/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 8e663e88..f6e09fa1 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -617,8 +617,22 @@ def test_openworlds_table_bounds_and_anchors_the_chronicle(self): # Auto-follow respects a reader scrolled up, and a new move re-pins to the latest. self.assertIn("stickToBottomRef", source) self.assertIn("snapNextRef", source) + # Long completed narration aligns to the START of the latest beat so first lines do not + # land above the fold; pending/stuck feedback still aligns to the end so the player sees + # the live "DM is narrating…" state. + self.assertIn("latestBeatRef", source) + self.assertIn("pendingBeatRef", source) + self.assertIn("programmaticScrollRef", source) + self.assertIn("scrollIntoView", source) + self.assertIn('block: pendingTarget ? "end" : "start"', source) + self.assertIn("programmaticScrollRef.current = true", source) + self.assertIn("stickToBottomRef.current = true;", source) + self.assertIn('"chronicle-latest-beat"', source) + self.assertIn('data-worldos-testid="chronicle-pending-beat"', source) + self.assertIn('data-worldos-testid="chronicle-stuck-beat"', source) + self.assertIn('aria-label="Chronicle — latest narration starts in view"', source) # The auto-scroll effect follows the pending/narrating indicator into view too (not just log). - self.assertIn("}, [renderedLog, pending]);", source) + self.assertIn("}, [renderedLog, pendingActive, pendingStuck]);", source) # The action bar is explicitly anchored (never pushed out by a growing chronicle). self.assertIn('flex: "0 0 auto"', source) From d8367acc5ffbf1f2bbada6200bb6da13f6031e0e Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 10:29:30 +0700 Subject: [PATCH 2/2] Target last visible chronicle beat --- viewer/openworlds/screen-table.jsx | 19 +++++++++++++++++-- viewer/tests/test_openworlds_static.py | 6 ++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index a3ef25d4..92be66e9 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -170,6 +170,20 @@ function sanitizeNarration(text) { return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } +function isVisibleChronicleEntry(entry) { + if (!entry) return false; + if (entry.kind === "narration") return Boolean(sanitizeNarration(entry.text)); + return true; +} + +function lastVisibleChronicleIndex(rows) { + if (!Array.isArray(rows)) return -1; + for (let i = rows.length - 1; i >= 0; i -= 1) { + if (isVisibleChronicleEntry(rows[i])) return i; + } + return -1; +} + // #337: the quick-action buttons (Continue / Say / Do / Check / Save) and the dice buttons are // icon+label only — a first-timer can't tell how they differ from typing free-text + Declare, so // the #324 newbie ignored all of them. These short hints surface as native `title=` tooltips @@ -420,6 +434,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { // turns, and ≫ one multi-paragraph DM turn) so we never clip an in-flight beat as it streams. const hiddenLogCount = Math.max(0, visibleLog.length - CHRONICLE_RENDER_CAP); const renderedLog = hiddenLogCount > 0 ? visibleLog.slice(visibleLog.length - CHRONICLE_RENDER_CAP) : visibleLog; + const lastVisibleLogIndex = lastVisibleChronicleIndex(renderedLog); const actionById = (id) => actions.find((a) => a.id === id); const enabledActionById = (id) => enabledActions.find((a) => a.id === id); const composerMode = COMPOSER_MODES[composerModeId] || COMPOSER_MODES.do; @@ -852,8 +867,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { {renderedLog.length ? renderedLog.map((entry, i) => (
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index f6e09fa1..ecf4949b 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -623,11 +623,17 @@ def test_openworlds_table_bounds_and_anchors_the_chronicle(self): self.assertIn("latestBeatRef", source) self.assertIn("pendingBeatRef", source) self.assertIn("programmaticScrollRef", source) + self.assertIn("function isVisibleChronicleEntry(entry)", source) + self.assertIn('if (entry.kind === "narration") return Boolean(sanitizeNarration(entry.text));', source) + self.assertIn("function lastVisibleChronicleIndex(rows)", source) + self.assertIn("const lastVisibleLogIndex = lastVisibleChronicleIndex(renderedLog);", source) self.assertIn("scrollIntoView", source) self.assertIn('block: pendingTarget ? "end" : "start"', source) self.assertIn("programmaticScrollRef.current = true", source) self.assertIn("stickToBottomRef.current = true;", source) self.assertIn('"chronicle-latest-beat"', source) + self.assertIn("i === lastVisibleLogIndex ? latestBeatRef : null", source) + self.assertNotIn("i === renderedLog.length - 1 ? latestBeatRef : null", source) self.assertIn('data-worldos-testid="chronicle-pending-beat"', source) self.assertIn('data-worldos-testid="chronicle-stuck-beat"', source) self.assertIn('aria-label="Chronicle — latest narration starts in view"', source)