Skip to content

Commit a2cf157

Browse files
committed
better scroll logic
1 parent bbea128 commit a2cf157

1 file changed

Lines changed: 86 additions & 37 deletions

File tree

packages/dashboard/src/components/MessageList.tsx

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)