diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 18899fec92..54f1b229d1 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,38 @@ 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 = []; - 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]); + // Refresh only the current selection. Do not open previews for unrelated + // elements, and remove side/page selections that no longer exist. + setSideView((current) => { + if (!current) return current; + const updatedElements = current.elements + .map((selected) => { + const updated = elements.find( + (element) => element.id === selected.id + ); + if (updated) return updated; + return selected.display === 'side' || selected.display === 'page' + ? undefined + : selected; + }) + .filter((element): element is IMessageElement => element !== undefined); + if (updatedElements.length === 0) return undefined; + if ( + updatedElements.every( + (element, index) => element === current.elements[index] + ) + ) { + return current; + } + const title = + current.elements.length === 1 && + current.title === current.elements[0].name + ? updatedElements[0].name + : current.title; + return { ...current, title, elements: updatedElements }; + }); + }, [elements, setSideView]); const onElementRefClick = useCallback( (element: IMessageElement) => { diff --git a/frontend/tests/MessagesContainer.interactions.spec.tsx b/frontend/tests/MessagesContainer.interactions.spec.tsx new file mode 100644 index 0000000000..5faf5fdb08 --- /dev/null +++ b/frontend/tests/MessagesContainer.interactions.spec.tsx @@ -0,0 +1,236 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { RecoilRoot, useRecoilState } 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 actual = await vi.importActual( + '@chainlit/react-client' + ); + const { atom } = await import('recoil'); + return { + ...actual, + // The library and app have separate pnpm installations of Recoil in Vitest. + messagesState: atom({ key: 'review-messages', default: [] }), + sessionIdState: atom({ key: 'review-session', default: undefined }), + sideViewState: atom({ key: 'review-side-view', default: undefined }), + useChatData: vi.fn(), + useChatInteract: () => ({ uploadFile: vi.fn() }), + useChatMessages: () => ({ messages: [] }), + useConfig: () => ({ config: { features: {} } }) + }; +}); + +vi.mock('@/components/chat/Messages', async () => { + const { ElementRef } = await import('@/components/Elements/ElementRef'); + return { + Messages: ({ elements }: { elements: IMessageElement[] }) => ( +
+ {elements.map((element) => ( + + ))} +
+ ) + }; +}); + +vi.mock('@/components/i18n/Translator', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +function element( + id: string, + name = id, + display: IMessageElement['display'] = 'side' +): IMessageElement { + return { + id, + name, + display, + type: 'text', + forId: 'review-message' + }; +} + +function setElements(elements: IMessageElement[]) { + vi.mocked(useChatData).mockReturnValue({ + elements, + actions: [], + loading: false + } as ReturnType); +} + +function SideView() { + const [view, setView] = useRecoilState(sideViewState); + return view ? ( + + ) : null; +} + +function App() { + return ( + + + + + ); +} + +describe('MessagesContainer explicit preview intent', () => { + beforeEach(() => vi.clearAllMocks()); + + it('opens only the selected element through the real element reference', () => { + setElements([element('First'), element('Second')]); + render(); + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + expect(screen.getByRole('complementary')).toHaveTextContent('First'); + expect(screen.getByRole('complementary')).not.toHaveTextContent('Second'); + }); + + it('preserves a manual close across incoming updates and allows reopening', () => { + setElements([element('First')]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + fireEvent.click(screen.getByRole('button', { name: 'Close preview' })); + setElements([element('First'), element('Second')]); + rerender(); + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('link', { name: 'Second' })); + expect(screen.getByRole('complementary')).toHaveTextContent('Second'); + }); + + it('does not replace a user-selected preview when another source arrives', () => { + setElements([element('First')]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + setElements([element('First'), element('Second')]); + rerender(); + expect(screen.getByRole('complementary')).toHaveTextContent('First'); + expect(screen.getByRole('complementary')).not.toHaveTextContent('Second'); + }); + + it('clears the old preview when a new empty thread replaces the elements', () => { + setElements([element('First')]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + setElements([]); + rerender(); + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + }); + + it('refreshes an open preview when the selected element is updated', () => { + setElements([{ ...element('First'), url: '/version-1.txt' }]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + expect(screen.getByLabelText('Preview URL')).toHaveTextContent( + '/version-1.txt' + ); + setElements([{ ...element('First'), url: '/version-2.txt' }]); + rerender(); + expect(screen.getByLabelText('Preview URL')).toHaveTextContent( + '/version-2.txt' + ); + }); + it('removes selected elements that are no longer available', () => { + const first = element('First'); + const second = element('Second'); + setElements([first, second]); + const { rerender } = render( + + set(sideViewState, { + title: 'Selected elements', + elements: [first, second] + }) + } + > + + + + ); + + setElements([second]); + rerender( + + + + + ); + + expect(screen.getByLabelText('Preview IDs')).toHaveTextContent('Second'); + expect(screen.getByLabelText('Preview IDs')).not.toHaveTextContent('First'); + }); + + it('keeps a page preview open while refreshing its element', () => { + const page = element('Page', 'Page', 'page'); + setElements([page]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'Page' })); + + setElements([{ ...page, url: '/updated-page.txt' }]); + rerender(); + + expect(screen.getByRole('complementary')).toBeInTheDocument(); + expect(screen.getByLabelText('Preview URL')).toHaveTextContent( + '/updated-page.txt' + ); + }); + + it('preserves a custom title and sidebar key when content changes', () => { + const selected = element('First'); + setElements([selected]); + const { rerender } = render( + + set(sideViewState, { + title: 'Custom title', + key: 'server-key', + elements: [selected] + }) + } + > + + + + ); + setElements([{ ...selected, name: 'Renamed', url: '/updated.txt' }]); + rerender( + + + + + ); + expect(screen.getByRole('complementary')).toHaveTextContent('Custom title'); + expect(screen.getByLabelText('Preview URL')).toHaveTextContent( + '/updated.txt' + ); + expect(screen.getByRole('complementary')).toHaveAttribute( + 'data-sidebar-key', + 'server-key' + ); + }); + + it('updates the default preview title when the selected element is renamed', () => { + setElements([element('First')]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + setElements([element('First', 'Renamed')]); + rerender(); + expect(screen.getByRole('complementary')).toHaveTextContent('Renamed'); + }); +}); 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(); + }); +});