Conversation
…rch#122721) ResponseMessages computed a stable id for every row in its signature and then threw it away, keying each child MessageByIndex by its array index. When the transcript is replaced (session switch, history backfill, LCM compaction) React reuses the mounted fiber at the same ordinal, and assistant-ui's PartByIndexProvider keeps a per-index lastPartRef that hands back the PREVIOUS message's part once the new message has fewer parts. The result is one conversation's blocks appearing in another's transcript with the order scrambled -- the reported cross-session bleed and the duplicated blocks of NousResearch#121096. Carry the computed id alongside the index and key on it, so a replaced message remounts instead of inheriting the old one's part cache.
Halldrix
marked this pull request as ready for review
September 25, 2026 20:58
This branch has not been deployed
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 free
to 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.
What does this PR do?
ResponseMessagescomputed a stable message id for every row it renders, then threw it away and keyed each child by its array index:When the transcript is replaced under a mounted turn — a REST re-hydration graft, a background transcript refresh, a session switch — React reuses the fiber at the same ordinal. Two upstream pieces make that reuse observable:
MessageByIndexmemoizes onindexalone, andPartByIndexProviderkeeps a per-indexlastPartRefit hands back once the new message has fewer parts. The response rows then carry another message's mounted state.This is reachable in production because
TurnRowis keyed on the user message id (list.tsx:1459), so replacing a later assistant row does not remount the turn — and becausetoChatMessagessynthesizes ids positionally (hydration.ts:491,${timestamp}-${index}-${role}), a re-hydrated tail row routinely comes back with a different id at the same index. The writers are the graft attranscript-backfill.ts:119-137, the background refresh atuse-background-sync.ts:282-298and:306-372, and the compaction re-hydrate atgateway-event/status.ts:104.The fix carries the id already computed alongside the index and keys on it, so a replaced row remounts.
list.tsx,buildGroups, andhydration.tsare untouched:TurnRowand the standalone branch were already id-keyed, and re-synthesizing ids is a larger semantic change (ids address reactions, branch,rowId) that this does not require.Related Issue
Refs #122721
Refs #121096
Not
Fixeson either: see Exclusions. This closes the render-level keying defect, not the whole reported symptom.Type of Change
Changes Made
apps/desktop/src/components/assistant-ui/thread/response-group.tsx—ResponseSection.indices: number[]→rows: ResponseRow[]; the child key is nowrow.idinstead ofrow.index. Six lines of production code.apps/desktop/src/components/assistant-ui/thread/response-group.test.tsx— one regression case: a response row whose message is replaced at the same index must remount (old content absent,data-message-idupdated, DOM node not reused).How to Test
cd apps/desktop && npx vitest run --project ui src/components/assistant-ui/thread/response-group.test.tsx— 3/3 pass with the fix.git checkout HEAD~1 -- apps/desktop/src/components/assistant-ui/thread/response-group.tsx), the new case fails:AssertionError: expected <div …> not to be <div …>atresponse-group.test.tsx:148. Verified on a bare-main worktree at59004a6and in place.npm run typecheck,eslintandprettier --checkon both touched files are clean; the fulltest:uisuite is unchanged by this diff.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — N/A, TypeScript-only underapps/desktop. The desktop suite is the relevant one.Documentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AExclusions
What this does NOT fix. #122721 reports a broad symptom under one title, and I traced at least three distinct mechanisms behind it. Only one is unclaimed, and this PR covers only that one:
message.reactionmutating the foreground transcript regardless of sessionmessage.startclobbering the shared stream pin, so A's events resolve to BThe reporter's own hypothesis list ("background events may be applied to the active transcript") is not supported by the code: stream/tool/reasoning events are routed by explicit
session_id,syncSessionStateToViewrefuses non-active sessions, and the TUI emitter stampssession_idon every turn frame. I did not add a broad session-state guard because there is nothing to guard.The stale-text claim is not proven. My regression test asserts the row remounts. It does not assert that the previous message's parts were visible, and I could not reproduce that:
MessagePrimitivePartsderives its ranges from the current message's parts, so on unmodified main thenot.toContain('old content')assertions pass even though the node is reused. I probed three shapes (3-parts→1, tool→text, full transcript swap) and none leaked text. The node-identity assertion is the real, honest regression. If maintainers can show a shape where stale text actually renders, that is a separate, stronger test — not this PR.Why not touch
hydration.tsid synthesis. Ids are synthesized positionally, which is what makes them unstable across a re-hydrate. But ids address reactions, branch identity, androwId; changing the synthesis is a much larger semantic change than this defect needs, and this fix makes React re-key correctly regardless. Revisit if a duplicate-id collision is ever demonstrated.Why not touch
list.tsx/buildGroups.TurnRowalready keys ongroup.id(the first message id) and the standalone branch keys on the message id. The response path was the only index-keyed one; this makes it consistent. A latent pre-existing fragility I noticed but did not touch:structuralSignaturejoins${index}:${id}:${role}andbuildGroupssplits on:, so a message id containing a colon would corrupt the parse. No current writer emits one.