Keep compact transcript history loading

This commit is contained in:
iris committed 2026-09-11 01:18:45 -04:00
1 parent 22f263ccce
commit 6226a1cb43
3 files changed
+101 -37

No files matched your search

+13 -10
View File
@@ -408,16 +408,19 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
the reader hit the end of what was loaded on every swipe and stood there the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen. is actually on screen.
- **A page landing while the history observer was fetching it could spend the - **A page landing while the history observer was fetching it must trigger its
layout change meant to ask for the next one.** The observer collected only own successor.** The observer once collected only `LazyListState.layoutInfo`;
`LazyListState.layoutInfo`; while its collector was suspended in while its collector was suspended in `loadOlderPage`, a compact page could
`loadOlderPage`, a compact page could be composed and laid out without be composed and laid out without leaving another change to observe afterward.
leaving another change to observe afterward. Codex exposed it because a Keying the effect on `oldestSeq` still missed the opening prefetch: that key
page full of calls collapses into one tool group: loading stopped until changed while `loadingHistory` was true, so the restarted effect declined to
expanding that group forced a layout. The observer is also keyed on overlap it and never noticed the flag returning to false. Codex exposes both
`oldestSeq` now, so every successful page restarts it against the settled failures because a page full of calls collapses into one tool group: loading
layout. A failed page does not advance that cursor and still waits for the stopped until expanding that group forced a layout. The observer now collects
next scroll instead of retrying in a loop. the cursor, loading, restoring and failure state with the layout, so returning
to not-loading always rechecks the settled height. A failed page turns the
history boundary into a Try again control rather than retrying in a loop or
requiring another scroll.
- **Only `fetchTranscript` was off the main thread; the fold was not.** - **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of `foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800, a growing list — fine at 80 events and about 300,000 element copies at 800,
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.rememberSelectionState import androidx.compose.foundation.text.selection.rememberSelectionState
@@ -156,6 +157,16 @@ private const val RESTORE_PAGE_MAX = 4000
*/ */
private const val RESTORE_PAGE_CUSHION = 400 private const val RESTORE_PAGE_CUSHION = 400
/** Every input that can make the history boundary need another request. */
private data class HistoryLoadSignal(
val layout: LazyListLayoutInfo,
val restoring: Boolean,
val moreHistory: Boolean,
val loading: Boolean,
val error: String?,
val oldestSeq: Long,
)
/** /**
* Which row was asked to hold its top edge, and how tall it was when it last measured. * Which row was asked to hold its top edge, and how tall it was when it last measured.
* *
@@ -369,6 +380,7 @@ fun SessionScreen(
val running = sessionWorking(status) val running = sessionWorking(status)
var moreHistory by remember { mutableStateOf(true) } var moreHistory by remember { mutableStateOf(true) }
var loadingHistory by remember { mutableStateOf(false) } var loadingHistory by remember { mutableStateOf(false) }
var historyError by remember(address, epoch) { mutableStateOf<String?>(null) }
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. // Replies parsed ahead of the rows that draw them; see [ParsedReplies].
val replies = remember(address) { ParsedReplies() } val replies = remember(address) { ParsedReplies() }
@@ -429,6 +441,7 @@ fun SessionScreen(
held = listOf() held = listOf()
oldestSeq = 0L oldestSeq = 0L
moreHistory = true moreHistory = true
historyError = null
// A send the server could not accept is this phone's only copy. A reset replaces server // A send the server could not accept is this phone's only copy. A reset replaces server
// state, not that local outbox, so dropping it here would eat the message a second time. // state, not that local outbox, so dropping it here would eat the message a second time.
replaceQueued(queued.filter { it.local }) replaceQueued(queued.filter { it.local })
@@ -659,6 +672,21 @@ fun SessionScreen(
return true return true
} }
/** One guarded history request, shared by automatic paging and the explicit retry control. */
suspend fun requestOlderPage(): Boolean {
if (loadingHistory) return false
loadingHistory = true
historyError = null
return try {
loadOlderPage()
} catch (e: ApiException) {
historyError = e.message ?: "Unknown error"
false
} finally {
loadingHistory = false
}
}
// A call opened on its own stays open when a second call in the same run turns it into a group. // A call opened on its own stays open when a second call in the same run turns it into a group.
// Until this, watching a Bash call and having the session make another one shut the one being // Until this, watching a Bash call and having the session make another one shut the one being
// read and folded it behind "Called 2 tools". // read and folded it behind "Called 2 tools".
@@ -844,14 +872,7 @@ fun SessionScreen(
// scroll met it and waited a round trip. So the first full page goes right behind it, while // scroll met it and waited a round trip. So the first full page goes right behind it, while
// the screen is already up. A restore skips this: it has just paged as deep as it needed. // the screen is already up. A restore skips this: it has just paged as deep as it needed.
if (savedAnchor == null && moreHistory && !loadingHistory) { if (savedAnchor == null && moreHistory && !loadingHistory) {
loadingHistory = true requestOlderPage()
try {
loadOlderPage()
} catch (_: ApiException) {
// The next scroll asks again.
} finally {
loadingHistory = false
}
} }
// Last, and off this thread: this session is what must not be evicted, so it is marked as // Last, and off this thread: this session is what must not be evicted, so it is marked as
// visited before the budget is applied, and both are a walk of the cache directory. // visited before the budget is applied, and both are a walk of the cache directory.
@@ -1041,18 +1062,38 @@ fun SessionScreen(
// There is no correction beside this one. Following the newest message is not an effect: the // There is no correction beside this one. Following the newest message is not an effect: the
// list is reversed, so an arriving message extends the end the viewport is pinned to. // list is reversed, so an arriving message extends the end the viewport is pinned to.
val unitSizes = remember(address) { HashMap<Any, Int>() } val unitSizes = remember(address) { HashMap<Any, Int>() }
// A successful page advances `oldestSeq` even when its collapsed rows add too little height to // Every state that can make another request useful is part of the collected value. In
// produce another layout after this collector returns. Restarting on that cursor makes the // particular, the opening prefetch used to advance `oldestSeq` while `loadingHistory` was still
// promised re-check happen; a failed page leaves it unchanged and still waits for a scroll. // true; the effect restarted, declined to overlap it, and never noticed when loading became
LaunchedEffect(listState, moreHistory, oldestSeq) { // false. A tool expansion happened to cause the next layout and unstick it. Collecting the
snapshotFlow { listState.layoutInfo } // loading transition itself makes a compact page immediately ask for its successor.
.collect { info -> LaunchedEffect(listState) {
snapshotFlow {
HistoryLoadSignal(
listState.layoutInfo,
restoring,
moreHistory,
loadingHistory,
historyError,
oldestSeq,
)
}
.collect { signal ->
val info = signal.layout
val visible = info.visibleItemsInfo val visible = info.visibleItemsInfo
if (visible.isEmpty()) return@collect if (visible.isEmpty()) return@collect
// Before the guards below, so sizes keep accumulating while a page is in flight and // Before the guards below, so sizes keep accumulating while a page is in flight and
// the next estimate starts better informed. // the next estimate starts better informed.
visible.forEach { unitSizes[it.key] = it.size } visible.forEach { unitSizes[it.key] = it.size }
if (restoring || !moreHistory || loadingHistory) return@collect if (
signal.restoring ||
!signal.moreHistory ||
signal.loading ||
signal.error != null ||
signal.oldestSeq == 0L
) {
return@collect
}
val viewport = info.viewportSize.height val viewport = info.viewportSize.height
if (viewport == 0) return@collect if (viewport == 0) return@collect
val loaded = currentUnits val loaded = currentUnits
@@ -1066,16 +1107,11 @@ fun SessionScreen(
room += unitSizes[loaded[index].key] ?: average room += unitSizes[loaded[index].key] ?: average
if (room >= cushion) return@collect if (room >= cushion) return@collect
} }
loadingHistory = true
try {
// One page, and then this fires again if it was not enough -- the estimate is // One page, and then this fires again if it was not enough -- the estimate is
// re-made from what the page actually added. // re-made when loading returns to false, even when folding the page did not change
loadOlderPage() // the list's height at all. A failure leaves `moreHistory` alone but records an
} catch (_: ApiException) { // error, stopping this loop until the boundary's Try again control is pressed.
// Leave `moreHistory` alone: the next scroll asks again. requestOlderPage()
} finally {
loadingHistory = false
}
} }
} }
@@ -1476,6 +1512,11 @@ fun SessionScreen(
units = units, units = units,
state = listState, state = listState,
moreHistory = moreHistory, moreHistory = moreHistory,
historyError = historyError,
// A button means "make the request", not merely "let the estimator decide
// again". Its measurements can be stale after the tall error row is
// replaced by the spinner, and that used to make a press do nothing.
onRetryHistory = { scope.launch { requestOlderPage() } },
selection = selection, selection = selection,
modifier = modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
@@ -1,6 +1,7 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -10,6 +11,9 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState import androidx.compose.foundation.text.selection.SelectionState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -49,6 +53,8 @@ fun TranscriptList(
units: List<TranscriptUnit>, units: List<TranscriptUnit>,
state: LazyListState, state: LazyListState,
moreHistory: Boolean, moreHistory: Boolean,
historyError: String?,
onRetryHistory: () -> Unit,
selection: SelectionState, selection: SelectionState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
below: @Composable () -> Unit, below: @Composable () -> Unit,
@@ -94,15 +100,29 @@ fun TranscriptList(
DebugStats.count("unit composed") DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) } Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
} }
// Standing in for everything not fetched yet. Only here while there is more -- its // Standing in for everything not fetched yet. A failed fetch stays actionable here:
// appearance at the top edge is also roughly when the next page is asked for, so what // when the loaded transcript is too short to scroll, this boundary is the only place
// it reports is a fetch in flight rather than an end reached. // the reader can be given another way to ask.
if (moreHistory) { if (moreHistory) {
item(key = "history", contentType = "history") { item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
if (historyError == null) {
CircularProgressIndicator( CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER) Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
) )
} else {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Couldn't load earlier messages. $historyError",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetryHistory) { Text("Try again") }
}
}
} }
} }
} }