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 802c07f..fb79953 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -65,6 +65,19 @@ import kotlinx.coroutines.withContext private const val RECONNECT_DELAY_MS = 1500L +/** + * How many rows to keep loaded past the oldest one on screen. + * + * Both the point at which history starts loading and how much of it a load has to produce before it + * stops. A cushion rather than a page count because a page is measured in events and this list is + * measured in rows, and the two are not close: a page of eighty events can be one message. + * + * Small enough that opening a long session still costs one page, large enough that a fling upwards + * lands on rows that are already there. Fewer, and reading back means waiting for the network at + * every screenful, which is what it did. + */ +private const val HISTORY_LOOKAHEAD = 8 + /** * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The * stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes @@ -759,16 +772,30 @@ fun SessionScreen( } .collect { (last, total) -> if (!moreHistory || loadingHistory || total == 0) return@collect - if (last < total - 3) return@collect + if (last < total - HISTORY_LOOKAHEAD) return@collect loadingHistory = true try { - val older = - withContext(Dispatchers.IO) { - fetchTranscript(settings, summary.id, before = oldestSeq) + // Pages until there are rows behind them again, not one page and stop. + // + // A page is eighty *events*, and eighty events are routinely one row: a + // reply arrives as hundreds of text deltas that fold into a single message. + // So a page that lands can leave the far end exactly where it was -- and + // since this is triggered by the far end moving, nothing asks for the next + // one. The list then only loads when somebody drags it again, a page at a + // time, which is what "it only loads when you touch the top" was. + // Counted from `items` rather than from `rows`, which is the + // composition's value and does not change under a running coroutine. + val start = groupToolRuns(items).size + var have = start + while (moreHistory && have - start < HISTORY_LOOKAHEAD) { + val older = + withContext(Dispatchers.IO) { + fetchTranscript(settings, summary.id, before = oldestSeq) + } + if (older.isEmpty()) { + moreHistory = false + break } - if (older.isEmpty()) { - moreHistory = false - } else { oldestSeq = older.first().seq moreHistory = oldestSeq > 1L // Folded oldest-first into a list of their own, then @@ -783,6 +810,7 @@ fun SessionScreen( } } items = joinPages(earlier, items) + have = groupToolRuns(items).size } } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. @@ -1100,7 +1128,12 @@ fun SessionScreen( // reader and nothing to whoever finds this in six months. if (!atNewest) { Surface( - onClick = { scope.launch { listState.animateScrollToItem(0) } }, + // Instantly. An animated scroll travels the whole transcript, so the + // further back somebody has read the longer this takes -- the one press + // whose cost grows with how much there is to skip, which is backwards. The + // list is keyed and composes only what it lands on, so going straight there + // costs the same from anywhere. + onClick = { scope.launch { listState.scrollToItem(0) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier =