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 893f801..78ed30d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -93,6 +93,17 @@ sealed class TranscriptItem { data class ToolRun( override val seq: Long, val id: String, + /** + * The run of adjacent calls this one belongs to, named once when the call is folded in and + * never recomputed. + * + * Carried rather than derived because a run can gain members at *either* end -- a new call + * arriving beside it, or a page of history arriving in front of it -- so no function of its + * current members is stable. It is the first call's id at the moment the run started, which + * is a name rather than a description: [joinPages] hands it to older calls that turn out to + * belong to the same run, instead of renaming the run they joined. + */ + val runId: String, val tool: String, val input: String, val output: String, @@ -177,6 +188,16 @@ sealed class TranscriptItem { ) : TranscriptItem() } +/** + * The run a call joins: the one it lands next to, or a new one named after itself. + * + * Only ever consulted when the call is first folded in. That is what makes the name stable -- a run + * keeps whatever it was called when it started, however many calls arrive at either end of it + * afterwards. + */ +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 * boundary cut in two. @@ -220,7 +241,28 @@ fun joinPages(earlier: List, later: List): List< row } } - return healed + later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } + val kept = later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } + return adoptRun(healed, kept) + kept +} + +/** + * Hands the older calls at the join the name of the run they are joining. + * + * The two pages were folded separately, so a run split by the boundary came back as two runs with + * two names. Naming the joined run after the *older* half would be the obvious way round and is the + * wrong one: the newer half is the part already on screen, and renaming it is renaming the row the + * reader is looking at, which is how a list loses its anchor and steps under them. So the arriving + * calls take the name of the ones already there, and nothing visible changes identity. + */ +private fun adoptRun( + earlier: List, + later: List, +): List { + val joining = (later.firstOrNull() as? TranscriptItem.ToolRun)?.runId ?: return earlier + val tail = earlier.takeLastWhile { it is TranscriptItem.ToolRun } + if (tail.isEmpty()) return earlier + return earlier.dropLast(tail.size) + + tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) } } fun foldEvent(items: List, entry: SeqEvent): List = @@ -242,6 +284,7 @@ fun foldEvent(items: List, entry: SeqEvent): List, entry: SeqEvent): List + items(rows.asReversed(), key = { it.key }) { row -> when (row) { is TranscriptRow.Tools -> ToolGroup( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 66d3af6..d2c3570 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -32,26 +32,34 @@ import androidx.compose.ui.unit.dp */ sealed class TranscriptRow { /** - * This row's identity in the list, taken from the first transcript event behind it. + * This row's identity in the list, which must survive everything that can happen to the row. * - * See [TranscriptItem.seq]: the list is keyed by this so that inserting a new message at one - * end, or a page of history at the other, moves the rows and not the reader. + * The list is keyed by this so that inserting a new message at one end, or a page of history at + * the other, moves the rows and not the reader. That makes it the load-bearing value on this + * screen: when a key changes, the list loses its anchor and the transcript steps under whoever + * is reading it. + * + * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, + * and it is the *same* value whether the run is drawn as one card or as a group. A lone call + * that gains a neighbour becomes a group without changing identity, which is the case a + * seq-based key got wrong: the row the reader was looking at was replaced rather than updated. + * Everything else keys on the seq of the event behind it, which never moves. */ - abstract val seq: Long + abstract val key: Any data class Single(val item: TranscriptItem) : TranscriptRow() { - override val seq: Long - get() = item.seq + override val key: Any + get() = (item as? TranscriptItem.ToolRun)?.runId ?: item.seq } /** Two or more calls with nothing between them; drawn as one collapsed card. */ data class Tools(val calls: List) : TranscriptRow() { - /** Stable across reloads because it is the first call's own id. */ + /** The run's own name, which every call in it already carries. */ val id: String - get() = calls.first().id + get() = calls.first().runId - override val seq: Long - get() = calls.first().seq + override val key: Any + get() = id } } @@ -75,10 +83,16 @@ fun groupToolRuns(items: List): List { } items.forEach { item -> - if (item is TranscriptItem.ToolRun) run += item - else { + // Grouped by the run each call says it belongs to, not by adjacency worked out here. + // Adjacency is the same answer most of the time and a worse one at the edges: a call + // arriving next to an existing run, or a page of history arriving in front of one, both + // change which call is *first*, and a group named after its first member is a different + // group every time that happens. + if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { + run += item + } else { flush() - rows += TranscriptRow.Single(item) + if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item) } } flush()