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. *