diff --git a/PLAN.md b/PLAN.md index 379932f..c3228a5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1099,13 +1099,16 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). - The transcript rendered from the event stream: markdown, inline images, tool cards, question cards. - **A run of adjacent tool calls is one collapsed card, except for the - call still running** (2026-09-15). What the session is doing right now - is the one thing worth seeing without opening anything, and a heading - counting it hides it; the call folds back into its run the moment it - ends, which is the moment it stops being what is happening. Grouping is - a display decision (`groupToolRuns`) and a cut run's pieces are keyed - there — the first piece keeps the run's name, since that name is what - survives a page of history landing in front of it. + call still running and the last call in the transcript** (2026-09-15). + What the session is doing right now, or did last, is the one thing worth + seeing without opening anything, and a heading counting it hides it. What + folds a call back into its run is not finishing but being *overtaken*: + anything arriving behind it, a reply included, makes it history, and a + session that has run its last command and is composing its answer leaves + that command standing until the answer starts. Grouping is a display + decision (`groupToolRuns`) and a cut run's pieces are keyed there — the + first piece keeps the run's name, since that name is what survives a page + of history landing in front of it. - **Anything that is a note *about* the conversation rather than a turn in it is closed by default** — a tool call, a peer message, a memory note. Open-ness is the screen's, never the card's: a card that remembered for 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 a403acf..785f5b8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -693,9 +693,9 @@ fun SessionScreen( } // A call opened on its own stays open when it is folded into a group -- either because a second - // call in the same run arrived, or because it finished and rejoined the run it was running - // outside of. Until this, watching a Bash call and having the session make another one shut the - // one being read and folded it behind "Called 2 tools". + // call in the same run arrived, or because something landed behind the last call and it + // rejoined the run it was standing outside of. Until this, watching a Bash call and having the + // session make another one shut the one being read and folded it behind "Called 2 tools". // // Per call rather than per group, because a group outlives the calls joining it: considered at // the moment each call first lands inside one, and never again, so the reader who then shuts 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 7db97d7..0894bed 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -97,11 +97,10 @@ sealed class TranscriptRow { * A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words, * and the run this exists for is the burst of five greps nobody wants to scroll past. * - * A call that has not finished is left alone too, wherever in its run it sits. What the session is - * doing *now* is the one thing worth seeing without opening anything, and grouping it hides the - * running command behind a heading that counts it. The call rejoins its run when it ends, which is - * the moment it stops being what is happening and becomes history -- and it is a row at the live - * end of the transcript, so nothing above the reader moves when it does. + * The last call is left alone too, and so is one still running wherever in its run it sits. What + * the session is doing, or did last, is the one thing worth seeing without opening anything, and a + * heading counting it hides it. What folds a call back into its run is therefore not finishing but + * being overtaken: anything arriving behind it, a reply included, makes it history. */ fun groupToolRuns(items: List): List = DebugStats.timed("grouped tool runs") { groupRuns(items) } @@ -118,10 +117,10 @@ private fun groupRuns(items: List): List { 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 running call 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 still running. + // 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}" rows += if (run.size == 1) TranscriptRow.Single(first, key) @@ -130,7 +129,7 @@ private fun groupRuns(items: List): List { run = mutableListOf() } - items.forEach { item -> + items.forEachIndexed { index, item -> // 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 @@ -143,7 +142,10 @@ private fun groupRuns(items: List): List { } when { call == null -> rows += TranscriptRow.Single(item) - call.done -> run += call + // Standing outside the run is the call's place in the list as it is now, not something + // recorded on the call: the same finished call is a row of its own while it is the last + // thing that happened and part of its group once a reply lands behind it. + call.done && index != items.lastIndex -> run += call else -> { flush() run += call 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 63b0ec2..8f0ed44 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt @@ -5,9 +5,10 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * How a run of tool calls is cut into rows: the call still running is drawn on its own, and every - * piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app - * down, and a key that moves takes the reader's place with it. + * How a run of tool calls is cut into rows: the call still running and the last call in the + * transcript are drawn on their own, and every piece the cut leaves behind still has a key of its + * own -- two rows sharing one key take the app down, and a key that moves takes the reader's place + * with it. */ class ToolRowsTest { private var seq = 0L @@ -23,10 +24,13 @@ class ToolRowsTest { done = done, ) + /** Something that is not a tool call, to put behind the run so its last call folds in. */ + private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done") + private fun shape(rows: List) = rows.map { row -> when (row) { is TranscriptRow.Tools -> row.calls.map { it.id } - is TranscriptRow.Single -> listOf((row.item as TranscriptItem.ToolRun).id) + is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply") } } @@ -37,9 +41,18 @@ class ToolRowsTest { fun the_call_still_running_is_a_row_of_its_own() { val rows = groupToolRuns( - listOf(call("a"), call("b", runId = "a"), call("c", runId = "a", done = false)) + listOf( + call("a"), + call("b", runId = "a"), + call("c", runId = "a", done = false), + call("d", runId = "a"), + reply(), + ) ) - assertEquals(listOf(listOf("a", "b"), listOf("c")), shape(rows)) + assertEquals( + listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")), + shape(rows), + ) assertKeysDistinct(rows) } @@ -52,9 +65,13 @@ class ToolRowsTest { call("b", runId = "a", done = false), call("c", runId = "a"), call("d", runId = "a"), + reply(), ) ) - assertEquals(listOf(listOf("a"), listOf("b"), listOf("c", "d")), shape(rows)) + assertEquals( + listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")), + shape(rows), + ) assertKeysDistinct(rows) } @@ -71,23 +88,34 @@ class ToolRowsTest { assertEquals("b", rows.first().key) } - /** What happens the moment a command finishes: it folds back into the run it was cut out of. */ + /** + * 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. + */ @Test - fun a_call_that_finishes_rejoins_its_run_without_moving_the_run() { - val running = listOf(call("a"), call("b", runId = "a", done = false)) - val finished = listOf(running[0], (running[1] as TranscriptItem.ToolRun).copy(done = true)) - val before = groupToolRuns(running) - val after = groupToolRuns(finished) - assertEquals(listOf(listOf("a", "b")), shape(after)) - // The run keeps the key it was drawn under, so the list rebuilds a row rather than losing - // its anchor. - assertEquals(before.first().key, after.first().key) + fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() { + val a = call("a") + val running = call("b", runId = "a", done = false) + val finished = running.copy(done = true) + val whileRunning = groupToolRuns(listOf(a, running)) + val afterItEnds = groupToolRuns(listOf(a, finished)) + val afterTheReply = groupToolRuns(listOf(a, finished, reply())) + assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning)) + assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds)) + assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply)) + // The run keeps the key it was drawn under throughout, so the list rebuilds a row rather + // than losing its anchor. + assertEquals(whileRunning.first().key, afterItEnds.first().key) + assertEquals(whileRunning.first().key, afterTheReply.first().key) } @Test - fun a_run_of_finished_calls_is_still_one_group() { - val rows = groupToolRuns(listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"))) - assertEquals(listOf(listOf("a", "b", "c")), shape(rows)) - assertTrue(rows.single() is TranscriptRow.Tools, "$rows") + fun a_run_of_finished_calls_is_one_group_once_something_follows_it() { + val rows = + groupToolRuns( + listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply()) + ) + assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows)) + assertTrue(rows.first() is TranscriptRow.Tools, "$rows") } }