Thanks to visit codestin.com
Credit goes to github.com

Skip to content

perf(chat): virtualize the agent-chat message list#87

Merged
Zeus-Deus merged 1 commit into
mainfrom
feature/77-perf-chat-virtualize-the-agent-chat-message-list
Jun 9, 2026
Merged

perf(chat): virtualize the agent-chat message list#87
Zeus-Deus merged 1 commit into
mainfrom
feature/77-perf-chat-virtualize-the-agent-chat-message-list

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Closes #77

Summary

Replaces the plain slots.map() transcript render in MessageList.tsx with a virtualized list, so only the on-screen window of rows is mounted regardless of conversation length. A 797-message session now mounts at most ~20 row nodes (was: one DOM subtree per message, up to the 5,000-message reducer cap).

Library choice (issue item 1)

react-virtuoso v4.18.7 (MIT). Evaluated against @tanstack/react-virtual (MIT, headless — would require hand-rolled dynamic measurement + bottom-pinning) and react-window (MIT, weak variable-height support). Virtuoso is the only one with built-in ResizeObserver-driven row measurement and bottom-anchoring primitives — exactly the two hard parts called out in the issue. The commercially licensed @virtuoso.dev/message-list package is not used.

What's preserved (issue item 2)

  • Stable keys + memo rows — per-slot keys are still slot.item.id / run:<first-id>, rows render through MessageRowMemo; a streaming token re-renders exactly one row.
  • Tail-follow auto-scrollMessageList owns the scroller now (the virtualizer must control it). Pinned-ness is tracked from real scroll events (≤ 80 px, same threshold as before); every transcript change snaps to the tail only while pinned. Content growth never fires a scroll event, so streaming can never unpin — and a user wheel-up unpins immediately, so auto-scroll never fights the reader.
  • Variable row heights — tool-run collapses render as a single virtual row that expands in place; Virtuoso re-measures via ResizeObserver. The thinking pulse renders as the last virtual row (not a footer) so the tail snap keeps it visible while streaming.
  • Tool-run collapse / merged-slot logic — unchanged; ChatTranscript shrinks to a thin shell that derives showThinking.

Also in this PR

  • StrictMode hydrate fix (AgentChatPane.tsx): hydrate-on-mount never applied in dev builds — the first effect invocation was cancelled by StrictMode's mount/unmount cycle and its attempt marker blocked the re-run. Production (single mount) was unaffected.
  • Dev-mock chat harness (src/dev/): seeded agent-chat-demo workspace with enable_agent_chat on, a ~790-row transcript hydrated through the real reducer via agent_chat_list_messages, simulated streaming replies on agent_chat_send_turn, and window.__codemuxChatMock.streamReply() for on-demand streams. Verified tree-shaken from the production bundle.
  • Docs: virtualization contract + dev harness documented in docs/features/agent-chat.md.

Acceptance criteria — verified E2E in the real UI (browser mock, real store/reducer/event pipeline)

  • 1,000+ message session keeps only on-screen rows in the DOM — 797 store messages → max 20 mounted rows across a full-history scroll sweep (11 at rest); session opens at the tail (Turn 220 visible).
  • Streaming auto-scroll + manual scroll-up behave — pinned 100-token stream: distance-from-bottom samples all ≤ 80 px, tail visible; mid-stream wheel-up: scrollTop frozen exactly while tokens kept appending; scrolled-up reading position unmoved by a background stream.
  • Tokens re-render exactly one row — MutationObserver during a 30-token stream: only the tail item mutated (plus a render-count unit test in MessageList.virtualization.test.tsx).
  • Tool collapses, approvals, plan blocks render and resize correctly — collapse expands 4→8 rows in place and back with zero scroll drift; dispatch tests (plan / user-input / generic approval) all green under VirtuosoMockContext.

Verify

  • npm run verify fully green: cargo check, 1684 Rust lib tests + all integration suites, tsc --noEmit, 124 test files / 1826 frontend tests (2 new virtualization tests; existing MessageList tests updated to render inside VirtuosoMockContext).
  • npm run build (production) passes; dist/ contains no mock code.
  • Note: two pre-existing Rust tests are environment-sensitive on dev machines (project_codemux_entry_is_filtered_out reads the real ~/.claude.json; resolve_binary_finds_native_binary_from_project_root depends on a system-installed agent-browser outside npm run PATH). Both pass under a clean-HOME npm run verify; this PR touches no Rust beyond zero lines.

Manual repro for reviewers

  1. npm run devcodemux browser open http://localhost:1420
  2. Open the agent-chat-demo workspace — long transcript opens at the tail.
  3. DevTools: document.querySelector('[data-testid="virtuoso-item-list"]').children.length stays ~10–20 while scrolling.
  4. window.__codemuxChatMock.streamReply('thread-mock-chat', { tokens: 120 }) — pinned: view follows; scrolled up: view stays put.

Replace the plain slots.map() transcript render with react-virtuoso
(MIT; evaluated against @tanstack/react-virtual and react-window —
chosen for built-in dynamic row measurement and bottom-anchoring; the
commercially licensed message-list package is not used). Only the
on-screen window of rows mounts in the DOM, so a 5,000-message session
(the reducer cap) opens and scrolls like a short one — a 797-row
session now mounts at most ~20 row nodes.

Contract preserved from the pre-virtualization renderer:
- stable per-slot keys (item id / run:first-id) + memo'd rows: a
  streaming token still re-renders exactly one row (verified by a
  MutationObserver in-browser and a render-count unit test)
- stick-to-bottom: MessageList owns the scroller now; pinned-ness is
  tracked from real scroll events (<= 80px), and every transcript
  change snaps to the tail only while pinned — content growth never
  unpins, so auto-scroll never fights a user reading history
- tool-run collapses expand in place as one virtual row (Virtuoso
  re-measures via ResizeObserver); thinking pulse renders as the last
  virtual row so the tail snap keeps it visible

ChatTranscript shrinks to a shell that derives showThinking. Fix a
dev-only StrictMode bug where hydrate-on-mount never applied (the
first effect invocation was cancelled and the attempt marker blocked
the re-run).

Dev mock grows an agent-chat-demo workspace: enable_agent_chat on,
~790-row transcript hydrated through the real reducer, simulated
streaming replies on send_turn, and window.__codemuxChatMock for
on-demand streams — the standing browser harness for transcript work.

jsdom tests wrap renders in VirtuosoMockContext; new
MessageList.virtualization.test.tsx covers the bounded-window and
single-row-re-render guarantees.

Closes #77
@Zeus-Deus Zeus-Deus merged commit 3a6821e into main Jun 9, 2026
4 checks passed
@Zeus-Deus Zeus-Deus deleted the feature/77-perf-chat-virtualize-the-agent-chat-message-list branch June 9, 2026 19:28
Sign up for free to 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.

[perf][chat] Virtualize the agent-chat message list

1 participant