#1932·shadcn-vue

[Bug]: MessageScroller `autoScroll` stops following while the reader is still at the live edge

Author: phojieCreated Aug 11, 2026Updated Aug 11, 2026

Reproduction

Reproduces on this repo's own docs demo: https://www.shadcn-vue.com/docs/components/message-scroller#following-the-live-edge — the MessageScrollerStreamingDemo card. Send a message, stay at the bottom, and give the wheel a single tick downward while the reply streams. The view stops following even though the pointer never carried the reader away from the live edge.

The root cause is visible in the registry source without running anything — apps/v4/registry/new-york-v4/ui/message-scroller/useMessageScroller.ts, userScrollIntent() — see below.

Describe the bug

autoScroll releases the view and then never re-arms, leaving the reader behind mid-stream even though they never scrolled away. Recovering requires clicking the scroll button. Two independent causes; either one alone reproduces it.

Both contradict the component's documented behaviour. From message-scroller.md, on following the live edge: "Scrolling away from the live edge … releases the view". And the autoScroll prop row: "Follow the live edge while the reader is pinned to the bottom."

Neither holds once the reader touches the wheel.

Cause 1 — a gesture that moved nothing releases the view

userScrollIntent() is wired to @wheel, @touchmove and @keydown on the viewport and drops out of following-bottom without checking direction, or whether the viewport actually moved:

typescript
function userScrollIntent() {
  if (
    mode === 'following-bottom'
    || mode === 'anchored-to-message'
    || mode === 'settling-jump'
  ) {
    streamingTurn = null
    mode = 'free-scrolling'
  }
}

A downward wheel tick at the bottom, ArrowDown at the bottom, or an inertial scroll settling all fire this. Measured with the viewport pinned at the bottom and one downward tick:

wheel deltaY:120 → viewport moved 0px
  append → 88px below the fold
  append → 176px below the fold
  ↓ button → follows again

The docs condition release on scrolling away from the live edge. Direction is never consulted.

Cause 2 — following can never re-arm during a stream

Re-entry into following-bottom only happens when a measurement catches the reader within scrollEdgeThreshold (default 8px) of the end. Each streamed line grows the content 12–30px before that measurement runs, so the gap is never small enough at the moment it is sampled — and no other event fires while the reader sits still:

gap while sitting at the bottom mid-stream:  0 → 12 → 12 → 12 → 32 → 32

So even scrolling manually back to the bottom does not resume following while content is still arriving. The reader is stuck until they use a scroll button.

Suggested fix

Two small changes.

Direction-aware release. Pass the gesture direction into userScrollIntent and only release following-bottom when the gesture carries toward the start — deltaY < 0 for wheel, a downward finger drag for touch, and ArrowUp / PageUp / Home / Shift+Space for keys. The anchored-to-message and settling-jump branches should keep releasing on any gesture; only the bottom-following branch needs the test.

Record the pre-growth edge state. Set a flag in commitScrollState() from the settled measurement:

typescript
atLiveEdge = !measured.end

then re-enter following from that flag rather than from a live re-measurement:

typescript
function resumeFollowingFromLiveEdge() {
  if (!autoScroll() || mode !== 'free-scrolling' || !atLiveEdge)
    return false
  mode = 'following-bottom'
  return scrollToEnd({ behavior: 'auto' })
}

Call it from the content-change and resize paths. Clearing atLiveEdge on release is what keeps a reader who genuinely scrolled away from being yanked back by a growth that lands before their scroll event.

After both:

gap while sitting at the bottom mid-stream:  0 → 12 → 0 → 0 → 0 → 0

Verified against

These numbers were measured in una-ui, where this engine is carried over verbatim — the file differs from the registry source only in type-name prefixes and quote style, so the behaviour under test is yours, not a reinterpretation.

case result
no-op wheel down at the bottom → append follows
ArrowDown at the bottom → append follows
genuine scroll up → append released, reader held in place
return to the edge → append resumes following
mid-stream: scroll away held, no yank-back
mid-stream: return to the edge resumes
prepend, reader mid-history position preserved to the pixel
prepend, reader at the live edge unchanged from baseline
anchored turn pin + spacer, scroll button unchanged

System Info

bash
  System:
    OS: macOS 26.5.2
    CPU: (14) arm64 Apple M3 Max
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 22.18.0
    pnpm: 10.29.3
  Browsers:
    Chrome: 151.0.7922.108
    Safari: 26.5.2

Contributes

  • I am willing to submit a PR to fix this issue

Happy to open it against apps/v4/registry/new-york-v4/ui/message-scroller if the approach looks right.