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 e7bd6d9..76cd288 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 @@ -204,9 +217,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 @@ -222,16 +240,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( @@ -246,10 +265,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. * @@ -765,16 +809,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 @@ -789,6 +847,7 @@ fun SessionScreen( } } items = joinPages(earlier, items) + have = groupToolRuns(items).size } } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. @@ -1124,7 +1183,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 =