diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 895398f..48755b5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -95,41 +95,46 @@ private const val RECONNECT_DELAY_MS = 1500L 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 - * stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance - * the question is actually about: how far the reader can keep going before they run out. A row is + * Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance 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 - * 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 - * there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of - * rows fixed the size of the mistake without fixing its kind; pixels are the unit itself. + * there, which is a list running out of transcript rather than a slow frame. * - * Three, so a fling lands on rows that are already there and the page after them is on its way. The - * cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that - * stops under a finger, and those are not the same size. - * - * Counted at the server rather than inferred from the screen, which is the only measurement here - * that does not depend on how the emulator renders: against a 24,000-event transcript at `--delay - * 120`, ten swipes asked for **ten** pages before this and **three** after. + * Six, because the two ways to be wrong are not the same size: firing early costs a page fetched + * that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over + * 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 + * cushion no longer amplifies a bad guess the way it would have when the guess came from whatever + * happened to be on screen. */ -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 - * transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, and - * the median run of consecutive text deltas that fold into one of them is four hundred. A page 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 - * seconds of a list that will not move over the tunnel, which reads as history having run out. + * The floor is that an event is not a row, and the ratio is nothing like one to one. Measured on a + * real transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, + * and the median run of consecutive text deltas that fold into one of them is four hundred. A page + * 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. Below this number a page can add no visible + * 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, * 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. @@ -1078,6 +1083,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // state the screen can draw, and a permanently blank one is not. restoring = false 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 @@ -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 // -- 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, - // at the typical size of the units that are on screen. A unit is at most a block of a reply, - // which is what makes the estimate usable where a count of rows was not -- a row is anything - // from one line to twenty-five screens, a block is roughly a paragraph. Being wrong is - // cheap and one-sided in effect: too low fetches a page early, too high is corrected a few - // frames later as the real sizes scroll in, and the spinner item stands at the edge for - // whatever slips through. + // has not composed. So the room ahead is added up from the real size of every unit the + // list *has* laid out, kept by key as units pass through the viewport, with the running + // average standing in for the ones it has never seen. It used to be the average of the + // units currently on screen, and the units on screen are the worst possible sample: two + // tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and + // report screens of room when the end is one swipe away -- so the reader met the spinner + // 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: // 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. + val unitSizes = remember(summary.id) { HashMap() } LaunchedEffect(listState, moreHistory) { - snapshotFlow { - val info = listState.layoutInfo - val visible = info.visibleItemsInfo - if (visible.isEmpty()) null - else - Triple( - info.totalItemsCount - 1 - visible.last().index, - visible.sumOf { it.size } / visible.size, - info.viewportSize.height, - ) - } - .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 + snapshotFlow { listState.layoutInfo } + .collect { info -> + val visible = info.visibleItemsInfo + if (visible.isEmpty()) return@collect + // Before the guards below, so sizes keep accumulating while a page is in + // flight and the next estimate starts better informed. + visible.forEach { unitSizes[it.key] = it.size } + if (restoring || !moreHistory || loadingHistory) return@collect + 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 + } loadingHistory = true try { // One page, and then this fires again if it was not enough -- the estimate