Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/chainlit/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)


Expand Down
54 changes: 54 additions & 0 deletions backend/tests/test_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions backend/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docs/side-panel-auto-expand.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion frontend/src/components/ReadOnlyThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
40 changes: 5 additions & 35 deletions frontend/src/components/chat/MessagesContainer/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
}
Expand Down Expand Up @@ -91,47 +93,15 @@ const MessagesContainer = ({ navigate }: Props) => {
[]
);

const knownSideElementsRef = useRef<Map<string, IMessageElement>>(new Map());
const knownSideOrderRef = useRef<string[]>([]);

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<string, IMessageElement>();
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) => {
if (
element.display === 'side' ||
(element.display === 'page' && !navigate)
) {
setSideView({ title: element.name, elements: [element] });
setSideView(createMessageSideView([element]));
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<NonNullable<SideView>>();

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<SideView>
) {
const knownSideElementsRef = useRef<Map<string, IMessageElement>>(new Map());
const knownSideOrderRef = useRef<string[]>([]);

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]);
}
Loading