@@ -6,16 +6,15 @@ const BOTTOM_THRESHOLD = 30
66 * Manages sticky auto-scroll for a streaming chat container.
77 *
88 * Stays pinned to the bottom while content streams in. Detaches when the user
9- * explicitly scrolls up (wheel, touch, or scrollbar drag). Re-attaches when
10- * the scroll position returns to within {@link BOTTOM_THRESHOLD} of the bottom.
9+ * scrolls beyond {@link BOTTOM_THRESHOLD} from the bottom. Re-attaches when
10+ * the scroll position returns within the threshold. Preserves bottom position
11+ * across container resizes (e.g. sidebar collapse).
1112 */
1213export function useAutoScroll ( isStreaming : boolean ) {
1314 const containerRef = useRef < HTMLDivElement > ( null )
14- const stickyRef = useRef ( true )
15- const prevScrollTopRef = useRef ( 0 )
16- const prevScrollHeightRef = useRef ( 0 )
17- const touchStartYRef = useRef ( 0 )
15+ const atBottomRef = useRef ( true )
1816 const rafIdRef = useRef ( 0 )
17+ const teardownRef = useRef < ( ( ) => void ) | null > ( null )
1918
2019 const scrollToBottom = useCallback ( ( ) => {
2120 const el = containerRef . current
@@ -24,80 +23,57 @@ export function useAutoScroll(isStreaming: boolean) {
2423 } , [ ] )
2524
2625 const callbackRef = useCallback ( ( el : HTMLDivElement | null ) => {
26+ teardownRef . current ?.( )
27+ teardownRef . current = null
2728 containerRef . current = el
28- if ( el ) el . scrollTop = el . scrollHeight
29- } , [ ] )
30-
31- useEffect ( ( ) => {
32- if ( ! isStreaming ) return
33- const el = containerRef . current
3429 if ( ! el ) return
3530
36- stickyRef . current = true
37- prevScrollTopRef . current = el . scrollTop
38- prevScrollHeightRef . current = el . scrollHeight
39- scrollToBottom ( )
31+ el . scrollTop = el . scrollHeight
32+ atBottomRef . current = true
4033
41- const detach = ( ) => {
42- stickyRef . current = false
34+ const onScroll = ( ) => {
35+ const { scrollTop, scrollHeight, clientHeight } = el
36+ atBottomRef . current = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
4337 }
4438
45- const onWheel = ( e : WheelEvent ) => {
46- if ( e . deltaY < 0 ) detach ( )
47- }
39+ const ro = new ResizeObserver ( ( ) => {
40+ if ( atBottomRef . current ) el . scrollTop = el . scrollHeight
41+ } )
4842
49- const onTouchStart = ( e : TouchEvent ) => {
50- touchStartYRef . current = e . touches [ 0 ] . clientY
51- }
43+ el . addEventListener ( 'scroll' , onScroll , { passive : true } )
44+ ro . observe ( el )
5245
53- const onTouchMove = ( e : TouchEvent ) => {
54- if ( e . touches [ 0 ] . clientY > touchStartYRef . current ) detach ( )
46+ teardownRef . current = ( ) => {
47+ el . removeEventListener ( 'scroll' , onScroll )
48+ ro . disconnect ( )
5549 }
50+ } , [ ] )
5651
57- const onScroll = ( ) => {
58- const { scrollTop, scrollHeight, clientHeight } = el
59- const distanceFromBottom = scrollHeight - scrollTop - clientHeight
60-
61- if ( distanceFromBottom <= BOTTOM_THRESHOLD ) {
62- stickyRef . current = true
63- } else if (
64- scrollTop < prevScrollTopRef . current &&
65- scrollHeight <= prevScrollHeightRef . current
66- ) {
67- stickyRef . current = false
68- }
69-
70- prevScrollTopRef . current = scrollTop
71- prevScrollHeightRef . current = scrollHeight
72- }
52+ useEffect ( ( ) => {
53+ if ( ! isStreaming ) return
54+ const el = containerRef . current
55+ if ( ! el ) return
56+
57+ atBottomRef . current = true
58+ scrollToBottom ( )
7359
7460 const guardedScroll = ( ) => {
75- if ( stickyRef . current ) scrollToBottom ( )
61+ if ( atBottomRef . current ) scrollToBottom ( )
7662 }
7763
7864 const onMutation = ( ) => {
79- prevScrollHeightRef . current = el . scrollHeight
80- if ( ! stickyRef . current ) return
65+ if ( ! atBottomRef . current ) return
8166 cancelAnimationFrame ( rafIdRef . current )
8267 rafIdRef . current = requestAnimationFrame ( guardedScroll )
8368 }
8469
85- el . addEventListener ( 'wheel' , onWheel , { passive : true } )
86- el . addEventListener ( 'touchstart' , onTouchStart , { passive : true } )
87- el . addEventListener ( 'touchmove' , onTouchMove , { passive : true } )
88- el . addEventListener ( 'scroll' , onScroll , { passive : true } )
89-
9070 const observer = new MutationObserver ( onMutation )
9171 observer . observe ( el , { childList : true , subtree : true , characterData : true } )
9272
9373 return ( ) => {
94- el . removeEventListener ( 'wheel' , onWheel )
95- el . removeEventListener ( 'touchstart' , onTouchStart )
96- el . removeEventListener ( 'touchmove' , onTouchMove )
97- el . removeEventListener ( 'scroll' , onScroll )
9874 observer . disconnect ( )
9975 cancelAnimationFrame ( rafIdRef . current )
100- if ( stickyRef . current ) scrollToBottom ( )
76+ if ( atBottomRef . current ) scrollToBottom ( )
10177 }
10278 } , [ isStreaming , scrollToBottom ] )
10379
0 commit comments