Skip to content

refactor(client): render the streaming answer as a provisional message - #78

Closed
mm-zacharydavison wants to merge 2 commits into
masudahiroto/fix-streaming-text-selectionfrom
refactor/provisional-streaming-message
Closed

mm-zacharydavison wants to merge 2 commits into
masudahiroto/fix-streaming-text-selectionfrom
refactor/provisional-streaming-message

Conversation

@mm-zacharydavison

Copy link
Copy Markdown
Contributor

AI Disclosure: this PR description is written by AI.

Stacked on #77. Merge #77 first, or retarget this PR to main after #77 merges.

What

#77 keeps a text selection alive across the streaming-to-persisted swap by recording character offsets and reapplying them. This PR removes the swap, so there is no selection to restore.

  • useServerEvents allocates the message id at RUN_STARTED and exposes it as streamingMessageId. persistFinalResponse passes that id to saveAIResponse for the final answer, on both the normal path and the user-abort path.
  • saveAIResponse accepts an optional messageId and uses it for the persisted message.
  • 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. React updates the bubble in place. The provisional entry shows no timestamp or feedback buttons. Once a persisted message with the same id exists, the provisional entry is skipped, which covers the one render where both exist.
  • The loading bubble now shows only the dots and file-progress states.
  • Deleted useSelectionHandoff.ts, textSelection.ts, and their tests.

streamingText and streamingReasoning are unchanged, so CustomChat consumers see no difference. streamingMessageId is a new optional prop on UseAIChatPanel.

Tests

  • useServerEvents.streamingId.test.tsx: id assigned at RUN_STARTED, cleared at run end, passed to saveAIResponse on finish and on abort, fresh per run.
  • UseAIChatPanel.selection.test.tsx: the persisted answer reuses the streaming bubble element, the selection survives completion and user stop, one bubble during the overlap render, loading indicator placement.

bun run test in packages/client: 440 pass. E2E not run (no ANTHROPIC_API_KEY in this environment).

🤖 Generated with Claude Code

masudahiroto and others added 2 commits August 17, 2026 18:47
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 streaming answer and the persisted answer were two separate elements.
When a run finished, React unmounted the streaming bubble and mounted the
persisted one, which dropped any text selection. #77 restored the
selection by recording character offsets and reapplying them.

This change removes the swap instead. useServerEvents allocates the
message id at RUN_STARTED and persists the final answer under that id.
UseAIChatPanel renders the streaming answer as a provisional assistant
message with the same id, so the persisted answer arrives under the same
React key and React updates the bubble in place. Nothing unmounts, so
there is no selection to restore.

useSelectionHandoff and textSelection are deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@masudahiroto masudahiroto 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.

The AI review found these 2 points, and I think they need fixing. I checked both and they look valid. Could you take a look?

maxWidth: '80%',
}}
>
{streamingText || streamingReasoning ? (

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.

[nits] The streaming message is now rendered with

data-testid={`chat-message-${message.role}`}

and the e2e tests in apps/example/test use this test id to wait until streaming has finished (e.g. apps/example/test/extended-thinking.e2e.test.ts). They will now match the message while it is still streaming, so we need to fix them.

connected,
streamingText: effectiveStreamingText,
streamingReasoning: effectiveStreamingReasoning,
streamingMessageId: effectiveStreamingMessageId,

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.

We should also add streamingMessageId to chatUIContextValue, so that the message id is available when the chat is rendered with <UseAIChat />. Right now the streaming bubble and the final message bubble are different elements in that case.

@masudahiroto

masudahiroto commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

[FYI] I changed this PR as a stacked PR.
-> unstacked

@masudahiroto
masudahiroto force-pushed the masudahiroto/fix-streaming-text-selection branch from 8d3d2f7 to 75af09e Compare September 1, 2026 03:57
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