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
|
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
|
* 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
|
* 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
|
(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.
|
* 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
|
* 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
|
* 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
|
* 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.
|
* that loses nothing.
|
||||||
*/
|
*/
|
||||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||||
|
val (older, newer) = healSplitMessage(earlier, later)
|
||||||
val startedEarlier =
|
val startedEarlier =
|
||||||
earlier.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||||
if (startedEarlier.isEmpty()) return earlier + later
|
if (startedEarlier.isEmpty()) return older + newer
|
||||||
val endedLater =
|
val endedLater =
|
||||||
later
|
newer
|
||||||
.filterIsInstance<TranscriptItem.ToolRun>()
|
.filterIsInstance<TranscriptItem.ToolRun>()
|
||||||
.associateBy { it.id }
|
.associateBy { it.id }
|
||||||
.filterKeys { it in startedEarlier }
|
.filterKeys { it in startedEarlier }
|
||||||
if (endedLater.isEmpty()) return earlier + later
|
if (endedLater.isEmpty()) return older + newer
|
||||||
val healed = earlier.map { row ->
|
val healed = older.map { row ->
|
||||||
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
||||||
if (row is TranscriptItem.ToolRun && half != null) {
|
if (row is TranscriptItem.ToolRun && half != null) {
|
||||||
row.copy(
|
row.copy(
|
||||||
@@ -246,10 +265,35 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
|
|||||||
row
|
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
|
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.
|
* Hands the older calls at the join the name of the run they are joining.
|
||||||
*
|
*
|
||||||
@@ -765,16 +809,30 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
.collect { (last, total) ->
|
.collect { (last, total) ->
|
||||||
if (!moreHistory || loadingHistory || total == 0) return@collect
|
if (!moreHistory || loadingHistory || total == 0) return@collect
|
||||||
if (last < total - 3) return@collect
|
if (last < total - HISTORY_LOOKAHEAD) return@collect
|
||||||
loadingHistory = true
|
loadingHistory = true
|
||||||
try {
|
try {
|
||||||
val older =
|
// Pages until there are rows behind them again, not one page and stop.
|
||||||
withContext(Dispatchers.IO) {
|
//
|
||||||
fetchTranscript(settings, summary.id, before = oldestSeq)
|
// 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
|
oldestSeq = older.first().seq
|
||||||
moreHistory = oldestSeq > 1L
|
moreHistory = oldestSeq > 1L
|
||||||
// Folded oldest-first into a list of their own, then
|
// Folded oldest-first into a list of their own, then
|
||||||
@@ -789,6 +847,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items = joinPages(earlier, items)
|
items = joinPages(earlier, items)
|
||||||
|
have = groupToolRuns(items).size
|
||||||
}
|
}
|
||||||
} catch (_: ApiException) {
|
} catch (_: ApiException) {
|
||||||
// Leave `moreHistory` alone: the next scroll asks again.
|
// Leave `moreHistory` alone: the next scroll asks again.
|
||||||
@@ -1124,7 +1183,12 @@ fun SessionScreen(
|
|||||||
// reader and nothing to whoever finds this in six months.
|
// reader and nothing to whoever finds this in six months.
|
||||||
if (!atNewest) {
|
if (!atNewest) {
|
||||||
Surface(
|
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,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
Reference in new issue
Block a user