@@ -25,22 +25,15 @@ interface ProcessedMessageProps {
2525
2626export default function MessageList ( { messages} : MessageListProps ) {
2727 const [ scrollAreaRef , setScrollAreaRef ] = useState < HTMLDivElement | null > ( null ) ;
28-
29- // Track if user is at bottom - default to true for initial scroll
3028 const isAtBottomRef = useRef ( true ) ;
31- // Track the last known scroll height to detect new content
32- const lastScrollHeightRef = useRef ( 0 ) ;
33- // Track if we're currently doing a programmatic scroll
34- const isProgrammaticScrollRef = useRef ( false ) ;
3529
3630 const checkIfAtBottom = useCallback ( ( ) => {
3731 if ( ! scrollAreaRef ) return false ;
3832 const { scrollTop, scrollHeight, clientHeight } = scrollAreaRef ;
39- return scrollTop + clientHeight >= scrollHeight - 10 ; // 10px tolerance
33+ return scrollTop + clientHeight >= scrollHeight - 10 ;
4034 } , [ scrollAreaRef ] ) ;
4135
42- // Track Ctrl (Windows/Linux) or Cmd (Mac) key state
43- // This is so that underline is only visible when hover + cmd/ctrl
36+ // Track Ctrl (Windows/Linux) or Cmd (Mac) key state for link underlines.
4437 useEffect ( ( ) => {
4538 const handleKeyDown = ( e : KeyboardEvent ) => {
4639 if ( e . ctrlKey || e . metaKey ) document . documentElement . classList . add ( 'modifier-pressed' ) ;
@@ -56,62 +49,29 @@ export default function MessageList({messages}: MessageListProps) {
5649 window . removeEventListener ( "keydown" , handleKeyDown ) ;
5750 window . removeEventListener ( "keyup" , handleKeyUp ) ;
5851 document . documentElement . classList . remove ( 'modifier-pressed' ) ;
59-
6052 } ;
6153 } , [ ] ) ;
6254
63- // Update isAtBottom on scroll
55+ // Track whether the user is scrolled to the bottom. Every scroll event
56+ // updates the ref so auto-scroll decisions are always based on the
57+ // user's actual position.
6458 useEffect ( ( ) => {
6559 if ( ! scrollAreaRef ) return ;
66-
6760 const handleScroll = ( ) => {
68- if ( isProgrammaticScrollRef . current ) return ;
6961 isAtBottomRef . current = checkIfAtBottom ( ) ;
7062 } ;
71-
72- // Initial check
7363 handleScroll ( ) ;
74-
7564 scrollAreaRef . addEventListener ( "scroll" , handleScroll ) ;
76- scrollAreaRef . addEventListener ( "scrollend" , ( ) => isProgrammaticScrollRef . current = false ) ;
77- return ( ) => {
78- scrollAreaRef . removeEventListener ( "scroll" , handleScroll )
79- scrollAreaRef . removeEventListener ( "scrollend" , ( ) => isProgrammaticScrollRef . current = false ) ;
80-
81- } ;
65+ return ( ) => scrollAreaRef . removeEventListener ( "scroll" , handleScroll ) ;
8266 } , [ checkIfAtBottom , scrollAreaRef ] ) ;
8367
84- // Handle auto-scrolling when messages change
68+ // Pin to bottom when new content arrives, but only if the user hasn't
69+ // scrolled away. Direct scrollTop assignment is synchronous and avoids
70+ // the animation conflicts that smooth scrollTo causes during streaming.
8571 useLayoutEffect ( ( ) => {
8672 if ( ! scrollAreaRef ) return ;
87-
88- const currentScrollHeight = scrollAreaRef . scrollHeight ;
89-
90- // Check if this is new content (scroll height increased)
91- const hasNewContent = currentScrollHeight > lastScrollHeightRef . current ;
92- const isFirstRender = lastScrollHeightRef . current === 0 ;
93- const isNewUserMessage =
94- messages . length > 0 && messages [ messages . length - 1 ] . role === "user" ;
95-
96- // Auto-scroll only if:
97- // 1. It's the first render, OR
98- // 2. There's new content AND user was at the bottom, OR
99- // 3. The user sent a new message
100- if (
101- hasNewContent &&
102- ( isFirstRender || isAtBottomRef . current || isNewUserMessage )
103- ) {
104- isProgrammaticScrollRef . current = true ;
105- scrollAreaRef . scrollTo ( {
106- top : currentScrollHeight ,
107- behavior : isFirstRender ? "instant" : "smooth" ,
108- } ) ;
109- // After scrolling, we're at the bottom
110- isAtBottomRef . current = true ;
111- }
112-
113- // Update the last known scroll height
114- lastScrollHeightRef . current = currentScrollHeight ;
73+ if ( ! isAtBottomRef . current ) return ;
74+ scrollAreaRef . scrollTop = scrollAreaRef . scrollHeight ;
11575 } , [ messages , scrollAreaRef ] ) ;
11676
11777 // If no messages, show a placeholder
@@ -126,7 +86,7 @@ export default function MessageList({messages}: MessageListProps) {
12686 return (
12787 < div className = "overflow-y-auto flex-1" ref = { setScrollAreaRef } >
12888 < div
129- className = "p-4 flex flex-col gap-4 max-w-4xl mx-auto transition-all duration-300 ease-in-out min-h-0" >
89+ className = "p-4 flex flex-col gap-4 max-w-4xl mx-auto min-h-0" >
13090 { messages . map ( ( message , index ) => (
13191 < div
13292 key = { message . id ?? "draft" }
@@ -137,7 +97,7 @@ export default function MessageList({messages}: MessageListProps) {
13797 message . role === "user"
13898 ? "bg-accent-foreground rounded-lg max-w-[90%] px-4 py-3 text-accent"
13999 : "max-w-[80ch]"
140- } ${ message . id === undefined ? "animate-pulse" : "" } `}
100+ } `}
141101 >
142102 < div
143103 className = { `whitespace-pre-wrap break-words text-left text-xs md:text-sm leading-relaxed md:leading-normal ${
@@ -186,7 +146,7 @@ const ProcessedMessage = React.memo(function ProcessedMessage({
186146 } : ProcessedMessageProps ) {
187147 // Regex to find URLs
188148 // https://stackoverflow.com/a/17773849
189- const urlRegex = useMemo < RegExp > ( ( ) => / ( h t t p s ? : \/ \/ (?: w w w \. | (? ! w w w ) ) [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] + [ a - z A - Z 0 - 9 ] \. [ ^ \s ] { 2 , } | w w w \. [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] + [ a - z A - Z 0 - 9 ] \. [ ^ \s ] { 2 , } | h t t p s ? : \/ \/ (?: w w w \. | (? ! w w w ) ) [ a - z A - Z 0 - 9 ] + \. [ ^ \s ] { 2 , } | w w w \. [ a - z A - Z 0 - 9 ] + \. [ ^ \s ] { 2 , } ) / g , [ ] ) ;
149+ const urlRegex = useMemo < RegExp > ( ( ) => / ( h t t p s ? : \/ \/ (?: w w w \. | (? ! w w w ) ) [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] + [ a - z A - Z 0 - 9 ] \. [ ^ \s ] { 2 , } | w w w \. [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] + [ a - z A - Z 0 - 9 ] \. [ ^ \s ] { 2 , } | h t t p s ? : \/ \/ (?: w w w \. | (? ! w w w ) ) [ a - z A - Z 0 - 9 ] + \. [ ^ \s ] { 2 , } | w w w \. [ a - z A - Z 0 - 9 ] + \. [ ^ \s ] { 2 , } ) / , [ ] ) ;
190150
191151 const handleClick = ( e : React . MouseEvent < HTMLAnchorElement > , url : string ) => {
192152 if ( e . metaKey || e . ctrlKey ) {
0 commit comments