Open an adventure on a window of the story, and page upward

Opening a finished adventure fetched every action in one response: 589.5 kB on
production's longest, and nothing about that curve bends on its own, because a
story only ever gets longer. The page load now brings the newest 60 actions
and the reader pages up from there. On the harness's 600-action fixture that
is 606.0 kB down to 62.6 kB, and -- the part that matters -- it no longer
depends on how long the story is.

Paged by anchor, not by offset. `before_id` is the oldest action the caller
holds; the server returns what precedes it. An offset counted back from the
newest would shift every older position the moment a turn lands, which is
exactly when someone is likely to be scrolling, and the reader would get one
action twice and never see another. It also keeps working when the story stops
being a flat list: comparing indices to order a branch survives the story tree,
treating them as positions does not.

`has_more` comes from fetching one row past the window rather than from
counting. A deleted anchor -- undo, mid-scroll -- reports the end rather than
guessing and serving a page the reader already has.

GET /{id}/actions and POST /{id}/undo now return {actions, total, has_more}
instead of a bare list. Undo is the action most likely to be repeated several
times running, so having it re-fetch the whole story would have undone the
paging on the worst case.

The adventure payload gets its window through set_committed_value rather than
by assignment: the actions relationship cascades delete-orphan, so assigning a
60-item list to it would delete everything outside the window on the next
flush.

In Play.jsx the prepend is followed by a useLayoutEffect that restores the
scroll position, before paint, so the story does not jump. Loading starts 400px
from the top rather than at it, guarded by a ref because scroll fires far
faster than React re-renders. There is a button as well as the scroll trigger:
on a short viewport the transcript may not be tall enough to scroll at all, and
a reader who cannot scroll must still be able to reach the beginning.

Verified against a running backend and a 220-action adventure: the page load
returns 60 of 220 ending on the newest, and walking back from an anchor returns
exactly the actions before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-17 14:23:03 +05:30
co-authored by Claude Opus 5
parent ae6e5af6c7
commit cf8ec22e8b
7 changed files with 519 additions and 18 deletions
+11
View File
@@ -86,6 +86,17 @@ export const api = {
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
// A page of the story, walking backwards. `beforeId` is the oldest action
// already on screen; omit it for the newest window. Anchored on an action
// rather than an offset so a turn landing mid-scroll cannot shift the page
// out from under the reader.
getActions: (advId, { beforeId, limit } = {}) => {
const params = new URLSearchParams()
if (beforeId != null) params.set('before_id', beforeId)
if (limit != null) params.set('limit', limit)
const query = params.toString()
return request(`/adventures/${advId}/actions${query ? `?${query}` : ''}`)
},
updateAction: (advId, actionId, text) =>
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
deleteAction: (advId, actionId) =>
+25
View File
@@ -332,6 +332,31 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
white-space: pre-wrap;
padding-bottom: 16px;
}
/* The head of a windowed transcript: how much story is still above, and the
way back to it. Deliberately quiet — it is a seam in the page, not a
feature — and it sits inside .story so it inherits the story face. */
.story-earlier {
text-align: center;
margin: 4px 0 22px;
font-size: 0.9rem;
/* No action-in animation here: this appears above content the reader is
already looking at, and a fade would read as the story moving. */
}
.story-earlier .dim { color: var(--text-dim); font-style: italic; }
.story-earlier button {
background: none;
border: none;
border-bottom: 1px solid rgba(159, 199, 209, 0.3);
color: var(--text-dim);
font: inherit;
font-style: italic;
cursor: pointer;
padding: 2px 4px;
}
.story-earlier button:hover {
color: var(--accent-bright);
border-bottom-color: var(--accent-bright);
}
.story .action {
position: relative;
margin-bottom: 15px;
+106 -6
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
@@ -1310,10 +1310,20 @@ export default function Play() {
// Read-only browsing of an earlier attempt at a past turn (see VariantPager).
// One at a time; null when every message is showing its active version.
const [preview, setPreview] = useState(null)
// The transcript is a window on the story, not the whole of it: the page
// load brings the newest page and older ones arrive as the reader scrolls
// up. `total` is the story's real length, for the "N earlier" line.
const [total, setTotal] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loadingOlder, setLoadingOlder] = useState(false)
const storyEndRef = useRef(null)
const abortRef = useRef(null)
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
const inputRef = useRef(null)
// Set just before older actions are prepended, read once afterwards to put
// the reader back where they were. See the layout effect below.
const restoreScrollRef = useRef(null)
const loadingOlderRef = useRef(false)
// The drop cap belongs to the story's first narrated beat. `start` is the
// scenario's opening prompt, so it's usually that; an adventure begun blank
@@ -1340,18 +1350,76 @@ export default function Play() {
useEffect(() => {
api.getAdventure(id)
.then((adv) => { setAdventure(adv); setActions(adv.actions) })
.then((adv) => {
setAdventure(adv)
setActions(adv.actions)
setTotal(adv.action_count ?? adv.actions.length)
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
})
.catch(() => navigate('/'))
}, [id, navigate])
// Fetch the page above the one on screen and prepend it.
//
// Anchored on the oldest action we hold rather than on a count, so a turn
// landing while the reader scrolls cannot shift the page. Guarded by a ref
// as well as state because scroll fires far faster than React re-renders,
// and two in-flight requests would fetch the same page twice.
const loadOlder = useCallback(async () => {
if (loadingOlderRef.current || !hasMore) return
const oldest = actions[0]
if (!oldest) return
loadingOlderRef.current = true
setLoadingOlder(true)
try {
const page = await api.getActions(id, { beforeId: oldest.id })
if (page.actions.length) {
// Record the height before the prepend; the layout effect below uses
// it to keep the reader looking at the same paragraph.
restoreScrollRef.current = {
height: document.documentElement.scrollHeight,
top: window.scrollY,
}
setActions((prev) => {
// Defensive: never let a page the reader already holds duplicate a
// message. Cheap, and the alternative is a visibly doubled turn.
const known = new Set(prev.map((a) => a.id))
return [...page.actions.filter((a) => !known.has(a.id)), ...prev]
})
}
setTotal(page.total)
setHasMore(page.has_more)
} catch {
// Leave hasMore alone: a failed fetch should let the reader try again
// by scrolling, not permanently hide the rest of their story.
} finally {
loadingOlderRef.current = false
setLoadingOlder(false)
}
}, [actions, hasMore, id])
// Put the viewport back after a prepend. useLayoutEffect, not useEffect:
// this has to run before the browser paints, or the reader sees the story
// jump and then snap back.
useLayoutEffect(() => {
const mark = restoreScrollRef.current
if (!mark) return
restoreScrollRef.current = null
const grown = document.documentElement.scrollHeight - mark.height
if (grown > 0) window.scrollTo({ top: mark.top + grown })
}, [actions])
useEffect(() => {
const onScroll = () => {
pinnedRef.current =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
// Start the next page before the reader reaches the top, so the story
// is usually already there by the time they would have noticed its end.
if (window.scrollY < 400) loadOlder()
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
}, [loadOlder])
useEffect(() => {
// Snap to the real document bottom (below the sticky composer), not to
@@ -1375,6 +1443,9 @@ export default function Play() {
const handleEvent = useCallback((event) => {
if (event.type === 'player') {
setActions((prev) => [...prev, event.action])
// The window grew at the bottom, so the story did too. Kept in step by
// hand because nothing re-reads the count between turns.
setTotal((n) => n + 1)
} else if (event.type === 'chunk') {
setStreaming((prev) => (prev ?? '') + event.text)
} else if (event.type === 'reasoning') {
@@ -1383,6 +1454,7 @@ export default function Play() {
setStreaming(null)
setReasoningStream(null)
setActions((prev) => [...prev, event.action])
setTotal((n) => n + 1)
handleScriptReport(event.script)
} else if (event.type === 'stopped') {
setStreaming(null)
@@ -1443,8 +1515,14 @@ export default function Play() {
await api.retry(id, handleEvent, signal)
} catch (err) {
// Failed retry (409, network): the optimistically removed action may
// still exist server-side — resync instead of guessing.
api.getAdventure(id).then((adv) => setActions(adv.actions)).catch(() => {})
// still exist server-side — resync instead of guessing. Resyncing
// collapses the transcript back to the newest window, which is the
// right call: the reader's place is already lost by the failure.
api.getAdventure(id).then((adv) => {
setActions(adv.actions)
setTotal(adv.action_count ?? adv.actions.length)
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
}).catch(() => {})
throw err
}
})
@@ -1454,7 +1532,12 @@ export default function Play() {
setToast(null)
setPreview(null)
try {
setActions(await api.undo(id))
// A window, not the whole story — undo is the action most likely to be
// repeated several times running, so it must not re-fetch everything.
const page = await api.undo(id)
setActions(page.actions)
setTotal(page.total)
setHasMore(page.has_more)
} catch (err) {
setToast({ text: err.message, isError: true })
}
@@ -1494,6 +1577,7 @@ export default function Play() {
try {
await api.deleteAction(id, actionId)
setActions((prev) => prev.filter((a) => a.id !== actionId))
setTotal((n) => Math.max(0, n - 1))
} catch (err) {
setToast({ text: err.message, isError: true })
}
@@ -1534,6 +1618,22 @@ export default function Play() {
{actions.length === 0 && streaming === null && (
<div className="empty">A blank page. Type something below to begin your story.</div>
)}
{/* Scrolling up loads the rest. The button is not decoration: on a
short viewport the story may not be tall enough to scroll at all,
and a reader who cannot scroll must still be able to get back to
the beginning. */}
{hasMore && (
<div className="story-earlier">
{loadingOlder ? (
<span className="dim">Turning back the pages…</span>
) : (
<button type="button" onClick={loadOlder}>
{Math.max(total - actions.length, 0)} earlier
{total - actions.length === 1 ? ' moment' : ' moments'}
</button>
)}
</div>
)}
{actions.map((action, i) => {
const isPlayer = PLAYER_TYPES.includes(action.type)
// A player action opens a new turn, so that's where the ornamental