Merge remote-tracking branch 'origin/main'

This commit is contained in:
iris committed 2026-08-30 00:17:54 -04:00
commit 5d47a1ec89
2 files changed
+73 -15

No files matched your search

@@ -93,6 +93,17 @@ sealed class TranscriptItem {
data class ToolRun( data class ToolRun(
override val seq: Long, override val seq: Long,
val id: String, val id: String,
/**
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end -- a new call
* arriving beside it, or a page of history arriving in front of it -- so no function of its
* current members is stable. It is the first call's id at the moment the run started, which
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
* belong to the same run, instead of renaming the run they joined.
*/
val runId: String,
val tool: String, val tool: String,
val input: String, val input: String,
val output: String, val output: String,
@@ -177,6 +188,16 @@ sealed class TranscriptItem {
) : TranscriptItem() ) : TranscriptItem()
} }
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it
* afterwards.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String): String =
(items.lastOrNull() as? TranscriptItem.ToolRun)?.runId ?: id
/** /**
* Puts a page of older items in front of the ones already loaded, healing any tool call the page * Puts a page of older items in front of the ones already loaded, healing any tool call the page
* boundary cut in two. * boundary cut in two.
@@ -220,7 +241,28 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
row row
} }
} }
return healed + later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } val kept = later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
* calls take the name of the ones already there, and nothing visible changes identity.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val joining = (later.firstOrNull() as? TranscriptItem.ToolRun)?.runId ?: return earlier
val tail = earlier.takeLastWhile { it is TranscriptItem.ToolRun }
if (tail.isEmpty()) return earlier
return earlier.dropLast(tail.size) +
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
} }
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> = fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
@@ -242,6 +284,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun( TranscriptItem.ToolRun(
entry.seq, entry.seq,
event.id, event.id,
runIdFor(items, event.id),
event.tool, event.tool,
event.input, event.input,
"", "",
@@ -262,6 +305,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun( TranscriptItem.ToolRun(
entry.seq, entry.seq,
event.id, event.id,
runIdFor(items, event.id),
"tool", "tool",
"", "",
event.output, event.output,
@@ -908,7 +952,7 @@ fun SessionScreen(
// reason: the working indicator appearing and disappearing is another insertion // reason: the working indicator appearing and disappearing is another insertion
// at the same end. Paging older history is the opposite insertion and was // at the same end. Paging older history is the opposite insertion and was
// already fine, and stays fine, because a key survives both. // already fine, and stays fine, because a key survives both.
items(rows.asReversed(), key = { it.seq }) { row -> items(rows.asReversed(), key = { it.key }) { row ->
when (row) { when (row) {
is TranscriptRow.Tools -> is TranscriptRow.Tools ->
ToolGroup( ToolGroup(
@@ -32,26 +32,34 @@ import androidx.compose.ui.unit.dp
*/ */
sealed class TranscriptRow { sealed class TranscriptRow {
/** /**
* This row's identity in the list, taken from the first transcript event behind it. * This row's identity in the list, which must survive everything that can happen to the row.
* *
* See [TranscriptItem.seq]: the list is keyed by this so that inserting a new message at one * The list is keyed by this so that inserting a new message at one end, or a page of history at
* end, or a page of history at the other, moves the rows and not the reader. * the other, moves the rows and not the reader. That makes it the load-bearing value on this
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever
* is reading it.
*
* 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. A lone call
* that gains a neighbour becomes a group without changing identity, which is the case a
* seq-based key got wrong: the row the reader was looking at was replaced rather than updated.
* Everything else keys on the seq of the event behind it, which never moves.
*/ */
abstract val seq: Long abstract val key: Any
data class Single(val item: TranscriptItem) : TranscriptRow() { data class Single(val item: TranscriptItem) : TranscriptRow() {
override val seq: Long override val key: Any
get() = item.seq get() = (item as? TranscriptItem.ToolRun)?.runId ?: 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>) : TranscriptRow() {
/** Stable across reloads because it is the first call's own id. */ /** The run's own name, which every call in it already carries. */
val id: String val id: String
get() = calls.first().id get() = calls.first().runId
override val seq: Long override val key: Any
get() = calls.first().seq get() = id
} }
} }
@@ -75,10 +83,16 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
} }
items.forEach { item -> items.forEach { item ->
if (item is TranscriptItem.ToolRun) run += item // Grouped by the run each call says it belongs to, not by adjacency worked out here.
else { // 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*, and a group named after its first member is a different
// group every time that happens.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
flush() flush()
rows += TranscriptRow.Single(item) if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
} }
} }
flush() flush()