From 567fbb9569aab1e7b86ba232bea4d0320904b93b Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:12:07 -0400 Subject: [PATCH 1/6] feat: allow side elements to opt out of automatic expansion Co-Authored-By: Codex --- backend/chainlit/element.py | 9 ++- backend/tests/test_element.py | 24 ++++++ docs/side-panel-auto-expand.md | 25 ++++++ .../chat/MessagesContainer/index.tsx | 38 +-------- .../chat/MessagesContainer/useSideElements.ts | 53 +++++++++++++ frontend/tests/sideElements.spec.tsx | 78 +++++++++++++++++++ libs/react-client/src/types/element.ts | 2 + 7 files changed, 194 insertions(+), 35 deletions(-) create mode 100644 docs/side-panel-auto-expand.md create mode 100644 frontend/src/components/chat/MessagesContainer/useSideElements.ts create mode 100644 frontend/tests/sideElements.spec.tsx diff --git a/backend/chainlit/element.py b/backend/chainlit/element.py index 91e26adfed..9cdba78635 100644 --- a/backend/chainlit/element.py +++ b/backend/chainlit/element.py @@ -61,6 +61,7 @@ class ElementDict(TypedDict, total=False): page: Optional[int] props: Optional[Dict] autoPlay: Optional[bool] + autoExpand: bool playerConfig: Optional[dict] forId: Optional[str] mime: Optional[str] @@ -97,6 +98,9 @@ class Element: # Mime type, inferred based on content if not provided mime: Optional[str] = None + # Live presentation hint; excluded from persistence to avoid schema migrations. + auto_expand: bool = Field(default=True, kw_only=True) + def __post_init__(self) -> None: self.persisted = False self.updatable = False @@ -258,7 +262,10 @@ async def send(self, for_id: str, persist=True): if not self.url and not self.chainlit_key: raise ValueError("Must provide url or chainlit key to send element") - await context.emitter.send_element(self.to_dict()) + payload = self.to_dict() + if not self.auto_expand: + payload["autoExpand"] = False + await context.emitter.send_element(payload) ElementBased = TypeVar("ElementBased", bound=Element) diff --git a/backend/tests/test_element.py b/backend/tests/test_element.py index e1242aa9b4..24ff90deed 100644 --- a/backend/tests/test_element.py +++ b/backend/tests/test_element.py @@ -92,6 +92,30 @@ async def test_element_send(self, mock_chainlit_context): assert element.for_id == "message_123" ctx.emitter.send_element.assert_called_once() + @pytest.mark.parametrize("auto_expand", [True, False]) + @pytest.mark.parametrize( + ("element_type", "kwargs"), + [ + (Text, {"content": "Reference"}), + (File, {"url": "https://example.com/reference.pdf"}), + (CustomElement, {"props": {"label": "Reference"}}), + ], + ) + async def test_side_element_auto_expand_is_a_live_presentation_hint( + self, mock_chainlit_context, auto_expand, element_type, kwargs + ): + async with mock_chainlit_context as ctx: + element = element_type( + name="Sources", + display="side", + auto_expand=auto_expand, + **kwargs, + ) + await element.send(for_id="message_123") + payload = ctx.emitter.send_element.call_args.args[0] + assert payload.get("autoExpand", True) is auto_expand + assert "autoExpand" not in element.to_dict() + async def test_element_remove(self, mock_chainlit_context): """Test Element.remove() method.""" async with mock_chainlit_context as ctx: diff --git a/docs/side-panel-auto-expand.md b/docs/side-panel-auto-expand.md new file mode 100644 index 0000000000..cc7d61405e --- /dev/null +++ b/docs/side-panel-auto-expand.md @@ -0,0 +1,25 @@ +# Controlling automatic side-panel opening + +Side elements open the panel by default. Set `auto_expand=False` on an element +when it should remain available through its message reference without reopening +a panel the user has closed: + +```python +sources = cl.Text( + name="Sources", + content="Reference material", + display="side", + auto_expand=False, +) +await cl.Message(content="See Sources for details.", elements=[sources]).send() +``` + +The option also works on `CustomElement`. An already open panel still receives +content updates. Clicking an element reference explicitly opens it regardless of +`auto_expand`; inline and page display modes are unaffected. + +`auto_expand` is a live presentation hint, not persisted element data. It does +not require database migrations, and it does not change how restored historical +threads are displayed. Apply it to each new or updated element whose arrival +should not automatically open the panel. A batch containing a changed element +with the default `auto_expand=True` may still open the panel. diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 18899fec92..693410d398 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, useMemo } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { toast } from 'sonner'; @@ -21,6 +21,8 @@ import { import { Messages } from '@/components/chat/Messages'; import { useTranslation } from 'components/i18n/Translator'; +import { useSideElements } from './useSideElements'; + interface Props { navigate?: (to: string) => void; } @@ -91,39 +93,7 @@ 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]); + useSideElements(elements, setSideView); const onElementRefClick = useCallback( (element: IMessageElement) => { diff --git a/frontend/src/components/chat/MessagesContainer/useSideElements.ts b/frontend/src/components/chat/MessagesContainer/useSideElements.ts new file mode 100644 index 0000000000..9b91394457 --- /dev/null +++ b/frontend/src/components/chat/MessagesContainer/useSideElements.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; +import type { SetterOrUpdater } from 'recoil'; + +import type { IMessageElement } from '@chainlit/react-client'; + +type SideView = { title: string; elements: IMessageElement[] } | undefined; + +export function useSideElements( + elements: IMessageElement[], + setSideView: SetterOrUpdater +) { + 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; + const shouldOpen = sideElements.some( + (element) => + prevMap.get(element.id) !== element && element.autoExpand !== false + ); + setSideView((current) => + current || shouldOpen + ? { + title: sideElements[sideElements.length - 1].name, + elements: sideElements + } + : current + ); + } + }, [elements, setSideView]); +} diff --git a/frontend/tests/sideElements.spec.tsx b/frontend/tests/sideElements.spec.tsx new file mode 100644 index 0000000000..a60f9aef86 --- /dev/null +++ b/frontend/tests/sideElements.spec.tsx @@ -0,0 +1,78 @@ +import { act, renderHook } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { IMessageElement } from '@chainlit/react-client'; + +import { useSideElements } from '../src/components/chat/MessagesContainer/useSideElements'; + +const element = (id: string, autoExpand?: boolean): IMessageElement => ({ + id, + name: id, + type: 'text', + display: 'side', + forId: 'message', + url: 'https://example.com/text', + autoExpand +}); + +function renderPanel(elements: IMessageElement[]) { + return renderHook( + ({ elements }) => { + const [panel, setPanel] = useState<{ + title: string; + elements: IMessageElement[]; + }>(); + useSideElements(elements, setPanel); + return { + panel, + close: () => setPanel(undefined), + open: (el: IMessageElement) => + setPanel({ title: el.name, elements: [el] }) + }; + }, + { initialProps: { elements } } + ); +} + +describe('side element arrivals', () => { + it('opens new elements by default', () => { + const { result } = renderPanel([element('source')]); + expect(result.current.panel?.title).toBe('source'); + }); + + it('keeps a closed panel closed while opt-out sources arrive or update', () => { + const first = element('first'); + const { result, rerender } = renderPanel([first]); + act(() => result.current.close()); + const second = element('second', false); + rerender({ elements: [first, second] }); + expect(result.current.panel).toBeUndefined(); + rerender({ elements: [first, { ...second, name: 'updated' }] }); + expect(result.current.panel).toBeUndefined(); + }); + + it('allows an explicit open and keeps an open panel updated', () => { + const first = element('first', false); + const { result, rerender } = renderPanel([first]); + expect(result.current.panel).toBeUndefined(); + act(() => result.current.open(first)); + const updated = { ...first, name: 'updated' }; + rerender({ elements: [updated] }); + expect(result.current.panel?.elements).toEqual([updated]); + }); + + it('opens a mixed batch when a changed element opts in', () => { + const { result, rerender } = renderPanel([]); + rerender({ elements: [element('quiet', false), element('default')] }); + expect(result.current.panel?.elements).toHaveLength(2); + }); + + it('clears side content on reset and ignores inline arrivals', () => { + const { result, rerender } = renderPanel([element('first')]); + rerender({ elements: [{ ...element('inline'), display: 'inline' }] }); + expect(result.current.panel).toBeUndefined(); + rerender({ elements: [element('quiet', false)] }); + expect(result.current.panel).toBeUndefined(); + }); +}); diff --git a/libs/react-client/src/types/element.ts b/libs/react-client/src/types/element.ts index 0dfe0ad37e..7be14f62b5 100644 --- a/libs/react-client/src/types/element.ts +++ b/libs/react-client/src/types/element.ts @@ -37,6 +37,8 @@ interface TElement { interface TMessageElement extends TElement { name: string; display: 'inline' | 'side' | 'page'; + /** Whether a live update may open the side panel. Defaults to true. */ + autoExpand?: boolean; } export interface IImageElement extends TMessageElement<'image'> { From fb8d36e8025aadaf5b198d28529570080555ad9e Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:59:18 -0400 Subject: [PATCH 2/6] fix(frontend): preserve programmatically opened sidebars Only clear an empty side-element view when it still displays a side element tracked by the message hook. Preserve unrelated ElementSidebar state, including its title and key, when inline or page elements arrive or tracked content is replaced. Check display as well as ID so a reused ID cannot claim an inline view. Cover title-only and populated API sidebars, replacements with new or reused IDs, and cleanup of explicitly opened opt-out elements. Six preservation regressions fail before this change; all 44 frontend tests pass after it. Co-Authored-By: Codex --- .../chat/MessagesContainer/useSideElements.ts | 11 +++- frontend/tests/sideElements.spec.tsx | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/useSideElements.ts b/frontend/src/components/chat/MessagesContainer/useSideElements.ts index 9b91394457..4b82f03bd5 100644 --- a/frontend/src/components/chat/MessagesContainer/useSideElements.ts +++ b/frontend/src/components/chat/MessagesContainer/useSideElements.ts @@ -14,15 +14,22 @@ export function useSideElements( useEffect(() => { const sideElements = elements.filter((e) => e.display === 'side'); + const prevMap = knownSideElementsRef.current; if (sideElements.length === 0) { knownSideElementsRef.current = new Map(); knownSideOrderRef.current = []; - setSideView(undefined); + // Other callers, such as ElementSidebar, share this view state. + setSideView((current) => + current?.elements.some( + (element) => element.display === 'side' && prevMap.has(element.id) + ) + ? undefined + : current + ); return; } - const prevMap = knownSideElementsRef.current; const prevOrder = knownSideOrderRef.current; const currentIds = sideElements.map((e) => e.id); diff --git a/frontend/tests/sideElements.spec.tsx b/frontend/tests/sideElements.spec.tsx index a60f9aef86..9340d79c7a 100644 --- a/frontend/tests/sideElements.spec.tsx +++ b/frontend/tests/sideElements.spec.tsx @@ -22,10 +22,12 @@ function renderPanel(elements: IMessageElement[]) { const [panel, setPanel] = useState<{ title: string; elements: IMessageElement[]; + key?: string; }>(); useSideElements(elements, setPanel); return { panel, + setPanel, close: () => setPanel(undefined), open: (el: IMessageElement) => setPanel({ title: el.name, elements: [el] }) @@ -75,4 +77,62 @@ describe('side element arrivals', () => { rerender({ elements: [element('quiet', false)] }); expect(result.current.panel).toBeUndefined(); }); + + it.each(['inline', 'page'] as const)( + 'preserves a sidebar opened by title when a %s element arrives', + (display) => { + const { result, rerender } = renderPanel([]); + const sidebar = { title: 'API sidebar', elements: [] }; + act(() => result.current.setPanel(sidebar)); + + rerender({ elements: [{ ...element('arrival'), display }] }); + + expect(result.current.panel).toBe(sidebar); + } + ); + + it.each(['inline', 'page'] as const)( + 'preserves programmatic sidebar elements when a %s element arrives or updates', + (display) => { + const content: IMessageElement = { + ...element('api-content'), + display: 'inline' + }; + const { result, rerender } = renderPanel([content]); + const sidebar = { title: 'API sidebar', elements: [content], key: 'api' }; + act(() => result.current.setPanel(sidebar)); + const arrival: IMessageElement = { ...element('arrival'), display }; + + rerender({ elements: [content, arrival] }); + expect(result.current.panel).toBe(sidebar); + + rerender({ elements: [content, { ...arrival, name: 'updated' }] }); + expect(result.current.panel).toBe(sidebar); + } + ); + + it.each(['api-content', 'tracked'])( + 'preserves a replacement sidebar (%s) when tracked side content is removed', + (id) => { + const tracked = element('tracked'); + const content: IMessageElement = { ...element(id), display: 'inline' }; + const { result, rerender } = renderPanel([tracked]); + const sidebar = { title: 'API sidebar', elements: [content], key: 'api' }; + act(() => result.current.setPanel(sidebar)); + + rerender({ elements: [content] }); + + expect(result.current.panel).toBe(sidebar); + } + ); + + it('clears an explicitly opened side element when it is removed', () => { + const quiet = element('quiet', false); + const { result, rerender } = renderPanel([quiet]); + act(() => result.current.open(quiet)); + + rerender({ elements: [] }); + + expect(result.current.panel).toBeUndefined(); + }); }); From 3077a9a19a64f3387a88071cc66fec00a780c362 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:33:16 -0400 Subject: [PATCH 3/6] fix(frontend): retain sidebar ownership across navigation Preserve API replacements while cleaning up message-owned panels after remounts and explicit side-element opens. Co-Authored-By: Codex --- .../chat/MessagesContainer/index.tsx | 4 +- .../chat/MessagesContainer/useSideElements.ts | 32 ++-- frontend/tests/sideElements.spec.tsx | 43 +++++- .../tests/sideElementsNavigation.spec.tsx | 144 ++++++++++++++++++ 4 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 frontend/tests/sideElementsNavigation.spec.tsx diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 693410d398..d39d1f92a3 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -21,7 +21,7 @@ import { import { Messages } from '@/components/chat/Messages'; import { useTranslation } from 'components/i18n/Translator'; -import { useSideElements } from './useSideElements'; +import { createMessageSideView, useSideElements } from './useSideElements'; interface Props { navigate?: (to: string) => void; @@ -101,7 +101,7 @@ const MessagesContainer = ({ navigate }: Props) => { element.display === 'side' || (element.display === 'page' && !navigate) ) { - setSideView({ title: element.name, elements: [element] }); + setSideView(createMessageSideView([element])); return; } diff --git a/frontend/src/components/chat/MessagesContainer/useSideElements.ts b/frontend/src/components/chat/MessagesContainer/useSideElements.ts index 4b82f03bd5..2fc0bbd80f 100644 --- a/frontend/src/components/chat/MessagesContainer/useSideElements.ts +++ b/frontend/src/components/chat/MessagesContainer/useSideElements.ts @@ -5,6 +5,20 @@ import type { IMessageElement } from '@chainlit/react-client'; type SideView = { title: string; elements: IMessageElement[] } | undefined; +// The shared view can outlive this hook during navigation between threads. +const messageSideViews = new WeakSet>(); + +export function createMessageSideView(elements: IMessageElement[]) { + const view = { + title: elements[elements.length - 1].name, + elements + }; + if (elements.some((element) => element.display === 'side')) { + messageSideViews.add(view); + } + return view; +} + export function useSideElements( elements: IMessageElement[], setSideView: SetterOrUpdater @@ -14,22 +28,18 @@ export function useSideElements( useEffect(() => { const sideElements = elements.filter((e) => e.display === 'side'); - const prevMap = knownSideElementsRef.current; if (sideElements.length === 0) { knownSideElementsRef.current = new Map(); knownSideOrderRef.current = []; // Other callers, such as ElementSidebar, share this view state. setSideView((current) => - current?.elements.some( - (element) => element.display === 'side' && prevMap.has(element.id) - ) - ? undefined - : current + current && messageSideViews.has(current) ? undefined : current ); return; } + const prevMap = knownSideElementsRef.current; const prevOrder = knownSideOrderRef.current; const currentIds = sideElements.map((e) => e.id); @@ -47,14 +57,8 @@ export function useSideElements( (element) => prevMap.get(element.id) !== element && element.autoExpand !== false ); - setSideView((current) => - current || shouldOpen - ? { - title: sideElements[sideElements.length - 1].name, - elements: sideElements - } - : current - ); + const nextView = createMessageSideView(sideElements); + setSideView((current) => (current || shouldOpen ? nextView : current)); } }, [elements, setSideView]); } diff --git a/frontend/tests/sideElements.spec.tsx b/frontend/tests/sideElements.spec.tsx index 9340d79c7a..a25293bceb 100644 --- a/frontend/tests/sideElements.spec.tsx +++ b/frontend/tests/sideElements.spec.tsx @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest'; import type { IMessageElement } from '@chainlit/react-client'; -import { useSideElements } from '../src/components/chat/MessagesContainer/useSideElements'; +import { + createMessageSideView, + useSideElements +} from '../src/components/chat/MessagesContainer/useSideElements'; const element = (id: string, autoExpand?: boolean): IMessageElement => ({ id, @@ -29,8 +32,7 @@ function renderPanel(elements: IMessageElement[]) { panel, setPanel, close: () => setPanel(undefined), - open: (el: IMessageElement) => - setPanel({ title: el.name, elements: [el] }) + open: (el: IMessageElement) => setPanel(createMessageSideView([el])) }; }, { initialProps: { elements } } @@ -135,4 +137,39 @@ describe('side element arrivals', () => { expect(result.current.panel).toBeUndefined(); }); + + it.each(['title', 'elements'])( + 'preserves a sidebar replaced by the %s API when its side element is removed', + (update) => { + const tracked = element('tracked'); + const { result, rerender } = renderPanel([tracked]); + act(() => + result.current.setPanel((current) => + update === 'title' + ? { title: 'Custom title', elements: current?.elements || [] } + : { + title: current?.title || '', + elements: [tracked], + key: 'api' + } + ) + ); + const sidebar = result.current.panel; + + rerender({ elements: [] }); + + expect(result.current.panel).toBe(sidebar); + } + ); + + it('keeps an explicitly opened page preview on unrelated inline arrivals', () => { + const page: IMessageElement = { ...element('page'), display: 'page' }; + const { result, rerender } = renderPanel([page]); + act(() => result.current.open(page)); + const sidebar = result.current.panel; + + rerender({ elements: [page, { ...element('inline'), display: 'inline' }] }); + + expect(result.current.panel).toBe(sidebar); + }); }); diff --git a/frontend/tests/sideElementsNavigation.spec.tsx b/frontend/tests/sideElementsNavigation.spec.tsx new file mode 100644 index 0000000000..244d39531e --- /dev/null +++ b/frontend/tests/sideElementsNavigation.spec.tsx @@ -0,0 +1,144 @@ +import { act, renderHook, screen } from '@testing-library/react'; +import { type ReactNode, StrictMode } from 'react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { + RecoilRoot, + useRecoilState, + useRecoilValue, + useSetRecoilState +} from 'recoil'; +import { describe, expect, it, vi } from 'vitest'; + +import ThreadPage from '@/pages/Thread'; + +import { + type IMessageElement, + elementState, + sideViewState +} from '@chainlit/react-client'; + +import AutoResumeThread from '@/components/AutoResumeThread'; +import { useSideElements } from '@/components/chat/MessagesContainer/useSideElements'; + +vi.mock('@chainlit/react-client', async () => { + const { atom } = await import('recoil'); + return { + useConfig: () => ({ config: { threadResumable: true } }), + useChatMessages: () => ({ threadId: 'active' }), + threadHistoryState: atom({ key: 'navigationThreadHistory', default: {} }), + elementState: atom({ key: 'navigationElements', default: [] }), + sideViewState: atom({ key: 'navigationSideView', default: undefined }) + }; +}); + +vi.mock('pages/Page', () => ({ + default: ({ children }: { children: ReactNode }) => children +})); + +vi.mock('@/components/AutoResumeThread', () => ({ + default: vi.fn(() => null) +})); + +vi.mock('@/components/Loader', () => ({ Loader: () => null })); + +vi.mock('@/components/ReadOnlyThread', () => ({ + ReadOnlyThread: () =>
Shared thread
+})); + +vi.mock('@/components/chat', () => ({ + default: function Chat() { + const elements = useRecoilValue(elementState); + const setSideView = useSetRecoilState(sideViewState); + useSideElements(elements, setSideView); + return
Active thread
; + } +})); + +const source: IMessageElement = { + id: 'source', + name: 'Source', + type: 'text', + display: 'side', + forId: 'message', + url: 'https://example.com/text' +}; + +function renderActiveThread() { + return renderHook( + () => { + const [panel, setPanel] = useRecoilState(sideViewState); + const setElements = useSetRecoilState(elementState); + const navigate = useNavigate(); + return { panel, setPanel, setElements, navigate }; + }, + { + wrapper: ({ children }) => ( + + set(elementState, [source])} + > + + + } /> + } /> + + {children} + + + + ) + } + ); +} + +describe('side elements across thread navigation', () => { + it('clears stale automatic content when returning from a shared thread', () => { + const { result } = renderActiveThread(); + expect(result.current.panel?.elements).toEqual([source]); + + act(() => result.current.navigate('/share/other')); + expect(screen.getByText('Shared thread')).toBeInTheDocument(); + expect(screen.queryByText('Active thread')).not.toBeInTheDocument(); + + // The live session receives remove_element while its chat is unmounted. + act(() => result.current.setElements([])); + expect(result.current.panel?.elements).toEqual([source]); + + act(() => result.current.navigate('/thread/active')); + + expect(screen.getByText('Active thread')).toBeInTheDocument(); + expect(AutoResumeThread).not.toHaveBeenCalled(); + expect(result.current.panel).toBeUndefined(); + }); + + it('keeps automatic content whose element still exists after returning', () => { + const { result } = renderActiveThread(); + + act(() => result.current.navigate('/share/other')); + act(() => result.current.navigate('/thread/active')); + + expect(result.current.panel?.elements).toEqual([source]); + expect(AutoResumeThread).not.toHaveBeenCalled(); + }); + + it.each([ + { name: 'title-only', elements: [] }, + { name: 'inline', elements: [{ ...source, display: 'inline' as const }] }, + { name: 'page', elements: [{ ...source, display: 'page' as const }] }, + { name: 'side', elements: [source] } + ])( + 'preserves a programmatic $name sidebar after returning', + ({ elements }) => { + const { result } = renderActiveThread(); + const sidebar = { title: 'API sidebar', elements, key: 'api' }; + act(() => result.current.setPanel(sidebar)); + + act(() => result.current.navigate('/share/other')); + act(() => result.current.setElements([])); + act(() => result.current.navigate('/thread/active')); + + expect(result.current.panel).toBe(sidebar); + expect(AutoResumeThread).not.toHaveBeenCalled(); + } + ); +}); From 65a3849a633c0f022b701a1e16f3fc7e3b6f786a Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:51:10 -0400 Subject: [PATCH 4/6] fix: retain side panel auto-expand across updates --- backend/chainlit/server.py | 1 + backend/tests/test_element.py | 25 +++++++++++++++++++ .../tests/sideElementsNavigation.spec.tsx | 12 ++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 68238cd254..7e935e8172 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -1172,6 +1172,7 @@ def _sanitize_custom_element(element_dict: "ElementDict") -> "CustomElement": name=element_dict["name"], props=element_dict.get("props") or {}, display=element_dict["display"], + auto_expand=element_dict.get("autoExpand", True), ) diff --git a/backend/tests/test_element.py b/backend/tests/test_element.py index 24ff90deed..6738317a88 100644 --- a/backend/tests/test_element.py +++ b/backend/tests/test_element.py @@ -116,6 +116,31 @@ async def test_side_element_auto_expand_is_a_live_presentation_hint( assert payload.get("autoExpand", True) is auto_expand assert "autoExpand" not in element.to_dict() + async def test_custom_element_rest_update_preserves_collapsed_hint( + self, mock_chainlit_context + ): + from chainlit.server import _sanitize_custom_element + + async with mock_chainlit_context as ctx: + element = _sanitize_custom_element( + { + "id": "custom-1", + "type": "custom", + "name": "Sources", + "display": "side", + "props": {"label": "Reference"}, + "autoExpand": False, + } + ) + await element.update() + payload = ctx.emitter.send_element.call_args.args[0] + assert payload["autoExpand"] is False + + default_element = _sanitize_custom_element( + {"id": "custom-2", "type": "custom", "name": "Sources", "display": "side"} + ) + assert default_element.auto_expand is True + async def test_element_remove(self, mock_chainlit_context): """Test Element.remove() method.""" async with mock_chainlit_context as ctx: diff --git a/frontend/tests/sideElementsNavigation.spec.tsx b/frontend/tests/sideElementsNavigation.spec.tsx index 244d39531e..35b64340ae 100644 --- a/frontend/tests/sideElementsNavigation.spec.tsx +++ b/frontend/tests/sideElementsNavigation.spec.tsx @@ -107,7 +107,6 @@ describe('side elements across thread navigation', () => { act(() => result.current.navigate('/thread/active')); expect(screen.getByText('Active thread')).toBeInTheDocument(); - expect(AutoResumeThread).not.toHaveBeenCalled(); expect(result.current.panel).toBeUndefined(); }); @@ -118,7 +117,6 @@ describe('side elements across thread navigation', () => { act(() => result.current.navigate('/thread/active')); expect(result.current.panel?.elements).toEqual([source]); - expect(AutoResumeThread).not.toHaveBeenCalled(); }); it.each([ @@ -138,7 +136,15 @@ describe('side elements across thread navigation', () => { act(() => result.current.navigate('/thread/active')); expect(result.current.panel).toBe(sidebar); - expect(AutoResumeThread).not.toHaveBeenCalled(); } ); + + it('requests resume when navigating to a different private thread', () => { + vi.mocked(AutoResumeThread).mockClear(); + const { result } = renderActiveThread(); + + act(() => result.current.navigate('/thread/other')); + + expect(vi.mocked(AutoResumeThread).mock.calls.some(([props]) => props.id === 'other')).toBe(true); + }); }); From 865ebcc4bdb3edcc2fc97dc7a959a7f776c7a512 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:37:08 -0400 Subject: [PATCH 5/6] fix(side-panel): clear shared views after navigation Cover the REST update path and keep navigation assertions formatted.\n\nCo-Authored-By: Codex --- backend/tests/test_element.py | 9 +++- backend/tests/test_server.py | 46 +++++++++++++++++++ frontend/src/components/ReadOnlyThread.tsx | 3 +- .../tests/sideElementsNavigation.spec.tsx | 18 +++++++- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_element.py b/backend/tests/test_element.py index 6738317a88..b66757d206 100644 --- a/backend/tests/test_element.py +++ b/backend/tests/test_element.py @@ -116,7 +116,7 @@ async def test_side_element_auto_expand_is_a_live_presentation_hint( assert payload.get("autoExpand", True) is auto_expand assert "autoExpand" not in element.to_dict() - async def test_custom_element_rest_update_preserves_collapsed_hint( + async def test_custom_element_sanitize_update_preserves_collapsed_hint( self, mock_chainlit_context ): from chainlit.server import _sanitize_custom_element @@ -137,7 +137,12 @@ async def test_custom_element_rest_update_preserves_collapsed_hint( assert payload["autoExpand"] is False default_element = _sanitize_custom_element( - {"id": "custom-2", "type": "custom", "name": "Sources", "display": "side"} + { + "id": "custom-2", + "type": "custom", + "name": "Sources", + "display": "side", + } ) assert default_element.auto_expand is True diff --git a/backend/tests/test_server.py b/backend/tests/test_server.py index 2ba3333550..7c38bcf623 100644 --- a/backend/tests/test_server.py +++ b/backend/tests/test_server.py @@ -1149,6 +1149,52 @@ def test_share_thread_endpoint_sets_flags( data_mod._data_layer_initialized = False +def test_update_custom_element_keeps_auto_expand_hint( + test_client: TestClient, + mock_session: Mock, + monkeypatch: pytest.MonkeyPatch, +): + import importlib + + from chainlit.context import ChainlitContext, context_var + from chainlit.server import app as _app, get_current_user as _get_current_user + from chainlit.session import WebsocketSession + + emitter = Mock() + emitter.send_element = AsyncMock() + + def init_context(session: WebsocketSession) -> ChainlitContext: + context = ChainlitContext(session, emitter=emitter) + context_var.set(context) + return context + + monkeypatch.setattr(WebsocketSession, "get_by_id", lambda _: mock_session) + monkeypatch.setattr( + importlib.import_module("chainlit.context"), "init_ws_context", init_context + ) + _app.dependency_overrides[_get_current_user] = lambda: None + try: + response = test_client.put( + "/project/element", + json={ + "sessionId": mock_session.id, + "element": { + "id": "custom-1", + "type": "custom", + "name": "Sources", + "display": "side", + "props": {"label": "Reference"}, + "autoExpand": False, + }, + }, + ) + assert response.status_code == 200 + assert response.json() == {"success": True} + assert emitter.send_element.call_args.args[0]["autoExpand"] is False + finally: + del _app.dependency_overrides[_get_current_user] + + def test_health_check(test_client: TestClient): response = test_client.get("/health") assert response.status_code == 200 diff --git a/frontend/src/components/ReadOnlyThread.tsx b/frontend/src/components/ReadOnlyThread.tsx index 4036b4ed8c..d0532d3a1f 100644 --- a/frontend/src/components/ReadOnlyThread.tsx +++ b/frontend/src/components/ReadOnlyThread.tsx @@ -24,6 +24,7 @@ import { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth'; import { ErrorBoundary } from './ErrorBoundary'; import { Loader } from './Loader'; import { Messages } from './chat/Messages'; +import { createMessageSideView } from './chat/MessagesContainer/useSideElements'; type Props = { id: string; @@ -132,7 +133,7 @@ const ReadOnlyThread = ({ id }: Props) => { const onElementRefClick = useCallback( (element: IMessageElement) => { if (element.display === 'side') { - setSideView({ title: element.name, elements: [element] }); + setSideView(createMessageSideView([element])); return; } diff --git a/frontend/tests/sideElementsNavigation.spec.tsx b/frontend/tests/sideElementsNavigation.spec.tsx index 35b64340ae..4ad3a85a26 100644 --- a/frontend/tests/sideElementsNavigation.spec.tsx +++ b/frontend/tests/sideElementsNavigation.spec.tsx @@ -18,7 +18,10 @@ import { } from '@chainlit/react-client'; import AutoResumeThread from '@/components/AutoResumeThread'; -import { useSideElements } from '@/components/chat/MessagesContainer/useSideElements'; +import { + createMessageSideView, + useSideElements +} from '@/components/chat/MessagesContainer/useSideElements'; vi.mock('@chainlit/react-client', async () => { const { atom } = await import('recoil'); @@ -119,6 +122,17 @@ describe('side elements across thread navigation', () => { expect(result.current.panel?.elements).toEqual([source]); }); + it('clears a side view opened in a shared thread when the active thread is empty', () => { + const { result } = renderActiveThread(); + + act(() => result.current.navigate('/share/other')); + act(() => result.current.setPanel(createMessageSideView([source]))); + act(() => result.current.setElements([])); + act(() => result.current.navigate('/thread/active')); + + expect(result.current.panel).toBeUndefined(); + }); + it.each([ { name: 'title-only', elements: [] }, { name: 'inline', elements: [{ ...source, display: 'inline' as const }] }, @@ -145,6 +159,6 @@ describe('side elements across thread navigation', () => { act(() => result.current.navigate('/thread/other')); - expect(vi.mocked(AutoResumeThread).mock.calls.some(([props]) => props.id === 'other')).toBe(true); + expect(AutoResumeThread).toHaveBeenCalledWith({ id: 'other' }, {}); }); }); From a73a885a5c139902d8926ecc14f595170ad58d2d Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:10:14 -0400 Subject: [PATCH 6/6] fix: reconcile message-owned page previews Co-Authored-By: Codex --- .../chat/MessagesContainer/useSideElements.ts | 53 +++++++++++-------- frontend/tests/sideElements.spec.tsx | 35 ++++++++++++ .../tests/sideElementsNavigation.spec.tsx | 11 ++++ 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/chat/MessagesContainer/useSideElements.ts b/frontend/src/components/chat/MessagesContainer/useSideElements.ts index 2fc0bbd80f..53e0c39426 100644 --- a/frontend/src/components/chat/MessagesContainer/useSideElements.ts +++ b/frontend/src/components/chat/MessagesContainer/useSideElements.ts @@ -13,9 +13,7 @@ export function createMessageSideView(elements: IMessageElement[]) { title: elements[elements.length - 1].name, elements }; - if (elements.some((element) => element.display === 'side')) { - messageSideViews.add(view); - } + messageSideViews.add(view); return view; } @@ -29,16 +27,6 @@ export function useSideElements( useEffect(() => { const sideElements = elements.filter((e) => e.display === 'side'); - if (sideElements.length === 0) { - knownSideElementsRef.current = new Map(); - knownSideOrderRef.current = []; - // Other callers, such as ElementSidebar, share this view state. - setSideView((current) => - current && messageSideViews.has(current) ? undefined : current - ); - return; - } - const prevMap = knownSideElementsRef.current; const prevOrder = knownSideOrderRef.current; const currentIds = sideElements.map((e) => e.id); @@ -48,17 +36,38 @@ export function useSideElements( 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; - const shouldOpen = sideElements.some( + knownSideElementsRef.current = new Map(sideElements.map((e) => [e.id, e])); + knownSideOrderRef.current = currentIds; + const shouldOpen = + hasChanged && + sideElements.some( (element) => prevMap.get(element.id) !== element && element.autoExpand !== false ); - const nextView = createMessageSideView(sideElements); - setSideView((current) => (current || shouldOpen ? nextView : current)); - } + setSideView((current) => { + if ( + current && + messageSideViews.has(current) && + current.elements.every((e) => e.display === 'page') + ) { + const currentElements = new Map(elements.map((e) => [e.id, e])); + const pages = current.elements + .map((e) => currentElements.get(e.id)) + .filter((e): e is IMessageElement => e?.display === 'page'); + if (pages.length === 0) { + return shouldOpen ? createMessageSideView(sideElements) : undefined; + } + return pages.length === current.elements.length && + pages.every((e, i) => e === current.elements[i]) + ? current + : createMessageSideView(pages); + } + if (sideElements.length === 0) { + return current && messageSideViews.has(current) ? undefined : current; + } + return hasChanged && (current || shouldOpen) + ? createMessageSideView(sideElements) + : current; + }); }, [elements, setSideView]); } diff --git a/frontend/tests/sideElements.spec.tsx b/frontend/tests/sideElements.spec.tsx index a25293bceb..38ea5c3c16 100644 --- a/frontend/tests/sideElements.spec.tsx +++ b/frontend/tests/sideElements.spec.tsx @@ -40,6 +40,41 @@ function renderPanel(elements: IMessageElement[]) { } describe('side element arrivals', () => { + it('clears an explicitly opened page preview when its element is removed', () => { + const page: IMessageElement = { ...element('page'), display: 'page' }; + const { result, rerender } = renderPanel([page]); + act(() => result.current.open(page)); + rerender({ elements: [] }); + expect(result.current.panel).toBeUndefined(); + }); + + it('refreshes a page preview without replacing it with unrelated side arrivals', () => { + const page: IMessageElement = { ...element('page'), display: 'page' }; + const { result, rerender } = renderPanel([page]); + act(() => result.current.open(page)); + const sidebar = result.current.panel; + const side = element('side'); + rerender({ elements: [page, side] }); + expect(result.current.panel).toBe(sidebar); + const updated = { + ...page, + name: 'Updated page', + url: 'https://example.com/new' + }; + rerender({ elements: [updated, side] }); + expect(result.current.panel?.elements).toEqual([updated]); + expect(result.current.panel?.title).toBe('Updated page'); + }); + + it('closes a deleted page preview while quiet side elements remain', () => { + const page: IMessageElement = { ...element('page'), display: 'page' }; + const side = element('side', false); + const { result, rerender } = renderPanel([page, side]); + act(() => result.current.open(page)); + rerender({ elements: [side] }); + expect(result.current.panel).toBeUndefined(); + }); + it('opens new elements by default', () => { const { result } = renderPanel([element('source')]); expect(result.current.panel?.title).toBe('source'); diff --git a/frontend/tests/sideElementsNavigation.spec.tsx b/frontend/tests/sideElementsNavigation.spec.tsx index 4ad3a85a26..d991184d88 100644 --- a/frontend/tests/sideElementsNavigation.spec.tsx +++ b/frontend/tests/sideElementsNavigation.spec.tsx @@ -95,6 +95,17 @@ function renderActiveThread() { } describe('side elements across thread navigation', () => { + it('clears a page preview removed while the active thread is unmounted', () => { + const { result } = renderActiveThread(); + const page = { ...source, display: 'page' as const }; + act(() => result.current.setElements([page])); + act(() => result.current.setPanel(createMessageSideView([page]))); + act(() => result.current.navigate('/share/other')); + act(() => result.current.setElements([])); + act(() => result.current.navigate('/thread/active')); + expect(result.current.panel).toBeUndefined(); + }); + it('clears stale automatic content when returning from a shared thread', () => { const { result } = renderActiveThread(); expect(result.current.panel?.elements).toEqual([source]);