From 036eb375aa94d4a58beb7bd228806b7781f0bb66 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 01:13:03 -0400 Subject: [PATCH] Keep the running tool call outside its group A run of adjacent calls is drawn as one collapsed card, which hid the one thing worth seeing without opening anything: the command the session is running right now. It is a row of its own while it runs and folds back into the run when it ends. Grouping stays a display decision, so the pieces a running call cuts a run into are keyed there. The first piece keeps the run's name -- that name is what survives a page of history landing in front of it -- and later pieces take their own first call's id behind it, since the call a run was named after can itself be the one running. The echo rig's /tools gap now runs between a call's start and its end rather than between one call and the next, which is where a real session's time goes and what makes the running state observable at all. Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest (new ToolRowsTest) and lintDebug, cargo fmt/clippy/test, and on the emulator against the sandbox: "Called 2 tools" with the live Bash card beneath it. --- PLAN.md | 8 ++ .../kotlin/com/example/aiapp/SessionScreen.kt | 34 ++++--- .../main/kotlin/com/example/aiapp/ToolRows.kt | 64 ++++++++----- .../kotlin/com/example/aiapp/ToolRowsTest.kt | 93 +++++++++++++++++++ server/src/session/echo.rs | 18 ++-- 5 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt diff --git a/PLAN.md b/PLAN.md index 2aaa61e..379932f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1098,6 +1098,14 @@ 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** (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. - **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 4fdef21..a403acf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -302,10 +302,10 @@ fun SessionScreen( // words were in. This is whatever was true as of the last frame. val selecting = selection.selectedTexts.isNotEmpty() var expandedTools by remember { mutableStateOf(setOf()) } - // Which runs of adjacent tool calls are open. Keyed by the first call's id, so a group survives + // 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()) } - // Runs already drawn as a group, so the transition into one is noticed exactly once. + // 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. @@ -692,19 +692,23 @@ fun SessionScreen( } } - // A call opened on its own stays open when a second call in the same run turns it into a group. - // 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". + // 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". // - // Considered once per run, at the moment it first becomes a group, and never again: after that - // the group's own toggle owns it. + // 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().filter { it.id !in everGrouped } + 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 { group -> group.calls.any { it.id in expandedTools } }.map { it.id } - everGrouped = everGrouped + fresh.map { it.id } + 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 @@ -1624,13 +1628,13 @@ fun SessionScreen( is TranscriptRow.Tools -> ToolGroup( group = row, - expanded = row.id in expandedGroups, + expanded = row.key in expandedGroups, onToggle = { toggleAnchored(row) { expandedGroups = - if (row.id in expandedGroups) - expandedGroups - row.id - else expandedGroups + row.id + if (row.key in expandedGroups) + expandedGroups - row.key + else expandedGroups + row.key } }, isToolExpanded = { it in expandedTools }, 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 32e7855..7db97d7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -61,7 +61,9 @@ sealed class TranscriptRow { * * 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. Which value - * that is belongs to the item ([TranscriptItem.key]), not to a `when` here. + * that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where + * [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing + * each piece its key. */ abstract val key: Any @@ -75,23 +77,15 @@ sealed class TranscriptRow { */ abstract val startSeq: Long - data class Single(val item: TranscriptItem) : TranscriptRow() { - override val key: Any - get() = item.key - + data class Single(val item: TranscriptItem, override val key: Any = item.key) : + TranscriptRow() { override val startSeq: Long get() = item.seq } /** Two or more calls with nothing between them; drawn as one collapsed card. */ - data class Tools(val calls: List) : TranscriptRow() { - /** The run's own name, which every call in it already carries. */ - val id: String - get() = calls.first().runId - - override val key: Any - get() = id - + data class Tools(val calls: List, override val key: String) : + TranscriptRow() { override val startSeq: Long get() = calls.first().seq } @@ -102,6 +96,12 @@ 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. */ fun groupToolRuns(items: List): List = DebugStats.timed("grouped tool runs") { groupRuns(items) } @@ -109,13 +109,24 @@ fun groupToolRuns(items: List): List = private fun groupRuns(items: List): 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. + var runId: String? = null + var emitted = 0 fun flush() { - when (run.size) { - 0 -> {} - 1 -> rows += TranscriptRow.Single(run.first()) - else -> rows += TranscriptRow.Tools(run.toList()) - } + 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. + val key = if (emitted == 0) first.runId else "${first.runId}/${first.id}" + rows += + if (run.size == 1) TranscriptRow.Single(first, key) + else TranscriptRow.Tools(run.toList(), key) + emitted++ run = mutableListOf() } @@ -124,11 +135,20 @@ private fun groupRuns(items: List): List { // 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*. - if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { - run += item - } else { + val call = item as? TranscriptItem.ToolRun + if (call == null || call.runId != runId) { flush() - if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item) + runId = call?.runId + emitted = 0 + } + when { + call == null -> rows += TranscriptRow.Single(item) + call.done -> run += call + else -> { + flush() + run += call + flush() + } } } flush() diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt new file mode 100644 index 0000000..63b0ec2 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolRowsTest.kt @@ -0,0 +1,93 @@ +package com.example.aiapp + +import kotlin.test.Test +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. + */ +class ToolRowsTest { + private var seq = 0L + + private fun call(id: String, runId: String = id, done: Boolean = true) = + TranscriptItem.ToolRun( + seq = ++seq, + id = id, + runId = runId, + tool = "Bash", + input = "{}", + output = if (done) "ok" else "", + done = 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) + } + } + + private fun assertKeysDistinct(rows: List) = + assertEquals(rows.size, rows.map { it.key }.toSet().size, "$rows") + + @Test + 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)) + ) + assertEquals(listOf(listOf("a", "b"), listOf("c")), shape(rows)) + assertKeysDistinct(rows) + } + + @Test + fun a_call_running_in_the_middle_of_its_run_splits_the_group_in_two() { + val rows = + groupToolRuns( + listOf( + call("a"), + call("b", runId = "a", done = false), + call("c", runId = "a"), + call("d", runId = "a"), + ) + ) + assertEquals(listOf(listOf("a"), listOf("b"), listOf("c", "d")), shape(rows)) + assertKeysDistinct(rows) + } + + /** + * 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 + * calls to the newer run's name). Both rows would key on that name. + */ + @Test + fun the_run_keeps_its_name_even_when_the_call_it_is_named_after_is_the_one_running() { + val rows = groupToolRuns(listOf(call("a", runId = "b"), call("b", done = false))) + assertEquals(listOf(listOf("a"), listOf("b")), shape(rows)) + assertKeysDistinct(rows) + assertEquals("b", rows.first().key) + } + + /** What happens the moment a command finishes: it folds back into the run it was cut out of. */ + @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) + } + + @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") + } +} diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index c216211..b6b9baf 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -601,10 +601,15 @@ impl EchoDriver { .and_then(|w| w.parse().ok()) .unwrap_or(3usize) .clamp(2, 12); - // How long to wait between calls, default none. A run that arrives - // all at once cannot exercise a run *growing*: the case worth - // watching is a call somebody has opened and is reading when the - // next one turns it into a group. + // How long each call spends running, default none. A run that + // arrives all at once cannot exercise a run *growing*: the case + // worth watching is a call somebody has opened and is reading when + // the next one turns it into a group. Spent between the call's start + // and its end rather than between one call and the next, because + // that is where a real session's time goes -- and a call is drawn + // outside its group while it runs, which is a state nothing could + // see while every call here ended a few milliseconds after it + // started. let gap = Duration::from_secs( words .next() @@ -700,9 +705,6 @@ impl EchoDriver { if let Some((count, gap)) = many_tools { for i in 1..=count { - if i > 1 { - tokio::time::sleep(gap).await; - } let id = format!("t-{}", super::random_hex()); send(Event::ToolStart { id: id.clone(), @@ -733,7 +735,7 @@ impl EchoDriver { }); } } - tokio::time::sleep(DELTA_DELAY).await; + tokio::time::sleep(DELTA_DELAY + gap).await; send(Event::ToolEnd { id, output: format!("call {i} finished"),