perf(chat): window long chats, so only rows near the viewport are mounted
Merged
RA
1d ago+414+414+012345678901234567890123456789-39-39-01234567890123456789
Description
Opening or switching to a long chat mounted every message at once: markdown, code blocks, timelines and document cards. That one synchronous render blocked the main thread. It blocked even for a chat that was already in the store, so the switch froze and the first-open loader could not paint.
This PR windows the chat's message rows:
WindowedRow(new,sections/chat/WindowedRow.tsx): a row mounts its content only while it is within two viewport heights of the scroll position (anIntersectionObserveron the chat scroll container). Farther away, it is an empty placeholder at its last measured height. The placeholder keeps themessage-<id>DOM id, so scroll anchors still resolve.- Scroll position: when a row above the viewport changes height (a placeholder mounts, or content grows), the row moves
scrollTopby the same amount in itsResizeObservercallback, before paint. What is on screen stays still. ChatScrollContainersetsoverflow-anchor: none. Otherwise Chrome and Firefox would correct the position a second time.ChatUI: the last 6 rows mount on the first render. These rows never unmount:- the latest turn (the last 2 rows);
- a reply that is still generating;
- the
DynamicBottomSpaceranchor; - a row that holds focus, for example a message being edited.
useOptionalScrollContainer: outside aChatScrollContainer(for example in the Jest tests), rows always render their content.
Trade-offs: browser find (⌘F) finds only mounted rows, and a row that remounts loses its local UI state (for example an expanded timeline).
Docs
- Customer-facing change: I updated
documentation/(text and screenshots). - No docs change needed (internal, tests, CI, or multi-tenant cloud only).
Additional Options
- [Optional] Please cherry-pick this PR to the latest release version.
Summary by cubic
Windows long chats so only rows near the viewport mount, so opening or switching to a long chat no longer blocks the main thread.
- Rows farther than two viewport heights render as empty placeholders at their last measured height; the placeholder keeps the
message-<id>DOM id so scroll anchors still resolve. - The last 6 rows mount on first render; the latest turn, a streaming reply, and the scroll anchor stay mounted. A row holding focus stays mounted but unmounts once focus leaves it.
- A message that unmounts resets text-to-speech only if it is the one speaking.
- Rows outside a
ChatScrollContainer(for example in the Jest tests) always render content. - When a row above the viewport changes height, the scroll position adjusts by the same amount before paint; rows leave scroll anchoring to the browser where supported.
- Reopening a stored chat used to scroll from its top and stop short of the end; the scroll container now takes the store's session so it resets with the right chat's messages, and it follows content that settles below the end for a second after opening.
- The scroll-to-bottom button jumps to one viewport above the end, then glides the rest, so it never mounts every row on the way; the glide settles on the end only if the user did not scroll away.
- The content observers re-attach per session, so auto-follow keeps working after switching to a cached chat.
- E2E specs now assert on saved turns read from the server, not on a count of mounted messages; the wait on the latest reply accepts a multi-model turn.
Trade-offs: browser find (⌘F) finds only mounted rows, and a row that remounts loses its local UI state (for example an expanded timeline).
Summary by CodeRabbit
- Performance
- Long chat conversations now render more efficiently, helping keep scrolling responsive while nearby, active, and focused messages remain available.
- Bug Fixes
- Scrolling to the latest message is smoother, and new content is less likely to interrupt an in-progress scroll.
- Removing a message that isn’t speaking no longer interrupts text-to-speech playback for a different message.
- Improvements
- Chat scrolling follows the active session more reliably, including when opening a conversation with existing messages.