diff --git a/PLAN.md b/PLAN.md index 498fcd6..c18c209 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1157,16 +1157,27 @@ 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 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. + that is still running, last in the transcript, or held out of its run by + being read** (2026-09-15, corrected 2026-09-16). What the session is + doing right now, or did last, is the one thing worth seeing without a + heading hiding it. What folds a finished call back into its run is being + *overtaken*: anything arriving behind it, a reply included, makes it + history — except while somebody has it open, since a card being read is + not history to them, and a command finishing behind it used to shut it + and fold it away mid-sentence. + **Being open only ever holds a call out; it never takes one back out of + a group it is already in.** That was tried for a day and is what + "collapsing jumps" was: grouping gives a row its identity, so a rule + reading the open set both ways let one tap rebuild the rows around the + finger — closing a call replaced three rows with one, and no anchor + survives a row that has ceased to exist. A call inside an open group is + visible where it is and has nothing to gain by moving. The screen is + what remembers which calls have been in a group (`everGrouped` feeding + `heldOut`), because that is a fact about what the reader has been shown + rather than about the transcript. Grouping is otherwise 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 91ba83c..9db63b1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -319,6 +319,11 @@ 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 that have been drawn inside a group. A call that never has and is open is one the + // reader pulled up while it stood on its own, and it keeps standing there until they are done + // with it; a call already in a group is visible inside it, and taking it out to open it would + // rebuild the rows around the reader's finger. See [groupToolRuns]. + 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()) } @@ -422,7 +427,8 @@ 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, expandedTools) { groupToolRuns(items, expandedTools) } + val heldOut = expandedTools - everGrouped + val rows = remember(items, heldOut) { groupToolRuns(items, heldOut) } // 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 = @@ -634,7 +640,7 @@ fun SessionScreen( * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. */ fun anchorRow(seq: Long): Long? { - val ordered = groupToolRuns(items, expandedTools) + val ordered = groupToolRuns(items, heldOut) 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 @@ -727,6 +733,17 @@ fun SessionScreen( } } + // Which calls have been inside a group, which is what [heldOut] subtracts: a call the reader + // opened while it stood on its own is held out of the run it belongs to until they close it, + // and a call that has been in a group is one that opening will never take back out. + LaunchedEffect(rows) { + val fresh = + rows.filterIsInstance().flatMap { group -> + group.calls.map { it.id }.filter { it !in everGrouped } + } + if (fresh.isNotEmpty()) everGrouped = everGrouped + fresh + } + // 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 865a23e..b43ea6f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -97,20 +97,26 @@ 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, 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. + * 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. + * + * [heldOut] is the one thing being read can change, and only in that direction: a call standing on + * its own that somebody is reading is not overtaken while they read it. Opening a call *already* + * inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it + * is, and grouping is what gives a row its identity, so a rule that reads the open set both ways + * makes the reader's own tap rebuild the rows around it: three rows became one the moment a call + * was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and + * took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being + * inside a group once is what settles it. */ fun groupToolRuns( items: List, - expandedTools: Set = emptySet(), -): List = DebugStats.timed("grouped tool runs") { groupRuns(items, expandedTools) } + heldOut: Set = emptySet(), +): List = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) } -private fun groupRuns( - items: List, - expandedTools: Set, -): List { +private fun groupRuns(items: List, heldOut: Set): List { val rows = mutableListOf() var run = mutableListOf() // A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript @@ -150,9 +156,10 @@ private fun groupRuns( 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 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 + // recorded on the call: the same finished call is a row of its own while it is the last + // thing that happened, or open and never yet grouped, and part of its group once a + // reply lands behind it. + call.done && call.id !in heldOut && 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 7e44dc6..bba5d77 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: 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. + * How a run of tool calls is cut into rows: the call still running, the last call in the + * transcript, and one held out because the reader has it open 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 @@ -74,8 +75,12 @@ class ToolRowsTest { assertKeysDistinct(rows) } + /** + * A call held out is one the reader opened while it stood on its own; being overtaken while + * they read it does not fold it away, and closing it hands it back to its run. + */ @Test - fun an_open_call_stays_out_of_its_group_until_it_is_closed() { + fun a_held_out_call_stays_out_of_its_group() { val calls = listOf( call("a"), @@ -85,15 +90,12 @@ class ToolRowsTest { reply(), ) - val whileOpen = groupToolRuns(calls, expandedTools = setOf("b")) + val whileHeld = groupToolRuns(calls, heldOut = setOf("d")) 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"), listOf("d"), listOf("reply")), shape(whileHeld)) + assertTrue(whileHeld[1] is TranscriptRow.Single, "$whileHeld") + assertKeysDistinct(whileHeld) assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses)) assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses") }