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 039a1ca..865a23e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -113,24 +113,27 @@ private fun groupRuns( ): List { val rows = mutableListOf() var run = mutableListOf() - // The run being walked, and how many rows it has produced so far -- which is what decides the - // keys below. + // A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript + // rather than resetting at each piece. var runId: String? = null - var emitted = 0 + val claimedKeys = mutableSetOf() fun flush() { val first = run.firstOrNull() ?: return - // The first row a run produces keeps the run's name, which is the name that survives a page - // of history landing in front of it ([adoptRun]); losing it is the transcript stepping - // under whoever is reading. The pieces a call standing on its own cuts off the back of the - // run have no such name, so each takes its own first call's id, which is unique because a - // call id is. The run's name goes in front of it because the two can otherwise be the same - // string: the call a run was named after can itself be the one standing on its own. - val key = if (emitted == 0) first.runId else "${first.runId}/${first.id}" + // The first piece keeps the run's name, which survives a page landing in front of it + // ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the + // final guard because a duplicate LazyColumn key takes down the whole screen. + var key = first.runId + if (!claimedKeys.add(key)) { + key = "${first.runId}/${first.id}" + var suffix = 2 + while (!claimedKeys.add(key)) { + key = "${first.runId}/${first.id}/${suffix++}" + } + } rows += if (run.size == 1) TranscriptRow.Single(first, key) else TranscriptRow.Tools(run.toList(), key) - emitted++ run = mutableListOf() } @@ -143,7 +146,6 @@ private fun groupRuns( if (call == null || call.runId != runId) { flush() runId = call?.runId - emitted = 0 } when { call == null -> rows += TranscriptRow.Single(item) diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt index f0f52d8..7e44dc6 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt @@ -111,6 +111,27 @@ class ToolRowsTest { assertEquals("b", rows.first().key) } + @Test + fun a_run_that_reappears_after_another_row_keeps_distinct_keys() { + val rows = + groupToolRuns( + listOf( + call("older", runId = "exec-1"), + call("older-2", runId = "exec-1"), + reply(), + call("exec-1", runId = "exec-1"), + call("newer", runId = "exec-1"), + reply(), + ) + ) + + assertTrue(rows[0] is TranscriptRow.Tools, "$rows") + assertTrue(rows[2] is TranscriptRow.Tools, "$rows") + assertKeysDistinct(rows) + assertEquals("exec-1", rows[0].key) + assertEquals("exec-1/exec-1", rows[2].key) + } + /** * Finishing is not what folds a call back in -- being overtaken is. A session that has run its * last command and is writing its reply leaves that command standing until the reply starts.