Explorar el Código

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>
tags/v0.20.0
Charles Copley hace 3 meses
padre
commit
2bf4ed6512
No account linked to committer's email address

+ 127
- 0
web/src/hooks/__tests__/logic-hooks.useScrollToBottom.test.tsx Ver fichero

@@ -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;
});

+ 55
- 16
web/src/hooks/logic-hooks.ts Ver fichero

@@ -27,6 +27,14 @@ import { useTranslate } from './common-hooks';
import { useSetPaginationParams } from './route-hook';
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>() => {
const [currentRecord, setCurrentRecord] = useState<T>({} 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<HTMLDivElement>,
) => {
const ref = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true);

const scrollToBottom = useCallback(() => {
if (messages) {
ref.current?.scrollIntoView({ behavior: 'instant' });
}
}, [messages]); // If the message changes, scroll to the bottom
useEffect(() => {
isAtBottomRef.current = isAtBottom;
}, [isAtBottom]);

const checkIfUserAtBottom = useCallback(() => {
if (!containerRef?.current) return true;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
return Math.abs(scrollTop + clientHeight - scrollHeight) < 25;
}, [containerRef]);

useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
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 ref;
return { scrollRef: ref, isAtBottom, scrollToBottom };
};

export const useHandleMessageInputChange = () => {

+ 20
- 4
web/src/pages/chat/chat-container/index.tsx Ver fichero

@@ -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<HTMLDivElement>(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 (
<>
<Flex flex={1} className={styles.chatContainer} vertical>
<Flex flex={1} vertical className={styles.messageContainer}>
<Flex
flex={1}
vertical
className={styles.messageContainer}
ref={messageContainerRef}
>
<div>
<Spin spinning={loading}>
{derivedMessages?.map((message, i) => {
@@ -91,7 +107,7 @@ const ChatContainer = ({ controller }: IProps) => {
})}
</Spin>
</div>
<div ref={ref} />
<div ref={scrollRef} />
</Flex>
<MessageInput
disabled={disabled}

Cargando…
Cancelar
Guardar