🐛 fix: fix auto scroll (#11734)

* fix auto scroll

* fix auto scroll

* Update DebugInspector.tsx
This commit is contained in:
Arvin Xu
2026-01-23 20:00:20 +08:00
committed by GitHub
parent b15d821ddb
commit 892fa9fac3
8 changed files with 683 additions and 80 deletions

View File

@@ -40,7 +40,7 @@ const DebugInspector = memo(() => {
style={{
background: 'rgba(0,0,0,0.9)',
borderRadius: 8,
bottom: 80,
bottom: 135,
display: 'flex',
fontFamily: 'monospace',
fontSize: 11,

View File

@@ -8,9 +8,14 @@ import {
useConversationStore,
virtuaListSelectors,
} from '../../../store';
import BackBottom from '../BackBottom';
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from './DebugInspector';
/**
* AutoScroll component - handles auto-scrolling logic during AI generation.
* Should be placed inside the last item of VList so it only triggers when visible.
*
* This component has no visual output - it only contains the auto-scroll logic.
* Debug UI and BackBottom button are rendered separately outside VList.
*/
const AutoScroll = memo(() => {
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
@@ -31,54 +36,8 @@ const AutoScroll = memo(() => {
}
}, [shouldAutoScroll, scrollToBottom, dbMessages.length, lastMessageContentLength]);
return (
<div style={{ position: 'relative', width: '100%' }}>
{OPEN_DEV_INSPECTOR && (
<>
{/* Threshold 区域顶部边界线 */}
<div
style={{
background: atBottom ? '#22c55e' : '#ef4444',
height: 2,
left: 0,
opacity: 0.5,
pointerEvents: 'none',
position: 'absolute',
right: 0,
top: -AT_BOTTOM_THRESHOLD,
}}
/>
{/* Threshold 区域 mask - 显示在指示线上方 */}
<div
style={{
background: atBottom
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
height: AT_BOTTOM_THRESHOLD,
left: 0,
pointerEvents: 'none',
position: 'absolute',
right: 0,
top: -AT_BOTTOM_THRESHOLD,
}}
/>
{/* AutoScroll 位置指示线(底部) */}
<div
style={{
background: atBottom ? '#22c55e' : '#ef4444',
height: 2,
position: 'relative',
width: '100%',
}}
/>
</>
)}
<BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
</div>
);
// No visual output - this component only handles auto-scroll logic
return null;
});
AutoScroll.displayName = 'ConversationAutoScroll';

View File

@@ -4,30 +4,83 @@ import { ArrowDownIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { AT_BOTTOM_THRESHOLD, OPEN_DEV_INSPECTOR } from '../AutoScroll/DebugInspector';
import { styles } from './style';
export interface BackBottomProps {
atBottom: boolean;
onScrollToBottom: () => void;
visible: boolean;
}
const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
const BackBottom = memo<BackBottomProps>(({ visible, atBottom, onScrollToBottom }) => {
const { t } = useTranslation('chat');
return (
<ActionIcon
className={cx(styles.container, visible && styles.visible)}
glass
icon={ArrowDownIcon}
onClick={onScrollToBottom}
size={{
blockSize: 36,
borderRadius: 36,
size: 18,
}}
title={t('backToBottom')}
variant={'outlined'}
/>
<>
{/* Debug: 底部指示线 */}
{OPEN_DEV_INSPECTOR && (
<div
style={{
bottom: 0,
left: 0,
pointerEvents: 'none',
position: 'absolute',
right: 0,
}}
>
{/* Threshold 区域顶部边界线 */}
<div
style={{
background: atBottom ? '#22c55e' : '#ef4444',
height: 2,
left: 0,
opacity: 0.5,
position: 'absolute',
right: 0,
top: -AT_BOTTOM_THRESHOLD,
}}
/>
{/* Threshold 区域 mask - 显示在指示线上方 */}
<div
style={{
background: atBottom
? 'linear-gradient(to top, rgba(34, 197, 94, 0.15), transparent)'
: 'linear-gradient(to top, rgba(239, 68, 68, 0.1), transparent)',
height: AT_BOTTOM_THRESHOLD,
left: 0,
position: 'absolute',
right: 0,
top: -AT_BOTTOM_THRESHOLD,
}}
/>
{/* AutoScroll 位置指示线(底部) */}
<div
style={{
background: atBottom ? '#22c55e' : '#ef4444',
height: 2,
width: '100%',
}}
/>
</div>
)}
<ActionIcon
className={cx(styles.container, visible && styles.visible)}
glass
icon={ArrowDownIcon}
onClick={onScrollToBottom}
size={{
blockSize: 36,
borderRadius: 36,
size: 18,
}}
title={t('backToBottom')}
variant={'outlined'}
/>
</>
);
});

View File

@@ -5,12 +5,14 @@ import { type ReactElement, type ReactNode, memo, useCallback, useEffect, useRef
import { VList, type VListHandle } from 'virtua';
import WideScreenContainer from '../../../WideScreenContainer';
import { useConversationStore, virtuaListSelectors } from '../../store';
import { dataSelectors, useConversationStore, virtuaListSelectors } from '../../store';
import { useScrollToUserMessage } from '../hooks/useScrollToUserMessage';
import AutoScroll from './AutoScroll';
import DebugInspector, {
AT_BOTTOM_THRESHOLD,
OPEN_DEV_INSPECTOR,
} from './AutoScroll/DebugInspector';
import BackBottom from './BackBottom';
interface VirtualizedListProps {
dataSource: string[];
@@ -24,7 +26,6 @@ interface VirtualizedListProps {
*/
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
const virtuaRef = useRef<VListHandle>(null);
const prevDataLengthRef = useRef(dataSource.length);
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Store actions
@@ -112,15 +113,18 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
};
}, [resetVisibleItems]);
// Auto scroll to bottom when new messages arrive
useEffect(() => {
const shouldScroll = dataSource.length > prevDataLengthRef.current;
prevDataLengthRef.current = dataSource.length;
// Get the last message to check if it's a user message
const displayMessages = useConversationStore(dataSelectors.displayMessages);
const lastMessage = displayMessages.at(-1);
const isLastMessageFromUser = lastMessage?.role === 'user';
if (shouldScroll && virtuaRef.current) {
virtuaRef.current.scrollToIndex(dataSource.length - 2, { align: 'start', smooth: true });
}
}, [dataSource.length]);
// Auto scroll to user message when user sends a new message
// Only scroll when the new message is from the user, not when AI/agent responds
useScrollToUserMessage({
dataSourceLength: dataSource.length,
isLastMessageFromUser,
scrollToIndex: virtuaRef.current?.scrollToIndex ?? null,
});
// Scroll to bottom on initial render
useEffect(() => {
@@ -129,8 +133,11 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
}
}, []);
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
return (
<>
<div style={{ height: '100%', position: 'relative' }}>
{/* Debug Inspector - 放在 VList 外面,不会被虚拟列表回收 */}
{OPEN_DEV_INSPECTOR && <DebugInspector />}
<VList
@@ -143,15 +150,16 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
>
{(messageId, index): ReactElement => {
const isAgentCouncil = messageId.includes('agentCouncil');
const isLastItem = index === dataSource.length - 1;
const content = itemContent(index, messageId);
const isLast = index === dataSource.length - 1;
if (isAgentCouncil) {
// AgentCouncil needs full width for horizontal scroll
return (
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
{content}
{isLast && <AutoScroll />}
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
{isLastItem && <AutoScroll />}
</div>
);
}
@@ -159,12 +167,15 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
return (
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
{content}
{isLast && <AutoScroll />}
{/* AutoScroll 放在最后一个 Item 里面,这样只有当最后一个 Item 可见时才会触发自动滚动 */}
{isLastItem && <AutoScroll />}
</WideScreenContainer>
);
}}
</VList>
</>
{/* BackBottom 放在 VList 外面,这样无论滚动到哪里都能看到 */}
<BackBottom atBottom={atBottom} onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />
</div>
);
}, isEqual);

View File

@@ -0,0 +1,224 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useScrollToUserMessage } from './useScrollToUserMessage';
describe('useScrollToUserMessage', () => {
describe('when user sends a new message', () => {
it('should scroll to user message when new message is from user', () => {
const scrollToIndex = vi.fn();
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}),
{
initialProps: {
dataSourceLength: 2,
isLastMessageFromUser: false,
},
},
);
// User sends a new message (length increases, last message is from user)
rerender({
dataSourceLength: 3,
isLastMessageFromUser: true,
});
expect(scrollToIndex).toHaveBeenCalledTimes(1);
expect(scrollToIndex).toHaveBeenCalledWith(1, { align: 'start', smooth: true });
});
it('should scroll to correct index when multiple user messages are sent', () => {
const scrollToIndex = vi.fn();
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}),
{
initialProps: {
dataSourceLength: 5,
isLastMessageFromUser: false,
},
},
);
// User sends a new message
rerender({
dataSourceLength: 6,
isLastMessageFromUser: true,
});
// Should scroll to index 4 (dataSourceLength - 2 = 6 - 2 = 4)
expect(scrollToIndex).toHaveBeenCalledWith(4, { align: 'start', smooth: true });
});
});
describe('when AI/agent responds', () => {
it('should NOT scroll when new message is from AI', () => {
const scrollToIndex = vi.fn();
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}),
{
initialProps: {
dataSourceLength: 2,
isLastMessageFromUser: true,
},
},
);
// AI responds (length increases, but last message is NOT from user)
rerender({
dataSourceLength: 3,
isLastMessageFromUser: false,
});
expect(scrollToIndex).not.toHaveBeenCalled();
});
it('should NOT scroll when multiple agents respond in group chat', () => {
const scrollToIndex = vi.fn();
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}),
{
initialProps: {
dataSourceLength: 3,
isLastMessageFromUser: false,
},
},
);
// First agent responds
rerender({
dataSourceLength: 4,
isLastMessageFromUser: false,
});
expect(scrollToIndex).not.toHaveBeenCalled();
// Second agent responds
rerender({
dataSourceLength: 5,
isLastMessageFromUser: false,
});
expect(scrollToIndex).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should NOT scroll when length decreases (message deleted)', () => {
const scrollToIndex = vi.fn();
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}),
{
initialProps: {
dataSourceLength: 5,
isLastMessageFromUser: true,
},
},
);
// Message deleted (length decreases)
rerender({
dataSourceLength: 4,
isLastMessageFromUser: true,
});
expect(scrollToIndex).not.toHaveBeenCalled();
});
it('should NOT scroll when length stays the same', () => {
const scrollToIndex = vi.fn();
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}),
{
initialProps: {
dataSourceLength: 3,
isLastMessageFromUser: true,
},
},
);
// Length stays the same (content update, not new message)
rerender({
dataSourceLength: 3,
isLastMessageFromUser: true,
});
expect(scrollToIndex).not.toHaveBeenCalled();
});
it('should handle null scrollToIndex gracefully', () => {
const { rerender } = renderHook(
({ dataSourceLength, isLastMessageFromUser }) =>
useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex: null,
}),
{
initialProps: {
dataSourceLength: 2,
isLastMessageFromUser: false,
},
},
);
// Should not throw when scrollToIndex is null
expect(() => {
rerender({
dataSourceLength: 3,
isLastMessageFromUser: true,
});
}).not.toThrow();
});
it('should NOT scroll on initial render', () => {
const scrollToIndex = vi.fn();
renderHook(() =>
useScrollToUserMessage({
dataSourceLength: 5,
isLastMessageFromUser: true,
scrollToIndex,
}),
);
// Should not scroll on initial render even if last message is from user
expect(scrollToIndex).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react';
interface UseScrollToUserMessageOptions {
/**
* Current data source length (number of messages)
*/
dataSourceLength: number;
/**
* Whether the last message is from the user
*/
isLastMessageFromUser: boolean;
/**
* Function to scroll to a specific index
*/
scrollToIndex:
| ((index: number, options?: { align?: 'start' | 'center' | 'end'; smooth?: boolean }) => void)
| null;
}
/**
* Hook to handle scrolling to user message when user sends a new message.
* Only triggers scroll when the new message is from the user, not when AI/agent responds.
*
* This ensures that in group chat scenarios, when multiple agents are responding,
* the view doesn't jump around as each agent starts speaking.
*/
export function useScrollToUserMessage({
dataSourceLength,
isLastMessageFromUser,
scrollToIndex,
}: UseScrollToUserMessageOptions): void {
const prevLengthRef = useRef(dataSourceLength);
useEffect(() => {
const hasNewMessage = dataSourceLength > prevLengthRef.current;
prevLengthRef.current = dataSourceLength;
// Only scroll when user sends a new message
if (hasNewMessage && isLastMessageFromUser && scrollToIndex) {
// Scroll to the second-to-last message (user's message) with the start aligned
scrollToIndex(dataSourceLength - 2, { align: 'start', smooth: true });
}
}, [dataSourceLength, isLastMessageFromUser, scrollToIndex]);
}

View File

@@ -0,0 +1,289 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAutoScroll } from './useAutoScroll';
describe('useAutoScroll', () => {
let rafCallbacks: FrameRequestCallback[] = [];
beforeEach(() => {
rafCallbacks = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
const flushRAF = () => {
const callbacks = [...rafCallbacks];
rafCallbacks = [];
callbacks.forEach((cb) => cb(performance.now()));
};
const createMockContainer = (scrollTop = 0, scrollHeight = 1000, clientHeight = 400) => {
return {
clientHeight,
scrollHeight,
scrollTop,
} as HTMLDivElement;
};
describe('when enabled changes from true to false (streaming ends)', () => {
it('should maintain scroll position when streaming ends', () => {
const { result, rerender } = renderHook(
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
{ initialProps: { content: 'initial', enabled: true } },
);
// Simulate container scrolled to bottom (scrollTop = scrollHeight - clientHeight = 600)
const mockContainer = createMockContainer(600, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
// Trigger auto-scroll with content change while streaming
rerender({ content: 'updated content', enabled: true });
act(() => {
flushRAF();
flushRAF();
});
// Should scroll to bottom (scrollTop = scrollHeight = 1000)
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
// Record scroll position before disabling
const scrollPositionBeforeDisable = mockContainer.scrollTop;
// Now simulate streaming end: enabled becomes false
// This is where the bug occurs - the hook stops maintaining scroll position
rerender({ content: 'final content', enabled: false });
act(() => {
flushRAF();
flushRAF();
});
// BUG TEST: After enabled becomes false, scroll position should be maintained
// Currently, the hook doesn't actively preserve position when disabled,
// which can cause scroll to reset when DOM changes occur
expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
});
it('should actively restore scroll position when DOM resets it after enabled becomes false', () => {
const { result, rerender } = renderHook(
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
{ initialProps: { content: 'initial', enabled: true } },
);
const mockContainer = createMockContainer(600, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
// Auto-scroll to bottom while streaming
rerender({ content: 'streaming content...', enabled: true });
act(() => {
flushRAF();
flushRAF();
});
expect(mockContainer.scrollTop).toBe(1000);
// Record the scroll position at bottom
const scrollPositionAtBottom = mockContainer.scrollTop;
// Streaming ends - enabled becomes false
rerender({ content: 'final content', enabled: false });
// Simulate DOM change that resets scroll position to top
// This happens in real browsers when content re-renders
mockContainer.scrollTop = 0;
act(() => {
flushRAF();
flushRAF();
});
// BUG: The hook should restore scroll position when enabled transitions from true to false
// Currently it does nothing when enabled=false, so scroll position stays at 0
// Expected behavior: hook should detect enabled transition and restore position
expect(mockContainer.scrollTop).toBe(scrollPositionAtBottom);
});
it('should preserve scroll position when user has scrolled and streaming ends', () => {
const { result, rerender } = renderHook(
({ content, enabled }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled }),
{ initialProps: { content: 'initial', enabled: true } },
);
// Container at middle position (user scrolled up)
const mockContainer = createMockContainer(300, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
// Simulate user scroll (triggers userHasScrolled = true)
act(() => {
result.current.handleScroll();
});
expect(result.current.userHasScrolled).toBe(true);
const scrollPositionBeforeDisable = mockContainer.scrollTop;
// Streaming ends
rerender({ content: 'final content', enabled: false });
act(() => {
flushRAF();
flushRAF();
});
// Position should remain unchanged
expect(mockContainer.scrollTop).toBe(scrollPositionBeforeDisable);
});
});
describe('basic auto-scroll functionality', () => {
it('should auto-scroll to bottom when deps change and enabled is true', () => {
const { result, rerender } = renderHook(
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
{ initialProps: { content: 'initial' } },
);
const mockContainer = createMockContainer(0, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
rerender({ content: 'new content' });
act(() => {
flushRAF();
flushRAF();
});
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
});
it('should not auto-scroll when enabled is false', () => {
const { result, rerender } = renderHook(
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: false }),
{ initialProps: { content: 'initial' } },
);
const mockContainer = createMockContainer(100, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
const initialScrollTop = mockContainer.scrollTop;
rerender({ content: 'new content' });
act(() => {
flushRAF();
flushRAF();
});
expect(mockContainer.scrollTop).toBe(initialScrollTop);
});
it('should stop auto-scroll when user scrolls away from bottom', () => {
const { result, rerender } = renderHook(
({ content }) =>
useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true, threshold: 20 }),
{ initialProps: { content: 'initial' } },
);
// Container NOT at bottom (distance to bottom > threshold)
const mockContainer = createMockContainer(100, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
// Simulate user scroll event
act(() => {
result.current.handleScroll();
});
expect(result.current.userHasScrolled).toBe(true);
// Content changes but should not auto-scroll due to user scroll lock
const scrollTopBeforeUpdate = mockContainer.scrollTop;
rerender({ content: 'new content' });
act(() => {
flushRAF();
flushRAF();
});
expect(mockContainer.scrollTop).toBe(scrollTopBeforeUpdate);
});
it('should reset scroll lock when resetScrollLock is called', () => {
const { result, rerender } = renderHook(
({ content }) => useAutoScroll<HTMLDivElement>({ deps: [content], enabled: true }),
{ initialProps: { content: 'initial' } },
);
const mockContainer = createMockContainer(100, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
// User scrolls away
act(() => {
result.current.handleScroll();
});
expect(result.current.userHasScrolled).toBe(true);
// Reset scroll lock
act(() => {
result.current.resetScrollLock();
});
expect(result.current.userHasScrolled).toBe(false);
// Now auto-scroll should work again
rerender({ content: 'new content' });
act(() => {
flushRAF();
flushRAF();
});
expect(mockContainer.scrollTop).toBe(mockContainer.scrollHeight);
});
});
describe('threshold behavior', () => {
it('should not set userHasScrolled when at bottom within threshold', () => {
const { result } = renderHook(() =>
useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
);
// Container at bottom (distance = scrollHeight - scrollTop - clientHeight = 1000 - 590 - 400 = 10 < 20)
const mockContainer = createMockContainer(590, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
act(() => {
result.current.handleScroll();
});
// Should NOT set userHasScrolled because we're within threshold
expect(result.current.userHasScrolled).toBe(false);
});
it('should set userHasScrolled when scrolled beyond threshold', () => {
const { result } = renderHook(() =>
useAutoScroll<HTMLDivElement>({ deps: [], enabled: true, threshold: 20 }),
);
// Container NOT at bottom (distance = 1000 - 500 - 400 = 100 > 20)
const mockContainer = createMockContainer(500, 1000, 400);
(result.current.ref as { current: HTMLDivElement | null }).current = mockContainer;
act(() => {
result.current.handleScroll();
});
expect(result.current.userHasScrolled).toBe(true);
});
});
});

View File

@@ -67,6 +67,7 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
const ref = useRef<T | null>(null);
const [userHasScrolled, setUserHasScrolled] = useState(false);
const isAutoScrollingRef = useRef(false);
const prevEnabledRef = useRef(enabled);
// Handle user scroll detection
const handleScroll = useCallback(() => {
@@ -91,6 +92,28 @@ export function useAutoScroll<T extends HTMLElement = HTMLDivElement>(
setUserHasScrolled(false);
}, []);
// Preserve scroll position when enabled transitions from true to false (streaming ends)
// This prevents scroll position from being lost when DOM re-renders after streaming
useEffect(() => {
const container = ref.current;
if (!container) return;
// Detect enabled transition from true to false
if (prevEnabledRef.current && !enabled) {
const currentScrollTop = container.scrollTop;
isAutoScrollingRef.current = true;
requestAnimationFrame(() => {
// Restore scroll position in case DOM changes reset it
container.scrollTop = currentScrollTop;
requestAnimationFrame(() => {
isAutoScrollingRef.current = false;
});
});
}
prevEnabledRef.current = enabled;
}, [enabled]);
// Auto scroll to bottom when deps change (unless user has scrolled or disabled)
useEffect(() => {
if (!enabled || userHasScrolled) return;