75e02b3857e9ef0550fa9c680445b0b27c14e880
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>
Languages
Rust
54%
Kotlin
43.6%
Shell
2.4%