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
80 changes: 67 additions & 13 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -513,23 +531,40 @@ 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
// (it wasn't a dependency), so the "DM is narrating…" beat could sit below the fold. Now we scroll
// 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
Expand All @@ -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;
}, []);
Expand Down Expand Up @@ -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 }}
Expand All @@ -826,10 +865,25 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
</div>
)}
{renderedLog.length ? renderedLog.map((entry, i) => (
<LogEntry key={entry.id || `${entry.kind || "n"}-${i}`} entry={entry} />
<div
key={entry.id || `${entry.kind || "n"}-${i}`}
ref={i === lastVisibleLogIndex ? latestBeatRef : null}
data-worldos-testid={i === lastVisibleLogIndex ? "chronicle-latest-beat" : undefined}
style={{ scrollMarginBlock: 12 }}
>
<LogEntry entry={entry} />
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)) : <div className="body-sm muted">No moves yet</div>}
{pendingActive && <DmNarratingBeat since={pending.since} firstBeat={pending.firstBeat} />}
{pendingStuck && <DmStuckBeat />}
{pendingActive && (
<div ref={pendingBeatRef} data-worldos-testid="chronicle-pending-beat" style={{ scrollMarginBlock: 12 }}>
<DmNarratingBeat since={pending.since} firstBeat={pending.firstBeat} />
</div>
)}
{pendingStuck && (
<div ref={pendingBeatRef} data-worldos-testid="chronicle-stuck-beat" style={{ scrollMarginBlock: 12 }}>
<DmStuckBeat />
</div>
)}
</div>

{/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle
Expand Down
22 changes: 21 additions & 1 deletion viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,28 @@ 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("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)
# 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)

Expand Down
Loading