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:
irisandClaude Opus 5 committed 2026-08-31 01:31:01 -04:00
1 parent 3095052df0
commit ee1c493559
2 files changed
+341 -258

No files matched your search

@@ -7,8 +7,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -48,6 +45,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot 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. * 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 * 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 * stops. Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance
* row is anything from one line to a page and a fixed count is therefore a distance only by * the question is actually about: how far the reader can keep going before they run out. A row is
* accident. Eight rows was the number, and on a tool-heavy transcript eight rows is less than one * anything from one line to a page, so a count of rows is that distance only by accident. Eight
* screen: the reader reached the end of what was loaded on *every* swipe and waited a round trip * rows was the number, and on a tool-heavy transcript eight rows is less than one screen: the
* standing there, which is a list running out of transcript rather than a slow frame. * 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 * 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 * 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 // it is the question "where did I leave off" and the answer stops being interesting the
// moment the list is on screen. // moment the list is on screen.
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } 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: // Whether the history the saved position needs is still being fetched. Nothing is drawn while
// opening at the newest end and then travelling to the anchor is exactly the journey // 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. // 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) } 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 // 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 // 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 // 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 // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because
// it describes that session's rows and nothing else. // it describes that session's rows and nothing else.
val replies = remember(summary.id) { ParsedReplies() } val replies = remember(summary.id) { ParsedReplies() }
val listState = rememberLazyListState() val listState = rememberTranscriptScroll(summary.id)
// Whether the newest message is on screen right now. The list is laid out from the bottom // Whether the newest message is on screen right now. The content hangs from its newest end
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of // (see [TranscriptScroll]), so being there is being at scroll position zero. This is what the
// it. This is what the jump-to-newest button watches: it is about what the reader can see. // 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]. // It is also the gate on everything the list draws -- see [record].
val atNewest by remember { val atNewest by remember { derivedStateOf { listState.atNewest } }
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
// Transcript events that arrived while somebody was reading further back, in the order they // 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. // arrived, waiting for them to return to the newest end. See [record] for why.
var held by remember { mutableStateOf(listOf<SeqEvent>()) } 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 * Whether the row holding transcript position [seq] is loaded, with older history behind it.
* otherwise.
* *
* Read both here and by the `item` that draws that bubble, so a restored position and the list * "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row --
* cannot disagree about what is at which index. Anything else added above the rows later * [joinPages] welds the other half onto it when the page before it arrives, and it grows -- so
* belongs in this count. * 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.
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.
* *
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives, and this * The last row starting at or before [seq], rather than one starting exactly there: the events
* is the caller that makes it matter: the scroll listener below is started once and keyed on * behind a row can be regrouped between the save and the reopen -- a run of calls folds
* the list state, so a `rows` read inside it is the value from the *first* composition, which * differently when a page boundary moves, and two halves of a reply become one message -- and
* is empty. That saved a null anchor on every scroll -- indistinguishable from having been left * the reader's place is inside whichever row now holds that seq, not gone.
* 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.
* *
* 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? { fun anchorRow(seq: Long): Long? {
// 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.
val ordered = groupToolRuns(items) val ordered = groupToolRuns(items)
val row = ordered.indexOfLast { it.startSeq <= seq } val at = ordered.indexOfLast { it.startSeq <= seq }
if (row < 0) return null // Zero is the oldest loaded row, which is the half-row above; not found is -1.
return ordered.size - 1 - row + itemsAboveRows() 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, // 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 // 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. // conversation every time an active session was reopened.
var index = indexOfSeq(anchor.seq) while (moreHistory && anchorRow(anchor.seq) == null) {
while (moreHistory && (index == null || index >= listItemCount() - 1)) {
// The whole span in one request rather than a page at a time. `read_window` // 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 // 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 // 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. // the newest end has to be there for the list to be able to count to it.
val span = oldestSeq - anchor.seq + HISTORY_PAGE val span = oldestSeq - anchor.seq + HISTORY_PAGE
if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break 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 // Both writes before this coroutine yields, so the layout that first measures
// the one with every row in it *and* the requested position -- the list is drawn // these rows is also the one that puts the position back -- the transcript is
// where it was left rather than drawn and then moved. `requestScrollToItem` is // drawn where it was left rather than drawn and then moved. The pixels do not
// the form that is applied during a layout pass; see [holdTopEdge]. // exist until that measurement, which is why the anchor is handed to the layout
// rather than applied here; see [TranscriptScroll.pending].
restoring = false 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. // a session cleared from elsewhere.
index?.let { anchorRow(anchor.seq)?.let { rowSeq ->
listState.requestScrollToItem(it, anchor.offset) listState.restore(ScrollAnchor(rowSeq, anchor.offset))
restored = true restored = true
} }
} }
@@ -1059,7 +1033,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// slow but complete. Saying so beats silently showing nothing. // slow but complete. Saying so beats silently showing nothing.
streamError = e.message 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 // 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. // state the screen can draw, and a permanently blank one is not.
restoring = false restoring = false
@@ -1155,18 +1130,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
backlog.forEach { record(it) } 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. // 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 // 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 // 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. // "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 // There is no separate "did they choose to be here" flag any more, and there is nothing left
// list moves its own anchor to keep content still when a row arrives, so the position reports // for one to protect against. It existed because a keyed lazy list moved its own anchor when a
// itself as one item back for a frame every time a message lands. That frame is the whole // row arrived, so for one frame the position reported itself scrolled back from the newest end
// reason [followTail] is a remembered answer, and reading it here instead would stop new // when nobody had scrolled at all. This layout hangs from that end, so a row arriving does not
// messages being followed. // move the position: zero still means the newest message, during the frame it lands and after.
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow { if (listState.scroll.isScrollInProgress) null else listState.scroll.value }
if (listState.isScrollInProgress) null // The value `snapshotFlow` emits on collection is where the list sits before anybody
else listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset // 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.
// 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.
.drop(1) .drop(1)
.collect { settled -> .collect { settled ->
if (settled == null) return@collect if (settled == null || listState.settling) return@collect
saveScrollAnchor( saveScrollAnchor(
context, context,
summary.id, summary.id,
// Nothing to restore at the newest end, which is where a session with no // 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 // anchor opens anyway -- so the ordinary case costs a `remove` and no
// page-back on the way in. [followTail] covers the frame described above, // page-back on the way in.
// where a row has just arrived and the position has not caught up yet; if (settled == 0) null else listState.anchor(),
// `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) },
) )
} }
} }
// A new item at the newest end shifts every index by one, so the view // Reaching the far end of what is loaded fetches the page before it.
// 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.
// //
// Counted in list items rather than in transcript rows, because the rows are not all of it: // Measured in pixels of scroll, which is the unit the question is actually about: how far can
// the working indicator and a queued message are items too, and they arrive at exactly the // the reader keep going before they run out. Rows are the wrong unit for it and were the
// same end. Sibling to the paging trigger below, which is the same count read from the other // reason this went wrong twice -- a row is anything from one line to a screenful, so a cushion
// end for the same reason. // 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 // There is no correction beside this one any more. Following the newest message used to be an
// the condition beside it: a list must not be moved out from under a hand that is moving it. // effect here too, watching the item count and the viewport for a change and snapping back --
// The two disagree because [followTail] is deliberately a *remembered* answer, rewritten only // and it had to be told not to fire during a scroll, because a page of history landing mid-
// when a scroll settles -- so for the whole of a fling it still reports the newest end, where // fling looked exactly like a message arriving and threw the reader to the bottom. The content
// the reader was when they threw it. A page of history landing during that fling is a change // now hangs from the newest end, so an arriving message needs no correction and a page of
// in the count, and the correction meant for an insertion at the newest end then fired for // history moves nothing; see [TranscriptScroll].
// one at the oldest: the reader was thrown back to the bottom mid-flight. It could happen LaunchedEffect(listState, moreHistory) {
// only once, which is what made it look arbitrary rather than mechanical -- the snap settles snapshotFlow { listState.roomAbove to listState.viewport }
// the scroll at the newest end, so the next fling gets far enough to settle away from it, and .collect { (room, viewport) ->
// from then on [followTail] is false and nothing fires. Skipping the correction outright is if (!moreHistory || loadingHistory || viewport == 0) return@collect
// right rather than merely safe: the count can only have grown at the newest end while the val cushion = viewport * HISTORY_SCREENS
// reader is already there, because [record] holds everything else until they come back. if (room >= cushion) return@collect
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
loadingHistory = true loadingHistory = true
try { 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 // Deliberately not a loop: how much room a page bought is a fact about the
// as hundreds of text deltas that fold into a single message, and a run of // *layout* it produced, and no layout has happened yet inside this coroutine
// thirty-five tool calls is one row. So a page that lands can leave the far // -- so a loop would be re-reading the height from before the page it just
// end almost exactly where it was -- and since this is triggered by the far // fetched and would ask for the whole conversation. Letting the measurement
// end moving, nothing asks for the next one. The list then only loads when // answer means each page is checked against what it actually added, and a
// somebody drags it again, a page at a time, which is what "it only loads // page that folds into almost no new rows is followed by another because the
// when you touch the top" was, and later what the wait at every swipe was. // room genuinely did not grow.
// Counted from `items` rather than from `rows`, which is the loadOlderPage()
// 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
}
} catch (_: ApiException) { } catch (_: ApiException) {
// Leave `moreHistory` alone: the next scroll asks again. // Leave `moreHistory` alone: the next scroll asks again.
} finally { } finally {
@@ -1462,46 +1373,38 @@ 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 // The obvious arrangement -- oldest first, then scroll to the end -- opens at the top and
// -- opens at the top and travels the whole transcript to get // travels the whole transcript to get where it belongs. On an imported session that was
// where it belongs. On an imported session that is nine hundred // nine hundred rows measured before anything was readable, seen as the view visibly racing
// items measured before anything is readable, seen as the view // downward every time it opened. Scrolling the content in reverse removes the journey
// visibly racing downward every time it opened. // 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 // Drawn only once there is nothing left to put back, and measured throughout -- the
// it: the first frame is already the newest message, and older // heights are what a saved position is expressed in, so the rows have to be laid out
// ones are composed only as somebody scrolls back to them, which // before it can be applied. Held out of the drawing rather than out of the list, so there
// is also what makes history cheap on a long conversation. // is no frame in which the transcript is somewhere other than where it was left.
// Empty until a saved position has been put back -- see the opening effect. Held out of val settled = !restoring && !listState.settling
// 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()
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
LazyColumn( BoxWithConstraints(Modifier.fillMaxSize()) {
TranscriptColumn(
rows = rows,
state = listState, state = listState,
reverseLayout = true, // A scrollable child is measured with no height bound, so the content cannot
modifier = Modifier.fillMaxSize(), // ask how tall the visible area is; this is the only place that knows.
contentPadding = PaddingValues(16.dp), viewportHeight = maxHeight,
// Bottom, and it has to be said: `reverseLayout` defaults the arrangement to contentPadding = TRANSCRIPT_PADDING,
// `Bottom` on its own, but naming `spacedBy` replaces that default with spacing = TRANSCRIPT_SPACING,
// `spacedBy`'s own, which is `Top`. The arrangement is what places the content modifier =
// when there is less of it than the viewport -- so a session whose loaded rows Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
// did not fill the screen drew them against the *top*, leaving a gap between the below = {
// newest message and the box you type in, and no room to scroll the gap away. // The last thing in the transcript, because that is where they are in the
// Opening the keyboard shrank the viewport enough for the content to overflow it // session's reading of events: after everything it has taken in, and not
// and the list snapped down, which is what made it look like a scrolling fault // yet taken in themselves. What the session is *doing* about them is a
// 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]. // line below, in [SessionStatusRow].
if (!restoring && itemsAboveRows() > 0) { if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) -> waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true) CommandBubble(text, waiting = true)
@@ -1517,35 +1420,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
} }
} },
// Reversed to match the layout, so index 0 is the newest and ) { row ->
// 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 ->
val bounds = remember { RowBounds() } val bounds = remember { RowBounds() }
Box( Box(
Modifier.onGloballyPositioned { Modifier.onGloballyPositioned {
bounds.top = it.positionInRoot().y bounds.top = it.positionInRoot().y
bounds.height = it.size.height.toFloat() bounds.height = it.size.height.toFloat()
} }
.holdTopEdge(row.key, topEdgeHeld) { grew -> .holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(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,
)
}
) { ) {
when (row) { when (row) {
is TranscriptRow.Tools -> 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 // 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. // 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)) CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER))
} }
@@ -1723,22 +1610,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Surface( Surface(
// Instantly. An animated scroll travels the whole transcript, so the // Instantly. An animated scroll travels the whole transcript, so the
// further back somebody has read the longer this takes -- the one press // 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 // whose cost grows with how much there is to skip, which is backwards.
// list is keyed and composes only what it lands on, so going straight there //
// costs the same from anywhere. // Arriving there is all this has to do now. The newest end is where the
onClick = { // content hangs from, so being at it is the whole of following it, and there
// Says what it means as well as doing it. Pressing this is the reader // is no separate flag to set -- which is what this press used to forget,
// choosing the newest end, which is exactly what [followTail] records -- // landing the reader at the bottom with new messages not bringing the view
// and nothing else here would notice, because a snap moves the list // with them.
// inside one frame and the listener that decides [followTail] waits for a onClick = { scope.launch { listState.scroll.scrollTo(0) } },
// 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) }
},
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier =
@@ -0,0 +1,204 @@
package com.example.aiapp
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The transcript's scroll position, addressed the way the transcript itself is: a row, and how far
* that row has been scrolled past the top of the viewport.
*
* Pixels are the unit underneath because this list is a plain [Column] rather than a lazy one --
* every loaded row is composed, measured and kept, so there is a real height for every row whether
* or not it is on screen, and no index has to stand in for one. What that buys is that scrolling
* back over a message never rebuilds it: a Compose node that is still alive and whose constraints
* have not changed is not re-measured at all (`MeasurePassDelegate.remeasure` returns early), so
* the text keeps the layout it was given. A lazy list disposes a row the moment it leaves the
* viewport, and the markdown tree, the measured lines and the cached paragraph go with it -- which
* is the lag when scrolling back over history that has already been read.
*
* The content is laid out oldest-first and scrolled in reverse, which is what makes the two
* insertions this list actually gets free rather than corrected:
*
* - `reverseScrolling` places the content by its *end*, so `value == 0` is the newest message and
* growing the content at the far end -- a page of older history landing -- moves nothing on
* screen. `maxValue` grows and `value` is left alone.
* - A new message extends the same end the viewport is pinned to, so following it is not an effect
* that has to notice and correct: at `value == 0` the newest content is simply what is on screen.
* The keyboard opening is the same case from the other side -- the viewport shrinks, `maxValue`
* grows, and the newest message is still against the bottom.
*
* Both of those were scroll corrections in the lazy version, each with a comment explaining a way
* it had been seen to fire at the wrong moment.
*/
@Stable
class TranscriptScroll(internal val scroll: ScrollState) {
/**
* Where each row's top edge sits inside the content, by the seq that names it.
*
* Written from the layout pass as rows are placed, so it describes the layout that is on
* screen. Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason
* the anchor is: a tool run is renamed when the newest page starts somewhere new, and a
* position recorded against the old name is never found again.
*/
private val tops = HashMap<Long, Int>()
/**
* A position waiting to be put back, applied by the layout that first places its row.
*
* Held here rather than applied by whoever loaded the row because the pixels do not exist yet
* at that point: a plain column has no height for a row until it has been measured. Applying it
* from the placement is what makes the restore frame-exact -- the transcript is drawn where it
* was left rather than drawn at the newest end and then moved, which is a journey the reader
* would see. [settling] is the same fact asked the other way, and gates drawing.
*/
var pending: ScrollAnchor? by mutableStateOf(null)
private set
/** Whether a saved position is still being put back, so nothing should be drawn yet. */
val settling: Boolean
get() = pending != null
internal fun placed(seq: Long, top: Int) {
tops[seq] = top
pending?.let { anchor ->
if (anchor.seq != seq) return@let
scroll.dispatchRawDelta(
(scroll.maxValue - top - anchor.offset - scroll.value).toFloat()
)
pending = null
}
}
/** Everything these described is gone -- a stream reset, or a different session. */
fun clear() = tops.clear()
/** Whether the newest message is on screen. See the class comment: the newest end is zero. */
val atNewest: Boolean
get() = scroll.value == 0
/**
* How much unread history sits above the viewport, in pixels.
*
* This is the paging question asked in the unit it is actually about. Counting rows could not
* answer it: a row is anything from one line to a screenful, so "three rows back" is a
* different amount of reading depending on which three, and the count that mattered -- how far
* somebody can scroll before running out -- was never what was measured.
*/
val roomAbove: Int
get() = scroll.maxValue - scroll.value
/** The height of the visible area, 0 until the first measurement. */
val viewport: Int
get() = scroll.viewportSize
/** Where the reader is now, or null before anything has been laid out. */
fun anchor(): ScrollAnchor? {
val top = scroll.maxValue - scroll.value
// The row covering the top of the viewport: the last one that starts at or above it.
val at = tops.entries.filter { it.value <= top }.maxByOrNull { it.value } ?: return null
return ScrollAnchor(at.key, top - at.value)
}
/**
* Asks for [anchor] to be put back by the layout that places its row; see [pending].
*
* The caller is responsible for the row being loaded. Nothing here can wait for one that never
* arrives, and a position held open for it would leave the transcript blank -- so a row that is
* not in the transcript any more is [giveUp]'s case, not this one.
*/
fun restore(anchor: ScrollAnchor) {
pending = anchor
}
/** Draw where we are instead: the anchored row is not in this transcript any more. */
fun giveUp() {
pending = null
}
/**
* Scrolls by [pixels] without animating, from wherever the caller is.
*
* Used to hold a row's top edge still when it changes height. Growth already goes upward
* because the content hangs from its end, so the bottom edge is held for free and this is the
* other case; see `holdTopEdge`.
*/
fun by(pixels: Int) {
scroll.dispatchRawDelta(pixels.toFloat())
}
}
@Composable
fun rememberTranscriptScroll(key: Any?): TranscriptScroll {
val scroll = rememberScrollState()
return remember(key) { TranscriptScroll(scroll) }
}
/**
* Every loaded row, composed and kept. See [TranscriptScroll] for why this is not a lazy list.
*
* [viewportHeight] is passed in rather than measured here because a scrollable child is measured
* with no height bound at all, so the content cannot ask how tall the visible area is. It is what
* holds a conversation shorter than the screen against the bottom, where the composer is, instead
* of leaving a gap under it that cannot be scrolled away.
*/
@Composable
fun TranscriptColumn(
rows: List<TranscriptRow>,
state: TranscriptScroll,
viewportHeight: Dp,
contentPadding: PaddingValues,
spacing: Dp,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
row: @Composable (TranscriptRow) -> Unit,
) {
Column(
modifier
.verticalScroll(state.scroll, reverseScrolling = true)
.padding(contentPadding)
.heightIn(min = viewportHeight)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom),
) {
rows.forEach { item ->
// Keyed so that a row keeps its composition -- and so the state inside it, an open
// tool call or an expanded reply, stays with the row rather than with the position.
key(item.key) {
Column(
Modifier.fillMaxWidth().onPlaced {
state.placed(item.startSeq, it.positionInParent().y.toInt())
}
) {
row(item)
}
}
}
below()
}
}
/** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)