Anchor a saved scroll position on a seq, not on a row's name

ai-app-2 found the reason a session Iris had scrolled in took a while to
open, and it is a defect in what I shipped this morning. A `ScrollAnchor`
stored the list's own row key. That key looks stable and is not: a tool
row is named after its run, `joinPages` gives a run the name its *newest*
half carries, and the newest half is whatever the newest page happened to
start with. The newest page is the last eighty events, so the name is
fixed for an idle session -- which is why this passed on the emulator --
and changes the moment the session says anything. An active session
therefore renamed its tool runs on every reopen, the anchor was never
found, and the restore paged backwards until there was no history left,
i.e. to the first event of the conversation.

So the anchor is a sequence number now, taken from the oldest event
behind the row. That is the server's own numbering: assigned once, never
moved, the same on every device. The restore finds the last row starting
at or before it, so it still lands correctly when grouping has changed
underneath it -- a run folding differently, two halves of a reply
becoming one message -- and the page-back loop gains an exact stop,
because `oldestSeq` walks strictly backwards and a row always matches
once the window passes the anchor. It cannot run to seq 1 any more.

`TranscriptRow` now carries `startSeq` beside `key`, and the two are
documented against each other: `key` is the list's identity and a display
decision, `startSeq` is a place in the conversation. Anything that has to
point at a place and find it later uses the second.

Two more found while testing this, both the same shape -- a listener
waiting for a scroll to *end* never runs when the list moves inside one
frame:

- The anchor was read from `rows`, which a `LaunchedEffect(listState)`
  captures from the first composition, where it is empty. Every scroll
  saved a null anchor, indistinguishable from being left at the newest
  end, so the position was silently never recorded at all. It reads the
  live state now, as the paging code beside it already warned it must.
  The anchor is also written from the settled *position* rather than from
  the scroll flag, because Jump to latest snaps within one frame: it left
  the old position recorded, so pressing the control that means "take me
  to the end" and coming back put the reader where they had been.

- Jump to latest did not set `followTail` either, which is the same hole
  in the sibling and predates today: the view landed at the bottom with
  following switched off, and the next message did not bring it along.
  The button states what it means now. `followTail` itself stays on the
  scroll settle, deliberately -- a keyed list moves its own anchor when a
  row arrives, so the position reports itself as scrolled back for a
  frame every time a message lands, which is the whole reason that value
  is remembered rather than read.

Verified against an echo session grown to 1,327 events between saving the
position and reopening, so the newest window slid and the runs were
renamed: the same row is on screen before and after, it appears 419ms in
and never moves, and a 200-piece streamed reply still follows the bottom
after a jump.

The threading of `loadOlderPage`'s fold is ai-app-2's, not touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 19:46:45 -04:00
1 parent c782a1264b
commit 75e02b3857
3 files changed
+127 -59

No files matched your search

@@ -8,16 +8,19 @@ private const val ANCHORS = "session-scroll"
/**
* Where a session's transcript was left, so reopening it lands where reading stopped.
*
* Named by the *row* rather than by an index, because an index means nothing across a reopen: the
* transcript is fetched newest-first and a session that has said anything since has renumbered
* every position. The row's own key survives all of it -- it is the same value the list is keyed
* by, which is what already stops the view moving when history pages in.
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first and a session that has said anything since has renumbered every position.
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
* the name of its newest half, and the newest half is whatever the newest page happened to start
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
*
* [offset] is how far into that row the viewport starts, in pixels, and is the reason this is a
* pair rather than a key: a reader stopped halfway down a long tool output is put back halfway down
* it.
* pair rather than a bare seq: a reader stopped halfway down a long tool output is put back halfway
* down it.
*/
data class ScrollAnchor(val key: String, val offset: Int)
data class ScrollAnchor(val seq: Long, val offset: Int)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
@@ -29,10 +32,9 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
?: return null
// Offset first so the split is unambiguous: a row key is an arbitrary string and may contain
// anything, where the offset is digits.
val offset = stored.substringBefore(':').toIntOrNull() ?: return null
return ScrollAnchor(stored.substringAfter(':'), offset)
val seq = stored.substringBefore(':').toLongOrNull() ?: return null
val offset = stored.substringAfter(':').toIntOrNull() ?: return null
return ScrollAnchor(seq, offset)
}
/**
@@ -46,6 +48,6 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
if (anchor == null) remove(sessionId)
else putString(sessionId, "${anchor.offset}:${anchor.key}")
else putString(sessionId, "${anchor.seq}:${anchor.offset}")
}
}
@@ -98,14 +98,6 @@ private const val HISTORY_LOOKAHEAD = 8
*/
private const val HISTORY_PAGE = 800
/**
* The list key of the bubble holding what has been sent and not read yet.
*
* Named rather than written at the `item` that draws it, because a saved scroll position stores
* whatever key it was left on and this is one of the values that can be.
*/
private const val QUEUED_KEY = "queued"
/**
* Which row was asked to hold its top edge, and how tall it was when it last measured.
*
@@ -823,19 +815,37 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
*/
fun itemsAboveRows() = if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) 1 else 0
/**
* Where the row named [key] sits in the list, or null when it is not loaded.
*
* Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives.
*/
/** Everything the list draws, in items rather than in rows. See [itemsAboveRows]. */
fun listItemCount() = groupToolRuns(items).size + itemsAboveRows()
fun indexOfKey(key: String): Int? {
// The bubble is not a row, and it is above all of them.
if (key == QUEUED_KEY) return if (itemsAboveRows() > 0) 0 else null
val row = groupToolRuns(items).asReversed().indexOfFirst { it.key.toString() == key }
return if (row < 0) null else row + 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
* 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.
*
* Computed from `items` rather than from `rows`, as [rowAt] is and for the same reason.
*/
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.
val ordered = groupToolRuns(items)
val row = ordered.indexOfLast { it.startSeq <= seq }
if (row < 0) return null
return ordered.size - 1 - row + itemsAboveRows()
}
/**
@@ -935,15 +945,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// the one worth restoring -- somebody who read to the bottom has no anchor at all --
// and the cost was already paid on the way down there.
savedAnchor?.let { anchor ->
// Pages until the row is loaded and has something older behind it. The oldest
// loaded row is a half-row: `joinPages` welds the other half onto it when the
// page behind it arrives, and it grows -- so anchoring into one puts the reader
// where they were only until the next page lands. Any row that is not the oldest
// is final. Landing a screen and a half out was what this cost.
var index = indexOfKey(anchor.key)
// Pages until the anchor's row is loaded and has something older behind it. The
// oldest loaded row is a half-row: `joinPages` welds the other half onto it when
// the page behind it arrives, and it grows -- so anchoring into one puts the
// reader where they were only until the next page lands, which landed a screen
// and a half out. Any row that is not the oldest is final.
//
// This terminates because `oldestSeq` walks strictly backwards and the anchor is
// a seq: once the window reaches past it, some loaded row starts at or before it
// and [indexOfSeq] answers. Keying on the row's *name* instead could not promise
// 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)) {
if (!loadOlderPage()) break
index = indexOfKey(anchor.key)
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
@@ -1059,34 +1076,52 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
}
// 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. Written
// where a scroll settles, below.
// 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 at the newest end, and so as an instruction to forget where the reader
// was. That wiped every saved position on the way in, before the restore below could
// use it. Only the transitions after it are scrolls.
// 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) return@collect
followTail = atNewest
// Written where the answer settles, for the same reason [followTail] is: mid-fling
// is not where anybody left off. Cleared at the newest end rather than recorded,
// because that is where a session with nothing to restore opens anyway -- so the
// ordinary case costs a `remove` and no page-back on the way in.
.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
// *programmatic* scroll moves the list within one frame, so `isScrollInProgress` never
// observably changes and anything waiting for a settle never runs. Jump to latest is exactly
// 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.
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.
.drop(1)
.collect { settled ->
if (settled == null) return@collect
saveScrollAnchor(
context,
summary.id,
if (atNewest) null
else
listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { first ->
ScrollAnchor(
first.key.toString(),
listState.firstVisibleItemScrollOffset,
)
},
// 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) },
)
}
}
@@ -1378,7 +1413,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow].
if (!restoring && itemsAboveRows() > 0) {
item(key = QUEUED_KEY) {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
@@ -1562,7 +1597,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// whose cost grows with how much there is to skip, which is backwards. The
// list is keyed and composes only what it lands on, so going straight there
// costs the same from anywhere.
onClick = { scope.launch { listState.scrollToItem(0) } },
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) }
},
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
@@ -60,9 +60,25 @@ sealed class TranscriptRow {
*/
abstract val key: Any
/**
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision -- a tool row is named after its run, and a run takes its name from whichever call
* was first when it was folded, which changes as pages arrive. A seq is the server's own
* numbering: it is assigned once, never moves, and means the same thing to every device. So
* anything that has to point at a place in the conversation and still find it later -- a saved
* scroll position is the one -- points with this, and anything that has to identify a row
* within one composition uses [key].
*/
abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() {
override val key: Any
get() = (item as? TranscriptItem.ToolRun)?.runId ?: item.seq
override val startSeq: Long
get() = item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
@@ -73,6 +89,9 @@ sealed class TranscriptRow {
override val key: Any
get() = id
override val startSeq: Long
get() = calls.first().seq
}
}