1+ /* eslint-disable @typescript-eslint/no-explicit-any */
12import { useRef , useEffect , useState , useCallback } from 'react' ;
23import { useAtomValue } from 'jotai' ;
34import { replayStateAtom } from '@/common/state/atoms/replay' ;
45
56// Constants
67const SCROLL_CHECK_DELAY = 100 ; // ms - delay for DOM updates
7- const SCROLL_ANIMATION_DELAY = 300 ; // ms - delay to account for smooth scroll animation
88const REPLAY_AUTO_SCROLL_DELAY = 50 ; // ms - delay for auto-scroll in replay mode
99
1010interface UseScrollToBottomOptions {
@@ -24,7 +24,7 @@ interface UseScrollToBottomReturn {
2424
2525/**
2626 * Custom hook for managing scroll-to-bottom indicator in chat
27- *
27+ *
2828 * Features:
2929 * - Shows scroll-to-bottom indicator when user has scrolled up
3030 * - Manual scroll to bottom functionality
@@ -41,72 +41,37 @@ export const useScrollToBottom = ({
4141 const messagesContainerRef = useRef < HTMLDivElement > ( null ) ;
4242 const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
4343 const [ showScrollToBottom , setShowScrollToBottom ] = useState ( false ) ;
44- const isScrollingRef = useRef ( false ) ;
4544 const lastSessionIdRef = useRef < string | undefined > ( sessionId ) ;
4645 const replayState = useAtomValue ( replayStateAtom ) ;
4746 const lastEventIndexRef = useRef < number > ( - 1 ) ;
4847 const lastMessageCountRef = useRef < number > ( 0 ) ;
4948 const lastUserMessageIdRef = useRef < string | null > ( null ) ;
5049
51- // Check if container is at bottom
52- const checkIsAtBottom = useCallback ( ( ) => {
53- const container = messagesContainerRef . current ;
54- if ( ! container ) return false ;
55-
56- const { scrollTop, scrollHeight, clientHeight } = container ;
57- const distanceFromBottom = scrollHeight - scrollTop - clientHeight ;
58-
59- // Account for sub-pixel differences and rounding errors
60- return distanceFromBottom <= Math . max ( threshold , 3 ) ;
61- } , [ threshold ] ) ;
62-
6350 // Handle scroll events
6451 const handleScroll = useCallback ( ( ) => {
6552 const container = messagesContainerRef . current ;
6653 if ( ! container ) return ;
67-
68- // Don't update state during programmatic scrolling to prevent flickering
69- if ( isScrollingRef . current ) return ;
70-
54+
7155 const { scrollTop, scrollHeight, clientHeight } = container ;
7256 const distanceFromBottom = scrollHeight - scrollTop - clientHeight ;
73-
74- // More robust bottom detection with better tolerance for rounding errors
75- const atBottom = distanceFromBottom <= Math . max ( threshold , 3 ) ;
76-
77- // Only show button when:
78- // 1. NOT at bottom
79- // 2. There's scrollable content (scrollHeight > clientHeight)
80- // 3. User has actually scrolled up (not just a minor difference)
81- const hasScrollableContent = scrollHeight > clientHeight + 5 ;
82- const hasScrolledUp = distanceFromBottom > 10 ; // Must be meaningfully away from bottom
83- const shouldShow = ! atBottom && hasScrollableContent && hasScrolledUp ;
84-
85- setShowScrollToBottom ( shouldShow ) ;
57+
58+ // Simple, robust bottom detection
59+ const atBottom = distanceFromBottom <= threshold ;
60+ const hasScrollableContent = scrollHeight > clientHeight + 10 ;
61+
62+ setShowScrollToBottom ( ! atBottom && hasScrollableContent ) ;
8663 } , [ threshold ] ) ;
8764
88- // Smooth scroll to bottom
89- const scrollToBottom = useCallback ( ( forceCheck = true ) => {
65+ // Scroll to bottom
66+ const scrollToBottom = useCallback ( ( ) => {
9067 const container = messagesContainerRef . current ;
9168 if ( ! container ) return ;
92-
93- isScrollingRef . current = true ;
94-
69+
9570 container . scrollTo ( {
9671 top : container . scrollHeight ,
97- behavior : 'smooth'
72+ behavior : 'smooth' ,
9873 } ) ;
99-
100- // Reset scrolling flag after animation completes
101- setTimeout ( ( ) => {
102- isScrollingRef . current = false ;
103- // Force check scroll position to ensure button hides when at bottom
104- if ( forceCheck ) handleScroll ( ) ;
105- } , SCROLL_ANIMATION_DELAY ) ;
106- } , [ handleScroll ] ) ;
107-
108- // Auto-scroll to bottom (reuse scrollToBottom without force check)
109- const autoScrollToBottom = useCallback ( ( ) => scrollToBottom ( false ) , [ scrollToBottom ] ) ;
74+ } , [ ] ) ;
11075
11176 // Delayed scroll check helper
11277 const scheduleScrollCheck = useCallback ( ( ) => {
@@ -121,13 +86,12 @@ export const useScrollToBottom = ({
12186 if ( sessionId !== lastSessionIdRef . current ) {
12287 lastSessionIdRef . current = sessionId ;
12388 setShowScrollToBottom ( false ) ;
124- isScrollingRef . current = false ;
125-
89+
12690 // Schedule a check after session content loads
12791 const timer = setTimeout ( ( ) => {
12892 handleScroll ( ) ;
12993 } , SCROLL_CHECK_DELAY * 2 ) ;
130-
94+
13195 return ( ) => clearTimeout ( timer ) ;
13296 }
13397 } , [ sessionId , handleScroll ] ) ;
@@ -136,12 +100,12 @@ export const useScrollToBottom = ({
136100 useEffect ( ( ) => {
137101 const container = messagesContainerRef . current ;
138102 if ( ! container ) return ;
139-
103+
140104 container . addEventListener ( 'scroll' , handleScroll , { passive : true } ) ;
141-
105+
142106 // Initial check
143107 const timer = scheduleScrollCheck ( ) ;
144-
108+
145109 return ( ) => {
146110 container . removeEventListener ( 'scroll' , handleScroll ) ;
147111 clearTimeout ( timer ) ;
@@ -165,15 +129,15 @@ export const useScrollToBottom = ({
165129 // This covers both sequential playback and manual jumps/seeks
166130 if ( replayState . currentEventIndex !== lastEventIndexRef . current ) {
167131 lastEventIndexRef . current = replayState . currentEventIndex ;
168-
132+
169133 // Schedule auto-scroll after DOM updates
170134 const timer = setTimeout ( ( ) => {
171- autoScrollToBottom ( ) ;
135+ scrollToBottom ( ) ;
172136 } , REPLAY_AUTO_SCROLL_DELAY ) ;
173-
137+
174138 return ( ) => clearTimeout ( timer ) ;
175139 }
176- } , [ isReplayMode , replayState . isActive , replayState . currentEventIndex , autoScrollToBottom ] ) ;
140+ } , [ isReplayMode , replayState . isActive , replayState . currentEventIndex , scrollToBottom ] ) ;
177141
178142 // Auto-scroll for user messages in normal mode
179143 useEffect ( ( ) => {
@@ -183,7 +147,7 @@ export const useScrollToBottom = ({
183147
184148 const messages = dependencies [ 0 ] as any [ ] ;
185149 const currentMessageCount = messages . length ;
186-
150+
187151 // Only check for new user messages when message count increases
188152 if ( currentMessageCount <= lastMessageCountRef . current ) {
189153 lastMessageCountRef . current = currentMessageCount ;
@@ -194,29 +158,31 @@ export const useScrollToBottom = ({
194158 const allUserMessages = messages
195159 . flatMap ( ( group : any ) => group ?. messages || [ group ] )
196160 . filter ( ( msg : any ) => msg ?. role === 'user' ) ;
197-
161+
198162 const latestUserMessage = allUserMessages [ allUserMessages . length - 1 ] ;
199-
163+
200164 // Auto-scroll ONLY if:
201165 // 1. We have a new user message
202166 // 2. It's the LAST user message (most recent one)
203167 // 3. It has the isLocalMessage flag (indicating it was just sent by user)
204- if ( latestUserMessage ?. id &&
205- latestUserMessage . id !== lastUserMessageIdRef . current &&
206- latestUserMessage . isLocalMessage ) {
168+ if (
169+ latestUserMessage ?. id &&
170+ latestUserMessage . id !== lastUserMessageIdRef . current &&
171+ latestUserMessage . isLocalMessage
172+ ) {
207173 lastUserMessageIdRef . current = latestUserMessage . id ;
208-
209- const timer = setTimeout ( autoScrollToBottom , SCROLL_CHECK_DELAY ) ;
174+
175+ const timer = setTimeout ( scrollToBottom , SCROLL_CHECK_DELAY ) ;
210176 return ( ) => clearTimeout ( timer ) ;
211177 }
212-
178+
213179 lastMessageCountRef . current = currentMessageCount ;
214- } , [ autoScrollOnUserMessage , isReplayMode , autoScrollToBottom , ...dependencies ] ) ;
180+ } , [ autoScrollOnUserMessage , isReplayMode , scrollToBottom , ...dependencies ] ) ;
215181
216182 return {
217183 messagesContainerRef,
218184 messagesEndRef,
219185 showScrollToBottom,
220- scrollToBottom : ( ) => scrollToBottom ( ) ,
186+ scrollToBottom,
221187 } ;
222188} ;
0 commit comments