Skip to content

Commit 367fa4a

Browse files
authored
🤖 fix: scroll to bottom on workspace switch using ResizeObserver (#911)
Fixes workspace switch not scrolling to bottom reliably. ## Problem Clicking a workspace didn't always scroll to the bottom because async content (Shiki highlighting, Mermaid diagrams, images) rendered after the double `requestAnimationFrame` scroll completed. ## Solution Replace double RAF with a `ResizeObserver` on the inner message container. When the container grows during the auto-scroll window, we re-scroll to bottom. The observer is managed via a callback ref that properly cleans up on unmount/re-attach. _Generated with `mux`_
1 parent d4d6816 commit 367fa4a

File tree

2 files changed

+48
-13
lines changed

2 files changed

+48
-13
lines changed

src/browser/components/AIView.tsx

Lines changed: 5 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,10 @@ 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
505+
ref={innerRef}
506+
className={cn("max-w-4xl mx-auto", mergedMessages.length === 0 && "h-full")}
507+
>
504508
{mergedMessages.length === 0 ? (
505509
<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]">
506510
<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)