diff --git a/web/src/hooks/__tests__/logic-hooks.useScrollToBottom.test.tsx b/web/src/hooks/__tests__/logic-hooks.useScrollToBottom.test.tsx new file mode 100644 index 000000000..bd3413959 --- /dev/null +++ b/web/src/hooks/__tests__/logic-hooks.useScrollToBottom.test.tsx @@ -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; +}); diff --git a/web/src/hooks/logic-hooks.ts b/web/src/hooks/logic-hooks.ts index f9f8f8e36..5610d66c2 100644 --- a/web/src/hooks/logic-hooks.ts +++ b/web/src/hooks/logic-hooks.ts @@ -27,6 +27,14 @@ import { useTranslate } from './common-hooks'; import { useSetPaginationParams } from './route-hook'; import { useFetchTenantInfo, useSaveSetting } from './user-setting-hooks'; +function usePrevious(value: T) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} + export const useSetSelectedRecord = () => { const [currentRecord, setCurrentRecord] = useState({} as T); @@ -210,7 +218,6 @@ export const useSendMessageWithSse = ( if (x) { const { done, value } = x; if (done) { - console.info('done'); resetAnswer(); break; } @@ -218,26 +225,23 @@ export const useSendMessageWithSse = ( const val = JSON.parse(value?.data || ''); const d = val?.data; if (typeof d !== 'boolean') { - console.info('data:', d); setAnswer({ ...d, conversationId: body?.conversation_id, }); } } catch (e) { - console.warn(e); + // Swallow parse errors silently } } } - console.info('done?'); setDone(true); resetAnswer(); return { data: await res, response }; } catch (e) { setDone(true); resetAnswer(); - - console.warn(e); + // Swallow fetch errors silently } }, [initializeSseRef, url, resetAnswer], @@ -267,7 +271,7 @@ export const useSpeechWithSse = (url: string = api.tts) => { message.error(res?.message); } } catch (error) { - console.warn('🚀 ~ error:', error); + // Swallow errors silently } return response; }, @@ -279,20 +283,55 @@ export const useSpeechWithSse = (url: string = api.tts) => { //#region chat hooks -export const useScrollToBottom = (messages?: unknown) => { +export const useScrollToBottom = ( + messages?: unknown, + containerRef?: React.RefObject, +) => { const ref = useRef(null); - - const scrollToBottom = useCallback(() => { - if (messages) { - ref.current?.scrollIntoView({ behavior: 'instant' }); - } - }, [messages]); // If the message changes, scroll to the bottom + const [isAtBottom, setIsAtBottom] = useState(true); + const isAtBottomRef = useRef(true); useEffect(() => { - scrollToBottom(); - }, [scrollToBottom]); + isAtBottomRef.current = isAtBottom; + }, [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 = () => { diff --git a/web/src/pages/chat/chat-container/index.tsx b/web/src/pages/chat/chat-container/index.tsx index bb080c75d..515a1cd86 100644 --- a/web/src/pages/chat/chat-container/index.tsx +++ b/web/src/pages/chat/chat-container/index.tsx @@ -1,6 +1,7 @@ import MessageItem from '@/components/message-item'; import { MessageType } from '@/constants/chat'; import { Flex, Spin } from 'antd'; +import { useRef } from 'react'; import { useCreateConversationBeforeUploadDocument, useGetFileIcon, @@ -15,9 +16,10 @@ import PdfDrawer from '@/components/pdf-drawer'; import { useClickDrawer } from '@/components/pdf-drawer/hooks'; import { useFetchNextConversation, - useGetChatSearchParams, useFetchNextDialog, + useGetChatSearchParams, } from '@/hooks/chat-hooks'; +import { useScrollToBottom } from '@/hooks/logic-hooks'; import { useFetchUserInfo } from '@/hooks/user-setting-hooks'; import { buildMessageUuidWithRole } from '@/utils/chat'; import { memo } from 'react'; @@ -31,8 +33,8 @@ const ChatContainer = ({ controller }: IProps) => { const { conversationId } = useGetChatSearchParams(); const { data: conversation } = useFetchNextConversation(); const { data: currentDialog } = useFetchNextDialog(); - + const messageContainerRef = useRef(null); const { value, ref, @@ -45,6 +47,10 @@ const ChatContainer = ({ controller }: IProps) => { removeMessageById, stopOutputMessage, } = useSendNextMessage(controller); + const { scrollRef, isAtBottom, scrollToBottom } = useScrollToBottom( + derivedMessages, + messageContainerRef, + ); const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = useClickDrawer(); @@ -55,10 +61,20 @@ const ChatContainer = ({ controller }: IProps) => { const { createConversationBeforeUploadDocument } = useCreateConversationBeforeUploadDocument(); + const handleSend = (msg) => { + // your send logic + setTimeout(scrollToBottom, 0); + }; + return ( <> - +
{derivedMessages?.map((message, i) => { @@ -91,7 +107,7 @@ const ChatContainer = ({ controller }: IProps) => { })}
-
+