Skip to content

Client-side UI message state#194

Draft
cpsievert wants to merge 12 commits intomainfrom
refactor/client-side-message-state
Draft

Client-side UI message state#194
cpsievert wants to merge 12 commits intomainfrom
refactor/client-side-message-state

Conversation

@cpsievert
Copy link
Copy Markdown
Collaborator

Summary

Moves UI message state ownership to the React client. After each completed message (stream end or non-streamed), the React component calls setInputValue with {role, content, content_type}. The Python server accumulates these into a reactive list powering messages(). Bookmark save/restore for UI messages also shifts: save reads from the server's accumulated list directly (no client round-trip), and restore sends a restore_messages reducer action to bulk-load messages on the client.

What's in here

JS (commits 1–3):

  • setInputValue called on message completion (message action and chunk_end)
  • restore_messages reducer action for bookmark restore
  • Streaming content tracked imperatively via streamingRef (fixes stale React state from batched updates)
  • Removed dead shinyChatBookmarkSave client protocol

Python (commit 4):

  • messages() reads from _messages_list (accumulated from client input values via @reactive.event)
  • Bookmark save reads _messages_list() directly; restore re-sends HTML deps via render_deps then dispatches restore_messages
  • Removed StoredMessage, TransformedMessage, _store_message, _transform_message, _needs_transform, _current_stream_deps
  • transform_user/transform_assistant params on messages() are now deprecated no-ops
  • Transform tests updated to reflect new behavior

Why this is useful

  • R users get reactive message access for free. The client pushing setInputValue on each message means R can read session$input[[paste0(id, "_message")]] without building a parallel server-side accumulation system. This was the primary motivation.
  • Less code. Removes ~500 lines net, mostly the StoredMessage/TransformedMessage machinery and associated tests. Simplifies append_message, _append_message_chunk, and bookmark save/restore.
  • Cleaner separation. The client owns what it displays; the server reads back the results.

Honest downsides

  1. messages() is no longer synchronously consistent after append_message(). Previously, messages() was updated immediately when append_message was called. Now there's a round-trip delay (server → client → setInputValue → server). The dominant on_user_submitmessages() pattern isn't affected (the user message already round-tripped before the callback fires), but any code that appends and immediately reads in the same context would see stale data.

  2. System messages are silently dropped from messages(). The old code stored system-role messages server-side even though they weren't sent to the client. Now the only path into _messages_list is the client round-trip, so system messages never appear in messages().

  3. clear_messages() has a theoretical race. In-flight setInputValue calls sent before the client receives the clear action could re-add messages to _messages_list.

  4. Server depends on client correctness. If the JS fails or setInputValue doesn't fire, messages() would be incomplete. The old approach had the server as its own source of truth.

  5. Transform behavior changed. messages() now returns the transformed content (what the client echoes back) rather than the pre-transform content. This only affects the deprecated transform feature.

Open question

Most of the code complexity on main comes from the transform machinery (TransformedMessage, content_client/content_server, etc.), not from server-side message accumulation itself. If we stripped out transforms (they're deprecated) and kept server-side storage, _store_message would be ~3 lines. That would preserve synchronous consistency and server-side robustness while still getting most of the simplification. The JS changes (setInputValue on completion, restore_messages action) are valuable either way — R benefits regardless.

Whether the full client-ownership approach is worth the tradeoffs above, or whether a hybrid (Python keeps its own state, R reads from input values) is better, is worth discussing before merging.

Test plan

  • JS: 264/264 tests pass, lint clean
  • Python: 59/59 tests pass (29 unit + 30 Playwright)
  • Bookmark save/restore works (including HTML deps)
  • Streaming messages accumulate correctly
  • Module-namespaced chat IDs work (hyphenated IDs)
  • Transform tests updated for new behavior
  • Manual testing with a real LLM chat app

🤖 Generated with Claude Code

@cpsievert cpsievert marked this pull request as draft April 10, 2026 00:25
cpsievert and others added 5 commits April 9, 2026 20:23
After each message completes (chunk_end or message action), the React
component calls setInputValue with {role, content, content_type}.
This allows the server to read UI message state from the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On bookmark save, the server asks the client for its message state via
a custom message. The client responds with the serialized message array
via setInputValue. On restore, the server sends a restore_messages
action that bulk-loads messages into the React reducer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move UI message state ownership to the client. The React component
sends each completed message as a Shiny input value, and the server
accumulates them for messages(). This removes StoredMessage,
_store_message, _current_stream_deps, html_deps tracking, and
_transform_message.

Key changes:
- Server reads messages from client via _accumulate_message effect
- Bookmark save reads from server-side _messages_list (no client round-trip)
- Bookmark restore re-sends HTML deps and uses restore_messages action
- Fix stale React state in streaming by tracking content imperatively
- Remove dead _bookmark_save client protocol (server reads directly)
- Remove TransformedMessage (no longer needed)
- Update transform tests to reflect new behavior (messages() returns
  what the client echoes back, which is the transformed content)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the refactor/client-side-message-state branch from 8b71b7e to 09fe538 Compare April 10, 2026 01:24
cpsievert and others added 7 commits April 9, 2026 20:29
Without this, content_type was silently dropped when accumulating
client input values into _messages_list, causing HTML messages to
be misidentified as markdown on bookmark restore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Augment hast's ElementData interface to accept the rawHtml property and
fix node typing in the test's visit callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant