mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 20:42:30 +08:00
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:
127
web/src/hooks/__tests__/logic-hooks.useScrollToBottom.test.tsx
Normal file
127
web/src/hooks/__tests__/logic-hooks.useScrollToBottom.test.tsx
Normal 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;
|
||||||
|
});
|
||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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';
|
||||||
@ -31,8 +33,8 @@ const ChatContainer = ({ controller }: IProps) => {
|
|||||||
const { conversationId } = useGetChatSearchParams();
|
const { conversationId } = useGetChatSearchParams();
|
||||||
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user