@@ -26,9 +26,10 @@ interface UseAutoScrollOptions {
2626 * Manages sticky auto-scroll for a streaming chat container.
2727 *
2828 * Stays pinned to the bottom while content streams in. Detaches immediately
29- * on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
30- * the user must scroll back to within {@link REATTACH_THRESHOLD} of the
31- * bottom to re-engage.
29+ * on any upward user gesture (wheel, touch, scrollbar drag, keyboard). Once
30+ * detached, the user must scroll back to within {@link REATTACH_THRESHOLD} of
31+ * the bottom to re-engage. Each streaming start re-seeds stickiness from the
32+ * current scroll position, so a user who scrolled up beforehand stays put.
3233 *
3334 * Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
3435 * for imperative use after layout-changing events like panel expansion.
@@ -45,7 +46,17 @@ export function useAutoScroll(
4546 const touchStartYRef = useRef ( 0 )
4647 const rafIdRef = useRef ( 0 )
4748 const scrollOnMountRef = useRef ( scrollOnMount )
49+ /**
50+ * Whether the user is actively dragging the scrollbar — a pointer press on the
51+ * container itself rather than its content. Reset on teardown so a pointer held
52+ * as one stream ends can't leak into the next session and authorize a detach.
53+ */
4854 const pointerDownRef = useRef ( false )
55+ /**
56+ * Timestamp of the last keyboard scroll, the only detach gesture that emits no
57+ * wheel/touch/pointer signal. Gates {@link USER_GESTURE_WINDOW}; reset on teardown
58+ * so a keypress near a stream's end can't carry into the next session.
59+ */
4960 const lastUserGestureAtRef = useRef ( Number . NEGATIVE_INFINITY )
5061
5162 const scrollToBottom = useCallback ( ( ) => {
@@ -64,7 +75,6 @@ export function useAutoScroll(
6475 const el = containerRef . current
6576 if ( ! el ) return
6677
67- // Don't jump if the user scrolled up — keep their position.
6878 const distanceFromBottom = el . scrollHeight - el . scrollTop - el . clientHeight
6979 const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
7080 stickyRef . current = isNearBottom
@@ -90,8 +100,13 @@ export function useAutoScroll(
90100 if ( e . touches [ 0 ] . clientY > touchStartYRef . current ) detach ( )
91101 }
92102
93- const onPointerDown = ( ) => {
94- pointerDownRef . current = true
103+ /**
104+ * A scrollbar press targets the scroll container itself; a press on message
105+ * content targets a descendant. Only the former is a scroll gesture, so a
106+ * text-selection drag on content can't authorize a detach.
107+ */
108+ const onPointerDown = ( e : PointerEvent ) => {
109+ if ( e . target === el ) pointerDownRef . current = true
95110 }
96111 const onPointerUp = ( ) => {
97112 pointerDownRef . current = false
@@ -100,15 +115,17 @@ export function useAutoScroll(
100115 lastUserGestureAtRef . current = performance . now ( )
101116 }
102117
118+ /**
119+ * Re-engages when the user returns near the bottom, and detaches on an upward
120+ * scroll — but only a genuine user scroll qualifies: an active scrollbar drag
121+ * (pointer held) or a recent keyboard scroll. A programmatic upward scroll, e.g.
122+ * a virtualizer re-pinning content on a row-size shrink, has neither and must not
123+ * be mistaken for the user scrolling away.
124+ */
103125 const onScroll = ( ) => {
104126 const { scrollTop, scrollHeight, clientHeight } = el
105127 const distanceFromBottom = scrollHeight - scrollTop - clientHeight
106128 const threshold = userDetachedRef . current ? REATTACH_THRESHOLD : STICK_THRESHOLD
107-
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.
112129 const userDriven =
113130 pointerDownRef . current ||
114131 performance . now ( ) - lastUserGestureAtRef . current < USER_GESTURE_WINDOW
@@ -139,11 +156,12 @@ export function useAutoScroll(
139156 rafIdRef . current = requestAnimationFrame ( guardedScroll )
140157 }
141158
142- // CSS-driven height animations (e.g. Radix Collapsible expanding
143- // mid-stream) grow scrollHeight without triggering MutationObserver,
144- // so auto-scroll stops following. When any animation starts in the
145- // container, follow rAF for a short window so the container stays
146- // pinned to the bottom while the animation runs.
159+ /**
160+ * CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
161+ * grow scrollHeight without triggering MutationObserver, so auto-scroll stops
162+ * following. When any animation starts in the container, follow rAF for a short
163+ * window so the container stays pinned to the bottom while the animation runs.
164+ */
147165 const onAnimationStart = ( ) => {
148166 if ( ! stickyRef . current ) return
149167 const until = performance . now ( ) + 500
@@ -180,10 +198,8 @@ export function useAutoScroll(
180198 window . removeEventListener ( 'pointercancel' , onPointerUp )
181199 observer . disconnect ( )
182200 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.
186201 pointerDownRef . current = false
202+ lastUserGestureAtRef . current = Number . NEGATIVE_INFINITY
187203 if ( stickyRef . current ) scrollToBottom ( )
188204 }
189205 } , [ isStreaming , scrollToBottom ] )
0 commit comments