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

+11 -11
View File
@@ -1129,17 +1129,17 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
4. **Session screen** — the core: 4. **Session screen** — the core:
- The transcript rendered from the event stream: markdown, inline images, - The transcript rendered from the event stream: markdown, inline images,
tool cards, question cards. tool cards, question cards.
- **A run of adjacent tool calls is one collapsed card, except for the - **A run of adjacent tool calls is one collapsed card, except for a call
call still running and the last call in the transcript** (2026-09-15). that is still running, open, or last in the transcript** (2026-09-15).
What the session is doing right now, or did last, is the one thing worth What the session is doing right now, did last, or what the reader is
seeing without opening anything, and a heading counting it hides it. What looking at is the one thing worth seeing without a heading hiding it.
folds a call back into its run is not finishing but being *overtaken*: What folds a finished call back into its run is being *overtaken* while
anything arriving behind it, a reply included, makes it history, and a closed: anything arriving behind it, a reply included, makes it history,
session that has run its last command and is composing its answer leaves and a session that has run its last command and is composing its answer
that command standing until the answer starts. Grouping is a display leaves that command standing until the answer starts. Grouping is a
decision (`groupToolRuns`) and a cut run's pieces are keyed there — the display decision (`groupToolRuns`) and a cut run's pieces are keyed there
first piece keeps the run's name, since that name is what survives a page — the first piece keeps the run's name, since that name is what survives
of history landing in front of it. a page of history landing in front of it.
- **Anything that is a note *about* the conversation rather than a turn in - **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. 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 Open-ness is the screen's, never the card's: a card that remembered for
@@ -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 // Which runs of adjacent tool calls are open, by the group row's own key, so a group survives
// more calls arriving after it. // more calls arriving after it.
var expandedGroups by remember { mutableStateOf(setOf<String>()) } 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 // 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. // default, which is the rule for anything new in this transcript.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) } 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]. // arrived, waiting for them to return to the newest end. See [record].
var held by remember { mutableStateOf(listOf<SeqEvent>()) } var held by remember { mutableStateOf(listOf<SeqEvent>()) }
// What is actually drawn: the transcript with runs of adjacent tool calls folded into one row. // 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. // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
var warmedTick by remember { mutableIntStateOf(0) } var warmedTick by remember { mutableIntStateOf(0) }
val units = val units =
@@ -609,7 +607,7 @@ fun SessionScreen(
* Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives.
*/ */
fun anchorRow(seq: Long): Long? { fun anchorRow(seq: Long): Long? {
val ordered = groupToolRuns(items) val ordered = groupToolRuns(items, expandedTools)
val at = ordered.indexOfLast { it.startSeq <= seq } val at = ordered.indexOfLast { it.startSeq <= seq }
// Zero is the oldest loaded row, which is the half-row above; not found is -1. // 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 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 // 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 // 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 // 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, * 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. * 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 last call is left alone too, as is one still running or one the reader has open, wherever in
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a * its run it sits. What the session is doing, did last, or is being read is the one thing worth
* heading counting it hides it. What folds a call back into its run is therefore not finishing but * seeing without a heading hiding it. A finished call therefore folds back into its run only once
* being overtaken: anything arriving behind it, a reply included, makes it history. * it has been overtaken and is closed.
*/ */
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> = fun groupToolRuns(
DebugStats.timed("grouped tool runs") { groupRuns(items) } 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>() val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>() var run = mutableListOf<TranscriptItem.ToolRun>()
// The run being walked, and how many rows it has produced so far -- which is what decides the // 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 { when {
call == null -> rows += TranscriptRow.Single(item) call == null -> rows += TranscriptRow.Single(item)
// Standing outside the run is the call's place in the list as it is now, not something // 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 // recorded on the call: the same finished call is a row of its own while it is last or
// thing that happened and part of its group once a reply lands behind it. // open, and part of its group once something follows it and the reader closes it.
call.done && index != items.lastIndex -> run += call call.done && call.id !in expandedTools && index != items.lastIndex -> run += call
else -> { else -> {
flush() flush()
run += call run += call
@@ -5,10 +5,9 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* How a run of tool calls is cut into rows: the call still running and the last call in the * How a run of tool calls is cut into rows: a call still running, open, or last in the transcript
* transcript are drawn on their own, and every piece the cut leaves behind still has a key of its * is drawn on its own, and every piece the cut leaves behind still has a key of its own -- two rows
* own -- two rows sharing one key take the app down, and a key that moves takes the reader's place * sharing one key take the app down, and a key that moves takes the reader's place with it.
* with it.
*/ */
class ToolRowsTest { class ToolRowsTest {
private var seq = 0L private var seq = 0L
@@ -75,6 +74,30 @@ class ToolRowsTest {
assertKeysDistinct(rows) 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 * 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 * history joined onto a run whose own first call is still going ([joinPages] renames the older