Repository navigation
Re-diff only the changed response while a chat reply streams - #342254
Merged
Karthik Nadig (karthiknadig) merged 1 commit intoOct 31, 2026
Conversation
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
The shared tree-update path needs human validation of focus, selection, scrolling, and cache invalidation beyond the supplied tests.
No open issues 🎉
What changed in this PR
Reduces chat-list processing during streamed replies in long conversations, partially addressing the reported responsiveness issues.
Changes:
- Passes response-specific change events from the view model to the list.
- Updates only affected response subtrees when safe, retaining full-refresh fallbacks.
- Adds tests for streaming, scrolling, and fallback behavior.
| File | Description |
|---|---|
| src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts | Tests incremental updates and full-refresh fallbacks. |
| src/vs/workbench/contrib/chat/common/model/chatViewModel.ts | Emits response-specific change events. |
| src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts | Forwards batched changes to the list. |
| src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts | Caches rendered state and selectively updates response subtrees. |
Review effort: Balanced
Karthik Nadig (karthiknadig)
force-pushed
the
karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh
branch
from
October 18, 2026 05:16
f762bad to
cf4a4dd
Compare
Karthik Nadig (karthiknadig)
force-pushed
the
karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh
branch
from
October 30, 2026 03:46
cf4a4dd to
8ff4c2e
Compare
Karthik Nadig (karthiknadig)
marked this pull request as ready for review
October 30, 2026 04:01
Benjamin Christopher Simmonds (benibenj)
previously approved these changes
Oct 30, 2026
Every streamed update to a response refreshed the chat list by diffing the whole transcript, so the cost of each update grew with the number of earlier turns. The view model now reports which response changed, and the list re-diffs only that response under its request when nothing else changed, falling back to the full refresh otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Karthik Nadig (karthiknadig)
force-pushed
the
karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh
branch
from
October 30, 2026 15:26
8ff4c2e to
9880b39
Compare
Logan Ramos (lramos15)
approved these changes
Oct 30, 2026
Karthik Nadig (karthiknadig)
deleted the
karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh
branch
October 31, 2026 05:31
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.
Refs #328610
Refs #316407
This is a partial fix. It removes the chat list's share of the per-update cost in long chats. Other per-update work that grows with transcript length is still there; see Follow-ups.
Problem
When a reply streams into a long chat, every streamed chunk makes the renderer re-check the entire transcript. With the same reply, a session with 2,000 earlier turns used 4.1× the renderer script time of a 20-turn session (median 3,051 ms vs 748 ms on
mainatcd2259b), and the main thread was 91% busy for the whole stream.With this PR, the same reply into the 2,000-turn session takes about half the renderer CPU: median script time drops from 3,051 ms to 1,521 ms, and the main thread is 74% busy instead of 91%. 20-turn sessions are unchanged (748 → 786 ms, within run-to-run noise).
At the stream rate used here (one chunk about every 32 ms), the visible text already kept up on both builds. The median time between visible updates is 32–35 ms either way. So the gain is CPU headroom for typing and other work during a stream, not visibly smoother text.
The 2,000-turn session still costs 1.6–1.9× the 20-turn session (3.7–4.3× on
main). The rest of that cost comes from code outside the chat list (see Follow-ups).Root cause
Every progress event on a response fires the view model's
onDidChangewithnull(chatViewModel.ts).ChatWidgetbatches those events and refreshes the list (chatWidget.ts).ChatListWidget.refresh()then rebuilds the whole request/response tree, builds a diff identity string for every item, and callssetChildren(null, allItems, { diffDepth: 1 })(chatListWidget.ts).In the tree,
spliceSmartdiffs every request. BecausediffDepthis 1, it also recurses into every unchanged request (indexTreeModel.ts). Each recursion callsgetParentNodeWithListIndex, which adds up the row counts of all earlier requests (indexTreeModel.ts).So one streamed chunk costs time proportional to the square of the transcript length, plus a string per item, even though only the streaming response changed. In a CPU profile of a 100-chunk reply streamed into the 2,000-turn session on
main(at1e81fc7, when this PR was opened),ChatListWidget.refreshtook 1,946 ms (46% of the profile), compared with 610 ms with 20 turns. The difference is tree diffing; re-rendering the changed row costs the same at both sizes.Fix
ChatViewModelnow fires{ kind: 'changeResponse', response }instead ofnullwhen a response changes.ChatWidgetpasses the batched events toChatListWidget.refresh(changes).refresh()remembers what its last full refresh rendered: the items, each item's diff identity, which requests follow another request, the request being edited, and the visibility and setting counters.setChildren(request, itsResponses, { diffIdentityProvider })for just those requests, inside the same auto-scroll handling as before. This is the same child-level update the full diff would have made, so the tree's stored identities, focus, selection and auto-scroll behave as before.getDiffIdentity(). The full refresh now computes each item's identity once per refresh and reuses it.Before / after
Both recordings show the same 120-chunk reply streaming into the 2,000-turn session in real time, from the first update the overlay reports to just before the stream finishes. The banner at the top states the run's result from the median of three runs: renderer busy % and renderer script time. The box below it updates live with renderer busy % so far and the time between visible updates. The recordings were made when this PR was opened, on
mainat1e81fc7; the numbers in Measurements are from the current base.Before
After
Measurements
Conditions for every number below:
out/),mainatcd2259bdf72vs this PR rebased on it. Windows x64, Agents window,VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING=1. The PR has since been rebased ontomainat42aaa12cec5. The rebase did not change the PR's code, so these numbers were not re-measured; the rebased change passes its tests (see Validation).--enable-mock-agent), so there is no model or network traffic. Runs were serialized on the same machine.sessions.chatTimeline.display: off,chat.stickyScroll.enabled: false). It has its own per-update cost that is being fixed separately, and that cost would hide the chat list's.What each metric measures:
ScriptDuration(Chrome DevTools ProtocolPerformance.getMetrics) from pressing Enter until the last chunk is on screen.TaskDurationover the same interval, divided by its wall-clock time.main, 20 turnsmain, 2,000 turnsMemory and listeners were measured when this PR was opened (
mainat1e81fc7) and were not re-measured after the rebase. They were taken after three forced garbage collections, once after the warm-up and once after the last run (3 more replies per session). The growth was the same on both builds, so this change adds no retention:main: heap 255.5 → 261.8 MB (+6.3), event listeners 3,229 → 3,356 (+127), DOM nodes 3,569 → 3,839 (+270).The growth comes from the six new turns the runs add. Absolute heap differed between the two windows; that difference is not attributed to this change.
Follow-ups
These per-update costs still grow with transcript length and live outside the chat list, so they are left out of this PR. The costs are rough self times from a CPU profile of a 100-chunk reply into the 2,000-turn session on
mainat1e81fc7:AgentSessionApprovalModelautorun subscribes to every response's change signal and walks all requests on any change.getGeneratedImageResourcesscans the whole transcript on each update.ChatListItemRenderer.observeToolConfirmationsre-filtersgetItems()and re-keys every response on each view-model change. Its cost was not isolated; it is part of about 65 ms of observable bookkeeping in that profile.ChatViewModel.computeItems, about 65 ms including its callees. It re-filters the whole transcript after every change because the item cache is invalidated on each event.How to test
ChatListWidget.refreshno longer callssetChildrenon the root for each chunk; it updates only the streaming response under its request.Automated streaming scenario (mock Agent Host + Agents window)
mock-agent-host.mjs.BS_PORT=8968 node mock-agent-host.mjs. Keep stdin open, because the server exits when stdin closes. It serves sessions with 20, 500 and 2,000 synthetic turns, and the promptstream:Kstreams a reply of K chunks.VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING=1and these settings:chat.remoteAgentHostsEnabled: true,chat.remoteAgentHosts: [{ "address": "ws://127.0.0.1:8968", "name": "BS Mock" }],chat.agentHost.allowSignedOutWhenUsable: true,sessions.chatTimeline.display: "off",chat.stickyScroll.enabled: false.mainatcd2259bit fails at 3.7–4.3×; with this PR it measures 1.6–1.9×.Validation
GITHUB_WORKSPACE=<empty temp dir>for every unit test run belowtest/unit/electron/index.jsonly shows the test window (inactive) on CI. Locally the window stays hidden, Windows throttles its animation frames, and layout tests time out onmaintoo. This runs the window the way CI does, which also uses CI's 30 s test timeout.main@42aaa12scripts\test.bat --run src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts --run src/vs/workbench/contrib/chat/test/common/model/chatViewModel.test.ts --run src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts42aaa12scripts\test.bat --run src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts --grep "streaming into a long transcript|restores the row at the top", with one temporary (not committed) extra testrestoreViewStatereturned to them after scrolling to the bottom. The restore reads row positions from the tree, which the new path keeps up to date like a full refresh does.1e81fc7)refreshChangedResponsesforced to returnfalse, thenscripts\test.bat --grep "streaming into a long transcript"npm run typecheck-client42aaa12)npm run eslint -- <the 4 changed files>42aaa12)main@cd2259bcd2259b1e81fc7)Performance.getMetricsafter the warm-up and after the last runmain: +6.2 vs +6.3 MB heap, +126 vs +127 listeners, +260 vs +270 nodesFound and fixed with automated exploratory testing. Draft until reviewed by a human.