[NONE-33017] fix(client): make text selection work while an answer streams - #77
Conversation
mm-zacharydavison
left a comment
There was a problem hiding this comment.
I think we can avoid doing useSelectionHandoff, I will open another PR.
| * Renders markdown content with appropriate styling for the chat panel. | ||
| * Both of these must stay module-level constants. | ||
| * | ||
| * react-markdown re-creates its element tree on every render, and React |
There was a problem hiding this comment.
this comment is useful, but it seems in the wrong place? it's tied to the REMARK_PLUGINS const, and I think it's referring to the MARKDOWN_COMPONENTS part really.
There was a problem hiding this comment.
Thanks for confirming. It looks like we should attach the comment to MARKDOWN_COMPONENTS instead.
There was a problem hiding this comment.
I moved the comment to refer to the MARKDOWN_COMPONENTS.
check this PR |
|
Thank you. I'll check #78. |
|
@mm-zacharydavison I've pulled the changes from #78 into this branch and added small fixes. Now you can review this PR. |
The markdown component built its element override map inside its render function, so every entry was a new function on each render and React remounted the whole answer instead of updating it. During streaming that happened on every token, which cleared any selection the user was making. Hoist the map and the remark plugins to module scope and memoize the component. The streaming answer and the persisted answer are separate elements, so the swap at the end of a run collapsed the selection as well. useSelectionHandoff records the selection as character offsets while the answer streams, then applies them to the persisted element. It restores nothing when the text does not match, or when the run ends without a new answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed REMARK_PLUGINS also had to be module-level, but react-markdown v8 rebuilds its unified processor on every render regardless of the plugin array's identity. Only the components map affects reconciliation, since its functions become the element types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The streaming answer and the persisted answer were separate elements, so React unmounted one and mounted the other when a run finished, and a text selection inside the bubble was lost. useSelectionHandoff worked around this by recording character offsets and reapplying them; removing the swap removes the need for it. useServerEvents allocates the message id at RUN_STARTED and exposes it as streamingMessageId, and persistFinalResponse passes it to saveAIResponse on both the normal and the user-abort path. UseAIChatPanel renders the streaming answer as a provisional assistant message under that id, in the same list and with the same key as the persisted message that replaces it, so React updates the bubble in place. The provisional entry shows no timestamp or feedback buttons, and is skipped once a persisted message with the same id exists. The loading bubble now shows only the dots and file-progress states. streamingMessageId is also carried through the chat UI context so a chat rendered with <UseAIChat /> behaves the same. The provisional bubble is deliberately kept out of the chat-message-assistant and chat-message-content test ids: the E2E suites use them to mean the answer is finished, and a bubble that appears with the first token would satisfy that wait mid-stream. useSelectionHandoff and textSelection are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8d3d2f7 to
75af09e
Compare
AI Disclosure: this PR description is written by AI.
Make text selection work while the AI generates an answer.
Two things broke it.
Tests cover both cases.
🤖 Generated with Claude Code
2026-08-17.19.06.47.mov