Skip to content

Commit 297bab9

Browse files
committed
🤖 fix: scroll to bottom on workspace switch using ResizeObserver
The previous double requestAnimationFrame approach failed when async content (Shiki highlighting, Mermaid diagrams, images) rendered after the scroll completed. Now we observe the inner message container for size changes during the auto-scroll window and re-scroll as needed. Changes: - Add ResizeObserver in useAutoScroll that triggers scroll on growth - Return innerRef callback ref from hook for attaching to content div - Wire up innerRef in AIView's message container _Generated with `mux`_
1 parent 3a84ef4 commit 297bab9

File tree

2 files changed

+45
-13
lines changed

2 files changed

+45
-13
lines changed

src/browser/components/AIView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
209209
// Use auto-scroll hook for scroll management
210210
const {
211211
contentRef,
212+
innerRef,
212213
autoScroll,
213214
setAutoScroll,
214215
performAutoScroll,
@@ -500,7 +501,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
500501
data-testid="message-window"
501502
className="h-full overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap"
502503
>
503-
<div className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}>
504+
<div ref={innerRef} className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}>
504505
{mergedMessages.length === 0 ? (
505506
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
506507
<h3>No Messages Yet</h3>

src/browser/hooks/useAutoScroll.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { useRef, useState, useCallback } from "react";
22

33
/**
4-
* Hook to manage auto-scrolling behavior for a scrollable container
4+
* Hook to manage auto-scrolling behavior for a scrollable container.
5+
*
6+
* Scroll container structure expected:
7+
* <div ref={contentRef}> ← scroll container (overflow-y: auto)
8+
* <div ref={innerRef}> ← inner content wrapper (observed for size changes)
9+
* {children}
10+
* </div>
11+
* </div>
512
*
613
* Auto-scroll is enabled when:
714
* - User sends a message
@@ -17,10 +24,35 @@ export function useAutoScroll() {
1724
const lastUserInteractionRef = useRef<number>(0);
1825
// Ref to avoid stale closures in async callbacks - always holds current autoScroll value
1926
const autoScrollRef = useRef<boolean>(true);
27+
// Track the ResizeObserver so we can disconnect it when the element unmounts
28+
const observerRef = useRef<ResizeObserver | null>(null);
2029

2130
// Sync ref with state to ensure callbacks always have latest value
2231
autoScrollRef.current = autoScroll;
2332

33+
// Callback ref for the inner content wrapper - sets up ResizeObserver when element mounts.
34+
// ResizeObserver fires when the content size changes (Shiki highlighting, Mermaid, images, etc.),
35+
// allowing us to scroll to bottom even when async content renders after the initial mount.
36+
const innerRef = useCallback((element: HTMLDivElement | null) => {
37+
// Cleanup previous observer if any
38+
if (observerRef.current) {
39+
observerRef.current.disconnect();
40+
observerRef.current = null;
41+
}
42+
43+
if (!element) return;
44+
45+
const observer = new ResizeObserver(() => {
46+
// Only auto-scroll if enabled - user may have scrolled up
47+
if (autoScrollRef.current && contentRef.current) {
48+
contentRef.current.scrollTop = contentRef.current.scrollHeight;
49+
}
50+
});
51+
52+
observer.observe(element);
53+
observerRef.current = observer;
54+
}, []);
55+
2456
const performAutoScroll = useCallback(() => {
2557
if (!contentRef.current) return;
2658

@@ -38,18 +70,14 @@ export function useAutoScroll() {
3870
}, []); // No deps - ref ensures we always check current value
3971

4072
const jumpToBottom = useCallback(() => {
41-
if (!contentRef.current) return;
42-
43-
// Double RAF: First frame for DOM updates (async highlighting, image loads),
44-
// second frame to scroll after layout is complete
45-
requestAnimationFrame(() => {
46-
requestAnimationFrame(() => {
47-
if (contentRef.current) {
48-
contentRef.current.scrollTop = contentRef.current.scrollHeight;
49-
}
50-
});
51-
});
73+
// Enable auto-scroll first so ResizeObserver will handle subsequent changes
5274
setAutoScroll(true);
75+
autoScrollRef.current = true;
76+
77+
// Immediate scroll for content that's already rendered
78+
if (contentRef.current) {
79+
contentRef.current.scrollTop = contentRef.current.scrollHeight;
80+
}
5381
}, []);
5482

5583
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
@@ -73,9 +101,11 @@ export function useAutoScroll() {
73101
if (isScrollingUp) {
74102
// Always disable auto-scroll when scrolling up
75103
setAutoScroll(false);
104+
autoScrollRef.current = false;
76105
} else if (isScrollingDown && isAtBottom) {
77106
// Only enable auto-scroll if scrolling down AND reached the bottom
78107
setAutoScroll(true);
108+
autoScrollRef.current = true;
79109
}
80110
// If scrolling down but not at bottom, auto-scroll remains disabled
81111

@@ -89,6 +119,7 @@ export function useAutoScroll() {
89119

90120
return {
91121
contentRef,
122+
innerRef,
92123
autoScroll,
93124
setAutoScroll,
94125
performAutoScroll,

0 commit comments

Comments
 (0)