@@ -5,12 +5,16 @@ const STICK_THRESHOLD = 30
55/** User must scroll back to within this distance to re-engage auto-scroll. */
66const REATTACH_THRESHOLD = 5
77/**
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.
8+ * A keyboard-driven scroll (PageUp, arrows) only emits `scroll` events, so its
9+ * detach is honored when it lands within this window of a `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.
1418 */
1519const USER_GESTURE_WINDOW = 250
1620
@@ -74,43 +78,37 @@ export function useAutoScroll(
7478 userDetachedRef . current = true
7579 }
7680
77- const markGesture = ( ) => {
78- lastUserGestureAtRef . current = performance . now ( )
79- }
80-
8181 const onWheel = ( e : WheelEvent ) => {
82- markGesture ( )
8382 if ( e . deltaY < 0 ) detach ( )
8483 }
8584
8685 const onTouchStart = ( e : TouchEvent ) => {
87- markGesture ( )
8886 touchStartYRef . current = e . touches [ 0 ] . clientY
8987 }
9088
9189 const onTouchMove = ( e : TouchEvent ) => {
92- markGesture ( )
9390 if ( e . touches [ 0 ] . clientY > touchStartYRef . current ) detach ( )
9491 }
9592
9693 const onPointerDown = ( ) => {
9794 pointerDownRef . current = true
98- markGesture ( )
9995 }
10096 const onPointerUp = ( ) => {
10197 pointerDownRef . current = false
10298 }
103- const onKeyDown = markGesture
99+ const onKeyDown = ( ) => {
100+ lastUserGestureAtRef . current = performance . now ( )
101+ }
104102
105103 const onScroll = ( ) => {
106104 const { scrollTop, scrollHeight, clientHeight } = el
107105 const distanceFromBottom = scrollHeight - scrollTop - clientHeight
108106 const threshold = userDetachedRef . current ? REATTACH_THRESHOLD : STICK_THRESHOLD
109107
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.
108+ // Only a genuine user scroll may detach via a scroll-position delta: an active
109+ // scrollbar drag (pointer held) or a recent keyboard scroll. A programmatic
110+ // upward scroll — e.g. a virtualizer re-pinning content on a row-size shrink —
111+ // has neither and must not be mistaken for the user scrolling away.
114112 const userDriven =
115113 pointerDownRef . current ||
116114 performance . now ( ) - lastUserGestureAtRef . current < USER_GESTURE_WINDOW
@@ -182,6 +180,10 @@ export function useAutoScroll(
182180 window . removeEventListener ( 'pointercancel' , onPointerUp )
183181 observer . disconnect ( )
184182 cancelAnimationFrame ( rafIdRef . current )
183+ // A pointer held through teardown (e.g. dragging the scrollbar as the stream
184+ // finishes) would never see its window `pointerup`, leaving the ref stuck true
185+ // into the next session and detaching on the first programmatic re-pin.
186+ pointerDownRef . current = false
185187 if ( stickyRef . current ) scrollToBottom ( )
186188 }
187189 } , [ isStreaming , scrollToBottom ] )
0 commit comments