refactor(client): render the streaming answer as a provisional message - #78
mm-zacharydavison wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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 ? ( |
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
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.
|
|
8d3d2f7 to
75af09e
Compare
AI Disclosure: this PR description is written by AI.
Stacked on #77. Merge #77 first, or retarget this PR to
mainafter #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.
useServerEventsallocates the message id atRUN_STARTEDand exposes it asstreamingMessageId.persistFinalResponsepasses that id tosaveAIResponsefor the final answer, on both the normal path and the user-abort path.saveAIResponseaccepts an optionalmessageIdand uses it for the persisted message.UseAIChatPanelrenders 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.loadingbubble now shows only the dots and file-progress states.useSelectionHandoff.ts,textSelection.ts, and their tests.streamingTextandstreamingReasoningare unchanged, soCustomChatconsumers see no difference.streamingMessageIdis a new optional prop onUseAIChatPanel.Tests
useServerEvents.streamingId.test.tsx: id assigned atRUN_STARTED, cleared at run end, passed tosaveAIResponseon 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 testinpackages/client: 440 pass. E2E not run (noANTHROPIC_API_KEYin this environment).🤖 Generated with Claude Code