Skip to content

Commit db5566d

Browse files
committed
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.
1 parent 91666b5 commit db5566d

1 file changed

Lines changed: 44 additions & 0 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ 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+
* A scrollbar-drag detach is only honored if a real user gesture occurred within
9+
* this window. Virtualizers (react-virtual) programmatically move `scrollTop` to
10+
* keep content stable when a measured row's size changes — including
11+
* the transient height *shrinks* a streaming markdown renderer emits as it re-parses
12+
* each token. Without this guard, that upward programmatic scroll is misread as the
13+
* user scrolling away and auto-scroll detaches mid-stream.
14+
*/
15+
const USER_GESTURE_WINDOW = 250
716

817
interface UseAutoScrollOptions {
918
scrollOnMount?: boolean
@@ -32,6 +41,8 @@ export function useAutoScroll(
3241
const touchStartYRef = useRef(0)
3342
const rafIdRef = useRef(0)
3443
const scrollOnMountRef = useRef(scrollOnMount)
44+
const pointerDownRef = useRef(false)
45+
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
3546

3647
const scrollToBottom = useCallback(() => {
3748
const el = containerRef.current
@@ -63,27 +74,52 @@ export function useAutoScroll(
6374
userDetachedRef.current = true
6475
}
6576

77+
const markGesture = () => {
78+
lastUserGestureAtRef.current = performance.now()
79+
}
80+
6681
const onWheel = (e: WheelEvent) => {
82+
markGesture()
6783
if (e.deltaY < 0) detach()
6884
}
6985

7086
const onTouchStart = (e: TouchEvent) => {
87+
markGesture()
7188
touchStartYRef.current = e.touches[0].clientY
7289
}
7390

7491
const onTouchMove = (e: TouchEvent) => {
92+
markGesture()
7593
if (e.touches[0].clientY > touchStartYRef.current) detach()
7694
}
7795

96+
const onPointerDown = () => {
97+
pointerDownRef.current = true
98+
markGesture()
99+
}
100+
const onPointerUp = () => {
101+
pointerDownRef.current = false
102+
}
103+
const onKeyDown = markGesture
104+
78105
const onScroll = () => {
79106
const { scrollTop, scrollHeight, clientHeight } = el
80107
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
81108
const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD
82109

110+
// Only a genuine, recent user gesture (scrollbar drag, keyboard) may detach via
111+
// a scroll-position delta. A programmatic upward scroll — e.g. a virtualizer
112+
// re-pinning content on a row-size shrink — has no preceding gesture and must
113+
// not be mistaken for the user scrolling away.
114+
const userDriven =
115+
pointerDownRef.current ||
116+
performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW
117+
83118
if (distanceFromBottom <= threshold) {
84119
stickyRef.current = true
85120
userDetachedRef.current = false
86121
} else if (
122+
userDriven &&
87123
scrollTop < prevScrollTopRef.current &&
88124
scrollHeight <= prevScrollHeightRef.current
89125
) {
@@ -126,6 +162,10 @@ export function useAutoScroll(
126162
el.addEventListener('touchmove', onTouchMove, { passive: true })
127163
el.addEventListener('scroll', onScroll, { passive: true })
128164
el.addEventListener('animationstart', onAnimationStart)
165+
el.addEventListener('pointerdown', onPointerDown, { passive: true })
166+
el.addEventListener('keydown', onKeyDown, { passive: true })
167+
window.addEventListener('pointerup', onPointerUp, { passive: true })
168+
window.addEventListener('pointercancel', onPointerUp, { passive: true })
129169

130170
const observer = new MutationObserver(onMutation)
131171
observer.observe(el, { childList: true, subtree: true, characterData: true })
@@ -136,6 +176,10 @@ export function useAutoScroll(
136176
el.removeEventListener('touchmove', onTouchMove)
137177
el.removeEventListener('scroll', onScroll)
138178
el.removeEventListener('animationstart', onAnimationStart)
179+
el.removeEventListener('pointerdown', onPointerDown)
180+
el.removeEventListener('keydown', onKeyDown)
181+
window.removeEventListener('pointerup', onPointerUp)
182+
window.removeEventListener('pointercancel', onPointerUp)
139183
observer.disconnect()
140184
cancelAnimationFrame(rafIdRef.current)
141185
if (stickyRef.current) scrollToBottom()

0 commit comments

Comments
 (0)