[Bug]: MessageScroller `autoScroll` stops following while the reader is still at the live edge
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:
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 againThe 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 → 32So 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:
atLiveEdge = !measured.endthen re-enter following from that flag rather than from a live re-measurement:
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 → 0Verified 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
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.2Contributes
- 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.
Source: unovue/shadcn-vue