Skip to content
Draft
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
69 changes: 69 additions & 0 deletions docs/plans/2026-04-07-client-side-message-state-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Client-Side Message State

## Problem

The Python server maintains a shadow copy of all UI messages (`self._messages()` as a `reactive.Value[tuple[StoredMessage, ...]]`). This creates a second source of truth alongside the React component's own message state, leading to sync issues — most notably HTMLDependency objects being lost during streaming accumulation and bookmark restore (#192). The server-side storage also requires `StoredMessage`, `_store_message()`, `_current_stream_deps`, `html_deps_serialized`, and dedicated `_on_bookmark_ui`/`_on_restore_ui` logic.

The R package doesn't have a `messages()` equivalent at all.

## Design

### Single source of truth

The React component owns the UI message state. The server sends messages to the client as it does today, but no longer maintains a parallel copy.

### Client → Server: Latest message input

After each completed message — on `chunk_end` (stream) or `message` (non-streamed) — the React component calls `Shiny.setInputValue("<id>_message", {role, content, content_type})` with the latest completed message only. This keeps per-message wire cost minimal.

### `messages()` API

**Python**: Accumulates the per-message input values into a list. `messages()` reads this accumulated list. Since Shiny input values are reactive, `messages()` remains reactive. There is a brief async delay (client round-trip) between the server finishing a response and `messages()` updating — this is acceptable.

**R**: Exposes the raw input value as a simple accessor (latest message only). Users accumulate if they need the full history.

### Bookmark save

Shiny bookmarking is server-driven (`on_bookmark`/`on_restore`), so the client can't save state independently. The protocol:

1. During `on_bookmark`, the server sends a custom message asking the client for its full message array.
2. The client serializes its React state (`messages` from the reducer) and responds (e.g., via `setInputValue` with a one-time bookmark-specific key).
3. The server includes this in the bookmark state alongside the LLM client state (which is saved via `_on_bookmark_client` as today).

### Bookmark restore

1. `_on_restore_client` runs as today — restores LLM client turns.
2. The server sends the saved message array to the client via a new `restore_messages` action.
3. The React reducer bulk-loads the messages into state.
4. If any messages originally had HTMLDependencies, the dep dicts are included in the saved state. On restore, the server registers the file routes (lightweight `_process_ui` call), and the client calls `renderDependenciesAsync` to inject the `<link>`/`<script>` tags.

### What gets removed (Python)

- `self._messages()` reactive value
- `StoredMessage` dataclass and `_store_message()`
- `_current_stream_deps` accumulator
- `html_deps` field in `ChatMessageDict`
- `html_deps_serialized` parameter on `_send_append_message`
- `_on_bookmark_ui` / `_on_restore_ui` (replaced by client-side bookmark)
- `_transform_message` / `_needs_transform` (transforms apply at send time, not storage time)

### What stays

- `_send_append_message` / `_send_action` — server still sends messages to client during normal operation
- `_on_bookmark_client` / `_on_restore_client` — LLM client state is server-managed
- `messages()` method — reads from accumulated client input values
- Transform decorators — apply before sending to client, no server-side storage

### What's new

- **JS**: `setInputValue` call on message completion
- **JS**: Bookmark save/restore of the full message array (new `restore_messages` reducer action)
- **JS**: Dep dicts included in bookmark state; client calls `renderDependenciesAsync` on restore
- **Python**: Input value accumulation logic for `messages()`
- **R**: Simple accessor for the message input value

### Open questions for implementation

- **Init messages**: `Chat(messages=["Welcome!"])` are sent to the client during init. Before any message round-trips back, `messages()` will be empty. The implementation plan should decide whether to pre-populate the accumulator or accept the gap.
- **Bookmark client protocol**: The exact handshake for asking the client for its state during `on_bookmark` (custom message + one-time input value, or a synchronous approach if available).
- **`bookmark_on="response"` removal**: This feature can be dropped since the client can trigger bookmarks directly on stream completion.
2 changes: 1 addition & 1 deletion js/dist/shinychat.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/dist/shinychat.js.map

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions js/src/chat/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,6 @@ export function chatReducer(state: ChatState, action: AnyAction): ChatState {
}
}

case "render_deps":
// Dependencies were already rendered by the transport layer before
// this action reached the reducer. Nothing to update in state.
return state

case "hide_tool_request": {
if (state.hiddenToolRequests.has(action.requestId)) return state
const newSet = new Set(state.hiddenToolRequests)
Expand Down
1 change: 0 additions & 1 deletion js/src/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export type ChatAction =
focus?: boolean
}
| { type: "remove_loading" }
| { type: "render_deps" }
| { type: "hide_tool_request"; requestId: string }

export type ShinyChatEnvelope = {
Expand Down
8 changes: 8 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* Fixed bookmark serialization failure when a `ToolResultDisplay` contained `HTMLDependency` objects in its `html`, `icon`, or `footer` fields. (#188)

* Fixed `HTMLDependency` objects being lost during streaming message accumulation, causing CSS/JS to be missing after bookmark restore for streamed messages. (#192, #193)

### Breaking changes

* Removed the deprecated `transform_user` and `transform_assistant` parameters from `.messages()`. These were deprecated in favor of the `.transform_user_input()` and `.transform_assistant_response()` decorators. (#193)

* `ChatMessageDict` (returned by `.messages()`) may now include an `html_deps` key containing serialized `HTMLDependency` dicts. Code that unpacks or iterates these dicts with a fixed set of keys should be updated to handle the new field. (#193)

### Improvements

* Migrated Google provider from the deprecated `google-generativeai` SDK to `google-genai`. (#174)
Expand Down
Loading
Loading