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:
1 parent
9477cd288a
commit
a63ae8347f
1 file changed
+74
-44
@@ -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
|
||||||
|
val average = unitSizes.values.sum() / unitSizes.size
|
||||||
|
// From the last visible lazy index: item zero is the "below" slot, so lazy
|
||||||
|
// index equals units index plus one -- starting the walk at `last().index`
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
.collect { measured ->
|
|
||||||
val (ahead, typical, viewport) = measured ?: return@collect
|
|
||||||
if (restoring || !moreHistory || loadingHistory || viewport == 0) return@collect
|
|
||||||
if (ahead.toLong() * typical >= viewport.toLong() * HISTORY_SCREENS) 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
|
||||||
|
|||||||
Reference in new issue
Block a user