Load history before the reader arrives, and estimate room from real sizes

The first loading boundary sat barely off-screen: the opening page is
deliberately small (sized for time-to-first-frame), so one short scroll
met a spinner and a round trip. And the prefetch that should have
hidden every later boundary estimated room-ahead from the average size
of the units currently visible -- the worst possible sample, since two
tall blocks filling a viewport multiply out over dozens of unseen
one-line rows and report screens of room when the end is one swipe
away. Iris's report showed exactly that: viewport 676px, two units
visible.

Three changes, one per cause:

- The opening page is followed by a full page fetched in the
  background as soon as the screen is up -- the same reasoning that
  keeps the opening page off the critical path puts the first real
  page right behind it. A restore skips this; it just paged as deep as
  its anchor needed.
- Room ahead is now added up from the real size of every unit the list
  has ever laid out, recorded by key as units pass through the
  viewport, with the running average standing in for units never seen.
  The walk early-outs at the cushion, so a frame's check stops after a
  few units in the common case.
- The cushion is six screens (was three) and a page is 400 events
  (was 800). The cushion grew because firing early costs a page nobody
  may read while firing late is a spinner under a finger for a tunnel
  round trip; the page shrank because pages now load before anybody
  waits on them, so a page only has to stay above the fold floor --
  the median run of deltas folding into one reply is ~400 events, and
  below that a page can add no visible room at all. Smaller pages
  cross the tunnel faster and land in smaller frame spikes, which is
  what makes the boundary hard to catch.

Verified on the emulator against the biggest transcript on the
machine at --delay 300: opening a session loads the first background
page unscrolled, twenty-five hard flings paged the rest in with zero
frames in which the spinner was on screen, and at --delay 2000 --
outrunning the chain deliberately -- the boundary shows the spinner
and then heals with the paragraph being read holding its position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-31 15:04:04 -04:00
1 parent 9477cd288a
commit a63ae8347f
1 file changed
+76 -46
@@ -95,41 +95,46 @@ private const val RECONNECT_DELAY_MS = 1500L
private val LOADING_SPINNER = 48.dp private val LOADING_SPINNER = 48.dp
/** /**
* How much history to keep loaded past the oldest row on screen, counted in screenfuls. * How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history
* before the next page is fetched.
* *
* Both the point at which history starts loading and how much of it a load has to produce before it * Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance the
* stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance * question is actually about: how far the reader can keep going before they run out. A row is
* the question is actually about: how far the reader can keep going before they run out. A row is
* anything from one line to a page, so a count of rows is that distance only by accident. Eight * anything from one line to a page, so a count of rows is that distance only by accident. Eight
* rows was the number, and on a tool-heavy transcript eight rows is less than one screen: the * rows was the number once, and on a tool-heavy transcript eight rows is less than one screen: the
* reader reached the end of what was loaded on *every* swipe and waited a round trip standing * reader reached the end of what was loaded on *every* swipe and waited a round trip standing
* there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of * there, which is a list running out of transcript rather than a slow frame.
* rows fixed the size of the mistake without fixing its kind; pixels are the unit itself.
* *
* Three, so a fling lands on rows that are already there and the page after them is on its way. The * Six, because the two ways to be wrong are not the same size: firing early costs a page fetched
* cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that * that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over
* stops under a finger, and those are not the same size. * the tunnel -- and the distance a hard fling covers while that fetch is in flight is several
* * screens on its own. The estimate this multiplies is built from measured unit sizes, so a bigger
* Counted at the server rather than inferred from the screen, which is the only measurement here * cushion no longer amplifies a bad guess the way it would have when the guess came from whatever
* that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay * happened to be on screen.
* 120`, ten swipes asked for **ten** pages before this and **three** after.
*/ */
private const val HISTORY_SCREENS = 3 private const val HISTORY_SCREENS = 6
/** /**
* How many events a backwards page asks for, which is ten times what the opening page takes. * How many events a backwards page asks for, which is five times what the opening page takes.
* *
* Because an event is not a row, and the ratio is nothing like one to one. Measured on a real * The floor is that an event is not a row, and the ratio is nothing like one to one. Measured on a
* transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and * real transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages,
* the median run of consecutive text deltas that fold into one of them is four hundred. A page of * and the median run of consecutive text deltas that fold into one of them is four hundred. A page
* eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about * of eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about
* thirty sequential round trips inside one collect -- a stutter on loopback, and four or five * thirty sequential round trips inside one collect. Below this number a page can add no visible
* seconds of a list that will not move over the tunnel, which reads as history having run out. * room at all, and the fetch chain degenerates into those round trips again.
*
* At the floor rather than above it, because pages are fetched in the background before the reader
* arrives -- the cushion decides how deep loading runs, and a page that was not enough is followed
* by another without anybody waiting on either. What a *smaller* page buys is hiding: it crosses
* the tunnel in half the time and lands in a smaller frame spike, so the case where the reader
* outruns an in-flight fetch is rarer and cheaper. This was 800 when the reader was the one
* standing at the boundary and each round trip had to be amortized as far as it would go.
* *
* The opening page stays small: it is the one on the critical path of showing the screen at all, * The opening page stays small: it is the one on the critical path of showing the screen at all,
* and it only has to fill a viewport. * and it only has to fill a viewport.
*/ */
private const val HISTORY_PAGE = 800 private const val HISTORY_PAGE = 400
/** /**
* The most events one request of a restore may ask for. * The most events one request of a restore may ask for.
@@ -1078,6 +1083,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// state the screen can draw, and a permanently blank one is not. // state the screen can draw, and a permanently blank one is not.
restoring = false restoring = false
ready = true ready = true
// The opening page is sized for time-to-first-frame, not for reading: it fills a
// viewport or two, so the first "still loading" boundary sat barely off-screen and the
// first upward scroll met it and waited a round trip. The same reasoning that keeps the
// opening page off the critical path puts the first full page right behind it, while
// the screen is already up. A restore skips this: it has just paged as deep as the
// anchor needed.
if (savedAnchor == null && moreHistory && !loadingHistory) {
loadingHistory = true
try {
loadOlderPage()
} catch (_: ApiException) {
// The next scroll asks again.
} finally {
loadingHistory = false
}
}
} }
// Only while the screen is actually on screen. Android stops the // Only while the screen is actually on screen. Android stops the
@@ -1214,37 +1235,46 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) )
} }
} }
// Reaching the far end of what is loaded fetches the page before it. // Reaching within a few screens of the far end of what is loaded fetches the page before
// it.
// //
// The question is pixels of scroll -- how far can the reader keep going before they run out // The question is pixels of scroll -- how far can the reader keep going before they run out
// -- and a lazy list cannot answer it exactly, because it has never measured the items it // -- and a lazy list cannot answer it exactly, because it has never measured the items it
// has not composed. So the room ahead is *estimated*: the units past the last visible one, // has not composed. So the room ahead is added up from the real size of every unit the
// at the typical size of the units that are on screen. A unit is at most a block of a reply, // list *has* laid out, kept by key as units pass through the viewport, with the running
// which is what makes the estimate usable where a count of rows was not -- a row is anything // average standing in for the ones it has never seen. It used to be the average of the
// from one line to twenty-five screens, a block is roughly a paragraph. Being wrong is // units currently on screen, and the units on screen are the worst possible sample: two
// cheap and one-sided in effect: too low fetches a page early, too high is corrected a few // tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and
// frames later as the real sizes scroll in, and the spinner item stands at the edge for // report screens of room when the end is one swipe away -- so the reader met the spinner
// whatever slips through. // at every boundary, which is exactly what the cushion exists to prevent.
// //
// There is no correction beside this one. Following the newest message is not an effect: // There is no correction beside this one. Following the newest message is not an effect:
// the list is reversed, so an arriving message extends the end the viewport is pinned to, // the list is reversed, so an arriving message extends the end the viewport is pinned to,
// and a page of history lands past every visible index and moves nothing. // and a page of history lands past every visible index and moves nothing.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) { LaunchedEffect(listState, moreHistory) {
snapshotFlow { snapshotFlow { listState.layoutInfo }
val info = listState.layoutInfo .collect { info ->
val visible = info.visibleItemsInfo val visible = info.visibleItemsInfo
if (visible.isEmpty()) null if (visible.isEmpty()) return@collect
else // Before the guards below, so sizes keep accumulating while a page is in
Triple( // flight and the next estimate starts better informed.
info.totalItemsCount - 1 - visible.last().index, visible.forEach { unitSizes[it.key] = it.size }
visible.sumOf { it.size } / visible.size, if (restoring || !moreHistory || loadingHistory) return@collect
info.viewportSize.height, val viewport = info.viewportSize.height
) if (viewport == 0) return@collect
} val loaded = currentUnits
.collect { measured -> val average = unitSizes.values.sum() / unitSizes.size
val (ahead, typical, viewport) = measured ?: return@collect // From the last visible lazy index: item zero is the "below" slot, so lazy
if (restoring || !moreHistory || loadingHistory || viewport == 0) return@collect // index equals units index plus one -- starting the walk at `last().index`
if (ahead.toLong() * typical >= viewport.toLong() * HISTORY_SCREENS) return@collect // begins one unit past the last visible one, and a visible spinner makes the
// range empty, which is room of zero.
var room = 0L
val cushion = viewport.toLong() * HISTORY_SCREENS
for (index in visible.last().index until loaded.size) {
room += unitSizes[loaded[index].key] ?: average
if (room >= cushion) return@collect
}
loadingHistory = true loadingHistory = true
try { try {
// One page, and then this fires again if it was not enough -- the estimate // One page, and then this fires again if it was not enough -- the estimate