diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 18899fec92..4d21dad2c8 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -1,5 +1,5 @@ import { MessageContext } from '@/contexts/MessageContext'; -import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { toast } from 'sonner'; @@ -91,39 +91,11 @@ const MessagesContainer = ({ navigate }: Props) => { [] ); - const knownSideElementsRef = useRef>(new Map()); - const knownSideOrderRef = useRef([]); - useEffect(() => { - const sideElements = elements.filter((e) => e.display === 'side'); - - if (sideElements.length === 0) { - knownSideElementsRef.current = new Map(); - knownSideOrderRef.current = []; + if (!elements.some((element) => element.display === 'side')) { setSideView(undefined); - return; - } - - const prevMap = knownSideElementsRef.current; - const prevOrder = knownSideOrderRef.current; - const currentIds = sideElements.map((e) => e.id); - - const hasChanged = - currentIds.length !== prevOrder.length || - currentIds.some((id, i) => prevOrder[i] !== id) || - sideElements.some((e) => prevMap.get(e.id) !== e); - - if (hasChanged) { - const newMap = new Map(); - sideElements.forEach((e) => newMap.set(e.id, e)); - knownSideElementsRef.current = newMap; - knownSideOrderRef.current = currentIds; - setSideView({ - title: sideElements[sideElements.length - 1].name, - elements: sideElements - }); } - }, [elements]); + }, [elements, setSideView]); const onElementRefClick = useCallback( (element: IMessageElement) => { diff --git a/frontend/tests/MessagesContainer.spec.tsx b/frontend/tests/MessagesContainer.spec.tsx new file mode 100644 index 0000000000..6780a96fb3 --- /dev/null +++ b/frontend/tests/MessagesContainer.spec.tsx @@ -0,0 +1,143 @@ +import { render, screen } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + IMessageElement, + sideViewState, + useChatData +} from '@chainlit/react-client'; + +import MessagesContainer from '@/components/chat/MessagesContainer'; + +vi.mock('@chainlit/react-client', async () => { + const { createContext } = await import('react'); + const { atom } = await import('recoil'); + + return { + ChainlitContext: createContext({}), + messagesState: atom({ key: 'messagesState', default: [] }), + sessionIdState: atom({ key: 'sessionIdState', default: undefined }), + sideViewState: atom({ key: 'sideViewState', default: undefined }), + updateMessageById: vi.fn(), + useChatData: vi.fn(), + useChatInteract: () => ({ uploadFile: vi.fn() }), + useChatMessages: () => ({ messages: [] }), + useConfig: () => ({ config: { features: {} } }) + }; +}); + +vi.mock('@/components/chat/Messages', () => ({ + Messages: () => null +})); + +vi.mock('@/components/i18n/Translator', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), promise: vi.fn() } +})); + +const sideElement = (name: string): IMessageElement => ({ + id: 'side-element', + name, + display: 'side', + type: 'text', + chainlitKey: null, + url: null, + objectKey: null, + path: null, + content: null, + props: null, + page: null, + size: null, + language: null, + mime: null, + threadId: null +}); + +const SideViewObserver = () => { + const sideView = useRecoilValue(sideViewState); + + return
{sideView?.title ?? 'closed'}
; +}; + +describe('MessagesContainer side view', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not open the side view when side elements change', () => { + vi.mocked(useChatData).mockReturnValue({ + elements: [sideElement('Initial')], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + const { rerender } = render( + + + + + ); + + expect(screen.getByText('closed')).toBeInTheDocument(); + + vi.mocked(useChatData).mockReturnValue({ + elements: [sideElement('Updated')], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + rerender( + + + + + ); + + expect(screen.getByText('closed')).toBeInTheDocument(); + }); + + it('clears the side view when no side elements remain', () => { + const element = sideElement('Open'); + vi.mocked(useChatData).mockReturnValue({ + elements: [element], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + const { rerender } = render( + + set(sideViewState, { title: element.name, elements: [element] }) + } + > + + + + ); + + expect(screen.getByText('Open')).toBeInTheDocument(); + + vi.mocked(useChatData).mockReturnValue({ + elements: [], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + rerender( + + + + + ); + + expect(screen.getByText('closed')).toBeInTheDocument(); + }); +});