Keep open tool cards out of groups

This commit is contained in:
iris-ai committed 2026-09-15 22:43:45 -04:00
1 parent 33b130b6bb
commit 9bcf0f1a48
4 files changed
+55 -48

No files matched your search

@@ -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<String>()) }
// Calls already drawn inside a group, so being folded into one is noticed exactly once each.
var everGrouped by remember { mutableStateOf(setOf<String>()) }
// 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<Long>()) }
@@ -412,7 +410,7 @@ fun SessionScreen(
// arrived, waiting for them to return to the newest end. See [record].
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
// 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<TranscriptRow.Tools>().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
@@ -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<TranscriptItem>): List<TranscriptRow> =
DebugStats.timed("grouped tool runs") { groupRuns(items) }
fun groupToolRuns(
items: List<TranscriptItem>,
expandedTools: Set<String> = emptySet(),
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, expandedTools) }
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
private fun groupRuns(
items: List<TranscriptItem>,
expandedTools: Set<String>,
): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>()
// 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<TranscriptItem>): List<TranscriptRow> {
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
@@ -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