Problem
When a chat message is delivered via streaming (append_message_stream()), HTMLDependency objects are sent to the client during individual chunks but are not accumulated on the stored message. This means self._messages() contains entries with html_deps=None for streamed messages, even though the deps were present during delivery.
This causes bookmark restore to fail for streamed messages with HTML dependencies — the deps are missing from the stored message, so they can't be serialized into bookmark state.
Reproducible example
Save the following as app.py, create _assets/custom.css with the CSS content below, then run with shiny run app.py:
_assets/custom.css:
.custom-styled-card { border: 3px solid red; padding: 12px; background: #fff0f0; }
app.py:
from pathlib import Path
from typing import Any
from htmltools import HTMLDependency, TagList, tags
from shiny.express import app_opts
from shiny.types import Jsonifiable
from shinychat.express import Chat
app_opts(bookmark_store="server")
custom_dep = HTMLDependency(
name="custom-styled-card",
version="1.0.0",
source={"subdir": str(Path(__file__).parent / "_assets")},
stylesheet=[{"href": "custom.css"}],
)
class MockClient:
def __init__(self) -> None:
self.turns: list[Any] = []
async def get_state(self) -> Jsonifiable:
return {"version": 1, "turns": self.turns}
async def set_state(self, state: Jsonifiable) -> None:
assert isinstance(state, dict)
self.turns = state["turns"]
client = MockClient()
chat = Chat(id="chat")
chat.ui()
chat.enable_bookmarking(client, bookmark_on="response")
async def styled_response_stream(user_input: str):
"""Yields a single styled HTML chunk with an HTMLDependency."""
yield TagList(
custom_dep,
tags.div(
{"class": "custom-styled-card"},
f"Streamed styled response to: {user_input}",
),
)
@chat.on_user_submit
async def handle_user_input(user_input: str):
client.turns.append({"role": "user", "content": user_input})
client.turns.append(
{"role": "assistant", "content": f"Streamed styled response to: {user_input}"}
)
await chat.append_message_stream(styled_response_stream(user_input))
Steps:
- Send a message — the response has a red border (CSS from
HTMLDependency is loaded)
- Open the bookmark URL in a new tab
- The message HTML is restored, but the red border is gone — the CSS dependency was not re-sent
Root cause
In _append_message_chunk, the streaming path accumulates text content via self._current_stream_message += str(msg.content), but discards msg.html_deps. At chunk == "end", a new ChatMessage is created from the accumulated string — losing any deps that arrived during the stream.
Python-only
The R package does not have this problem. On bookmark restore, R re-renders the display from scratch by calling contents_shinychat(client) on the restored ellmer client turns, then chat_append() on each result. This re-runs the full rendering pipeline (as.tags() → processDeps()), so HTMLDependencies are re-generated from the tag structure rather than saved/restored. Python's restore path instead saves and re-appends serialized message strings ({content: str, role: str}), which don't carry HTMLDependency objects.
PR #191 fixed bookmark restore for non-streamed messages by saving deps separately. However, that fix doesn't help streamed messages since their stored html_deps is None — there are no deps to save.
Problem
When a chat message is delivered via streaming (
append_message_stream()), HTMLDependency objects are sent to the client during individual chunks but are not accumulated on the stored message. This meansself._messages()contains entries withhtml_deps=Nonefor streamed messages, even though the deps were present during delivery.This causes bookmark restore to fail for streamed messages with HTML dependencies — the deps are missing from the stored message, so they can't be serialized into bookmark state.
Reproducible example
Save the following as
app.py, create_assets/custom.csswith the CSS content below, then run withshiny run app.py:_assets/custom.css:app.py:Steps:
HTMLDependencyis loaded)Root cause
In
_append_message_chunk, the streaming path accumulates text content viaself._current_stream_message += str(msg.content), but discardsmsg.html_deps. Atchunk == "end", a newChatMessageis created from the accumulated string — losing any deps that arrived during the stream.Python-only
The R package does not have this problem. On bookmark restore, R re-renders the display from scratch by calling
contents_shinychat(client)on the restored ellmer client turns, thenchat_append()on each result. This re-runs the full rendering pipeline (as.tags()→processDeps()), so HTMLDependencies are re-generated from the tag structure rather than saved/restored. Python's restore path instead saves and re-appends serialized message strings ({content: str, role: str}), which don't carry HTMLDependency objects.Relation to #190 / #191
PR #191 fixed bookmark restore for non-streamed messages by saving deps separately. However, that fix doesn't help streamed messages since their stored
html_depsisNone— there are no deps to save.