Skip to content

fix(ui): stop remounting a turn's answer as it settles - #2946

Merged
Astro-Han merged 4 commits into
mainfrom
fix/ui-answer-remount-drops-selection
Aug 13, 2026
Merged

fix(ui): stop remounting a turn's answer as it settles#2946
Astro-Han merged 4 commits into
mainfrom
fix/ui-answer-remount-drops-selection

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes the only red e2e on main: quote-selection.spec.ts never 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.ts already 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 settles
Key the assistant message by what it replies to — the steering message that opened it, or a disjoint assistant-opening for 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. Adds chat-turn-answer-identity.test.tsx, which renders TurnView into a real DOM and asserts the answer element isSameNode across the transition.

2. refactor(ui): narrow MessageBody to the user message it now renders
After 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 phase
The merged component spelled the answer's three lives — historical, streaming, settled — as two independent booleans, which made live: false, streaming: true representable and pushed gating of every live-only prop out to the call site. One was already forgotten (settledText reached historical bubbles). The same shape produced a real defect: settlement was inferred from a settledRef reset by [text, streaming], so a bubble mounted from history — already streaming: 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 one phase field: historical bubbles cannot carry live-only props at all, and settlement is the edge into settled.

4. docs(ui): say what the remount fix fixes, and record what it does not
The 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 the isStreaming and settledText props 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.fixme beside 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-selection passes 5/5 consecutive runs (5/5 failures before this change, same invocation)
  • Desktop e2e: 16 passed, 1 skipped (the fixme above)
  • @maka/ui 133/133, typecheck and format:check clean
  • Each new test was checked for teeth by reverting the behaviour it pins and confirming it goes red
  • Unrelated: slash-command-menu.spec.ts:87 is flaky on origin/main too — 6 failures in 20 runs there, on an untouched code path

Generated-by: Claude Code

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-Han
Astro-Hanforce-pushed the fix/ui-answer-remount-drops-selection branch from d5650bf to b6275a2CompareAugust 13, 2026 08:07
An 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
@Astro-Han
Astro-Han merged commit 417b8f7 into mainAug 13, 2026
10 checks passed
@Astro-Han
Astro-Han deleted the fix/ui-answer-remount-drops-selection branch August 13, 2026 09:08
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Astro-Han