fix(web): keep chat pinned on async content growth

This commit is contained in:
Louis Chu 2026-04-11 03:04:05 -07:00
parent ad2a397137
commit a162f66254
3 changed files with 238 additions and 99 deletions

View file

@ -38,7 +38,7 @@ export const RightPanel = () => {
const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Keep streamed replies pinned unless the user intentionally scrolls away from the bottom.
const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll(
const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll(
chatMessages,
isChatLoading,
);
@ -314,7 +314,7 @@ export const RightPanel = () => {
</div>
</div>
) : (
<div className="flex flex-col gap-6">
<div ref={messagesContainerRef} className="flex flex-col gap-6">
{chatMessages.map((message) => (
<div key={message.id} className="animate-fade-in">
{/* User message - compact label style */}
@ -390,14 +390,12 @@ export const RightPanel = () => {
))}
</div>
)}
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>
{/* Scroll to bottom */}
<button
aria-label="Scroll to bottom"
onClick={scrollToBottom}
onClick={() => scrollToBottom()}
className={`absolute bottom-20 left-1/2 z-10 -translate-x-1/2 rounded-full border border-border-subtle bg-elevated px-3 py-1.5 text-xs text-text-secondary shadow-lg transition-all duration-200 hover:border-accent hover:text-accent ${
!isAtBottom && chatMessages.length > 0
? 'translate-y-0 opacity-100'
@ -405,7 +403,7 @@ export const RightPanel = () => {
}`}
>
<ArrowDown className="mr-1 inline h-3.5 w-3.5" />
New messages
Scroll to bottom
</button>
{/* Input */}

View file

@ -1,45 +1,71 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
const BOTTOM_THRESHOLD = 100;
const DEFAULT_BOTTOM_THRESHOLD = 100;
const USER_SCROLL_EPSILON = 5;
export interface UseAutoScrollResult {
scrollContainerRef: React.RefObject<HTMLDivElement>;
messagesEndRef: React.RefObject<HTMLDivElement>;
messagesContainerRef: React.RefObject<HTMLDivElement>;
isAtBottom: boolean;
scrollToBottom: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
function isNearBottom(element: HTMLDivElement): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= BOTTOM_THRESHOLD;
function isNearBottom(element: HTMLElement, threshold: number): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold;
}
export function useAutoScroll(
chatMessages: unknown[],
export function useAutoScroll<T>(
chatMessages: T[],
isChatLoading: boolean,
bottomThreshold = DEFAULT_BOTTOM_THRESHOLD,
): UseAutoScrollResult {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const shouldStickToBottomRef = useRef(true);
const lastScrollTopRef = useRef(0);
const frameIdRef = useRef<number | null>(null);
const scrollFrameIdRef = useRef<number | null>(null);
const syncScrollState = useCallback(() => {
const element = scrollContainerRef.current;
if (!element) return;
const nearBottom = isNearBottom(element);
const currentScrollTop = element.scrollTop;
const nearBottom = isNearBottom(element, bottomThreshold);
if (nearBottom) {
shouldStickToBottomRef.current = true;
} else if (element.scrollTop < lastScrollTopRef.current) {
} else if (currentScrollTop < lastScrollTopRef.current - USER_SCROLL_EPSILON) {
shouldStickToBottomRef.current = false;
}
lastScrollTopRef.current = element.scrollTop;
lastScrollTopRef.current = currentScrollTop;
setIsAtBottom(nearBottom);
}, []);
}, [bottomThreshold]);
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
const element = scrollContainerRef.current;
if (!element) return;
shouldStickToBottomRef.current = true;
if (behavior === 'auto') {
element.scrollTop = element.scrollHeight;
lastScrollTopRef.current = element.scrollTop;
setIsAtBottom(isNearBottom(element, bottomThreshold));
return;
}
element.scrollTo({
top: element.scrollHeight,
behavior,
});
},
[bottomThreshold],
);
useEffect(() => {
const element = scrollContainerRef.current;
@ -48,12 +74,12 @@ export function useAutoScroll(
lastScrollTopRef.current = element.scrollTop;
const handleScroll = () => {
if (frameIdRef.current !== null) {
cancelAnimationFrame(frameIdRef.current);
if (scrollFrameIdRef.current !== null) {
cancelAnimationFrame(scrollFrameIdRef.current);
}
frameIdRef.current = requestAnimationFrame(() => {
frameIdRef.current = null;
scrollFrameIdRef.current = requestAnimationFrame(() => {
scrollFrameIdRef.current = null;
syncScrollState();
});
};
@ -63,32 +89,57 @@ export function useAutoScroll(
return () => {
element.removeEventListener('scroll', handleScroll);
if (frameIdRef.current !== null) {
cancelAnimationFrame(frameIdRef.current);
frameIdRef.current = null;
if (scrollFrameIdRef.current !== null) {
cancelAnimationFrame(scrollFrameIdRef.current);
scrollFrameIdRef.current = null;
}
};
}, [syncScrollState]);
const jumpToBottom = useCallback(() => {
const element = scrollContainerRef.current;
if (!element) return;
useEffect(() => {
const content = messagesContainerRef.current;
const scrollEl = scrollContainerRef.current;
if (!content || !scrollEl || typeof ResizeObserver === 'undefined') return;
element.scrollTop = element.scrollHeight;
lastScrollTopRef.current = element.scrollTop;
}, []);
let resizeFrameId: number | null = null;
const observer = new ResizeObserver(() => {
if (shouldStickToBottomRef.current) {
if (resizeFrameId !== null) {
cancelAnimationFrame(resizeFrameId);
}
resizeFrameId = requestAnimationFrame(() => {
resizeFrameId = null;
scrollToBottom('auto');
});
} else {
syncScrollState();
}
});
observer.observe(content);
return () => {
observer.disconnect();
if (resizeFrameId !== null) {
cancelAnimationFrame(resizeFrameId);
resizeFrameId = null;
}
};
}, [chatMessages.length, scrollToBottom, syncScrollState]);
useLayoutEffect(() => {
if (!shouldStickToBottomRef.current) return;
jumpToBottom();
setIsAtBottom(true);
}, [chatMessages, isChatLoading, jumpToBottom]);
scrollToBottom('auto');
}, [chatMessages.length, isChatLoading, scrollToBottom]);
const scrollToBottom = useCallback(() => {
shouldStickToBottomRef.current = true;
setIsAtBottom(true);
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, []);
return { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom };
}
return {
scrollContainerRef,
messagesContainerRef,
isAtBottom,
scrollToBottom,
};
}

View file

@ -8,7 +8,7 @@ interface HarnessProps {
}
function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) {
const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll(
const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll(
messages,
isChatLoading,
);
@ -17,9 +17,15 @@ function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) {
<>
<div data-testid="is-at-bottom">{String(isAtBottom)}</div>
<div data-testid="container" ref={scrollContainerRef}>
<div ref={messagesEndRef} />
{messages.length > 0 ? (
<div data-testid="messages-container" ref={messagesContainerRef}>
{messages.map((message, index) => (
<div key={index}>{String(message)}</div>
))}
</div>
) : null}
</div>
<button type="button" onClick={scrollToBottom}>
<button type="button" onClick={() => scrollToBottom()}>
Scroll to bottom
</button>
</>
@ -65,11 +71,34 @@ async function scrollContainer(element: HTMLDivElement, scrollTop: number) {
await flushAnimationFrame();
}
describe('useAutoScroll', () => {
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const resizeObserverInstances: ResizeObserverMock[] = [];
class ResizeObserverMock {
callback: ResizeObserverCallback;
observedElements: Element[] = [];
observe = vi.fn((element: Element) => {
this.observedElements.push(element);
});
unobserve = vi.fn();
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObserverInstances.push(this);
}
}
async function triggerResize(instance: ResizeObserverMock) {
await act(async () => {
instance.callback([], instance as unknown as ResizeObserver);
});
await flushAnimationFrame();
}
describe('useAutoScroll', () => {
beforeEach(() => {
vi.useFakeTimers();
resizeObserverInstances.length = 0;
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
@ -82,30 +111,45 @@ describe('useAutoScroll', () => {
clearTimeout(frameId);
}),
);
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
value: function (options: ScrollToOptions) {
if (options.top !== undefined) {
Object.defineProperty(this, 'scrollTop', {
configurable: true,
writable: true,
value: options.top,
});
}
},
});
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: originalScrollIntoView,
});
});
it('starts with isAtBottom true and auto-scrolls the very first message', () => {
const { rerender } = render(<AutoScrollHarness messages={[]} isChatLoading={false} />);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
expect(container.scrollTop).toBe(500);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('follows streaming updates while the view stays pinned to the bottom', () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, {
scrollTop: 700,
scrollHeight: 1000,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={true} />);
@ -117,22 +161,13 @@ describe('useAutoScroll', () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, {
scrollTop: 700,
scrollHeight: 1000,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
setScrollMetrics(container, {
scrollTop: 250,
scrollHeight: 1400,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(250);
@ -142,58 +177,113 @@ describe('useAutoScroll', () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, {
scrollTop: 700,
scrollHeight: 1000,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
setScrollMetrics(container, {
scrollTop: 1120,
scrollHeight: 1400,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1400, clientHeight: 200 });
await scrollContainer(container, 1120);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
setScrollMetrics(container, {
scrollTop: 1120,
scrollHeight: 1800,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1800, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(1800);
});
it('scrollToBottom re-engages auto-scroll and uses the sentinel element', async () => {
it('scrollToBottom re-engages auto-scroll and scrolls to the container bottom', async () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView);
const scrollTo = vi.spyOn(container, 'scrollTo');
setScrollMetrics(container, {
scrollTop: 700,
scrollHeight: 1000,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
fireEvent.click(screen.getByRole('button', { name: 'Scroll to bottom' }));
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' });
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
setScrollMetrics(container, {
scrollTop: 250,
scrollHeight: 1600,
clientHeight: 200,
});
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1600, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(1600);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('re-pins to the latest bottom when inner content grows asynchronously', async () => {
render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
setScrollMetrics(container, { scrollTop: 1000, scrollHeight: 1450, clientHeight: 200 });
await triggerResize(resizeObserverInstances[0]);
expect(container.scrollTop).toBe(1450);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('does not auto-scroll on async growth after user intentionally scrolls away', async () => {
render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 });
await triggerResize(resizeObserverInstances[0]);
expect(container.scrollTop).toBe(250);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
});
it('cancels the pending ResizeObserver rAF when the component unmounts', () => {
const cancelRAF = vi.mocked(cancelAnimationFrame);
const { unmount } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 950, scrollHeight: 1000, clientHeight: 200 });
const callsBefore = cancelRAF.mock.calls.length;
act(() => {
resizeObserverInstances[0].callback(
[],
resizeObserverInstances[0] as unknown as ResizeObserver,
);
});
unmount();
expect(cancelRAF.mock.calls.length).toBeGreaterThan(callsBefore);
expect(() => vi.runAllTimers()).not.toThrow();
});
it('attaches the observer when the messages wrapper first appears and disconnects on unmount', () => {
const { rerender, unmount } = render(
<AutoScrollHarness messages={[]} isChatLoading={false} />,
);
expect(screen.queryByTestId('messages-container')).toBeNull();
expect(resizeObserverInstances).toHaveLength(0);
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const messagesContainer = screen.getByTestId('messages-container');
const resizeObserver = resizeObserverInstances[0];
expect(resizeObserverInstances).toHaveLength(1);
expect(resizeObserver.observe).toHaveBeenCalledWith(messagesContainer);
unmount();
expect(resizeObserver.disconnect).toHaveBeenCalledTimes(1);
});
});