Keep compact transcript history loading
This commit is contained in:
1 parent
22f263ccce
commit
6226a1cb43
3 files changed
+101
-37
No files matched your search
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user