Make stick-to-bottom intent the single source of truth in MessageList and
drive onAtBottomChange from intent, not raw scroll position. A measurement
reflow can no longer flip the intent off (RC1), the second scrollPositions
writer no longer persists a drifting topmost index while stuck (RC2), and a
pin-on-rows layout effect re-pins through the two-phase data swap (RC3).
- scrollController: add tested nextStickIntent() state machine
- MessageList: input-event-based unstick (wheel/key/touch + scrollbar drag),
reveal after 2 stable frames, tabIndex for keyboard nav, remove debug overlay
- ConversationPage: reuse resolveInitialAnchor; harden handleRangeChanged
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Data from the on-screen overlay showed SCROLLABLE=YES but scrollTop=84/0 and atBottom=false: the initial pin happened before rows finished measuring, then a measurement reflow fired onScroll with the stale (top) scrollTop, flipping atBottom=false and disabling re-pinning, so the list never reached the bottom. Fix: a ResizeObserver re-pins to the true bottom as the content measures/grows; a programmatic-scroll guard makes onScroll ignore the scrolls we cause (so measurement reflows no longer flip the stick intent); overflow-anchor:none so the browser doesn't fight us; reveal waits for the height to settle. Overlay kept for one more verification pass.
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.