perf(chat): window long transcripts to fix input lag while streaming - #831
perf(chat): window long transcripts to fix input lag while streaming#831jaeeyoungkim wants to merge 7 commits into
Conversation
content-visibility (fathah#769) bounded the paint cost of off-screen rows, but every row still lives in the React tree: each streaming delta re-runs the transcript render, so reconciliation stays O(all rows). In long sessions (hundreds of messages) that competes with typing on the main thread — the remaining half of the fathah#748 input lag, felt precisely while the agent is streaming a reply. Render only the trailing TRANSCRIPT_WINDOW (100) rows and collapse the head behind a 'Show N earlier messages' button (one more window per click). The cut is nudged back so a tool-call run is never split through a ToolActivityGroup. Also drop the [...messages].reverse().find() clone that ran on every delta. Expansion state is kept as a count of extra rows rather than an absolute index, so new streaming rows never shift what the user chose to reveal.
Greptile SummaryThis PR addresses the React-tree half of the #748 input lag issue: long transcripts now mount only the trailing 100 rows by default, with earlier rows revealed on-demand via a "Show N earlier messages" pill or by scrolling to the top. A second change coalesces streaming delta commits to one
Confidence Score: 5/5Safe to merge — the windowing, coalescing, and write-through ref changes are all consistent with each other, and a thorough test suite exercises the corner cases that were flagged in earlier review rounds. All issues raised in prior review rounds (approval-bar disappearing behind trailing tool/reasoning rows, expansion carrying over across conversations, spurious avatars at the window cut, mixed-language locale) are explicitly fixed and covered by new tests. The delta coalescing, write-through ref, and scroll-restore logic have their own tests that verify the specific failure scenarios documented in the PR. No new correctness gaps were found in this pass. No files require special attention — the most complex logic lives in MessageList.tsx and useDashboardChatTransport.ts, both of which are well-tested. Important Files Changed
|
| error: "Couldn't deliver your answer — the turn may have ended. Try again.", | ||
| }, | ||
| thinking: "Thinking…", | ||
| showEarlierMessages: "Show {{count}} earlier messages", |
There was a problem hiding this comment.
The new chat.showEarlierMessages label is only added to the English locale. In other supported locales, the new button falls back to English inside an otherwise localized chat, so long transcripts show mixed-language UI.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…ar at cut, locales - isLast now keys off the newest visible bubble id instead of the trailing row index, so an approval prompt keeps its approve/deny controls when reasoning/tool rows stream after the bubble. - Expansion (extraRows) resets when the component is reused for a different conversation (first-message id as conversation identity), so a large expanded budget doesn't carry into the next long chat. - Avatar grouping at the window cut consults the hidden row above the cut, so a mid-turn cut no longer renders a duplicate avatar for one turn. - showEarlierMessages translated for all 11 non-English locales.
|
Addressed all four review comments in 728e77f:
Full Chat + i18n suites pass (208 tests), tsc/eslint clean. |
|
12dd5ea adds scroll-driven expansion on top of the windowing:
10 tests now, incl. an IntersectionObserver stub test for the auto-expand path. Chat suite 198 passing, tsc/eslint clean. |
Greptile round 2 (P1, Last Bubble Can Disappear): lastVisibleBubbleId is computed from visibleMessages, but rows render from windowedMessages. When an approval bubble is followed by more than a full window of reasoning/ tool rows, the naive cut slices that bubble out while its id still wins the scan — no rendered row receives isLast and the approval controls vanish while the turn is waiting for input. Compute the last-bubble index and clamp windowStart to it, so the actionable bubble always stays rendered (the tool-run nudge then walks further back as before). Regression test included.
An IntersectionObserver on the top marker reveals the next window when the user scrolls near it (rootMargin 300px preloads a step early, so scrolling up through history feels continuous). The scroll position is snapshotted before rows are prepended and restored in a layout effect (pre-paint), so the content being read never jumps. The 'Show N earlier messages' button stays as a fallback for keyboard users and engines without IntersectionObserver.
|
Round-2 P1 (Last Bubble Can Disappear) addressed in e05db97 — good catch, this was a real regression path in my round-1 fix:
Also pushed 3c0af19 (separate commit): scroll-driven expansion — an Note: the branch was rewritten to split these into two clean commits ( |
12dd5ea to
3c0af19
Compare
Streaming events (message.delta, reasoning.delta, tool.progress, ...) arrive many times per second and each called setMessages, costing a full transcript reconciliation per event. With windowing this is bounded but still competes with typing/IME on the main thread while a turn streams. Deltas keep applying to messagesRef synchronously (no data loss; the ref remains the source of truth for the next event), but the React commit now rides one animation frame — render work becomes O(frames), independent of event rate. Lifecycle events (start/complete/clarify/tool boundaries) flush immediately and cancel any queued frame, so isLoading/toolProgress/ approval state never observe a stale transcript. The flush callback only publishes if messagesRef still holds the queued array — if a non-delta path (user turn, clear, clarify) took over meanwhile, it already committed newer state and the stale frame is a no-op.
|
e233752 adds the second half of the streaming-lag fix: delta coalescing. Windowing bounds how much re-renders; this bounds how often. Streaming events fire many times per second and each called
2 new tests with a controllable rAF stub: a 5-delta burst commits once with full text; |
…g bounds - Route all transcript writers through a write-through ref (useTranscriptState): functional updates resolve against the ref at dispatch time, and the coalesced flush publishes the ref instead of a pinned snapshot. Fixes two races where a mid-frame writer (/btw, failure marking, clarify) could permanently drop streamed chunks or be erased by a stale frame. Replaces the transport's adopt-back effect and guard. - History loads scroll to the bottom instantly: a smooth multi-frame scroll raced the auto-expand observer, which aborted it and stranded the view mid-transcript with no self-heal. - Bound the tool-run nudge to one window so a pathological run of hundreds of contiguous tool rows splits instead of defeating the windowing cap, and derive each expansion budget from the current effective cut so every click makes progress. - Recreate the auto-expand observer per expansion: IntersectionObserver only reports transitions, so a short reveal (collapsed tool group) left it silent and auto-load stalled. - Count hidden bubbles, not raw rows, in the "show earlier" label. - Update lat.md (chat-performance windowing/coalescing, chat-commands streaming ref invariants) to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
#748 reported input lag in long conversations; #769 fixed the paint half with
content-visibility: autoon message rows. But the React-tree half remains: every transcript row still mounts, and each streaming delta re-runs the wholeMessageListrender, so filtering + reconciliation stays O(all rows).In a session with ~770 rows this reliably reproduces: typing is smooth while the app is idle, and lags precisely while the agent is streaming a reply (deltas arrive many times per second; the per-delta O(n) work competes with keystroke/IME events on the main thread). CJK IME composition is hit hardest.
Fix
TRANSCRIPT_WINDOW(100) visible rows. The head collapses behind a "Show N earlier messages" pill; each click reveals one more window. Expansion is stored as a count of extra rows, not an absolute index, so incoming streamed rows never shift what the user chose to reveal.ToolActivityGroupis never split in half.[...messages].reverse().find()array clone that ran on every delta.Windowing composes with #769:
content-visibilitykeeps paint cheap inside the window; the window keeps the tree itself bounded.Testing
MessageList.test.tsx(5 tests): under-window renders all rows; long transcripts hide the head + newest kept; button expands one window per click and disappears at the end; tool runs never split at the boundary; empty streaming placeholders stay hidden.npx vitest run src/renderer/src/screens/Chat/— 20 files, 193 tests pass.npx tsc --noEmit -p tsconfig.web.json --composite false— clean.npx eslinton touched files — clean.