Skip to content

Commit e48c960

Browse files
authored
fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during streaming (#5093)
* fix(chat): keep autoscroll pinned when the virtualizer re-scrolls during streaming The sticky-scroll detach heuristic (scrollTop drops while scrollHeight doesn't grow) could not distinguish a user scrollbar drag from a programmatic scroll. react-virtual re-pins content by moving scrollTop whenever a measured row's size changes — including the transient height shrinks streamdown emits as it re-parses each streaming token — so the hook misread those upward programmatic scrolls as the user scrolling away and detached mid-stream. Gate the scroll-delta detach branch behind a genuine recent user gesture (pointerdown/up tracking + wheel/touch/keydown stamp). Programmatic scrolls have no preceding gesture, so they no longer detach; scrollbar drag, wheel, and keyboard detach are preserved. * fix(chat): address review — reset pointer ref on teardown, stop wheel/touch opening detach window - Reset pointerDownRef in effect cleanup so a pointer held through teardown (e.g. dragging the scrollbar as a stream finishes) can't leak a stuck-true ref into the next session and detach on the first programmatic re-pin. - Wheel-up and touch-drag already detach directly, so the onScroll delta heuristic only needs to authorize scrollbar drag (pointerDownRef) and keyboard. Stop stamping the gesture window on wheel/touch, which otherwise let a harmless downward wheel open a 250ms window where a virtualizer shrink could falsely detach. * fix(chat): scope detach authorization to real scroll gestures; TSDoc comments - onPointerDown only marks an active drag when the press targets the scroll container itself (the scrollbar), not its content, so a text-selection drag on a message can't authorize a detach during a programmatic re-pin. - Reset lastUserGestureAtRef on teardown alongside pointerDownRef so neither a held pointer nor a late keydown can leak across streaming sessions. - Convert the hook's inline comments to TSDoc on the relevant declarations per codebase conventions. * fix(chat): only upward scroll keys authorize a keyboard detach onKeyDown stamped the gesture window on any bubbling key, so an unrelated keypress within USER_GESTURE_WINDOW of a programmatic virtualizer re-pin could satisfy userDriven and detach mid-stream. Filter to the upward scroll keys (ArrowUp, PageUp, Home, Shift+Space), mirroring the wheel handler's upward-only rule, so only a genuine upward keyboard scroll authorizes detach.
1 parent 6cbaf42 commit e48c960

1 file changed

Lines changed: 80 additions & 9 deletions

File tree

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ import { useCallback, useEffect, useRef } from 'react'
44
const STICK_THRESHOLD = 30
55
/** User must scroll back to within this distance to re-engage auto-scroll. */
66
const REATTACH_THRESHOLD = 5
7+
/**
8+
* An upward keyboard scroll ({@link SCROLL_UP_KEYS}) only emits `scroll` events, so
9+
* its detach is honored when it lands within this window of the `keydown`. Wheel and
10+
* touch detach directly via their own handlers, and scrollbar drags are tracked
11+
* through {@link pointerDownRef}, so neither feeds this window.
12+
*
13+
* The guard exists because virtualizers (react-virtual) programmatically move
14+
* `scrollTop` to keep content stable when a measured row's size changes —
15+
* including the transient height *shrinks* a streaming markdown renderer emits as
16+
* it re-parses each token. Without it, that upward programmatic scroll is misread
17+
* as the user scrolling away and auto-scroll detaches mid-stream.
18+
*/
19+
const USER_GESTURE_WINDOW = 250
20+
/**
21+
* Keys that scroll the viewport upward. Only these authorize a keyboard detach,
22+
* mirroring the wheel handler's upward-only ({@link WheelEvent.deltaY} < 0) rule,
23+
* so an unrelated keypress can't open the detach window. `Shift`+`Space` (handled
24+
* in the listener) is the other upward shortcut; plain `Space` pages down.
25+
*/
26+
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
727

828
interface UseAutoScrollOptions {
929
scrollOnMount?: boolean
@@ -13,9 +33,10 @@ interface UseAutoScrollOptions {
1333
* Manages sticky auto-scroll for a streaming chat container.
1434
*
1535
* Stays pinned to the bottom while content streams in. Detaches immediately
16-
* on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
17-
* the user must scroll back to within {@link REATTACH_THRESHOLD} of the
18-
* bottom to re-engage.
36+
* on any upward user gesture (wheel, touch, scrollbar drag, keyboard). Once
37+
* detached, the user must scroll back to within {@link REATTACH_THRESHOLD} of
38+
* the bottom to re-engage. Each streaming start re-seeds stickiness from the
39+
* current scroll position, so a user who scrolled up beforehand stays put.
1940
*
2041
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
2142
* for imperative use after layout-changing events like panel expansion.
@@ -32,6 +53,18 @@ export function useAutoScroll(
3253
const touchStartYRef = useRef(0)
3354
const rafIdRef = useRef(0)
3455
const scrollOnMountRef = useRef(scrollOnMount)
56+
/**
57+
* Whether the user is actively dragging the scrollbar — a pointer press on the
58+
* container itself rather than its content. Reset on teardown so a pointer held
59+
* as one stream ends can't leak into the next session and authorize a detach.
60+
*/
61+
const pointerDownRef = useRef(false)
62+
/**
63+
* Timestamp of the last keyboard scroll, the only detach gesture that emits no
64+
* wheel/touch/pointer signal. Gates {@link USER_GESTURE_WINDOW}; reset on teardown
65+
* so a keypress near a stream's end can't carry into the next session.
66+
*/
67+
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
3568

3669
const scrollToBottom = useCallback(() => {
3770
const el = containerRef.current
@@ -49,7 +82,6 @@ export function useAutoScroll(
4982
const el = containerRef.current
5083
if (!el) return
5184

52-
// Don't jump if the user scrolled up — keep their position.
5385
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
5486
const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
5587
stickyRef.current = isNearBottom
@@ -75,15 +107,43 @@ export function useAutoScroll(
75107
if (e.touches[0].clientY > touchStartYRef.current) detach()
76108
}
77109

110+
/**
111+
* A scrollbar press targets the scroll container itself; a press on message
112+
* content targets a descendant. Only the former is a scroll gesture, so a
113+
* text-selection drag on content can't authorize a detach.
114+
*/
115+
const onPointerDown = (e: PointerEvent) => {
116+
if (e.target === el) pointerDownRef.current = true
117+
}
118+
const onPointerUp = () => {
119+
pointerDownRef.current = false
120+
}
121+
const onKeyDown = (e: KeyboardEvent) => {
122+
if (SCROLL_UP_KEYS.has(e.key) || (e.key === ' ' && e.shiftKey)) {
123+
lastUserGestureAtRef.current = performance.now()
124+
}
125+
}
126+
127+
/**
128+
* Re-engages when the user returns near the bottom, and detaches on an upward
129+
* scroll — but only a genuine user scroll qualifies: an active scrollbar drag
130+
* (pointer held) or a recent keyboard scroll. A programmatic upward scroll, e.g.
131+
* a virtualizer re-pinning content on a row-size shrink, has neither and must not
132+
* be mistaken for the user scrolling away.
133+
*/
78134
const onScroll = () => {
79135
const { scrollTop, scrollHeight, clientHeight } = el
80136
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
81137
const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD
138+
const userDriven =
139+
pointerDownRef.current ||
140+
performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW
82141

83142
if (distanceFromBottom <= threshold) {
84143
stickyRef.current = true
85144
userDetachedRef.current = false
86145
} else if (
146+
userDriven &&
87147
scrollTop < prevScrollTopRef.current &&
88148
scrollHeight <= prevScrollHeightRef.current
89149
) {
@@ -105,11 +165,12 @@ export function useAutoScroll(
105165
rafIdRef.current = requestAnimationFrame(guardedScroll)
106166
}
107167

108-
// CSS-driven height animations (e.g. Radix Collapsible expanding
109-
// mid-stream) grow scrollHeight without triggering MutationObserver,
110-
// so auto-scroll stops following. When any animation starts in the
111-
// container, follow rAF for a short window so the container stays
112-
// pinned to the bottom while the animation runs.
168+
/**
169+
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
170+
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
171+
* following. When any animation starts in the container, follow rAF for a short
172+
* window so the container stays pinned to the bottom while the animation runs.
173+
*/
113174
const onAnimationStart = () => {
114175
if (!stickyRef.current) return
115176
const until = performance.now() + 500
@@ -126,6 +187,10 @@ export function useAutoScroll(
126187
el.addEventListener('touchmove', onTouchMove, { passive: true })
127188
el.addEventListener('scroll', onScroll, { passive: true })
128189
el.addEventListener('animationstart', onAnimationStart)
190+
el.addEventListener('pointerdown', onPointerDown, { passive: true })
191+
el.addEventListener('keydown', onKeyDown, { passive: true })
192+
window.addEventListener('pointerup', onPointerUp, { passive: true })
193+
window.addEventListener('pointercancel', onPointerUp, { passive: true })
129194

130195
const observer = new MutationObserver(onMutation)
131196
observer.observe(el, { childList: true, subtree: true, characterData: true })
@@ -136,8 +201,14 @@ export function useAutoScroll(
136201
el.removeEventListener('touchmove', onTouchMove)
137202
el.removeEventListener('scroll', onScroll)
138203
el.removeEventListener('animationstart', onAnimationStart)
204+
el.removeEventListener('pointerdown', onPointerDown)
205+
el.removeEventListener('keydown', onKeyDown)
206+
window.removeEventListener('pointerup', onPointerUp)
207+
window.removeEventListener('pointercancel', onPointerUp)
139208
observer.disconnect()
140209
cancelAnimationFrame(rafIdRef.current)
210+
pointerDownRef.current = false
211+
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
141212
if (stickyRef.current) scrollToBottom()
142213
}
143214
}, [isStreaming, scrollToBottom])

0 commit comments

Comments
 (0)