Hold every loaded row, and page by pixels rather than by rows
The transcript is a plain Column scrolled in reverse instead of a LazyColumn. Nothing about Compose was re-measuring text that had already been drawn -- a node that is still alive and whose constraints have not changed skips measurement outright, in MeasurePassDelegate.remeasure. What was throwing that away was disposal: a lazy list drops a row the moment it leaves the viewport, and the markdown tree, the measured lines and the cached paragraph go with it. Keeping the rows is the fix, and it is what a browser-based client does that we were not. Two scroll corrections go with it, each of which had a comment explaining a way it had been seen to fire at the wrong moment. The content now hangs from its newest end, so a page of older history extends the far end and moves nothing on screen, and an arriving message extends the end the viewport is already pinned to. Following the newest message is no longer an effect that notices and corrects; it is where the content is. The same goes for the keyboard opening, which was the case that used to get missed. Paging asks its question in pixels of scroll -- how far can the reader keep going before they run out -- which is what it was always about. Rows were the wrong unit twice: a fixed count of them is a distance only by accident, and counting screenfuls of rows fixed the size of that mistake without fixing its kind. A saved position is resolved to the row that now holds that seq before the layout is asked to put it back. The events behind a row regroup between the save and the reopen, so the seq that was a row's first is often no longer any row's first, and handing the layout the saved seq named a row that did not exist -- the position was never applied and the session opened at the newest end. Verified on the emulator against a real 1,200-event transcript: the position survives leaving and reopening, jump-to-latest arrives and stays followed, and the newest message holds its place against the composer as the keyboard opens and the draft grows. Scrolling measures the same as the lazy version did (2.6% vs 2.0% janky, p50 16ms, p90 21ms, no slow UI-thread frames either way) -- the cap and the off-thread parse had already taken that cost out, so this change is about what the list can no longer do wrong rather than about frames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3095052df0
commit
ee1c493559
2 files changed
+356
-273
No files matched your search
@@ -7,8 +7,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -17,9 +17,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -48,6 +45,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
@@ -84,11 +82,13 @@ private val LOADING_SPINNER = 48.dp
|
||||
* How much history to keep loaded past the oldest row on screen, counted in screenfuls.
|
||||
*
|
||||
* Both the point at which history starts loading and how much of it a load has to produce before it
|
||||
* stops. Measured against what is on screen rather than written down as a number of rows, because a
|
||||
* row is anything from one line to a page and a fixed count is therefore a distance only by
|
||||
* accident. Eight rows was the number, and on a tool-heavy transcript eight rows is less than one
|
||||
* screen: the reader reached the end of what was loaded on *every* swipe and waited a round trip
|
||||
* standing there, which is a list running out of transcript rather than a slow frame.
|
||||
* stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance
|
||||
* the question is actually about: how far the reader can keep going before they run out. A row is
|
||||
* anything from one line to a page, so a count of rows is that distance only by accident. Eight
|
||||
* rows was the number, and on a tool-heavy transcript eight rows is less than one screen: the
|
||||
* reader reached the end of what was loaded on *every* swipe and waited a round trip standing
|
||||
* there, which is a list running out of transcript rather than a slow frame. Counting screenfuls of
|
||||
* rows fixed the size of the mistake without fixing its kind; pixels are the unit itself.
|
||||
*
|
||||
* Three, so a fling lands on rows that are already there and the page after them is on its way. The
|
||||
* cost of being generous is a page fetched that nobody reads; the cost of being mean is a list that
|
||||
@@ -675,21 +675,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// it is the question "where did I leave off" and the answer stops being interesting the
|
||||
// moment the list is on screen.
|
||||
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
|
||||
// Whether the list is still being put back where it was left. Nothing is drawn while it is:
|
||||
// opening at the newest end and then travelling to the anchor is exactly the journey
|
||||
// `reverseLayout` exists to remove, and this transcript is not allowed to move under a reader.
|
||||
// Whether the history the saved position needs is still being fetched. Nothing is drawn while
|
||||
// it is: opening at the newest end and then travelling to the anchor is exactly the journey
|
||||
// this layout exists to remove, and this transcript is not allowed to move under a reader.
|
||||
// The other half of the wait is [TranscriptScroll.settling], which covers the frames between
|
||||
// the rows arriving and the layout that measures them putting the position back.
|
||||
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
|
||||
// Remembered, and only ever written when a scroll settles -- so it records where the reader
|
||||
// last left the list, and an insertion cannot change the answer. Reading the live position
|
||||
// instead looks right and is subtly wrong: a keyed list moves its anchor to keep the reader's
|
||||
// content still, so by the time the new item can be observed the view is already one item
|
||||
// away from the newest and reports itself as scrolled back. The message then never followed,
|
||||
// which was visible as a compaction whose progress bar sat just off the bottom of the screen
|
||||
// while the button that started it said it was running.
|
||||
//
|
||||
// Seeded from whether there is a position to go back to, so the correction it drives does not
|
||||
// pull the list to the newest end before the restore has put it anywhere.
|
||||
var followTail by remember(summary.id) { mutableStateOf(savedAnchor == null) }
|
||||
// Sent, but not yet read by the session -- which is when the backend
|
||||
// records it and it comes back as a row. Until then it is drawn below
|
||||
// the working indicator, because that is where it is in the session's
|
||||
@@ -711,17 +702,13 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because
|
||||
// it describes that session's rows and nothing else.
|
||||
val replies = remember(summary.id) { ParsedReplies() }
|
||||
val listState = rememberLazyListState()
|
||||
// Whether the newest message is on screen right now. The list is laid out from the bottom
|
||||
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
|
||||
// it. This is what the jump-to-newest button watches: it is about what the reader can see.
|
||||
val listState = rememberTranscriptScroll(summary.id)
|
||||
// Whether the newest message is on screen right now. The content hangs from its newest end
|
||||
// (see [TranscriptScroll]), so being there is being at scroll position zero. This is what the
|
||||
// jump-to-newest button watches: it is about what the reader can see.
|
||||
//
|
||||
// It is also the gate on everything the list draws -- see [record].
|
||||
val atNewest by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
val atNewest by remember { derivedStateOf { listState.atNewest } }
|
||||
// Transcript events that arrived while somebody was reading further back, in the order they
|
||||
// arrived, waiting for them to return to the newest end. See [record] for why.
|
||||
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
|
||||
@@ -853,46 +840,26 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
|
||||
/**
|
||||
* How many list items sit above every transcript row -- one while something is waiting, none
|
||||
* otherwise.
|
||||
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
|
||||
*
|
||||
* Read both here and by the `item` that draws that bubble, so a restored position and the list
|
||||
* cannot disagree about what is at which index. Anything else added above the rows later
|
||||
* belongs in this count.
|
||||
*/
|
||||
fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0
|
||||
|
||||
/** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */
|
||||
fun listItemCount() = groupToolRuns(items).size + itemsAboveRows()
|
||||
|
||||
/**
|
||||
* The row drawn at list index [index], or null when that item is not a row.
|
||||
* "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row --
|
||||
* [joinPages] welds the other half onto it when the page before it arrives, and it grows -- so
|
||||
* putting the reader inside one leaves them where they were only until the next page lands,
|
||||
* which was a screen and a half out. Any row that is not the oldest is final.
|
||||
*
|
||||
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives, and this
|
||||
* is the caller that makes it matter: the scroll listener below is started once and keyed on
|
||||
* the list state, so a `rows` read inside it is the value from the *first* composition, which
|
||||
* is empty. That saved a null anchor on every scroll -- indistinguishable from having been left
|
||||
* at the newest end, so the position was silently never recorded at all.
|
||||
*/
|
||||
fun rowAt(index: Int): TranscriptRow? =
|
||||
groupToolRuns(items).asReversed().getOrNull(index - itemsAboveRows())
|
||||
|
||||
/**
|
||||
* Where the row holding transcript position [seq] sits in the list, or null when nothing loaded
|
||||
* reaches back that far.
|
||||
* The last row starting at or before [seq], rather than one starting exactly there: the events
|
||||
* behind a row can be regrouped between the save and the reopen -- a run of calls folds
|
||||
* differently when a page boundary moves, and two halves of a reply become one message -- and
|
||||
* the reader's place is inside whichever row now holds that seq, not gone.
|
||||
*
|
||||
* Computed from `items` rather than from `rows`, as [rowAt] is and for the same reason.
|
||||
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives: `rows` is
|
||||
* the composition's value and does not change under a running coroutine.
|
||||
*/
|
||||
fun indexOfSeq(seq: Long): Int? {
|
||||
// The last row that starts at or before it, rather than one that starts exactly there:
|
||||
// the events behind a row can be regrouped between the save and the reopen -- a run of
|
||||
// calls folds differently when a page boundary moves, and two halves of a reply become
|
||||
// one message -- and the reader's place is inside whichever row now holds that seq, not
|
||||
// gone. Rows are oldest-first here, so that is the last match.
|
||||
fun anchorRow(seq: Long): Long? {
|
||||
val ordered = groupToolRuns(items)
|
||||
val row = ordered.indexOfLast { it.startSeq <= seq }
|
||||
if (row < 0) return null
|
||||
return ordered.size - 1 - row + itemsAboveRows()
|
||||
val at = ordered.indexOfLast { it.startSeq <= seq }
|
||||
// Zero is the oldest loaded row, which is the half-row above; not found is -1.
|
||||
return if (at > 0) ordered[at].startSeq else null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1022,8 +989,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// that -- a tool run is renamed whenever the newest page starts somewhere new,
|
||||
// so an anchor on one was never found and this paged to the first event of the
|
||||
// conversation every time an active session was reopened.
|
||||
var index = indexOfSeq(anchor.seq)
|
||||
while (moreHistory && (index == null || index >= listItemCount() - 1)) {
|
||||
while (moreHistory && anchorRow(anchor.seq) == null) {
|
||||
// The whole span in one request rather than a page at a time. `read_window`
|
||||
// counts *lines* and a transcript numbers them one per seq, so the distance
|
||||
// back to the anchor is the number of events to ask for -- and were seqs ever
|
||||
@@ -1040,17 +1006,25 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// the newest end has to be there for the list to be able to count to it.
|
||||
val span = oldestSeq - anchor.seq + HISTORY_PAGE
|
||||
if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break
|
||||
index = indexOfSeq(anchor.seq)
|
||||
}
|
||||
// Both writes before this coroutine yields, so the list's first measurement is
|
||||
// the one with every row in it *and* the requested position -- the list is drawn
|
||||
// where it was left rather than drawn and then moved. `requestScrollToItem` is
|
||||
// the form that is applied during a layout pass; see [holdTopEdge].
|
||||
// Both writes before this coroutine yields, so the layout that first measures
|
||||
// these rows is also the one that puts the position back -- the transcript is
|
||||
// drawn where it was left rather than drawn and then moved. The pixels do not
|
||||
// exist until that measurement, which is why the anchor is handed to the layout
|
||||
// rather than applied here; see [TranscriptScroll.pending].
|
||||
restoring = false
|
||||
// A null index is a row that is no longer in the transcript -- a reset stream, or
|
||||
// Resolved to the row that *holds* the saved position rather than passed
|
||||
// straight through, because the two are not always the same seq: the events
|
||||
// behind a row regroup between the save and the reopen -- a run of calls folds
|
||||
// differently when a page boundary moves, two halves of a reply become one
|
||||
// message -- and the layout can only recognise a row by the seq it now starts
|
||||
// at. Handing it the saved seq meant the row it named no longer existed, so the
|
||||
// position was never applied and the transcript opened at the newest end.
|
||||
//
|
||||
// Null is a row that is no longer in the transcript at all -- a reset stream, or
|
||||
// a session cleared from elsewhere.
|
||||
index?.let {
|
||||
listState.requestScrollToItem(it, anchor.offset)
|
||||
anchorRow(anchor.seq)?.let { rowSeq ->
|
||||
listState.restore(ScrollAnchor(rowSeq, anchor.offset))
|
||||
restored = true
|
||||
}
|
||||
}
|
||||
@@ -1059,7 +1033,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// slow but complete. Saying so beats silently showing nothing.
|
||||
streamError = e.message
|
||||
}
|
||||
if (!restored) followTail = true
|
||||
// Nothing to put back, so draw where the content already hangs: the newest end.
|
||||
if (!restored) listState.giveUp()
|
||||
// Whatever happened above, including a page that never arrived: an empty transcript is a
|
||||
// state the screen can draw, and a permanently blank one is not.
|
||||
restoring = false
|
||||
@@ -1155,18 +1130,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
backlog.forEach { record(it) }
|
||||
}
|
||||
}
|
||||
// Whether they *chose* to be at the newest end, which is a different question from being
|
||||
// there and the one that decides whether an arriving message brings the view with it.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.isScrollInProgress }
|
||||
// The value `snapshotFlow` emits on collection is the state of things before anybody
|
||||
// has touched the list, and it is `false` -- which reads here as a scroll that has
|
||||
// just ended, and so as an answer about where the reader chose to be. Only the
|
||||
// transitions after it are scrolls.
|
||||
.drop(1)
|
||||
.collect { scrolling -> if (!scrolling) followTail = atNewest }
|
||||
}
|
||||
|
||||
// Where the reader left off, written whenever the list settles somewhere new.
|
||||
//
|
||||
// Driven by the position rather than by the scroll flag, and that is the whole point: a
|
||||
@@ -1175,115 +1138,63 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// that, and it left the old position recorded -- so the reader pressed the control that means
|
||||
// "take me to the end", left, came back, and was put back where they had been.
|
||||
//
|
||||
// Deliberately not where [followTail] is decided, which has to stay on the settle: a keyed
|
||||
// list moves its own anchor to keep content still when a row arrives, so the position reports
|
||||
// itself as one item back for a frame every time a message lands. That frame is the whole
|
||||
// reason [followTail] is a remembered answer, and reading it here instead would stop new
|
||||
// messages being followed.
|
||||
// There is no separate "did they choose to be here" flag any more, and there is nothing left
|
||||
// for one to protect against. It existed because a keyed lazy list moved its own anchor when a
|
||||
// row arrived, so for one frame the position reported itself scrolled back from the newest end
|
||||
// when nobody had scrolled at all. This layout hangs from that end, so a row arriving does not
|
||||
// move the position: zero still means the newest message, during the frame it lands and after.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow {
|
||||
if (listState.isScrollInProgress) null
|
||||
else listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
// As above: where the list sits before anybody has touched it is not somewhere they
|
||||
// left off, and taking it as one wiped every saved anchor on the way in -- before the
|
||||
// restore below could use it.
|
||||
snapshotFlow { if (listState.scroll.isScrollInProgress) null else listState.scroll.value }
|
||||
// The value `snapshotFlow` emits on collection is where the list sits before anybody
|
||||
// has touched it, which is not somewhere they left off. Taking it as one wiped every
|
||||
// saved anchor on the way in -- before the restore above could use it.
|
||||
.drop(1)
|
||||
.collect { settled ->
|
||||
if (settled == null) return@collect
|
||||
if (settled == null || listState.settling) return@collect
|
||||
saveScrollAnchor(
|
||||
context,
|
||||
summary.id,
|
||||
// Nothing to restore at the newest end, which is where a session with no
|
||||
// anchor opens anyway -- so the ordinary case costs a `remove` and no
|
||||
// page-back on the way in. [followTail] covers the frame described above,
|
||||
// where a row has just arrived and the position has not caught up yet;
|
||||
// `rowAt` is null for the pending bubble, which sits above every row and is
|
||||
// not one, and being on that is being at the newest end too.
|
||||
if (followTail || atNewest) null
|
||||
else rowAt(settled.first)?.let { ScrollAnchor(it.startSeq, settled.second) },
|
||||
// page-back on the way in.
|
||||
if (settled == 0) null else listState.anchor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
// A new item at the newest end shifts every index by one, so the view
|
||||
// has to step back to 0 to stay put. One item, instantly -- not a
|
||||
// journey through the transcript.
|
||||
// Anything that changes how much room the list has, as well as a new
|
||||
// item arriving. Typing is the case that gets missed: the field grows
|
||||
// from one line to four and the keyboard opens under it, and neither
|
||||
// is a new message, so watching the item count alone leaves the newest
|
||||
// text drifting out of sight while somebody writes a reply to it.
|
||||
// Reaching the far end of what is loaded fetches the page before it.
|
||||
//
|
||||
// Counted in list items rather than in transcript rows, because the rows are not all of it:
|
||||
// the working indicator and a queued message are items too, and they arrive at exactly the
|
||||
// same end. Sibling to the paging trigger below, which is the same count read from the other
|
||||
// end for the same reason.
|
||||
// Measured in pixels of scroll, which is the unit the question is actually about: how far can
|
||||
// the reader keep going before they run out. Rows are the wrong unit for it and were the
|
||||
// reason this went wrong twice -- a row is anything from one line to a screenful, so a cushion
|
||||
// of "three rows" is a different amount of reading depending which three, and a page of eight
|
||||
// hundred *events* can fold into almost no new rows at all when it is one streamed reply and a
|
||||
// run of tool calls. Nothing then asked for the next page, and the transcript only loaded when
|
||||
// somebody dragged it again.
|
||||
//
|
||||
// Never while a scroll is running, and that is a rule of its own rather than a refinement of
|
||||
// the condition beside it: a list must not be moved out from under a hand that is moving it.
|
||||
// The two disagree because [followTail] is deliberately a *remembered* answer, rewritten only
|
||||
// when a scroll settles -- so for the whole of a fling it still reports the newest end, where
|
||||
// the reader was when they threw it. A page of history landing during that fling is a change
|
||||
// in the count, and the correction meant for an insertion at the newest end then fired for
|
||||
// one at the oldest: the reader was thrown back to the bottom mid-flight. It could happen
|
||||
// only once, which is what made it look arbitrary rather than mechanical -- the snap settles
|
||||
// the scroll at the newest end, so the next fling gets far enough to settle away from it, and
|
||||
// from then on [followTail] is false and nothing fires. Skipping the correction outright is
|
||||
// right rather than merely safe: the count can only have grown at the newest end while the
|
||||
// reader is already there, because [record] holds everything else until they come back.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow {
|
||||
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
|
||||
}
|
||||
.collect { (count, _) ->
|
||||
if (followTail && !listState.isScrollInProgress && count > 0) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reaching the far end of what is loaded -- the oldest item, which in
|
||||
// this layout is the last index -- fetches the page before it.
|
||||
//
|
||||
// Both numbers come from the list itself, and that is the point: an index into what is drawn
|
||||
// can only be compared against how much is drawn. Three things already make that differ from
|
||||
// the event count -- a run of adjacent tool calls is one row, and the queued bubble and the
|
||||
// working indicator are rows with no event behind them at all -- so measuring the far end in
|
||||
// events meant the threshold could not be reached, and a session with tool calls in it simply
|
||||
// stopped scrolling back. Anything added to this list later is a fourth, and totalItemsCount
|
||||
// already counts it.
|
||||
LaunchedEffect(listState, rows.size, moreHistory) {
|
||||
snapshotFlow {
|
||||
val layout = listState.layoutInfo
|
||||
// How many rows are on screen is the third thing this needs, and it is what turns a
|
||||
// cushion measured in rows into one measured in screens; see [HISTORY_SCREENS].
|
||||
Triple(
|
||||
layout.visibleItemsInfo.lastOrNull()?.index ?: 0,
|
||||
layout.totalItemsCount,
|
||||
layout.visibleItemsInfo.size,
|
||||
)
|
||||
}
|
||||
.collect { (last, total, onScreen) ->
|
||||
if (!moreHistory || loadingHistory || total == 0) return@collect
|
||||
val cushion = onScreen.coerceAtLeast(1) * HISTORY_SCREENS
|
||||
if (last < total - cushion) return@collect
|
||||
// There is no correction beside this one any more. Following the newest message used to be an
|
||||
// effect here too, watching the item count and the viewport for a change and snapping back --
|
||||
// and it had to be told not to fire during a scroll, because a page of history landing mid-
|
||||
// fling looked exactly like a message arriving and threw the reader to the bottom. The content
|
||||
// now hangs from the newest end, so an arriving message needs no correction and a page of
|
||||
// history moves nothing; see [TranscriptScroll].
|
||||
LaunchedEffect(listState, moreHistory) {
|
||||
snapshotFlow { listState.roomAbove to listState.viewport }
|
||||
.collect { (room, viewport) ->
|
||||
if (!moreHistory || loadingHistory || viewport == 0) return@collect
|
||||
val cushion = viewport * HISTORY_SCREENS
|
||||
if (room >= cushion) return@collect
|
||||
loadingHistory = true
|
||||
try {
|
||||
// Pages until there are rows behind them again, not one page and stop.
|
||||
// One page, and then this fires again if it was not enough.
|
||||
//
|
||||
// A page is eight hundred *events*, and events are not rows: a reply arrives
|
||||
// as hundreds of text deltas that fold into a single message, and a run of
|
||||
// thirty-five tool calls is one row. So a page that lands can leave the far
|
||||
// end almost 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, and later what the wait at every swipe 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 < cushion && loadOlderPage()) {
|
||||
have = groupToolRuns(items).size
|
||||
}
|
||||
// Deliberately not a loop: how much room a page bought is a fact about the
|
||||
// *layout* it produced, and no layout has happened yet inside this coroutine
|
||||
// -- so a loop would be re-reading the height from before the page it just
|
||||
// fetched and would ask for the whole conversation. Letting the measurement
|
||||
// answer means each page is checked against what it actually added, and a
|
||||
// page that folds into almost no new rows is followed by another because the
|
||||
// room genuinely did not grow.
|
||||
loadOlderPage()
|
||||
} catch (_: ApiException) {
|
||||
// Leave `moreHistory` alone: the next scroll asks again.
|
||||
} finally {
|
||||
@@ -1462,90 +1373,62 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
)
|
||||
}
|
||||
|
||||
// Laid out from the bottom, with the newest message at index 0.
|
||||
// Every loaded row composed and kept, hanging from the newest message.
|
||||
//
|
||||
// The obvious arrangement -- oldest first, then scroll to the end
|
||||
// -- opens at the top and travels the whole transcript to get
|
||||
// where it belongs. On an imported session that is nine hundred
|
||||
// items measured before anything is readable, seen as the view
|
||||
// visibly racing downward every time it opened.
|
||||
// The obvious arrangement -- oldest first, then scroll to the end -- opens at the top and
|
||||
// travels the whole transcript to get where it belongs. On an imported session that was
|
||||
// nine hundred rows measured before anything was readable, seen as the view visibly racing
|
||||
// downward every time it opened. Scrolling the content in reverse removes the journey
|
||||
// rather than hiding it: position zero *is* the newest message, so the first frame is
|
||||
// already the right one and nothing has to be scrolled at all. See [TranscriptScroll].
|
||||
//
|
||||
// Anchoring at the bottom removes the journey rather than hiding
|
||||
// it: the first frame is already the newest message, and older
|
||||
// ones are composed only as somebody scrolls back to them, which
|
||||
// is also what makes history cheap on a long conversation.
|
||||
// Empty until a saved position has been put back -- see the opening effect. Held out of
|
||||
// the list rather than drawn and scrolled, so there is no frame in which the transcript is
|
||||
// somewhere other than where it was left.
|
||||
val drawnRows = if (restoring) emptyList() else rows.asReversed()
|
||||
// Drawn only once there is nothing left to put back, and measured throughout -- the
|
||||
// heights are what a saved position is expressed in, so the rows have to be laid out
|
||||
// before it can be applied. Held out of the drawing rather than out of the list, so there
|
||||
// is no frame in which the transcript is somewhere other than where it was left.
|
||||
val settled = !restoring && !listState.settling
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
// Bottom, and it has to be said: `reverseLayout` defaults the arrangement to
|
||||
// `Bottom` on its own, but naming `spacedBy` replaces that default with
|
||||
// `spacedBy`'s own, which is `Top`. The arrangement is what places the content
|
||||
// when there is less of it than the viewport -- so a session whose loaded rows
|
||||
// did not fill the screen drew them against the *top*, leaving a gap between the
|
||||
// newest message and the box you type in, and no room to scroll the gap away.
|
||||
// Opening the keyboard shrank the viewport enough for the content to overflow it
|
||||
// and the list snapped down, which is what made it look like a scrolling fault
|
||||
// rather than a placement one.
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom),
|
||||
) {
|
||||
// The last thing in the transcript, because that is where
|
||||
// they are in the session's reading of events: after
|
||||
// everything it has taken in, and not yet taken in
|
||||
// themselves. What the session is *doing* about them is a
|
||||
// line below, in [SessionStatusRow].
|
||||
if (!restoring && itemsAboveRows() > 0) {
|
||||
item(key = "queued") {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
waitingCommands.forEach { (_, text) ->
|
||||
CommandBubble(text, waiting = true)
|
||||
}
|
||||
queued.forEach { waiting ->
|
||||
UserBubble(
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
text = waiting.text,
|
||||
images = waiting.images,
|
||||
pending = true,
|
||||
)
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
TranscriptColumn(
|
||||
rows = rows,
|
||||
state = listState,
|
||||
// A scrollable child is measured with no height bound, so the content cannot
|
||||
// ask how tall the visible area is; this is the only place that knows.
|
||||
viewportHeight = maxHeight,
|
||||
contentPadding = TRANSCRIPT_PADDING,
|
||||
spacing = TRANSCRIPT_SPACING,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
||||
below = {
|
||||
// The last thing in the transcript, because that is where they are in the
|
||||
// session's reading of events: after everything it has taken in, and not
|
||||
// yet taken in themselves. What the session is *doing* about them is a
|
||||
// line below, in [SessionStatusRow].
|
||||
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
waitingCommands.forEach { (_, text) ->
|
||||
CommandBubble(text, waiting = true)
|
||||
}
|
||||
queued.forEach { waiting ->
|
||||
UserBubble(
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
text = waiting.text,
|
||||
images = waiting.images,
|
||||
pending = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reversed to match the layout, so index 0 is the newest and
|
||||
// the reader still sees them in the order they happened.
|
||||
// Grouped first: adjacent tool calls collapse into one row,
|
||||
// which is a decision about this screen and not about the
|
||||
// transcript the stream and paging share.
|
||||
// Keyed, and this is what stops the list moving under whoever is reading
|
||||
// it. Every new message is an insertion at index 0 here, so without a key the
|
||||
// rows keep their positions and the content slides through them -- which looks
|
||||
// exactly like the view scrolling by itself. The keys above matter for the same
|
||||
// reason: the working indicator appearing and disappearing is another insertion
|
||||
// at the same end. Paging older history is the opposite insertion and was
|
||||
// already fine, and stays fine, because a key survives both.
|
||||
items(drawnRows, key = { it.key }) { row ->
|
||||
},
|
||||
) { row ->
|
||||
val bounds = remember { RowBounds() }
|
||||
Box(
|
||||
Modifier.onGloballyPositioned {
|
||||
bounds.top = it.positionInRoot().y
|
||||
bounds.height = it.size.height.toFloat()
|
||||
}
|
||||
.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
||||
// Requested rather than scrolled. Scrolling forces a remeasure,
|
||||
// and forcing one from inside a measure throws; this is the form
|
||||
// built to be asked for during layout and applied in that pass.
|
||||
listState.requestScrollToItem(
|
||||
listState.firstVisibleItemIndex,
|
||||
listState.firstVisibleItemScrollOffset + grew,
|
||||
)
|
||||
}
|
||||
.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }
|
||||
) {
|
||||
when (row) {
|
||||
is TranscriptRow.Tools ->
|
||||
@@ -1705,7 +1588,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
//
|
||||
// In the middle of the transcript rather than at either end, because it is not
|
||||
// reporting on the newest message or the oldest; it is standing in for all of them.
|
||||
if (!ready || restoring) {
|
||||
// `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching
|
||||
// the history a saved position needs, and then the frames between those rows arriving
|
||||
// and the layout that measures them putting the position back. They are the two halves
|
||||
// of the same wait and the transcript is not drawn for either.
|
||||
if (!ready || !settled) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER))
|
||||
}
|
||||
|
||||
@@ -1723,22 +1610,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
Surface(
|
||||
// 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 = {
|
||||
// Says what it means as well as doing it. Pressing this is the reader
|
||||
// choosing the newest end, which is exactly what [followTail] records --
|
||||
// and nothing else here would notice, because a snap moves the list
|
||||
// inside one frame and the listener that decides [followTail] waits for a
|
||||
// scroll to *end*. So this used to land at the bottom with following
|
||||
// still switched off, and the next message did not bring the view with
|
||||
// it: the reader pressed "take me to the latest" and then sat watching a
|
||||
// conversation that had moved on without them. Sibling to the anchor
|
||||
// above, which had the same hole and lost the saved position instead.
|
||||
followTail = true
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
},
|
||||
// whose cost grows with how much there is to skip, which is backwards.
|
||||
//
|
||||
// Arriving there is all this has to do now. The newest end is where the
|
||||
// content hangs from, so being at it is the whole of following it, and there
|
||||
// is no separate flag to set -- which is what this press used to forget,
|
||||
// landing the reader at the bottom with new messages not bringing the view
|
||||
// with them.
|
||||
onClick = { scope.launch { listState.scroll.scrollTo(0) } },
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier =
|
||||
|
||||
Reference in new issue
Block a user