Skip to content

fix(app): smooth scroll — custom RAF scroll replacing instant teleport #631

Description

@jeonghun-jj-lee

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

  • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
  • Smooth scroll cancels immediately on user scroll gesture (wheel, touchstart, pointerdown)
  • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
  • prefers-reduced-motion disables smooth scroll (instant fallback)

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

Metadata

Metadata

Labels

afkImplementable without human interactionarea:ui

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions