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.
This commit is contained in:
1 parent
b9b777acaf
commit
036eb375aa
5 files changed
+172
-45
No files matched your search
@@ -1098,6 +1098,14 @@ 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
|
||||||
|
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
|
- **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
|
||||||
|
|||||||
@@ -302,10 +302,10 @@ fun SessionScreen(
|
|||||||
// words were in. This is whatever was true as of the last frame.
|
// words were in. This is whatever was true as of the last frame.
|
||||||
val selecting = selection.selectedTexts.isNotEmpty()
|
val selecting = selection.selectedTexts.isNotEmpty()
|
||||||
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||||
// 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.
|
// more calls arriving after it.
|
||||||
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
|
||||||
// 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<String>()) }
|
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.
|
||||||
@@ -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.
|
// A call opened on its own stays open when it is folded into a group -- either because a second
|
||||||
// Until this, watching a Bash call and having the session make another one shut the one being
|
// call in the same run arrived, or because it finished and rejoined the run it was running
|
||||||
// read and folded it behind "Called 2 tools".
|
// 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
|
// Per call rather than per group, because a group outlives the calls joining it: considered at
|
||||||
// the group's own toggle owns it.
|
// the moment each call first lands inside one, and never again, so the reader who then shuts
|
||||||
|
// the group has shut it.
|
||||||
LaunchedEffect(rows) {
|
LaunchedEffect(rows) {
|
||||||
val fresh = rows.filterIsInstance<TranscriptRow.Tools>().filter { it.id !in everGrouped }
|
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
|
if (fresh.isEmpty()) return@LaunchedEffect
|
||||||
expandedGroups =
|
expandedGroups =
|
||||||
expandedGroups +
|
expandedGroups + fresh.filter { it.second in expandedTools }.map { it.first }
|
||||||
fresh.filter { group -> group.calls.any { it.id in expandedTools } }.map { it.id }
|
everGrouped = everGrouped + fresh.map { it.second }
|
||||||
everGrouped = everGrouped + fresh.map { it.id }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1624,13 +1628,13 @@ fun SessionScreen(
|
|||||||
is TranscriptRow.Tools ->
|
is TranscriptRow.Tools ->
|
||||||
ToolGroup(
|
ToolGroup(
|
||||||
group = row,
|
group = row,
|
||||||
expanded = row.id in expandedGroups,
|
expanded = row.key in expandedGroups,
|
||||||
onToggle = {
|
onToggle = {
|
||||||
toggleAnchored(row) {
|
toggleAnchored(row) {
|
||||||
expandedGroups =
|
expandedGroups =
|
||||||
if (row.id in expandedGroups)
|
if (row.key in expandedGroups)
|
||||||
expandedGroups - row.id
|
expandedGroups - row.key
|
||||||
else expandedGroups + row.id
|
else expandedGroups + row.key
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isToolExpanded = { it in expandedTools },
|
isToolExpanded = { it in expandedTools },
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ sealed class TranscriptRow {
|
|||||||
*
|
*
|
||||||
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
* 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
|
* 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
|
abstract val key: Any
|
||||||
|
|
||||||
@@ -75,23 +77,15 @@ sealed class TranscriptRow {
|
|||||||
*/
|
*/
|
||||||
abstract val startSeq: Long
|
abstract val startSeq: Long
|
||||||
|
|
||||||
data class Single(val item: TranscriptItem) : TranscriptRow() {
|
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
|
||||||
override val key: Any
|
TranscriptRow() {
|
||||||
get() = item.key
|
|
||||||
|
|
||||||
override val startSeq: Long
|
override val startSeq: Long
|
||||||
get() = item.seq
|
get() = item.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
||||||
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
|
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
|
||||||
/** The run's own name, which every call in it already carries. */
|
TranscriptRow() {
|
||||||
val id: String
|
|
||||||
get() = calls.first().runId
|
|
||||||
|
|
||||||
override val key: Any
|
|
||||||
get() = id
|
|
||||||
|
|
||||||
override val startSeq: Long
|
override val startSeq: Long
|
||||||
get() = calls.first().seq
|
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,
|
* 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.
|
||||||
|
*
|
||||||
|
* 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<TranscriptItem>): List<TranscriptRow> =
|
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
|
||||||
DebugStats.timed("grouped tool runs") { groupRuns(items) }
|
DebugStats.timed("grouped tool runs") { groupRuns(items) }
|
||||||
@@ -109,13 +109,24 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
|
|||||||
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
private fun groupRuns(items: List<TranscriptItem>): 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
|
||||||
|
// keys below.
|
||||||
|
var runId: String? = null
|
||||||
|
var emitted = 0
|
||||||
|
|
||||||
fun flush() {
|
fun flush() {
|
||||||
when (run.size) {
|
val first = run.firstOrNull() ?: return
|
||||||
0 -> {}
|
// The first row a run produces keeps the run's name, which is the name that survives a page
|
||||||
1 -> rows += TranscriptRow.Single(run.first())
|
// of history landing in front of it ([adoptRun]); losing it is the transcript stepping
|
||||||
else -> rows += TranscriptRow.Tools(run.toList())
|
// 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()
|
run = mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,11 +135,20 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
|||||||
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
// 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
|
// arriving next to an existing run, or a page of history arriving in front of one, both
|
||||||
// change which call is *first*.
|
// change which call is *first*.
|
||||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
val call = item as? TranscriptItem.ToolRun
|
||||||
run += item
|
if (call == null || call.runId != runId) {
|
||||||
} else {
|
|
||||||
flush()
|
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()
|
flush()
|
||||||
|
|||||||
@@ -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<TranscriptRow>) = 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<TranscriptRow>) =
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -601,10 +601,15 @@ impl EchoDriver {
|
|||||||
.and_then(|w| w.parse().ok())
|
.and_then(|w| w.parse().ok())
|
||||||
.unwrap_or(3usize)
|
.unwrap_or(3usize)
|
||||||
.clamp(2, 12);
|
.clamp(2, 12);
|
||||||
// How long to wait between calls, default none. A run that arrives
|
// How long each call spends running, default none. A run that
|
||||||
// all at once cannot exercise a run *growing*: the case worth
|
// arrives all at once cannot exercise a run *growing*: the case
|
||||||
// watching is a call somebody has opened and is reading when the
|
// worth watching is a call somebody has opened and is reading when
|
||||||
// next one turns it into a group.
|
// 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(
|
let gap = Duration::from_secs(
|
||||||
words
|
words
|
||||||
.next()
|
.next()
|
||||||
@@ -700,9 +705,6 @@ impl EchoDriver {
|
|||||||
|
|
||||||
if let Some((count, gap)) = many_tools {
|
if let Some((count, gap)) = many_tools {
|
||||||
for i in 1..=count {
|
for i in 1..=count {
|
||||||
if i > 1 {
|
|
||||||
tokio::time::sleep(gap).await;
|
|
||||||
}
|
|
||||||
let id = format!("t-{}", super::random_hex());
|
let id = format!("t-{}", super::random_hex());
|
||||||
send(Event::ToolStart {
|
send(Event::ToolStart {
|
||||||
id: id.clone(),
|
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 {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("call {i} finished"),
|
output: format!("call {i} finished"),
|
||||||
|
|||||||
Reference in new issue
Block a user