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>