Skip to content

Commit 2a3f48d

Browse files
committed
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.
1 parent c08a22c commit 2a3f48d

1 file changed

Lines changed: 35 additions & 19 deletions

File tree

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

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)