Merge remote-tracking branch 'origin/main'

This commit is contained in:
iris committed 2026-08-30 01:05:24 -04:00
commit a5659393e9
1 file changed
+79 -15
@@ -65,6 +65,19 @@ import kotlinx.coroutines.withContext
private const val RECONNECT_DELAY_MS = 1500L
/**
* How many rows to keep loaded past the oldest one on screen.
*
* Both the point at which history starts loading and how much of it a load has to produce before it
* stops. A cushion rather than a page count because a page is measured in events and this list is
* measured in rows, and the two are not close: a page of eighty events can be one message.
*
* Small enough that opening a long session still costs one page, large enough that a fling upwards
* lands on rows that are already there. Fewer, and reading back means waiting for the network at
* every screenful, which is what it did.
*/
private const val HISTORY_LOOKAHEAD = 8
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
@@ -204,9 +217,14 @@ 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 whatever the page
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
@@ -222,16 +240,17 @@ private fun runIdFor(items: List<TranscriptItem>, id: String): String =
* that loses nothing.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier =
earlier.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
if (startedEarlier.isEmpty()) return earlier + later
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
if (startedEarlier.isEmpty()) return older + newer
val endedLater =
later
newer
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
if (endedLater.isEmpty()) return earlier + later
val healed = earlier.map { row ->
if (endedLater.isEmpty()) return older + newer
val healed = older.map { row ->
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
if (row is TranscriptItem.ToolRun && half != null) {
row.copy(
@@ -246,10 +265,35 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
row
}
}
val kept = later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
* accumulate into the message before them -- so two meeting at a join are always the two halves of
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
* middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
* so the growth extends off the top of the screen, away from the row the list anchors to.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later
}
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
@@ -765,16 +809,30 @@ fun SessionScreen(
}
.collect { (last, total) ->
if (!moreHistory || loadingHistory || total == 0) return@collect
if (last < total - 3) return@collect
if (last < total - HISTORY_LOOKAHEAD) return@collect
loadingHistory = true
try {
val older =
withContext(Dispatchers.IO) {
fetchTranscript(settings, summary.id, before = oldestSeq)
// Pages until there are rows behind them again, not one page and stop.
//
// A page is eighty *events*, and eighty events are routinely one row: a
// reply arrives as hundreds of text deltas that fold into a single message.
// So a page that lands can leave the far end exactly where it was -- and
// since this is triggered by the far end moving, nothing asks for the next
// one. The list then only loads when somebody drags it again, a page at a
// time, which is what "it only loads when you touch the top" was.
// Counted from `items` rather than from `rows`, which is the
// composition's value and does not change under a running coroutine.
val start = groupToolRuns(items).size
var have = start
while (moreHistory && have - start < HISTORY_LOOKAHEAD) {
val older =
withContext(Dispatchers.IO) {
fetchTranscript(settings, summary.id, before = oldestSeq)
}
if (older.isEmpty()) {
moreHistory = false
break
}
if (older.isEmpty()) {
moreHistory = false
} else {
oldestSeq = older.first().seq
moreHistory = oldestSeq > 1L
// Folded oldest-first into a list of their own, then
@@ -789,6 +847,7 @@ fun SessionScreen(
}
}
items = joinPages(earlier, items)
have = groupToolRuns(items).size
}
} catch (_: ApiException) {
// Leave `moreHistory` alone: the next scroll asks again.
@@ -1124,7 +1183,12 @@ fun SessionScreen(
// reader and nothing to whoever finds this in six months.
if (!atNewest) {
Surface(
onClick = { scope.launch { listState.animateScrollToItem(0) } },
// Instantly. An animated scroll travels the whole transcript, so the
// further back somebody has read the longer this takes -- the one press
// whose cost grows with how much there is to skip, which is backwards. The
// list is keyed and composes only what it lands on, so going straight there
// costs the same from anywhere.
onClick = { scope.launch { listState.scrollToItem(0) } },
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =