From a76630e51358fb90f905eb0070e9012a137fee2b Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:55:22 +0800 Subject: [PATCH 1/9] fix(frontend): restore click-to-open for side elements Fixes #2979 Co-Authored-By: Codex --- .../chat/MessagesContainer/index.tsx | 34 +---- frontend/tests/MessagesContainer.spec.tsx | 143 ++++++++++++++++++ 2 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 frontend/tests/MessagesContainer.spec.tsx 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(); + }); +}); From 7f12f2c8307db3f0d3f1e3be031d208948fad3be Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:18:39 +0800 Subject: [PATCH 2/9] fix(frontend): refresh the open side element --- .../chat/MessagesContainer/index.tsx | 20 ++++++- frontend/tests/MessagesContainer.spec.tsx | 52 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 4d21dad2c8..f6cdfbcd48 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -33,6 +33,7 @@ const MessagesContainer = ({ navigate }: Props) => { const { uploadFile: _uploadFile } = useChatInteract(); const setMessages = useSetRecoilState(messagesState); const setSideView = useSetRecoilState(sideViewState); + const sideView = useRecoilValue(sideViewState); const sessionId = useRecoilValue(sessionIdState); const { t } = useTranslation(); @@ -93,9 +94,24 @@ const MessagesContainer = ({ navigate }: Props) => { useEffect(() => { if (!elements.some((element) => element.display === 'side')) { - setSideView(undefined); + if (sideView) setSideView(undefined); + return; } - }, [elements, setSideView]); + + if (!sideView) return; + + const refreshedElements = sideView.elements.map( + (openElement) => + elements.find((element) => element.id === openElement.id) ?? openElement + ); + const hasChanged = refreshedElements.some( + (element, index) => element !== sideView.elements[index] + ); + + if (hasChanged) { + setSideView({ ...sideView, elements: refreshedElements }); + } + }, [elements, setSideView, sideView]); const onElementRefClick = useCallback( (element: IMessageElement) => { diff --git a/frontend/tests/MessagesContainer.spec.tsx b/frontend/tests/MessagesContainer.spec.tsx index 6780a96fb3..78d13c3618 100644 --- a/frontend/tests/MessagesContainer.spec.tsx +++ b/frontend/tests/MessagesContainer.spec.tsx @@ -63,6 +63,12 @@ const SideViewObserver = () => { return
{sideView?.title ?? 'closed'}
; }; +const SideViewContentObserver = () => { + const sideView = useRecoilValue(sideViewState); + + return
{sideView?.elements[0]?.content ?? 'no-content'}
; +}; + describe('MessagesContainer side view', () => { beforeEach(() => { vi.clearAllMocks(); @@ -140,4 +146,50 @@ describe('MessagesContainer side view', () => { expect(screen.getByText('closed')).toBeInTheDocument(); }); + + it('refreshes the open element when its content changes', () => { + const initialElement = { ...sideElement('Initial'), content: 'version 1' }; + const updatedElement = { ...initialElement, name: 'Updated', content: 'version 2' }; + + vi.mocked(useChatData).mockReturnValue({ + elements: [initialElement], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + const { rerender } = render( + + set(sideViewState, { title: 'Custom title', elements: [initialElement] }) + } + > + + + + + ); + + expect(screen.getByText('Custom title')).toBeInTheDocument(); + expect(screen.getByText('version 1')).toBeInTheDocument(); + + vi.mocked(useChatData).mockReturnValue({ + elements: [updatedElement], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + rerender( + + + + + + ); + + expect(screen.getByText('Custom title')).toBeInTheDocument(); + expect(screen.getByText('version 2')).toBeInTheDocument(); + expect(screen.queryByText('version 1')).not.toBeInTheDocument(); + }); }); From 97751c2ed19ab4a1449be199094b1a18d3a4a878 Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:35:37 +0800 Subject: [PATCH 3/9] fix(frontend): preserve non-side element panels --- frontend/src/components/chat/MessagesContainer/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index f6cdfbcd48..f085e5efc0 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -94,7 +94,9 @@ const MessagesContainer = ({ navigate }: Props) => { useEffect(() => { if (!elements.some((element) => element.display === 'side')) { - if (sideView) setSideView(undefined); + if (sideView?.elements.some((element) => element.display === 'side')) { + setSideView(undefined); + } return; } From 1ce975f3336e4762cd579183d4cf9c90e320b90a Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:42:42 +0800 Subject: [PATCH 4/9] fix(frontend): refresh fallback element panels --- .../chat/MessagesContainer/index.tsx | 32 ++++---- frontend/tests/MessagesContainer.spec.tsx | 77 ++++++++++++++++++- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index f085e5efc0..76df59dca2 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -93,26 +93,32 @@ const MessagesContainer = ({ navigate }: Props) => { ); useEffect(() => { - if (!elements.some((element) => element.display === 'side')) { - if (sideView?.elements.some((element) => element.display === 'side')) { - setSideView(undefined); - } - return; - } - if (!sideView) return; const refreshedElements = sideView.elements.map( - (openElement) => - elements.find((element) => element.id === openElement.id) ?? openElement - ); - const hasChanged = refreshedElements.some( - (element, index) => element !== sideView.elements[index] - ); + (openElement) => elements.find((element) => element.id === openElement.id) + ).filter((element): element is IMessageElement => element !== undefined); + + if (refreshedElements.length === 0) { + setSideView(undefined); + return; + } + + const hasChanged = + refreshedElements.length !== sideView.elements.length || + refreshedElements.some( + (element, index) => element !== sideView.elements[index] + ); if (hasChanged) { setSideView({ ...sideView, elements: refreshedElements }); } + + if (!elements.some((element) => element.display === 'side')) { + if (sideView.elements.some((element) => element.display === 'side')) { + setSideView(undefined); + } + } }, [elements, setSideView, sideView]); const onElementRefClick = useCallback( diff --git a/frontend/tests/MessagesContainer.spec.tsx b/frontend/tests/MessagesContainer.spec.tsx index 78d13c3618..311f6a9166 100644 --- a/frontend/tests/MessagesContainer.spec.tsx +++ b/frontend/tests/MessagesContainer.spec.tsx @@ -57,6 +57,12 @@ const sideElement = (name: string): IMessageElement => ({ threadId: null }); +const pageElement = (name: string): IMessageElement => ({ + ...sideElement(name), + id: 'page-element', + display: 'page' +}); + const SideViewObserver = () => { const sideView = useRecoilValue(sideViewState); @@ -149,7 +155,11 @@ describe('MessagesContainer side view', () => { it('refreshes the open element when its content changes', () => { const initialElement = { ...sideElement('Initial'), content: 'version 1' }; - const updatedElement = { ...initialElement, name: 'Updated', content: 'version 2' }; + const updatedElement = { + ...initialElement, + name: 'Updated', + content: 'version 2' + }; vi.mocked(useChatData).mockReturnValue({ elements: [initialElement], @@ -192,4 +202,69 @@ describe('MessagesContainer side view', () => { expect(screen.getByText('version 2')).toBeInTheDocument(); expect(screen.queryByText('version 1')).not.toBeInTheDocument(); }); + + it('refreshes and closes a fallback page panel as its element changes', () => { + const initialElement = { ...pageElement('Initial'), content: 'version 1' }; + + vi.mocked(useChatData).mockReturnValue({ + elements: [initialElement], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + const { rerender } = render( + + set(sideViewState, { title: 'Custom title', elements: [initialElement] }) + } + > + + + + + ); + + expect(screen.getByText('version 1')).toBeInTheDocument(); + + const updatedElement = { + ...initialElement, + name: 'Updated', + content: 'version 2' + }; + vi.mocked(useChatData).mockReturnValue({ + elements: [updatedElement], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + rerender( + + + + + + ); + + expect(screen.getByText('Updated')).toBeInTheDocument(); + expect(screen.getByText('version 2')).toBeInTheDocument(); + + vi.mocked(useChatData).mockReturnValue({ + elements: [], + actions: [], + askUser: undefined, + loading: false + } as ReturnType); + + rerender( + + + + + + ); + + expect(screen.getByText('closed')).toBeInTheDocument(); + }); }); From 3e6fe5425a405d307a025e1270f2e9201c1297d8 Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:57:56 +0800 Subject: [PATCH 5/9] test: assert refreshed fallback panel content --- frontend/tests/MessagesContainer.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tests/MessagesContainer.spec.tsx b/frontend/tests/MessagesContainer.spec.tsx index 311f6a9166..c33f424f2b 100644 --- a/frontend/tests/MessagesContainer.spec.tsx +++ b/frontend/tests/MessagesContainer.spec.tsx @@ -247,7 +247,7 @@ describe('MessagesContainer side view', () => { ); - expect(screen.getByText('Updated')).toBeInTheDocument(); + expect(screen.getByText('Custom title')).toBeInTheDocument(); expect(screen.getByText('version 2')).toBeInTheDocument(); vi.mocked(useChatData).mockReturnValue({ From 54962aead4414f0eea93944ba2382c56aaf40f33 Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:47:51 +0800 Subject: [PATCH 6/9] fix(frontend): refresh selected side preview --- .../chat/MessagesContainer/index.tsx | 47 ++--- .../MessagesContainer.interactions.spec.tsx | 184 ++++++++++++++++++ frontend/tests/MessagesContainer.spec.tsx | 127 ------------ 3 files changed, 209 insertions(+), 149 deletions(-) create mode 100644 frontend/tests/MessagesContainer.interactions.spec.tsx diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 76df59dca2..e644bd53d6 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -33,7 +33,6 @@ const MessagesContainer = ({ navigate }: Props) => { const { uploadFile: _uploadFile } = useChatInteract(); const setMessages = useSetRecoilState(messagesState); const setSideView = useSetRecoilState(sideViewState); - const sideView = useRecoilValue(sideViewState); const sessionId = useRecoilValue(sessionIdState); const { t } = useTranslation(); @@ -93,33 +92,37 @@ const MessagesContainer = ({ navigate }: Props) => { ); useEffect(() => { - if (!sideView) return; - - const refreshedElements = sideView.elements.map( - (openElement) => elements.find((element) => element.id === openElement.id) - ).filter((element): element is IMessageElement => element !== undefined); - - if (refreshedElements.length === 0) { + const sideElements = elements.filter( + (element) => element.display === 'side' + ); + if (sideElements.length === 0) { setSideView(undefined); return; } - const hasChanged = - refreshedElements.length !== sideView.elements.length || - refreshedElements.some( - (element, index) => element !== sideView.elements[index] + // Refresh the current selection without reopening a closed preview or + // selecting unrelated elements that arrive later. + setSideView((current) => { + if (!current) return current; + const updatedElements = current.elements.map( + (selected) => + sideElements.find((element) => element.id === selected.id) ?? selected ); - - if (hasChanged) { - setSideView({ ...sideView, elements: refreshedElements }); - } - - if (!elements.some((element) => element.display === 'side')) { - if (sideView.elements.some((element) => element.display === 'side')) { - setSideView(undefined); + if ( + updatedElements.every( + (element, index) => element === current.elements[index] + ) + ) { + return current; } - } - }, [elements, setSideView, sideView]); + 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..e022599aa1 --- /dev/null +++ b/frontend/tests/MessagesContainer.interactions.spec.tsx @@ -0,0 +1,184 @@ +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): IMessageElement { + return { + id, + name, + display: 'side', + 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('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 index c33f424f2b..6780a96fb3 100644 --- a/frontend/tests/MessagesContainer.spec.tsx +++ b/frontend/tests/MessagesContainer.spec.tsx @@ -57,24 +57,12 @@ const sideElement = (name: string): IMessageElement => ({ threadId: null }); -const pageElement = (name: string): IMessageElement => ({ - ...sideElement(name), - id: 'page-element', - display: 'page' -}); - const SideViewObserver = () => { const sideView = useRecoilValue(sideViewState); return
{sideView?.title ?? 'closed'}
; }; -const SideViewContentObserver = () => { - const sideView = useRecoilValue(sideViewState); - - return
{sideView?.elements[0]?.content ?? 'no-content'}
; -}; - describe('MessagesContainer side view', () => { beforeEach(() => { vi.clearAllMocks(); @@ -152,119 +140,4 @@ describe('MessagesContainer side view', () => { expect(screen.getByText('closed')).toBeInTheDocument(); }); - - it('refreshes the open element when its content changes', () => { - const initialElement = { ...sideElement('Initial'), content: 'version 1' }; - const updatedElement = { - ...initialElement, - name: 'Updated', - content: 'version 2' - }; - - vi.mocked(useChatData).mockReturnValue({ - elements: [initialElement], - actions: [], - askUser: undefined, - loading: false - } as ReturnType); - - const { rerender } = render( - - set(sideViewState, { title: 'Custom title', elements: [initialElement] }) - } - > - - - - - ); - - expect(screen.getByText('Custom title')).toBeInTheDocument(); - expect(screen.getByText('version 1')).toBeInTheDocument(); - - vi.mocked(useChatData).mockReturnValue({ - elements: [updatedElement], - actions: [], - askUser: undefined, - loading: false - } as ReturnType); - - rerender( - - - - - - ); - - expect(screen.getByText('Custom title')).toBeInTheDocument(); - expect(screen.getByText('version 2')).toBeInTheDocument(); - expect(screen.queryByText('version 1')).not.toBeInTheDocument(); - }); - - it('refreshes and closes a fallback page panel as its element changes', () => { - const initialElement = { ...pageElement('Initial'), content: 'version 1' }; - - vi.mocked(useChatData).mockReturnValue({ - elements: [initialElement], - actions: [], - askUser: undefined, - loading: false - } as ReturnType); - - const { rerender } = render( - - set(sideViewState, { title: 'Custom title', elements: [initialElement] }) - } - > - - - - - ); - - expect(screen.getByText('version 1')).toBeInTheDocument(); - - const updatedElement = { - ...initialElement, - name: 'Updated', - content: 'version 2' - }; - vi.mocked(useChatData).mockReturnValue({ - elements: [updatedElement], - actions: [], - askUser: undefined, - loading: false - } as ReturnType); - - rerender( - - - - - - ); - - expect(screen.getByText('Custom title')).toBeInTheDocument(); - expect(screen.getByText('version 2')).toBeInTheDocument(); - - vi.mocked(useChatData).mockReturnValue({ - elements: [], - actions: [], - askUser: undefined, - loading: false - } as ReturnType); - - rerender( - - - - - - ); - - expect(screen.getByText('closed')).toBeInTheDocument(); - }); }); From ac53ec4213771e853157f3f66e4fd923c2f8a241 Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:42:14 +0800 Subject: [PATCH 7/9] fix(frontend): clear removed side selections --- .../components/chat/MessagesContainer/index.tsx | 14 ++++++++++---- .../tests/MessagesContainer.interactions.spec.tsx | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index e644bd53d6..333f92c7dd 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -104,10 +104,16 @@ const MessagesContainer = ({ navigate }: Props) => { // selecting unrelated elements that arrive later. setSideView((current) => { if (!current) return current; - const updatedElements = current.elements.map( - (selected) => - sideElements.find((element) => element.id === selected.id) ?? selected - ); + const updatedElements = current.elements + .map((selected) => + sideElements.find((element) => element.id === selected.id) + ) + .filter( + (element): element is IMessageElement => element !== undefined + ); + if (updatedElements.length === 0) { + return undefined; + } if ( updatedElements.every( (element, index) => element === current.elements[index] diff --git a/frontend/tests/MessagesContainer.interactions.spec.tsx b/frontend/tests/MessagesContainer.interactions.spec.tsx index e022599aa1..d5e972e4af 100644 --- a/frontend/tests/MessagesContainer.interactions.spec.tsx +++ b/frontend/tests/MessagesContainer.interactions.spec.tsx @@ -126,6 +126,18 @@ describe('MessagesContainer explicit preview intent', () => { expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); }); + it('clears a preview when its selected element is removed', () => { + setElements([element('First'), element('Second')]); + const { rerender } = render(); + fireEvent.click(screen.getByRole('link', { name: 'First' })); + + setElements([element('Second')]); + rerender(); + + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Second' })).toBeInTheDocument(); + }); + it('refreshes an open preview when the selected element is updated', () => { setElements([{ ...element('First'), url: '/version-1.txt' }]); const { rerender } = render(); From 5b26ec23a9f44538d7236417fd5d18cf550533de Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:52:49 +0800 Subject: [PATCH 8/9] fix stale side preview fallback --- .../components/chat/MessagesContainer/index.tsx | 14 ++++---------- .../tests/MessagesContainer.interactions.spec.tsx | 12 ------------ 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 333f92c7dd..e644bd53d6 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -104,16 +104,10 @@ const MessagesContainer = ({ navigate }: Props) => { // selecting unrelated elements that arrive later. setSideView((current) => { if (!current) return current; - const updatedElements = current.elements - .map((selected) => - sideElements.find((element) => element.id === selected.id) - ) - .filter( - (element): element is IMessageElement => element !== undefined - ); - if (updatedElements.length === 0) { - return undefined; - } + const updatedElements = current.elements.map( + (selected) => + sideElements.find((element) => element.id === selected.id) ?? selected + ); if ( updatedElements.every( (element, index) => element === current.elements[index] diff --git a/frontend/tests/MessagesContainer.interactions.spec.tsx b/frontend/tests/MessagesContainer.interactions.spec.tsx index d5e972e4af..e022599aa1 100644 --- a/frontend/tests/MessagesContainer.interactions.spec.tsx +++ b/frontend/tests/MessagesContainer.interactions.spec.tsx @@ -126,18 +126,6 @@ describe('MessagesContainer explicit preview intent', () => { expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); }); - it('clears a preview when its selected element is removed', () => { - setElements([element('First'), element('Second')]); - const { rerender } = render(); - fireEvent.click(screen.getByRole('link', { name: 'First' })); - - setElements([element('Second')]); - rerender(); - - expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Second' })).toBeInTheDocument(); - }); - it('refreshes an open preview when the selected element is updated', () => { setElements([{ ...element('First'), url: '/version-1.txt' }]); const { rerender } = render(); From 4a9868b488c9be151e5e68e42802dbfc911f96aa Mon Sep 17 00:00:00 2001 From: AxelRay <110029405+axelray-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:52:30 +0800 Subject: [PATCH 9/9] fix(frontend): reconcile removed element previews --- .../chat/MessagesContainer/index.tsx | 28 +++++----- .../MessagesContainer.interactions.spec.tsx | 56 ++++++++++++++++++- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index e644bd53d6..54f1b229d1 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -92,22 +92,22 @@ const MessagesContainer = ({ navigate }: Props) => { ); useEffect(() => { - const sideElements = elements.filter( - (element) => element.display === 'side' - ); - if (sideElements.length === 0) { - setSideView(undefined); - return; - } - - // Refresh the current selection without reopening a closed preview or - // selecting unrelated elements that arrive later. + // 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) => - sideElements.find((element) => element.id === selected.id) ?? selected - ); + 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] diff --git a/frontend/tests/MessagesContainer.interactions.spec.tsx b/frontend/tests/MessagesContainer.interactions.spec.tsx index e022599aa1..5faf5fdb08 100644 --- a/frontend/tests/MessagesContainer.interactions.spec.tsx +++ b/frontend/tests/MessagesContainer.interactions.spec.tsx @@ -45,11 +45,15 @@ vi.mock('@/components/i18n/Translator', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); -function element(id: string, name = id): IMessageElement { +function element( + id: string, + name = id, + display: IMessageElement['display'] = 'side' +): IMessageElement { return { id, name, - display: 'side', + display, type: 'text', forId: 'review-message' }; @@ -68,6 +72,9 @@ function SideView() { return view ? ( @@ -139,6 +146,51 @@ describe('MessagesContainer explicit preview intent', () => { '/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]);