diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index aafcd6b7..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
@@ -359,14 +373,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;
@@ -417,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;
@@ -513,7 +531,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 +539,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 +573,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 +849,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 +865,25 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
)}
{renderedLog.length ? renderedLog.map((entry, i) => (
-