Skip to content

[NONE-33017] fix(client): make text selection work while an answer streams - #77

Merged
mm-zacharydavison merged 3 commits into
mainfrom
masudahiroto/fix-streaming-text-selection
Sep 2, 2026
Merged

mm-zacharydavison merged 3 commits into
mainfrom
masudahiroto/fix-streaming-text-selection

Conversation

@masudahiroto

@masudahiroto masudahiroto commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

AI Disclosure: this PR description is written by AI.

Make text selection work while the AI generates an answer.

Two things broke it.

  • The markdown component built its element override map inside its render function, so React threw away the answer and built it again on every token. Each rebuild cleared the selection. This PR moves the map to module scope and memoizes the component.
  • The streaming answer and the saved answer are two separate elements. When a run finishes, React removes the streaming one and shows the saved one, and the selection goes with it. A new hook, useSelectionHandoff, records the selection as character offsets while the answer streams, then applies them to the saved element. It restores nothing when the text does not match, so it never selects text that the user did not highlight.

Tests cover both cases.

🤖 Generated with Claude Code


2026-08-17.19.06.47.mov

@notion-workspace

Copy link
Copy Markdown

@masudahiroto
masudahiroto marked this pull request as ready for review August 17, 2026 10:09

@mm-zacharydavison mm-zacharydavison left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming. It looks like we should attach the comment to MARKDOWN_COMPONENTS instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the comment to refer to the MARKDOWN_COMPONENTS.

@mm-zacharydavison

mm-zacharydavison commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

I think we can avoid doing useSelectionHandoff, I will open another PR.

#78

check this PR
basically the idea is to only use one stored message for both stored and streaming, so there is no need to do 'handover'

@masudahiroto

Copy link
Copy Markdown
Contributor Author

Thank you. I'll check #78.

@masudahiroto

Copy link
Copy Markdown
Contributor Author

@mm-zacharydavison I've pulled the changes from #78 into this branch and added small fixes. Now you can review this PR.

masudahiroto and others added 3 commits September 1, 2026 12:55
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>
@mm-zacharydavison
mm-zacharydavison merged commit 6228bfe into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants