Load history in one go, and go back to the newest instantly

Two things that made scrolling back feel like work.

The jump-to-newest button animated. An animated scroll travels the whole
transcript, so the further back somebody has read the longer the press
takes -- the one control whose cost grows with how much there is to
skip, which is backwards. It goes straight there now.

History loaded a page per gesture, and a page is eighty *events*. Eighty
events are routinely one row: a reply arrives as hundreds of text deltas
that fold into a single message. So a page could land and leave the far
end exactly where it was -- and since the far end moving is what asks
for the next page, nothing did. The list then only loaded when somebody
dragged it again, which is what "it only loads when you touch the top"
was. It now keeps fetching until there are rows behind the reader again,
and starts doing that a cushion before the end rather than at it.

Measured on a session of five very long replies, about two thousand
events: reaching the oldest message used to stall at every drag; it now
takes flings alone, and the jump back to the newest end is one frame.
This commit is contained in:
iris committed 2026-08-30 00:55:55 -04:00
1 parent c60b0cbac9
commit 8a1621a207
1 file changed
+41 -8
@@ -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 =