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/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 e1242aa9b4..b66757d206 100644 --- a/backend/tests/test_element.py +++ b/backend/tests/test_element.py @@ -92,6 +92,60 @@ 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_custom_element_sanitize_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/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/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/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/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 18899fec92..d39d1f92a3 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 { createMessageSideView, 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) => { @@ -131,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 new file mode 100644 index 0000000000..53e0c39426 --- /dev/null +++ b/frontend/src/components/chat/MessagesContainer/useSideElements.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import type { SetterOrUpdater } from 'recoil'; + +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 + }; + messageSideViews.add(view); + return view; +} + +export function useSideElements( + elements: IMessageElement[], + setSideView: SetterOrUpdater +) { + const knownSideElementsRef = useRef>(new Map()); + const knownSideOrderRef = useRef([]); + + useEffect(() => { + const sideElements = elements.filter((e) => e.display === 'side'); + + 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); + + 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 + ); + 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 new file mode 100644 index 0000000000..38ea5c3c16 --- /dev/null +++ b/frontend/tests/sideElements.spec.tsx @@ -0,0 +1,210 @@ +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 { + createMessageSideView, + 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[]; + key?: string; + }>(); + useSideElements(elements, setPanel); + return { + panel, + setPanel, + close: () => setPanel(undefined), + open: (el: IMessageElement) => setPanel(createMessageSideView([el])) + }; + }, + { initialProps: { elements } } + ); +} + +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'); + }); + + 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(); + }); + + 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(); + }); + + 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..d991184d88 --- /dev/null +++ b/frontend/tests/sideElementsNavigation.spec.tsx @@ -0,0 +1,175 @@ +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 { + createMessageSideView, + 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 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]); + + 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(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]); + }); + + 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 }] }, + { 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); + } + ); + + it('requests resume when navigating to a different private thread', () => { + vi.mocked(AutoResumeThread).mockClear(); + const { result } = renderActiveThread(); + + act(() => result.current.navigate('/thread/other')); + + expect(AutoResumeThread).toHaveBeenCalledWith({ id: 'other' }, {}); + }); +}); 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'> {