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(
override val seq: Long,
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 input: String,
val output: String,
@@ -177,6 +188,16 @@ sealed class 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
* boundary cut in two.
@@ -220,7 +241,28 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
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> =
@@ -242,6 +284,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id),
event.tool,
event.input,
"",
@@ -262,6 +305,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id),
"tool",
"",
event.output,
@@ -908,7 +952,7 @@ fun SessionScreen(
// reason: the working indicator appearing and disappearing is another insertion
// at the same end. Paging older history is the opposite insertion and was
// 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) {
is TranscriptRow.Tools ->
ToolGroup(
@@ -32,26 +32,34 @@ import androidx.compose.ui.unit.dp
*/
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
* end, or a page of history at the other, moves the rows and not the reader.
* The list is keyed by this so that inserting a new message at one end, or a page of history at
* 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() {
override val seq: Long
get() = item.seq
override val key: Any
get() = (item as? TranscriptItem.ToolRun)?.runId ?: item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
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
get() = calls.first().id
get() = calls.first().runId
override val seq: Long
get() = calls.first().seq
override val key: Any
get() = id
}
}
@@ -75,10 +83,16 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
}
items.forEach { item ->
if (item is TranscriptItem.ToolRun) run += item
else {
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// 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()
rows += TranscriptRow.Single(item)
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
}
}
flush()