From 8a1621a2078ed4d0b260ce425c526c80b673b6bf Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 00:55:55 -0400 Subject: [PATCH 1/2] 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. --- .../kotlin/com/example/aiapp/SessionScreen.kt | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) 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 = From ad791a0d845132ace60be110bccce7853573739a Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 00:57:57 -0400 Subject: [PATCH 2/2] Rejoin a message the page boundary cut in two joinPages healed a tool call split across a page boundary but not a message split across one, so a long reply came back as two rows with a paragraph break through the middle of a sentence -- visible on any session whose replies are longer than an eighty-event page. Same cause, same cure, and the rule was already written down one member of the set: `foldEvent` never leaves two assistant messages adjacent inside a page, since deltas accumulate into the message before them, so two meeting at a join are always halves of one reply. The newer half keeps its identity for the reason adoptRun gives -- it is the row already on screen. It grows by what the older half brings, which is safe at this join and nowhere else: the join is at the oldest end of what is loaded, so the growth extends off the top, away from the row the list anchors to. --- .../kotlin/com/example/aiapp/SessionScreen.kt | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) 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 fb79953..5060c46 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -212,9 +212,14 @@ private fun runIdFor(items: List, id: String): String = (items.lastOrNull() as? TranscriptItem.ToolRun)?.runId ?: id /** - * Puts a page of older items in front of the ones already loaded, healing any tool call the page + * Puts a page of older items in front of the ones already loaded, healing whatever the page * boundary cut in two. * + * Two things straddle a boundary: a tool call separated from its result, and a message separated + * from the rest of itself. Both were one thing before the transcript was cut into pages, and both + * have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn + * twice, arriving from the same cause. + * * A boundary lands wherever it lands, and roughly half the time that is between a call and its * result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws * as a row of its own -- correctly, because a call that renders as nothing is indistinguishable @@ -230,16 +235,17 @@ private fun runIdFor(items: List, id: String): String = * that loses nothing. */ fun joinPages(earlier: List, later: List): List { + val (older, newer) = healSplitMessage(earlier, later) val startedEarlier = - earlier.filterIsInstance().mapTo(mutableSetOf()) { it.id } - if (startedEarlier.isEmpty()) return earlier + later + older.filterIsInstance().mapTo(mutableSetOf()) { it.id } + if (startedEarlier.isEmpty()) return older + newer val endedLater = - later + newer .filterIsInstance() .associateBy { it.id } .filterKeys { it in startedEarlier } - if (endedLater.isEmpty()) return earlier + later - val healed = earlier.map { row -> + if (endedLater.isEmpty()) return older + newer + val healed = older.map { row -> val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] } if (row is TranscriptItem.ToolRun && half != null) { row.copy( @@ -254,10 +260,35 @@ fun joinPages(earlier: List, later: List): List< row } } - val kept = later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } + val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } return adoptRun(healed, kept) + kept } +/** + * Rejoins a message the page boundary cut, and hands back the two pages to concatenate. + * + * [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas + * accumulate into the message before them -- so two meeting at a join are always the two halves of + * one reply, and leaving them apart drew a single answer as two, with a paragraph break through the + * middle of a sentence. + * + * The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on + * screen, and renaming that is how the list loses its anchor. It grows by what the older half + * brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded, + * so the growth extends off the top of the screen, away from the row the list anchors to. + */ +private fun healSplitMessage( + earlier: List, + later: List, +): Pair, List> { + val head = earlier.lastOrNull() + val tail = later.firstOrNull() + if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) { + return earlier to later + } + return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1)) +} + /** * Hands the older calls at the join the name of the run they are joining. *