Uh oh!
There was an error while loading. Please reload this page.
fix(ui): stop remounting a turn's answer as it settles - #2946
Merged
Conversation
The assistant answer's React key was derived from its first timeline entry, so it changed whenever the entry did — most visibly when a turn finished and its Processing fold went away. React then unmounted the whole answer and mounted an identical-looking copy, and the browser discards any text Selection that lived in the removed subtree: text a user selected while the answer was arriving lost its highlight, and the quote affordance that reads from the Selection never appeared. Key the answer by what it replies to instead — the steering message that opened it, or the turn itself for the first answer. That identity does not move as the turn runs. The same unmount happened one level down: the answer swapped between a streaming component and a settled one. Fold them into a single memoized `AssistantAnswerBubble` that takes the state as props, so settling changes attributes rather than replacing nodes. The quote-selection e2e now selects from a settled answer. The markdown renderer rebuilds a paragraph's inline fragments when the stream closes and takes the Selection with them — a race that has nothing to do with the pointer-capture contract that test exists to pin, and that this change cannot reach. Generated-by: Claude Code
Folding the assistant bubbles into `AssistantAnswerBubble` left every remaining caller passing `role="user"`, so the generic `role` prop and the trailing assistant branch were unreachable. Drop both and name the component for what it renders. Generated-by: Claude Code
Astro-Hanforce-pushed
the
fix/ui-answer-remount-drops-selection
branch
from
August 13, 2026 08:07
d5650bf to
b6275a2CompareAn answer has three lives — replayed from history, streaming, and streamed-but-still-being-handed-off — and this component spelled them as two independent booleans. That made `live: false, streaming: true` representable, and pushed the gating of every live-only prop out to the call site, where forgetting one is silent. One was already forgotten: `settledText` reached historical bubbles, which seeds the markdown renderer's fade state for a stream that is not running. The same shape produced a real defect. Settlement was inferred from a `settledRef` reset by `[text, streaming]`, so a bubble that mounted from history — already past its stream, `streaming: false` — consumed the one-shot ref. When the same message id was then promoted to a completed live projection, neither dependency had changed, the announcement never fired, and the answer sat wearing the live marker until the shell's 1000ms fallback cleaned up. Collapse the pair into one `phase` field and make the illegal combination unrepresentable: historical bubbles cannot carry `settledText`, `truncated`, or `onSettled` at all. Settlement becomes what it always was — the edge into `settled` — so history never consumes it, and it no longer replays when the text changes underneath an answer that has already settled. Generated-by: Claude Code
The comments claimed a user-visible outcome the code does not produce. Stopping the remount keeps scroll position, open disclosures, and a held Selection across a turn settling — but a Selection taken inside a still streaming answer dies anyway, because the markdown renderer rebuilds the paragraph's inline fragments when the stream closes and the browser discards the Selection those nodes held, without a `selectionchange`. Describe the mechanism instead of an outcome, and put the remaining gap where it stays visible: a `test.fixme` next to the pointer-capture test whose precondition was narrowed to sidestep it. Pinning the `isStreaming` and `settledText` props that drive the rebuild still reproduces, so closing it needs an upstream fix or a product decision to snapshot the quote at pointerup. Generated-by: Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the only red e2e on
main:quote-selection.spec.tsnever sees.maka-quote-actions.The problem
The assistant answer's React key was derived from its first timeline entry. While a turn runs that entry is the Processing fold; when the last tools group is projected away the fold dissolves, the first entry changes, and so does the key — React unmounts the whole answer and mounts an identical-looking copy, taking with it the scroll position, any open disclosure, and any text Selection held inside.
timeline-fold.tsalready made this argument for the Processing block itself — its id comes from the preceding boundary precisely so it "survives the first tool being projected away without remounting". The answer element was still keyed by a guess at its first child.Changes
1.
fix(ui): stop remounting a turn's answer as it settlesKey the assistant message by what it replies to — the steering message that opened it, or a disjoint
assistant-openingfor the turn's first answer (a steering id is any string, so a bare sentinel could collide with a real one). Fold the streaming and settled bubbles into one component so settling changes attributes rather than swapping component types, which is itself an unmount. Addschat-turn-answer-identity.test.tsx, which rendersTurnViewinto a real DOM and asserts the answer elementisSameNodeacross the transition.2.
refactor(ui): narrow MessageBody to the user message it now rendersAfter the merge every caller passed
role="user", so the generic prop and the assistant branch were unreachable. Most of that diff is de-indentation.3.
fix(ui): announce settlement from the answer's phaseThe merged component spelled the answer's three lives — historical, streaming, settled — as two independent booleans, which made
live: false, streaming: truerepresentable and pushed gating of every live-only prop out to the call site. One was already forgotten (settledTextreached historical bubbles). The same shape produced a real defect: settlement was inferred from asettledRefreset by[text, streaming], so a bubble mounted from history — alreadystreaming: false— consumed the one-shot ref; when that same message id was promoted to a completed live projection neither dependency had changed, the announcement never fired, and the answer wore the live marker until the shell's 1000ms fallback. Collapsed to onephasefield: historical bubbles cannot carry live-only props at all, and settlement is the edge intosettled.4.
docs(ui): say what the remount fix fixes, and record what it does notThe comments claimed a user-visible outcome the code does not produce. See below.
The gap this does not close
A Selection taken inside a still-streaming answer dies anyway: the markdown renderer rebuilds the paragraph's inline fragments when the stream closes, and the browser discards the Selection those nodes held — without a
selectionchange, so the quote hook's read 350ms after pointer release finds nothing. Pinning theisStreamingandsettledTextprops that drive the rebuild still reproduces it; it happens inside@astryxdesign/core.The e2e therefore selects from a settled answer — it exists to pin the pointer-capture contract, not Selection survival across a stream close, and no capture/release assertion was dropped. The lost precondition is recorded as a
test.fixmebeside it rather than only in this description. Closing it needs an upstream fix, or a decision to snapshot the quote at pointerup — which would show a quote bar over text whose highlight the browser has already erased. That is a product call.Verification
quote-selectionpasses 5/5 consecutive runs (5/5 failures before this change, same invocation)@maka/ui133/133, typecheck andformat:checkcleanslash-command-menu.spec.ts:87is flaky onorigin/maintoo — 6 failures in 20 runs there, on an untouched code pathGenerated-by: Claude Code