From 9bcf0f1a4854a9765b8e6ddd68b1852152e3b6ea Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 22:43:45 -0400 Subject: [PATCH] Keep open tool cards out of groups --- PLAN.md | 22 ++++++------- .../kotlin/com/example/aiapp/SessionScreen.kt | 25 ++------------- .../main/kotlin/com/example/aiapp/ToolRows.kt | 25 +++++++++------ .../kotlin/com/example/aiapp/ToolRowsTest.kt | 31 ++++++++++++++++--- 4 files changed, 55 insertions(+), 48 deletions(-) diff --git a/PLAN.md b/PLAN.md index f69d41f..4595c38 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1129,17 +1129,17 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). 4. **Session screen** — the core: - 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 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. + - **A run of adjacent tool calls is one collapsed card, except for a call + that is still running, open, or last in the transcript** (2026-09-15). + What the session is doing right now, did last, or what the reader is + looking at is the one thing worth seeing without a heading hiding it. + What folds a finished call back into its run is being *overtaken* while + closed: 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 7160936..4f97eb0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -307,8 +307,6 @@ fun SessionScreen( // Which runs of adjacent tool calls are open, by the group row's own key, so a group survives // more calls arriving after it. var expandedGroups by remember { mutableStateOf(setOf()) } - // Calls already drawn inside a group, so being folded into one is noticed exactly once each. - var everGrouped by remember { mutableStateOf(setOf()) } // Which messages from other agents are open, by the seq that identifies their row. Closed by // default, which is the rule for anything new in this transcript. var expandedNotes by remember { mutableStateOf(setOf()) } @@ -412,7 +410,7 @@ fun SessionScreen( // arrived, waiting for them to return to the newest end. See [record]. var held by remember { mutableStateOf(listOf()) } // What is actually drawn: the transcript with runs of adjacent tool calls folded into one row. - val rows = remember(items) { groupToolRuns(items) } + val rows = remember(items, expandedTools) { groupToolRuns(items, expandedTools) } // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. var warmedTick by remember { mutableIntStateOf(0) } val units = @@ -609,7 +607,7 @@ fun SessionScreen( * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. */ fun anchorRow(seq: Long): Long? { - val ordered = groupToolRuns(items) + val ordered = groupToolRuns(items, expandedTools) val at = ordered.indexOfLast { it.startSeq <= seq } // Zero is the oldest loaded row, which is the half-row above; not found is -1. return if (at > 0) ordered[at].startSeq else null @@ -702,25 +700,6 @@ 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 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 - // the group has shut it. - LaunchedEffect(rows) { - val fresh = - rows.filterIsInstance().flatMap { group -> - group.calls.filter { it.id !in everGrouped }.map { group.key to it.id } - } - if (fresh.isEmpty()) return@LaunchedEffect - expandedGroups = - expandedGroups + fresh.filter { it.second in expandedTools }.map { it.first } - everGrouped = everGrouped + fresh.map { it.second } - } - // A compaction reports nothing about its own progress -- measured against the CLI, which says // it has started and then nothing at all until it is done. So what this counts is the one thing // anybody here can measure: how long it has been going. A bar filling up would be this screen 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 0894bed..039a1ca 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -97,15 +97,20 @@ 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. * - * 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. + * The last call is left alone too, as is one still running or one the reader has open, wherever in + * its run it sits. What the session is doing, did last, or is being read is the one thing worth + * seeing without a heading hiding it. A finished call therefore folds back into its run only once + * it has been overtaken and is closed. */ -fun groupToolRuns(items: List): List = - DebugStats.timed("grouped tool runs") { groupRuns(items) } +fun groupToolRuns( + items: List, + expandedTools: Set = emptySet(), +): List = DebugStats.timed("grouped tool runs") { groupRuns(items, expandedTools) } -private fun groupRuns(items: List): List { +private fun groupRuns( + items: List, + expandedTools: Set, +): 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 @@ -143,9 +148,9 @@ private fun groupRuns(items: List): List { when { call == null -> rows += TranscriptRow.Single(item) // 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 + // recorded on the call: the same finished call is a row of its own while it is last or + // open, and part of its group once something follows it and the reader closes it. + call.done && call.id !in expandedTools && 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 8f0ed44..f0f52d8 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt @@ -5,10 +5,9 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * 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. + * How a run of tool calls is cut into rows: a call still running, open, or last in the transcript + * 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. */ class ToolRowsTest { private var seq = 0L @@ -75,6 +74,30 @@ class ToolRowsTest { assertKeysDistinct(rows) } + @Test + fun an_open_call_stays_out_of_its_group_until_it_is_closed() { + val calls = + listOf( + call("a"), + call("b", runId = "a"), + call("c", runId = "a"), + call("d", runId = "a"), + reply(), + ) + + val whileOpen = groupToolRuns(calls, expandedTools = setOf("b")) + val afterItCloses = groupToolRuns(calls) + + assertEquals( + listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")), + shape(whileOpen), + ) + assertTrue(whileOpen[1] is TranscriptRow.Single, "$whileOpen") + assertKeysDistinct(whileOpen) + assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses)) + assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses") + } + /** * The one case where the run's name is a call that is not in the run's first row: a page of * history joined onto a run whose own first call is still going ([joinPages] renames the older