Fix: Disable Auto-scroll when user looks back through historical chat-Bug 9062 (#9107)

### What problem does this PR solve?

This code allows user chat to auto-scroll down when entered, but if user
scrolls up away from the generative feedback, autoscroll is disabled.
Close #9062 

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)

---------

Co-authored-by: Charles Copley <ccopley@ancera.com>
This commit is contained in:
Charles Copley
2025-07-31 00:13:15 -04:00
committed by GitHub
parent 6a170b2f6e
commit 2bf4ed6512
3 changed files with 203 additions and 21 deletions

View File

@ -0,0 +1,127 @@
jest.mock('eventsource-parser/stream', () => ({}));
import { act, renderHook } from '@testing-library/react';
import { useScrollToBottom } from '../logic-hooks';
function createMockContainer({ atBottom = true } = {}) {
const scrollTop = atBottom ? 100 : 0;
const clientHeight = 100;
const scrollHeight = 200;
const listeners = {};
return {
current: {
scrollTop,
clientHeight,
scrollHeight,
addEventListener: jest.fn((event, cb) => {
listeners[event] = cb;
}),
removeEventListener: jest.fn(),
},
listeners,
} as any;
}
// Helper to flush all timers and microtasks
async function flushAll() {
jest.runAllTimers();
// Flush microtasks
await Promise.resolve();
// Sometimes, effects queue more timers, so run again
jest.runAllTimers();
await Promise.resolve();
}
describe('useScrollToBottom', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should set isAtBottom true when user is at bottom', () => {
const containerRef = createMockContainer({ atBottom: true });
const { result } = renderHook(() => useScrollToBottom([], containerRef));
expect(result.current.isAtBottom).toBe(true);
});
it('should set isAtBottom false when user is not at bottom', () => {
const containerRef = createMockContainer({ atBottom: false });
const { result } = renderHook(() => useScrollToBottom([], containerRef));
expect(result.current.isAtBottom).toBe(false);
});
it('should scroll to bottom when isAtBottom is true and messages change', async () => {
const containerRef = createMockContainer({ atBottom: true });
const mockScroll = jest.fn();
function useTestScrollToBottom(messages: any, containerRef: any) {
const hook = useScrollToBottom(messages, containerRef);
hook.scrollRef.current = { scrollIntoView: mockScroll } as any;
return hook;
}
const { rerender } = renderHook(
({ messages }) => useTestScrollToBottom(messages, containerRef),
{ initialProps: { messages: [] } },
);
rerender({ messages: ['msg1'] });
await flushAll();
expect(mockScroll).toHaveBeenCalled();
});
it('should NOT scroll to bottom when isAtBottom is false and messages change', async () => {
const containerRef = createMockContainer({ atBottom: false });
const mockScroll = jest.fn();
function useTestScrollToBottom(messages: any, containerRef: any) {
const hook = useScrollToBottom(messages, containerRef);
hook.scrollRef.current = { scrollIntoView: mockScroll } as any;
console.log('HOOK: isAtBottom:', hook.isAtBottom);
return hook;
}
const { result, rerender } = renderHook(
({ messages }) => useTestScrollToBottom(messages, containerRef),
{ initialProps: { messages: [] } },
);
// Simulate user scrolls up before messages change
await act(async () => {
containerRef.current.scrollTop = 0;
containerRef.current.addEventListener.mock.calls[0][1]();
await flushAll();
// Advance fake timers by 10ms instead of real setTimeout
jest.advanceTimersByTime(10);
console.log('AFTER SCROLL: isAtBottom:', result.current.isAtBottom);
});
rerender({ messages: ['msg1'] });
await flushAll();
console.log('AFTER RERENDER: isAtBottom:', result.current.isAtBottom);
expect(mockScroll).not.toHaveBeenCalled();
// Optionally, flush again after the assertion to see if it gets called late
await flushAll();
});
it('should indicate button should appear when user is not at bottom', () => {
const containerRef = createMockContainer({ atBottom: false });
const { result } = renderHook(() => useScrollToBottom([], containerRef));
// The button should appear in the UI when isAtBottom is false
expect(result.current.isAtBottom).toBe(false);
});
});
const originalRAF = global.requestAnimationFrame;
beforeAll(() => {
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
});
afterAll(() => {
global.requestAnimationFrame = originalRAF;
});

View File

@ -27,6 +27,14 @@ import { useTranslate } from './common-hooks';
import { useSetPaginationParams } from './route-hook'; import { useSetPaginationParams } from './route-hook';
import { useFetchTenantInfo, useSaveSetting } from './user-setting-hooks'; import { useFetchTenantInfo, useSaveSetting } from './user-setting-hooks';
function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
export const useSetSelectedRecord = <T = IKnowledgeFile>() => { export const useSetSelectedRecord = <T = IKnowledgeFile>() => {
const [currentRecord, setCurrentRecord] = useState<T>({} as T); const [currentRecord, setCurrentRecord] = useState<T>({} as T);
@ -210,7 +218,6 @@ export const useSendMessageWithSse = (
if (x) { if (x) {
const { done, value } = x; const { done, value } = x;
if (done) { if (done) {
console.info('done');
resetAnswer(); resetAnswer();
break; break;
} }
@ -218,26 +225,23 @@ export const useSendMessageWithSse = (
const val = JSON.parse(value?.data || ''); const val = JSON.parse(value?.data || '');
const d = val?.data; const d = val?.data;
if (typeof d !== 'boolean') { if (typeof d !== 'boolean') {
console.info('data:', d);
setAnswer({ setAnswer({
...d, ...d,
conversationId: body?.conversation_id, conversationId: body?.conversation_id,
}); });
} }
} catch (e) { } catch (e) {
console.warn(e); // Swallow parse errors silently
} }
} }
} }
console.info('done?');
setDone(true); setDone(true);
resetAnswer(); resetAnswer();
return { data: await res, response }; return { data: await res, response };
} catch (e) { } catch (e) {
setDone(true); setDone(true);
resetAnswer(); resetAnswer();
// Swallow fetch errors silently
console.warn(e);
} }
}, },
[initializeSseRef, url, resetAnswer], [initializeSseRef, url, resetAnswer],
@ -267,7 +271,7 @@ export const useSpeechWithSse = (url: string = api.tts) => {
message.error(res?.message); message.error(res?.message);
} }
} catch (error) { } catch (error) {
console.warn('🚀 ~ error:', error); // Swallow errors silently
} }
return response; return response;
}, },
@ -279,20 +283,55 @@ export const useSpeechWithSse = (url: string = api.tts) => {
//#region chat hooks //#region chat hooks
export const useScrollToBottom = (messages?: unknown) => { export const useScrollToBottom = (
messages?: unknown,
containerRef?: React.RefObject<HTMLDivElement>,
) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const scrollToBottom = useCallback(() => { const isAtBottomRef = useRef(true);
if (messages) {
ref.current?.scrollIntoView({ behavior: 'instant' });
}
}, [messages]); // If the message changes, scroll to the bottom
useEffect(() => { useEffect(() => {
scrollToBottom(); isAtBottomRef.current = isAtBottom;
}, [scrollToBottom]); }, [isAtBottom]);
return ref; const checkIfUserAtBottom = useCallback(() => {
if (!containerRef?.current) return true;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
return Math.abs(scrollTop + clientHeight - scrollHeight) < 25;
}, [containerRef]);
useEffect(() => {
if (!containerRef?.current) return;
const container = containerRef.current;
const handleScroll = () => {
setIsAtBottom(checkIfUserAtBottom());
};
container.addEventListener('scroll', handleScroll);
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [containerRef, checkIfUserAtBottom]);
useEffect(() => {
if (!messages) return;
if (!containerRef?.current) return;
requestAnimationFrame(() => {
setTimeout(() => {
if (isAtBottomRef.current) {
ref.current?.scrollIntoView({ behavior: 'smooth' });
}
}, 30);
});
}, [messages, containerRef]);
// Imperative scroll function
const scrollToBottom = useCallback(() => {
ref.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
return { scrollRef: ref, isAtBottom, scrollToBottom };
}; };
export const useHandleMessageInputChange = () => { export const useHandleMessageInputChange = () => {

View File

@ -1,6 +1,7 @@
import MessageItem from '@/components/message-item'; import MessageItem from '@/components/message-item';
import { MessageType } from '@/constants/chat'; import { MessageType } from '@/constants/chat';
import { Flex, Spin } from 'antd'; import { Flex, Spin } from 'antd';
import { useRef } from 'react';
import { import {
useCreateConversationBeforeUploadDocument, useCreateConversationBeforeUploadDocument,
useGetFileIcon, useGetFileIcon,
@ -15,9 +16,10 @@ import PdfDrawer from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks'; import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { import {
useFetchNextConversation, useFetchNextConversation,
useGetChatSearchParams,
useFetchNextDialog, useFetchNextDialog,
useGetChatSearchParams,
} from '@/hooks/chat-hooks'; } from '@/hooks/chat-hooks';
import { useScrollToBottom } from '@/hooks/logic-hooks';
import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; import { useFetchUserInfo } from '@/hooks/user-setting-hooks';
import { buildMessageUuidWithRole } from '@/utils/chat'; import { buildMessageUuidWithRole } from '@/utils/chat';
import { memo } from 'react'; import { memo } from 'react';
@ -32,7 +34,7 @@ const ChatContainer = ({ controller }: IProps) => {
const { data: conversation } = useFetchNextConversation(); const { data: conversation } = useFetchNextConversation();
const { data: currentDialog } = useFetchNextDialog(); const { data: currentDialog } = useFetchNextDialog();
const messageContainerRef = useRef<HTMLDivElement>(null);
const { const {
value, value,
ref, ref,
@ -45,6 +47,10 @@ const ChatContainer = ({ controller }: IProps) => {
removeMessageById, removeMessageById,
stopOutputMessage, stopOutputMessage,
} = useSendNextMessage(controller); } = useSendNextMessage(controller);
const { scrollRef, isAtBottom, scrollToBottom } = useScrollToBottom(
derivedMessages,
messageContainerRef,
);
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
useClickDrawer(); useClickDrawer();
@ -55,10 +61,20 @@ const ChatContainer = ({ controller }: IProps) => {
const { createConversationBeforeUploadDocument } = const { createConversationBeforeUploadDocument } =
useCreateConversationBeforeUploadDocument(); useCreateConversationBeforeUploadDocument();
const handleSend = (msg) => {
// your send logic
setTimeout(scrollToBottom, 0);
};
return ( return (
<> <>
<Flex flex={1} className={styles.chatContainer} vertical> <Flex flex={1} className={styles.chatContainer} vertical>
<Flex flex={1} vertical className={styles.messageContainer}> <Flex
flex={1}
vertical
className={styles.messageContainer}
ref={messageContainerRef}
>
<div> <div>
<Spin spinning={loading}> <Spin spinning={loading}>
{derivedMessages?.map((message, i) => { {derivedMessages?.map((message, i) => {
@ -91,7 +107,7 @@ const ChatContainer = ({ controller }: IProps) => {
})} })}
</Spin> </Spin>
</div> </div>
<div ref={ref} /> <div ref={scrollRef} />
</Flex> </Flex>
<MessageInput <MessageInput
disabled={disabled} disabled={disabled}