perf(chat): virtualize the agent-chat message list#87
Merged
Zeus-Deus merged 1 commit intoJun 9, 2026
Merged
Conversation
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
9 tasks
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.
Closes #77
Summary
Replaces the plain
slots.map()transcript render inMessageList.tsxwith 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-virtuosov4.18.7 (MIT). Evaluated against@tanstack/react-virtual(MIT, headless — would require hand-rolled dynamic measurement + bottom-pinning) andreact-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-listpackage is not used.What's preserved (issue item 2)
slot.item.id/run:<first-id>, rows render throughMessageRowMemo; a streaming token re-renders exactly one row.MessageListowns 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.ChatTranscriptshrinks to a thin shell that derivesshowThinking.Also in this PR
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.src/dev/): seededagent-chat-demoworkspace withenable_agent_chaton, a ~790-row transcript hydrated through the real reducer viaagent_chat_list_messages, simulated streaming replies onagent_chat_send_turn, andwindow.__codemuxChatMock.streamReply()for on-demand streams. Verified tree-shaken from the production bundle.docs/features/agent-chat.md.Acceptance criteria — verified E2E in the real UI (browser mock, real store/reducer/event pipeline)
MessageList.virtualization.test.tsx).VirtuosoMockContext.Verify
npm run verifyfully green: cargo check, 1684 Rust lib tests + all integration suites,tsc --noEmit, 124 test files / 1826 frontend tests (2 new virtualization tests; existingMessageListtests updated to render insideVirtuosoMockContext).npm run build(production) passes;dist/contains no mock code.project_codemux_entry_is_filtered_outreads the real~/.claude.json;resolve_binary_finds_native_binary_from_project_rootdepends on a system-installedagent-browseroutsidenpm runPATH). Both pass under a clean-HOMEnpm run verify; this PR touches no Rust beyond zero lines.Manual repro for reviewers
npm run dev→codemux browser open http://localhost:1420document.querySelector('[data-testid="virtuoso-item-list"]').children.lengthstays ~10–20 while scrolling.window.__codemuxChatMock.streamReply('thread-mock-chat', { tokens: 120 })— pinned: view follows; scrolled up: view stays put.