@@ -110,13 +110,15 @@ export function MessageList({
110110 // This is used to only show the thinking indicator on the most recent message
111111 const latestMessageToAgent = new Map < string , string > ( ) ;
112112 const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
113+ const contentRef = useRef < HTMLDivElement > ( null ) ;
113114 const [ autoScroll , setAutoScroll ] = useState ( autoScrollDefault ) ;
114115 const prevFilteredLengthRef = useRef < number > ( 0 ) ;
115116 const prevChannelRef = useRef < string > ( currentChannel ) ;
116117 // Track if we should scroll on next render (set before DOM updates)
117118 const shouldScrollRef = useRef ( false ) ;
118119 // Track if a scroll is in progress to prevent race conditions
119120 const isScrollingRef = useRef ( false ) ;
121+ const resizeScrollRafRef = useRef < number | null > ( null ) ;
120122
121123 useEffect ( ( ) => {
122124 setAutoScroll ( autoScrollDefault ) ;
@@ -205,6 +207,48 @@ export function MessageList({
205207 }
206208 } , [ autoScroll ] ) ;
207209
210+ // Keep the view pinned to the bottom while auto-scroll is enabled, even when content grows
211+ // without new messages (e.g. log previews expanding from 1->5 lines, images loading).
212+ useEffect ( ( ) => {
213+ if ( ! autoScrollDefault ) return ;
214+ if ( ! autoScroll ) return ;
215+ if ( typeof ResizeObserver === 'undefined' ) return ;
216+
217+ const content = contentRef . current ;
218+ if ( ! content ) return ;
219+
220+ const ro = new ResizeObserver ( ( ) => {
221+ if ( ! autoScroll || ! scrollContainerRef . current ) return ;
222+
223+ // Batch multiple ResizeObserver events into a single RAF.
224+ if ( resizeScrollRafRef . current !== null ) return ;
225+ resizeScrollRafRef . current = requestAnimationFrame ( ( ) => {
226+ resizeScrollRafRef . current = null ;
227+ if ( ! autoScroll || ! scrollContainerRef . current ) return ;
228+
229+ isScrollingRef . current = true ;
230+ const container = scrollContainerRef . current ;
231+ container . scrollTop = container . scrollHeight ;
232+
233+ requestAnimationFrame ( ( ) => {
234+ setTimeout ( ( ) => {
235+ isScrollingRef . current = false ;
236+ } , 50 ) ;
237+ } ) ;
238+ } ) ;
239+ } ) ;
240+
241+ ro . observe ( content ) ;
242+
243+ return ( ) => {
244+ ro . disconnect ( ) ;
245+ if ( resizeScrollRafRef . current !== null ) {
246+ cancelAnimationFrame ( resizeScrollRafRef . current ) ;
247+ resizeScrollRafRef . current = null ;
248+ }
249+ } ;
250+ } , [ autoScroll , autoScrollDefault ] ) ;
251+
208252 // Auto-scroll to bottom when new messages arrive - use useLayoutEffect for immediate execution
209253 useLayoutEffect ( ( ) => {
210254 if ( shouldScrollRef . current && scrollContainerRef . current ) {
@@ -260,46 +304,51 @@ export function MessageList({
260304
261305 return (
262306 < div
263- className = { `flex flex-col bg-bg-secondary h-full overflow-y-auto ${
264- compactMode ? 'gap-0 p-1 sm:p-1.5' : 'gap-2 p-3 sm:p-4'
265- } `}
307+ className = "bg-bg-secondary h-full overflow-y-auto"
266308 ref = { scrollContainerRef }
267309 onScroll = { handleScroll }
268310 >
269- { filteredMessages . map ( ( message ) => {
270- // Check if message is from current user (Dashboard or GitHub username)
271- const isFromCurrentUser = message . from === 'Dashboard' ||
272- ( currentUser && message . from === currentUser . displayName ) ;
273-
274- // Check if this is the latest message from current user to this recipient
275- // Only the latest message should show the thinking indicator
276- const isLatestToRecipient = isFromCurrentUser && message . to !== '*' &&
277- latestMessageToAgent . get ( message . to ) === message . id ;
278-
279- // Check if the recipient is currently processing
280- // Only show thinking indicator for the LATEST message from current user to an agent
281- const recipientProcessing = isLatestToRecipient
282- ? processingAgents . get ( message . to )
283- : undefined ;
284-
285- return (
286- < MessageItem
287- key = { message . id }
288- message = { message }
289- isHighlighted = { message . id === highlightedMessageId }
290- onThreadClick = { onThreadClick }
291- recipientProcessing = { recipientProcessing }
292- currentUser = { currentUser }
293- showTimestamps = { showTimestamps }
294- compactMode = { compactMode }
295- agents = { agents }
296- onlineUsers = { onlineUsers }
297- onAgentClick = { onAgentClick }
298- onUserClick = { onUserClick }
299- onLogsClick = { onLogsClick }
300- />
301- ) ;
302- } ) }
311+ < div
312+ ref = { contentRef }
313+ className = { `flex flex-col ${
314+ compactMode ? 'gap-0 p-1 sm:p-1.5' : 'gap-2 p-3 sm:p-4'
315+ } `}
316+ >
317+ { filteredMessages . map ( ( message ) => {
318+ // Check if message is from current user (Dashboard or GitHub username)
319+ const isFromCurrentUser = message . from === 'Dashboard' ||
320+ ( currentUser && message . from === currentUser . displayName ) ;
321+
322+ // Check if this is the latest message from current user to this recipient
323+ // Only the latest message should show the thinking indicator
324+ const isLatestToRecipient = isFromCurrentUser && message . to !== '*' &&
325+ latestMessageToAgent . get ( message . to ) === message . id ;
326+
327+ // Check if the recipient is currently processing
328+ // Only show thinking indicator for the LATEST message from current user to an agent
329+ const recipientProcessing = isLatestToRecipient
330+ ? processingAgents . get ( message . to )
331+ : undefined ;
332+
333+ return (
334+ < MessageItem
335+ key = { message . id }
336+ message = { message }
337+ isHighlighted = { message . id === highlightedMessageId }
338+ onThreadClick = { onThreadClick }
339+ recipientProcessing = { recipientProcessing }
340+ currentUser = { currentUser }
341+ showTimestamps = { showTimestamps }
342+ compactMode = { compactMode }
343+ agents = { agents }
344+ onlineUsers = { onlineUsers }
345+ onAgentClick = { onAgentClick }
346+ onUserClick = { onUserClick }
347+ onLogsClick = { onLogsClick }
348+ />
349+ ) ;
350+ } ) }
351+ </ div >
303352 </ div >
304353 ) ;
305354}
0 commit comments