11import { useRef , useState , useCallback } from "react" ;
22
33/**
4- * Hook to manage auto-scrolling behavior for a scrollable container
4+ * Hook to manage auto-scrolling behavior for a scrollable container.
5+ *
6+ * Scroll container structure expected:
7+ * <div ref={contentRef}> ← scroll container (overflow-y: auto)
8+ * <div ref={innerRef}> ← inner content wrapper (observed for size changes)
9+ * {children}
10+ * </div>
11+ * </div>
512 *
613 * Auto-scroll is enabled when:
714 * - User sends a message
@@ -17,10 +24,35 @@ export function useAutoScroll() {
1724 const lastUserInteractionRef = useRef < number > ( 0 ) ;
1825 // Ref to avoid stale closures in async callbacks - always holds current autoScroll value
1926 const autoScrollRef = useRef < boolean > ( true ) ;
27+ // Track the ResizeObserver so we can disconnect it when the element unmounts
28+ const observerRef = useRef < ResizeObserver | null > ( null ) ;
2029
2130 // Sync ref with state to ensure callbacks always have latest value
2231 autoScrollRef . current = autoScroll ;
2332
33+ // Callback ref for the inner content wrapper - sets up ResizeObserver when element mounts.
34+ // ResizeObserver fires when the content size changes (Shiki highlighting, Mermaid, images, etc.),
35+ // allowing us to scroll to bottom even when async content renders after the initial mount.
36+ const innerRef = useCallback ( ( element : HTMLDivElement | null ) => {
37+ // Cleanup previous observer if any
38+ if ( observerRef . current ) {
39+ observerRef . current . disconnect ( ) ;
40+ observerRef . current = null ;
41+ }
42+
43+ if ( ! element ) return ;
44+
45+ const observer = new ResizeObserver ( ( ) => {
46+ // Only auto-scroll if enabled - user may have scrolled up
47+ if ( autoScrollRef . current && contentRef . current ) {
48+ contentRef . current . scrollTop = contentRef . current . scrollHeight ;
49+ }
50+ } ) ;
51+
52+ observer . observe ( element ) ;
53+ observerRef . current = observer ;
54+ } , [ ] ) ;
55+
2456 const performAutoScroll = useCallback ( ( ) => {
2557 if ( ! contentRef . current ) return ;
2658
@@ -38,18 +70,14 @@ export function useAutoScroll() {
3870 } , [ ] ) ; // No deps - ref ensures we always check current value
3971
4072 const jumpToBottom = useCallback ( ( ) => {
41- if ( ! contentRef . current ) return ;
42-
43- // Double RAF: First frame for DOM updates (async highlighting, image loads),
44- // second frame to scroll after layout is complete
45- requestAnimationFrame ( ( ) => {
46- requestAnimationFrame ( ( ) => {
47- if ( contentRef . current ) {
48- contentRef . current . scrollTop = contentRef . current . scrollHeight ;
49- }
50- } ) ;
51- } ) ;
73+ // Enable auto-scroll first so ResizeObserver will handle subsequent changes
5274 setAutoScroll ( true ) ;
75+ autoScrollRef . current = true ;
76+
77+ // Immediate scroll for content that's already rendered
78+ if ( contentRef . current ) {
79+ contentRef . current . scrollTop = contentRef . current . scrollHeight ;
80+ }
5381 } , [ ] ) ;
5482
5583 const handleScroll = useCallback ( ( e : React . UIEvent < HTMLDivElement > ) => {
@@ -73,9 +101,11 @@ export function useAutoScroll() {
73101 if ( isScrollingUp ) {
74102 // Always disable auto-scroll when scrolling up
75103 setAutoScroll ( false ) ;
104+ autoScrollRef . current = false ;
76105 } else if ( isScrollingDown && isAtBottom ) {
77106 // Only enable auto-scroll if scrolling down AND reached the bottom
78107 setAutoScroll ( true ) ;
108+ autoScrollRef . current = true ;
79109 }
80110 // If scrolling down but not at bottom, auto-scroll remains disabled
81111
@@ -89,6 +119,7 @@ export function useAutoScroll() {
89119
90120 return {
91121 contentRef,
122+ innerRef,
92123 autoScroll,
93124 setAutoScroll,
94125 performAutoScroll,
0 commit comments