22
33import { useToast } from '@/components/hooks/use-toast' ;
44import { Button } from '@/components/ui/button' ;
5- import { ScrollArea } from '@/components/ui/scroll-area' ;
65import { Separator } from '@/components/ui/separator' ;
76import { CustomSlateEditor } from '@/features/chat/customSlateEditor' ;
87import { AdditionalChatRequestParams , CustomEditor , LanguageModelInfo , SBChatMessage , SearchScope , Source } from '@/features/chat/types' ;
@@ -12,6 +11,7 @@ import { CreateUIMessage, DefaultChatTransport } from 'ai';
1211import { ArrowDownIcon , CopyIcon } from 'lucide-react' ;
1312import { useNavigationGuard } from 'next-navigation-guard' ;
1413import { Fragment , useCallback , useEffect , useRef , useState } from 'react' ;
14+ import { useStickToBottom } from 'use-stick-to-bottom' ;
1515import { Descendant } from 'slate' ;
1616import { useMessagePairs } from '../../useMessagePairs' ;
1717import { useSelectedLanguageModel } from '../../useSelectedLanguageModel' ;
@@ -67,10 +67,8 @@ export const ChatThread = ({
6767 chatName,
6868} : ChatThreadProps ) => {
6969 const [ isErrorBannerVisible , setIsErrorBannerVisible ] = useState ( false ) ;
70- const scrollAreaRef = useRef < HTMLDivElement > ( null ) ;
71- const latestMessagePairRef = useRef < HTMLDivElement > ( null ) ;
7270 const hasSubmittedInputMessage = useRef ( false ) ;
73- const [ isAutoScrollEnabled , setIsAutoScrollEnabled ] = useState ( false ) ;
71+ const { scrollRef , contentRef , scrollToBottom , isAtBottom } = useStickToBottom ( { initial : false } ) ;
7472 const { toast } = useToast ( ) ;
7573 const router = useRouter ( ) ;
7674 const params = useParams < { domain : string } > ( ) ;
@@ -204,9 +202,9 @@ export const ChatThread = ({
204202 }
205203
206204 sendMessage ( inputMessage ) ;
207- setIsAutoScrollEnabled ( true ) ;
205+ scrollToBottom ( ) ;
208206 hasSubmittedInputMessage . current = true ;
209- } , [ inputMessage , sendMessage ] ) ;
207+ } , [ inputMessage , scrollToBottom , sendMessage ] ) ;
210208
211209 // Restore pending message after OAuth redirect (askgh login wall)
212210 useEffect ( ( ) => {
@@ -234,28 +232,24 @@ export const ChatThread = ({
234232 const mentions = getAllMentionElements ( children ) ;
235233 const message = createUIMessage ( text , mentions . map ( ( { data } ) => data ) , selectedSearchScopes ) ;
236234 sendMessage ( message ) ;
237- setIsAutoScrollEnabled ( true ) ;
235+ scrollToBottom ( ) ;
238236 } catch ( error ) {
239237 console . error ( 'Failed to restore pending message:' , error ) ;
240238 }
241- } , [ isAuthenticated , isOwner , chatId , sendMessage , selectedSearchScopes ] ) ;
239+ } , [ isAuthenticated , isOwner , chatId , sendMessage , selectedSearchScopes , scrollToBottom ] ) ;
242240
243- // Track scroll position changes .
241+ // Track scroll position for history state restoration .
244242 useEffect ( ( ) => {
245- const scrollElement = scrollAreaRef . current ?. querySelector ( '[data-radix-scroll-area-viewport]' ) as HTMLElement ;
246- if ( ! scrollElement ) return ;
243+ const scrollElement = scrollRef . current ;
244+ if ( ! scrollElement ) {
245+ return ;
246+ }
247247
248248 let timeout : NodeJS . Timeout | null = null ;
249249
250250 const handleScroll = ( ) => {
251251 const scrollOffset = scrollElement . scrollTop ;
252252
253- const threshold = 50 ; // pixels from bottom to consider "at bottom"
254- const { scrollHeight, clientHeight } = scrollElement ;
255- const isAtBottom = scrollHeight - scrollOffset - clientHeight <= threshold ;
256- setIsAutoScrollEnabled ( isAtBottom ) ;
257-
258- // Debounce the history state update
259253 if ( timeout ) {
260254 clearTimeout ( timeout ) ;
261255 }
@@ -279,10 +273,11 @@ export const ChatThread = ({
279273 clearTimeout ( timeout ) ;
280274 }
281275 } ;
282- } , [ ] ) ;
276+ } , [ scrollRef ] ) ;
283277
278+ // Restore scroll position from history state on mount.
284279 useEffect ( ( ) => {
285- const scrollElement = scrollAreaRef . current ?. querySelector ( '[data-radix-scroll-area-viewport]' ) as HTMLElement ;
280+ const scrollElement = scrollRef . current ;
286281 if ( ! scrollElement ) {
287282 return ;
288283 }
@@ -298,26 +293,7 @@ export const ChatThread = ({
298293 behavior : 'instant' ,
299294 } ) ;
300295 } , 10 ) ;
301- } , [ ] ) ;
302-
303- // When messages are being streamed, scroll to the latest message
304- // assuming auto scrolling is enabled.
305- useEffect ( ( ) => {
306- if (
307- ! latestMessagePairRef . current ||
308- ! isAutoScrollEnabled ||
309- messages . length === 0
310- ) {
311- return ;
312- }
313-
314- latestMessagePairRef . current . scrollIntoView ( {
315- behavior : 'smooth' ,
316- block : 'end' ,
317- inline : 'nearest' ,
318- } ) ;
319-
320- } , [ isAutoScrollEnabled , messages ] ) ;
296+ } , [ scrollRef ] ) ;
321297
322298
323299 // Keep the error state & banner visibility in sync.
@@ -345,10 +321,10 @@ export const ChatThread = ({
345321 const message = createUIMessage ( text , mentions . map ( ( { data } ) => data ) , selectedSearchScopes ) ;
346322 sendMessage ( message ) ;
347323
348- setIsAutoScrollEnabled ( true ) ;
324+ scrollToBottom ( ) ;
349325
350326 resetEditor ( editor ) ;
351- } , [ sendMessage , selectedSearchScopes , isAuthenticated , captureEvent , chatId ] ) ;
327+ } , [ sendMessage , selectedSearchScopes , isAuthenticated , captureEvent , chatId , scrollToBottom ] ) ;
352328
353329 const onDuplicate = useCallback ( async ( newName : string ) : Promise < string | null > => {
354330 if ( ! defaultChatId ) {
@@ -379,64 +355,61 @@ export const ChatThread = ({
379355 />
380356 ) }
381357
382- < ScrollArea
383- ref = { scrollAreaRef }
384- className = "flex flex-col h-full w-full p-4 overflow-hidden"
385- >
386- {
387- messagePairs . length === 0 ? (
388- < div className = "flex items-center justify-center text-center h-full" >
389- < p className = "text-muted-foreground" > no messages</ p >
390- </ div >
391- ) : (
392- < >
393- { messagePairs . map ( ( [ userMessage , assistantMessage ] , index ) => {
394- const isLastPair = index === messagePairs . length - 1 ;
395- const isStreaming = isLastPair && ( status === "streaming" || status === "submitted" ) ;
396- // Use a stable key based on user message ID
397- const key = userMessage . id ;
398-
399- return (
400- < Fragment key = { key } >
401- < ChatThreadListItem
402- index = { index }
403- chatId = { chatId }
404- userMessage = { userMessage }
405- assistantMessage = { assistantMessage }
406- isStreaming = { isStreaming }
407- sources = { sources }
408- ref = { isLastPair ? latestMessagePairRef : undefined }
409- />
410- { index !== messagePairs . length - 1 && (
411- < Separator className = "my-12" />
412- ) }
413- </ Fragment >
414- ) ;
415- } ) }
416- </ >
417- )
418- }
358+ < div className = "relative h-full w-full p-4 overflow-hidden min-h-0" >
359+ < div
360+ ref = { scrollRef }
361+ className = "h-full w-full overflow-y-auto overflow-x-hidden"
362+ >
363+ < div ref = { contentRef } >
364+ {
365+ messagePairs . length === 0 ? (
366+ < div className = "flex items-center justify-center text-center h-full min-h-full" >
367+ < p className = "text-muted-foreground" > no messages</ p >
368+ </ div >
369+ ) : (
370+ < >
371+ { messagePairs . map ( ( [ userMessage , assistantMessage ] , index ) => {
372+ const isLastPair = index === messagePairs . length - 1 ;
373+ const isStreaming = isLastPair && ( status === "streaming" || status === "submitted" ) ;
374+ // Use a stable key based on user message ID
375+ const key = userMessage . id ;
376+
377+ return (
378+ < Fragment key = { key } >
379+ < ChatThreadListItem
380+ index = { index }
381+ chatId = { chatId }
382+ userMessage = { userMessage }
383+ assistantMessage = { assistantMessage }
384+ isStreaming = { isStreaming }
385+ sources = { sources }
386+ />
387+ { index !== messagePairs . length - 1 && (
388+ < Separator className = "my-12" />
389+ ) }
390+ </ Fragment >
391+ ) ;
392+ } ) }
393+ </ >
394+ )
395+ }
396+ </ div >
397+ </ div >
419398 {
420- ( ! isAutoScrollEnabled && status === "streaming" ) && (
399+ ( ! isAtBottom && status === "streaming" ) && (
421400 < div className = "absolute bottom-5 left-0 right-0 h-10 flex flex-row items-center justify-center" >
422401 < Button
423402 variant = "outline"
424403 size = "icon"
425404 className = "rounded-full animate-bounce-slow h-8 w-8"
426- onClick = { ( ) => {
427- latestMessagePairRef . current ?. scrollIntoView ( {
428- behavior : 'instant' ,
429- block : 'end' ,
430- inline : 'nearest' ,
431- } ) ;
432- } }
405+ onClick = { ( ) => scrollToBottom ( 'instant' ) }
433406 >
434407 < ArrowDownIcon className = "w-4 h-4" />
435408 </ Button >
436409 </ div >
437410 )
438411 }
439- </ ScrollArea >
412+ </ div >
440413 < div className = "w-full max-w-3xl mx-auto mb-8" >
441414 < SignInPromptBanner
442415 chatId = { chatId }
0 commit comments