Merge remote-tracking branch 'origin/main'
This commit is contained in:
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 =
|
||||
|
||||
Reference in new issue
Block a user