Skip to content

fix(desktop): keep message component types stable across Thread re-renders - #44884

Closed
travelreader8-stack wants to merge 1 commit into
NousResearch:mainfrom
travelreader8-stack:fix/thread-stable-message-components
Closed

fix(desktop): keep message component types stable across Thread re-renders#44884
travelreader8-stack wants to merge 1 commit into
NousResearch:mainfrom
travelreader8-stack:fix/thread-stable-message-components

Conversation

@travelreader8-stack

Copy link
Copy Markdown

What

Thread builds the component map it hands to the virtualizer inside a useMemo whose deps included the onBranchInNewChat / onCancel callbacks. Whenever a parent re-render handed down a fresh callback identity, the memo rebuilt the map and produced new component types, so React unmounted and remounted every visible message. Async-rendered parts (shiki code blocks) collapsed and re-expanded on each remount, making the whole thread visibly jump.

This PR routes the callbacks through a ref so the component types survive any parent re-render. Only the callbacks' definedness stays a memo dep, because it gates UI (the user-message Stop button renders only when onCancel is provided). A regression test asserts message DOM nodes keep their identity when callback props change identity — it fails on current main.

Why

This is the structural half of a bug that shipped in v0.15.1: the desktop controller passed an inline arrow for onBranchInNewChat, and the 15-second status-snapshot poll (use-status-snapshot.ts) re-rendered the controller — so any session containing code blocks visibly jumped every 15 seconds. Instrumenting the packaged app over CDP showed two layout shifts per poll cycle (scores 0.39 + 0.47, each preceded by a ~65 ms long task), and a MutationObserver confirmed the message subtrees were being removed and re-inserted with pre.shiki blocks re-highlighting from scratch.

#38333 already fixed that one call site by passing the useCallback'd function directly, but Thread itself was still one inline arrow away from regressing — nothing pinned the behavior. With this change, callback identity churn in any parent can no longer remount messages, and the new test pins it.

How to test

  • cd apps/desktop && npx vitest run --environment jsdom src/components/assistant-ui/thread-remount.test.tsx
    • fails on main (message <p> nodes are replaced across a callback-identity-only re-render), passes with this patch
  • npx vitest run --environment jsdom src/components/assistant-ui/streaming.test.tsx — 16/17 pass; the one failure (renders an incomplete streaming reasoning fenced code block as a code card) also fails on unpatched main in my environment, so it's unrelated
  • Manual repro of the original symptom on v0.15.1: open a session whose history contains fenced code blocks and wait ~15 s — the thread jumps as code blocks collapse/re-expand. With the rebuilt patched app, a 35 s CDP layout-shift observation over multiple poll cycles records zero entries.

Platforms tested

macOS 15 (arm64), Electron 40 — vitest suite plus the rebuilt packaged app with CDP layout-shift instrumentation.

🤖 Generated with Claude Code

…nders

The component map Thread passes to the virtualizer listed the
onBranchInNewChat / onCancel callbacks as useMemo deps. Whenever a parent
re-render handed down a fresh callback identity, the memo rebuilt the map
and produced new component *types*, so React unmounted and remounted every
visible message. Async-rendered parts (shiki code blocks) collapsed and
re-expanded on each remount, making the whole thread visibly jump.

That is exactly what shipped in v0.15.1: the desktop controller passed an
inline arrow for onBranchInNewChat, and the 15s status-snapshot poll
re-rendered the controller, so threads with code blocks jumped every 15
seconds (layout-shift scores of 0.39 + 0.47 per cycle, measured via CDP).
NousResearch#38333 fixed that one call site, but Thread itself was still one inline
arrow away from regressing.

Route the callbacks through a ref so the component types survive any
parent re-render; only the callbacks' definedness stays a dep, because it
gates UI (the user-message Stop button). Add a regression test that fails
on the old code by asserting message DOM nodes keep their identity when
callback props change identity.

Tested on macOS arm64 (vitest + rebuilt app, CDP layout-shift
instrumentation confirms zero shifts over multiple poll cycles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels Jun 12, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved

Excellent regression fix with thorough test coverage. The desktop status-snapshot poll (every 15s) was creating new callback identities on every render, which caused React to unmount/remount message DOM nodes — making shiki code blocks visibly collapse and re-expand.

Looks Good

  • Fix uses a callbacksRef to store callback references, only tracking boolean definedness in the useMemo deps
  • useEffect updates the ref on each render so the latest callbacks are always available
  • New test file explicitly tests the regression scenario: rerender with new callback identities but same data should preserve DOM node identity
  • Comment explains the root cause (#38333) clearly
  • The ref approach is the right pattern when parent re-renders are uncontrollable

Reviewed by Hermes Agent

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

Thanks for the focused regression test and the clear explanation of why component-type identity matters here. The underlying issue still exists on current main, but this needs a small re-salvage after later Thread work.

Problems

  • The PR edits apps/desktop/src/components/assistant-ui/thread.tsx, but commit 7ff6908a5 extracted the live component to apps/desktop/src/components/assistant-ui/thread/index.tsx. The current messageComponents memo is at thread/index.tsx:74-88, so this will not cherry-pick cleanly.
  • Current main has two additional callback dependencies in that memo: onDismissError and onRestoreToMessage (thread/index.tsx:88). Ref-proxying only branch/cancel callbacks would still allow callback-identity churn to recreate message component types.

Suggested changes

  • Port the fix and regression coverage to the co-located thread/ implementation/tests, and decouple the map from all current callback identities while retaining only definedness dependencies that gate UI.

Automated hermes-sweeper review.

() => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
AssistantMessage: () => (
<AssistantMessage

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.

This memo moved to apps/desktop/src/components/assistant-ui/thread/index.tsx in current main (commit 7ff6908a5). Please port the fix there and include its newer onDismissError and onRestoreToMessage callback dependencies; otherwise either can still recreate the message component types.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants