Files
ai-app/app
irisandClaude Opus 5 75e02b3857 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>
2026-08-30 19:46:45 -04:00
..