Context
Part of #628 — chat UI polish.
This slice replaces the instant virtualizer.scrollToEnd() calls with a custom 180ms ease-out RAF scroll animation when following new content at the bottom. Other scroll paths (history reveal, prepend-anchor, jump-to-index) remain instant. Independent of the dot/rail work.
Acceptance Criteria
Key Decisions
| Decision |
Rationale |
| Custom RAF loop, 180ms ease-out |
Native behavior: smooth is 500ms+ and browser-dependent; custom gives snappy control and deterministic timing |
| Only bottom-follow scrolls animate |
History jumps, prepend-anchor, and reveal-message need instant positioning — smooth would feel laggy there |
1 - (1 - t)^3 easing (ease-out cubic) |
Fast start, gentle settle — feels responsive without jarring stop |
| Cancel via event listeners on scroll container |
wheel, touchstart, pointerdown — matches the existing hasScrollGesture() detection |
| Entrance + scroll concurrent |
No sequencing — CSS animations fire on mount independently, scroll moves viewport. Feels natural. |
Reduced-motion check via matchMedia |
Single prefers-reduced-motion query; if active, skip animation and use instant scroll |
Testing Decisions
- Unit test:
smoothScrollHelper(startY, targetY, elapsed, duration) returns correct interpolated value at 0%, 50%, 100%
- Unit test: returns
targetY when elapsed >= duration (clamps)
- Integration: during streaming, scroll position is between start and target during animation (not instant jump)
- Integration: dispatching
wheel event mid-animation stops at current position (does not complete)
- Regression:
revealMessage and scrollToIndex calls still jump instantly (not animated)
Constraints & Invariants
- Must not interfere with
shouldAdjustScrollPositionOnItemSizeChange (item-resize anchoring is a different path)
- Prepend-anchor system (
applyPrependAnchor) uses direct scrollTop writes — bypasses scrollToFn, remains instant
- The virtualizer
followOnAppend still triggers scrollToEnd — the smooth version just makes it animate
- Only one animation in flight at a time — new
scrollToEnd call during animation restarts with current position as startY
Prior Art
packages/app/src/pages/session/timeline/message-timeline.tsx — current elementScroll override in scrollToFn, maybeAnchorBottom(), anchorResizedBottom()
- The existing
hasScrollGesture() detection pattern (already tracks user intent to leave bottom)
- "Jump to latest" button — currently uses instant
scrollToEnd(), should switch to smooth on click
Context
Part of #628 — chat UI polish.
This slice replaces the instant
virtualizer.scrollToEnd()calls with a custom 180ms ease-out RAF scroll animation when following new content at the bottom. Other scroll paths (history reveal, prepend-anchor, jump-to-index) remain instant. Independent of the dot/rail work.Acceptance Criteria
prefers-reduced-motiondisables smooth scroll (instant fallback)Key Decisions
behavior: smoothis 500ms+ and browser-dependent; custom gives snappy control and deterministic timing1 - (1 - t)^3easing (ease-out cubic)wheel,touchstart,pointerdown— matches the existinghasScrollGesture()detectionmatchMediaprefers-reduced-motionquery; if active, skip animation and use instant scrollTesting Decisions
smoothScrollHelper(startY, targetY, elapsed, duration)returns correct interpolated value at 0%, 50%, 100%targetYwhen elapsed >= duration (clamps)wheelevent mid-animation stops at current position (does not complete)revealMessageandscrollToIndexcalls still jump instantly (not animated)Constraints & Invariants
shouldAdjustScrollPositionOnItemSizeChange(item-resize anchoring is a different path)applyPrependAnchor) uses directscrollTopwrites — bypassesscrollToFn, remains instantfollowOnAppendstill triggersscrollToEnd— the smooth version just makes it animatescrollToEndcall during animation restarts with current position asstartYPrior Art
packages/app/src/pages/session/timeline/message-timeline.tsx— currentelementScrolloverride inscrollToFn,maybeAnchorBottom(),anchorResizedBottom()hasScrollGesture()detection pattern (already tracks user intent to leave bottom)scrollToEnd(), should switch to smooth on click