You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(webview): incremental clineMessages delivery for focused task #630
Sub-issue of #355 (Milestone 2, Story 3.2e). Builds on Stories 3.2c (focus gating) and 3.2d (per-task seq + taskId on messageUpdated).
The throttle fix in #629 reduces push frequency but doesn't address payload size. A single focused task with 3,525+ messages still serializes the full clineMessages array (~7MB) on every state push. At 10,000+ messages (long coding sessions), even throttled pushes will OOM the webview.
Developer Notes
Problem
postStateToWebviewWithoutTaskHistory sends the entire clineMessages array on every call. The lightweight { type: "messageUpdated" } path already exists for partial message updates (Task.ts:1048) but new message additions still go through the full-array path (Task.ts:1015).
Proposed Approach
Replace full-array state pushes with append/update messages for the focused task:
New message type:{ type: "clineMessageAppended", message: ClineMessage } — sent from addToClineMessages instead of a full state push
Webview handler:ExtensionStateContext appends to existing clineMessages array instead of replacing it
Sequencing: Use existing clineMessagesSeq to detect missed appends and trigger a full resync
Depends On
Story 3.2c — focus gating (only send messages for the focused task)
Story 3.2d — per-task clineMessagesSeq and taskId on messageUpdated
Without focus gating, appends from background tasks would corrupt the focused task's message list.
Impact
Payload per update drops from O(N) to O(1) — a single message (~2KB) instead of the full array (~7MB at 3,525 messages). Combined with #629's throttle, this eliminates the gray screen OOM for any message count.
Acceptance Criteria
New messages appear via append, not full-array replacement
Full sync fires on task switch, initial load, and checkpoint restore
Missed-append detection triggers automatic full resync
No regression in message ordering or partial message streaming
This issue's "Proposed Approach" keeps a full sync on initial load. That still calls postStateToWebview() with the complete clineMessages array from webviewDidLaunch (webviewMessageHandler.ts:561-566). The throttle in #629 does not wrap this call, so it stays a single unbounded payload.
This path is not rare. VS Code can tear down and recreate the webview process after the panel sits in the background for a while, or after the OS wakes from sleep, even with retainContextWhenHidden: true. Each teardown fires webviewDidLaunch again. So a long task can hit the full unthrottled hydration push every time the user rejoins it, not just on first install.
[BUG] #987: panel goes blank mid-use, then spins on reopen, on a build with a 2.53 GB extension cache.
Recommend widening this issue's scope so initial load also uses paged/incremental delivery, not just append events for new messages. For example, send only the last N messages on webviewDidLaunch, then load older ones on scroll-up. Without this change, a long task can still run out of memory on first hydration after this issue ships, even though the ongoing-streaming case is fixed.
Additional data supporting the payload-size argument in this issue, from a multi-day agent session
that repeatedly hit the gray-panel failure described in #629.
Short version: the payload is not large only because there are many messages. A small number of
messages can be enormous on their own, because each appliedDiff message embeds a full copy of
the edited file. In the session below, 181 diff messages account for 25.7 MB, of which 97.3% is
the originalContent field, against 0.34 MB of actual diff text. That is a separate contributor to
the size problem this issue is scoped to, and it is not addressed by the throttle in #1078.
Environment
Extension 3.78.0, VS Code 1.128.0
Remote-SSH, Windows client to Linux server (RHEL 8.10, 124 GB RAM, not memory constrained)
C++ workload: multi-day sessions, iterative edits to a handful of large source files
Note 3.78.0 was published one day before #1078 merged, so these measurements are from a
pre-throttle build. The frequency problem may already be improved for me on upgrade; the size
problem below is independent of that.
Symptom
The chat panel turns entirely gray. No content, no error dialog.
The agent stops. Any in-flight request never completes.
Developer: Reload Window restores a working panel, and task history survives.
Recurs at shortening intervals as the session grows.
Where the session state actually goes
One task's ui_messages.json, 3177 messages, 28.7 MB:
Message type
Count
Total
tool
406
27.2 MB
command_output
641
0.60 MB
text
720
0.22 MB
command
412
0.19 MB
api_req_started
860
0.18 MB
406 tool messages hold 95% of the file. Narrowing to the tool:
Tool
Count
Total
Avg
Max
appliedDiff
181
25.7 MB
142 KB
312 KB
updateTodoList
54
0.11 MB
2.1 KB
4.2 KB
searchFiles
34
0.09 MB
2.7 KB
20.9 KB
readFile
127
0.03 MB
0.2 KB
0.3 KB
A file read costs 0.2 KB. A file edit costs 142 KB on average.
The mechanism: whole files, not diffs
Each appliedDiff payload carries content, diff, diffStats, isProtected, originalContent, path, tool. Summed across all 181 messages:
Field
Total
Share of payload
originalContent
24.25 MB
97.3%
diff
0.34 MB
1.4%
content
0.31 MB
1.2%
everything else
~0.01 MB
0.0%
Whole-file copies are 98.6% of the payload. The actual diff text is 1.4%. The stored snapshots
are roughly 71x the size of the edits they describe.
The consequence is that recording an edit costs in proportion to the size of the file, not the
size of the change. A one-line comment fix in a 3000-line source file costs the same as rewriting
that file. Concretely, in this session:
File
Approx. size
Diffs
Total
Avg per diff
C++ test source
~6500 lines
36
10.7 MB
296 KB
C++ implementation
~3000 lines
77
10.1 MB
131 KB
markdown document
~3800 lines
17
2.3 MB
135 KB
Two files account for 20.8 MB, from ordinary implement-test-refine iteration.
Growth is monotonic, and condensing does not help
Cumulative appliedDiff bytes over the session's life, by elapsed hours:
Elapsed
Cumulative
+0:26
0.0 MB
+21:00
4.3 MB
+24:29
13.1 MB
+28:09
22.1 MB
+45:21
25.7 MB
Nothing is trimmed or evicted. Two condense_context operations occurred during this session;
condensing shrinks the model-facing history but not ui_messages.json, so it does not relieve the
webview at all. That surprised me as a user, and may be worth documenting either way.
Across 47 task directories on this machine: 366 MB total, six sessions above 18 MB. Not one runaway
session.
Why I think this is display-side rather than model-side
For the same session, the two state files differ by nearly 6x:
File
Purpose
Size
api_conversation_history.json
sent to the model
4.9 MB
ui_messages.json
rendered by the webview
28.7 MB
Also, in three days: zero API errors, zero retries, and normal token accounting right through the
failures (~501k input, ~499.7k cached). Both panel failures on the last day appear in the logs as Extension host terminating: received terminate message from renderer, 35 minutes apart, with
accumulated snapshot bytes at 25.4 MB and 25.7 MB. The renderer initiated both.
What I ruled out first
In case it saves anyone repeating it:
Network / remote-tunnel instability. VS Code logs will wait for reconnection 3h on genuine
connection loss. That marker appears on eight other dates and is absent on the days the panel
failed. The disconnects present were logged as disconnected gracefully, each followed by a new
token, which is the signature of a deliberate window reload.
The reload itself. Those graceful disconnects turned out to be my own Developer: Reload Window, the recovery, not the failure. This initially misled me.
Terminal / pty host. No disconnect recorded at the failure times.
Extension disposal errors. Present in 37 of 38 sessions, including sessions that never failed,
so not discriminating.
Server memory pressure. 124 GB total, 117 GB available.
Command output volume. 641 command outputs, including full builds and test runs, total
0.60 MB, about 2% of the file.
Confidence
What I am confident of: the state file grows without bound, 90% of it is redundant whole-file
snapshots, and the renderer tears down the extension host at a consistent accumulated size.
What is inference: that the renderer fails because of that size. I have correlation plus a
plausible mechanism, not proof. It should be cheap for you to confirm or dismiss by instrumenting
webview load against a large session, or by checking for a message-size or postMessage limit on
the extension-to-webview channel.
Suggestions
Stop persisting originalContent per diff message. It is 97.3% of the payload here. If
pre-edit content is needed for undo or a diff view, hold it in memory for the operation's
lifetime, or persist it once per file per session rather than once per edit. This alone would
have cut the session from 28.7 MB to roughly 4 MB.
Persist only the diff. It is 1.4% of the payload and is presumably enough to render what
changed.
Cap or compact the state file. Evict or summarize the oldest tool payloads past a threshold.
The human-readable transcript is worth keeping; the redundant file bodies are not.
Have context condensation also compact ui_messages.json, or document that it does not.
Fail loudly. A gray panel with no diagnostic leaves a user with nothing to act on or report. 3.78.0 declares no bugs URL and registers no issue-reporting command, so there is no
in-product path to report this.
On the relationship to this issue: item 1 seems complementary to incremental delivery rather than
superseded by it. Incremental delivery reduces how often the array is pushed, but a single message
here is still up to 312 KB, and the on-disk state stays large regardless of what goes over the wire.
Reproduction
No special setup; it emerges from ordinary iterative development.
Open a workspace with large source files, 1500+ lines.
Run a long agent session that repeatedly edits the same few large files: implement, build, test,
refine. Several dozen apply_diff operations across two or three files is enough.
Watch the session file grow:
ls -l ~/.vscode-server/data/User/globalStorage/zoocodeorganization.zoo-code/tasks/<taskId>/ui_messages.json
The panel eventually goes gray and needs Developer: Reload Window, more often as the file grows.
Self-check script
Prints sizes and field names only, no file contents or paths, so the output is safe to paste:
python3 - <<'PY'import json, collections, os, globbase = os.path.expanduser( '~/.vscode-server/data/User/globalStorage/zoocodeorganization.zoo-code/tasks')path = max(glob.glob(base + '/*/ui_messages.json'), key=os.path.getsize)msgs = json.load(open(path))tot = collections.Counter(); n = 0for x in msgs: if (x.get('say') or x.get('ask')) != 'tool': continue try: d = json.loads(x.get('text') or '') except Exception: continue if d.get('tool') != 'appliedDiff': continue n += 1 for k, v in d.items(): tot[k] += len(v if isinstance(v, str) else json.dumps(v))g = sum(tot.values()) or 1print('size: %.1f MB, appliedDiff messages: %d' % (os.path.getsize(path) / 1e6, n))for k, v in tot.most_common(): print(' %-18s %8.2f MB %5.1f%%' % (k, v / 1e6, 100 * v / g))whole = tot['content'] + tot['originalContent']print('whole-file copies: %.2f MB (%.1f%%), diff text: %.2f MB (%.1f%%)' % (whole / 1e6, 100 * whole / g, tot['diff'] / 1e6, 100 * tot['diff'] / g))PY
I work under source-code disclosure restrictions, so file names above are genericized and no session
contents are included; everything here is byte counts, message counts, and field names from the
extension's own schema. Happy to run any additional measurement that would be useful and report the
numbers.
97.6% whole-file copies, 2.4% actual diff text. Same ratio as the larger session above, which rules out a session-specific outlier.
Heap timeline (pre-crash, taken while the renderer was still live)
Chrome DevTools heap timeline captured immediately before the crash: 181 MB self-size across 2.78 M nodes.
Type
Self-size
Notes
Native
73 MB
vscode-oniguruma WASM + TextMate grammar data
Code (JIT)
35 MB
compiled JS bytecode for the webview bundle
String
30 MB
includes appliedDiff payload strings
Object / array / hidden
~28 MB
React vdom, closures
Remaining
~15 MB
—
The appliedDiff content (~10.6 MB on disk) appears in the 30 MB string pool. The dominant single allocation site (160 MB, 2.5 M objects) traces to (V8 API) — native bindings including the WASM heap — with no JS-level attribution because the webview bundle is minified without source maps.
181 MB is below the typical Chromium renderer limit, but the WASM baseline (73 MB fixed) plus JIT (35 MB) leaves less headroom than a raw number suggests. The Chrome breakpoint fires at 90% of the renderer process ceiling, which in VS Code's sandboxed webview may be lower than a normal browser window.
Virtualisation does not help here
react-virtuoso is already in the codebase (ChatView.tsx, react-virtuoso@^4.7.13). The DOM is windowed — only visible messages are mounted. But the full clineMessages array lives in React state regardless of what is rendered. Virtuoso has no effect on data-layer memory. The 11.8 MB array is in the heap whether one message or all 945 are visible.
Interaction with #1541 (globalState write-through)
On Developer: Reload Window (the recovery path), scheduleGlobalStateWriteThrough serialises the full task history over the VS Code RPC bridge before the webview reloads. The freeze from #1541 and the OOM described here compound: the renderer fails first, the extension host then freezes during the "recovery" write. Fixing #1541 does not address the webview OOM, but it does make the reload recovery faster.
Suggestions that add to those above
Strip originalContent before postMessage.ClineProvider.postStateToWebview (and the lightweight variants) could drop originalContent from appliedDiff payloads before sending. The full field is on disk; the webview only needs diff and diffStats to render a diff view. This would have cut this session from 11.8 MB to ~0.4 MB over the wire.
Lazy-fetch on expand. If the diff viewer needs originalContent (e.g. for a side-by-side view), fetch it on demand from the extension host rather than loading it with every state push.
Follow-up: webview VM confirmed, 5× string duplication, allocation entry point named
The previous comment used a timeline from an earlier profiling session. A second session taken from the webview renderer process adds two concrete findings.
Every large message string appears as five separate string nodes in the snapshot. A 197 KB user message costs ~985 KB. A 135 KB tool-call JSON string that appears in both an assistant message and related tool-result messages appears 15×. The 188 MB string pool in this snapshot (vs 30 MB in session 1) reflects a longer conversation; the 5× multiplier is structural, not session-specific.
This is the React state machine retaining the full clineMessages array in multiple simultaneous copies: the active render fiber, the previous fiber, a queued state update, the postMessage buffer, and at least one memo or selector cache.
react-virtuoso windows the DOM. It does not reduce the number of string copies held in JS memory.
Allocation entry point named (heap timeline, 131 MB file)
The allocation timeline captures trace_node_id on every live object. The top call chains by self-size:
Self
Call site
Notes
5.478 MB
(anon)@index.js:430 from (root)
React scheduler processing the queued state update
Direct allocation in the postMessage handler itself
HostMessaging.channel.port1.onmessage is the cut point. It allocates 0.272 MB parsing the incoming payload, then dispatches a React state update. The scheduler fires asynchronously (from the event loop root, no extension host frames in scope), which is why the 5.5 MB React render appears attributed to (root) rather than to onmessage.
Each full-array state push from the extension host costs approximately 6 MB in allocation pressure on the renderer. Under a long active session with frequent tool calls, that accumulates faster than GC can reclaim it.
What this changes in the fix
The fix needs to act before port1.onmessage receives the payload, or inside it before the state update is dispatched. The two suggestions from the previous comment (strip originalContent in postStateToWebview; lazy-fetch on expand) remain the right starting points. Stripping originalContent before the message is sent would reduce the per-push allocation proportionally and break the 5× duplication of the largest strings.
Activity
edelauna commentedon Aug 9, 2026
This issue's "Proposed Approach" keeps a full sync on initial load. That still calls
postStateToWebview()with the completeclineMessagesarray fromwebviewDidLaunch(webviewMessageHandler.ts:561-566). The throttle in #629 does not wrap this call, so it stays a single unbounded payload.This path is not rare. VS Code can tear down and recreate the webview process after the panel sits in the background for a while, or after the OS wakes from sleep, even with
retainContextWhenHidden: true. Each teardown fireswebviewDidLaunchagain. So a long task can hit the full unthrottled hydration push every time the user rejoins it, not just on first install.Two open reports match this path:
Recommend widening this issue's scope so initial load also uses paged/incremental delivery, not just append events for new messages. For example, send only the last N messages on
webviewDidLaunch, then load older ones on scroll-up. Without this change, a long task can still run out of memory on first hydration after this issue ships, even though the ongoing-streaming case is fixed.spiel2001 commentedon Aug 21, 2026
Additional data supporting the payload-size argument in this issue, from a multi-day agent session
that repeatedly hit the gray-panel failure described in #629.
Short version: the payload is not large only because there are many messages. A small number of
messages can be enormous on their own, because each
appliedDiffmessage embeds a full copy ofthe edited file. In the session below, 181 diff messages account for 25.7 MB, of which 97.3% is
the
originalContentfield, against 0.34 MB of actual diff text. That is a separate contributor tothe size problem this issue is scoped to, and it is not addressed by the throttle in #1078.
Environment
3.78.0, VS Code1.128.0Note
3.78.0was published one day before #1078 merged, so these measurements are from apre-throttle build. The frequency problem may already be improved for me on upgrade; the size
problem below is independent of that.
Symptom
Developer: Reload Windowrestores a working panel, and task history survives.Where the session state actually goes
One task's
ui_messages.json, 3177 messages, 28.7 MB:toolcommand_outputtextcommandapi_req_started406 tool messages hold 95% of the file. Narrowing to the tool:
appliedDiffupdateTodoListsearchFilesreadFileA file read costs 0.2 KB. A file edit costs 142 KB on average.
The mechanism: whole files, not diffs
Each
appliedDiffpayload carriescontent,diff,diffStats,isProtected,originalContent,path,tool. Summed across all 181 messages:originalContentdiffcontentWhole-file copies are 98.6% of the payload. The actual diff text is 1.4%. The stored snapshots
are roughly 71x the size of the edits they describe.
The consequence is that recording an edit costs in proportion to the size of the file, not the
size of the change. A one-line comment fix in a 3000-line source file costs the same as rewriting
that file. Concretely, in this session:
Two files account for 20.8 MB, from ordinary implement-test-refine iteration.
Growth is monotonic, and condensing does not help
Cumulative
appliedDiffbytes over the session's life, by elapsed hours:Nothing is trimmed or evicted. Two
condense_contextoperations occurred during this session;condensing shrinks the model-facing history but not
ui_messages.json, so it does not relieve thewebview at all. That surprised me as a user, and may be worth documenting either way.
Across 47 task directories on this machine: 366 MB total, six sessions above 18 MB. Not one runaway
session.
Why I think this is display-side rather than model-side
For the same session, the two state files differ by nearly 6x:
api_conversation_history.jsonui_messages.jsonAlso, in three days: zero API errors, zero retries, and normal token accounting right through the
failures (~501k input, ~499.7k cached). Both panel failures on the last day appear in the logs as
Extension host terminating: received terminate message from renderer, 35 minutes apart, withaccumulated snapshot bytes at 25.4 MB and 25.7 MB. The renderer initiated both.
What I ruled out first
In case it saves anyone repeating it:
will wait for reconnection 3hon genuineconnection loss. That marker appears on eight other dates and is absent on the days the panel
failed. The disconnects present were logged as
disconnected gracefully, each followed by a newtoken, which is the signature of a deliberate window reload.
Developer: Reload Window, the recovery, not the failure. This initially misled me.so not discriminating.
0.60 MB, about 2% of the file.
Confidence
What I am confident of: the state file grows without bound, 90% of it is redundant whole-file
snapshots, and the renderer tears down the extension host at a consistent accumulated size.
What is inference: that the renderer fails because of that size. I have correlation plus a
plausible mechanism, not proof. It should be cheap for you to confirm or dismiss by instrumenting
webview load against a large session, or by checking for a message-size or
postMessagelimit onthe extension-to-webview channel.
Suggestions
originalContentper diff message. It is 97.3% of the payload here. Ifpre-edit content is needed for undo or a diff view, hold it in memory for the operation's
lifetime, or persist it once per file per session rather than once per edit. This alone would
have cut the session from 28.7 MB to roughly 4 MB.
changed.
The human-readable transcript is worth keeping; the redundant file bodies are not.
ui_messages.json, or document that it does not.3.78.0declares nobugsURL and registers no issue-reporting command, so there is noin-product path to report this.
On the relationship to this issue: item 1 seems complementary to incremental delivery rather than
superseded by it. Incremental delivery reduces how often the array is pushed, but a single message
here is still up to 312 KB, and the on-disk state stays large regardless of what goes over the wire.
Reproduction
No special setup; it emerges from ordinary iterative development.
Open a workspace with large source files, 1500+ lines.
Run a long agent session that repeatedly edits the same few large files: implement, build, test,
refine. Several dozen
apply_diffoperations across two or three files is enough.Watch the session file grow:
The panel eventually goes gray and needs
Developer: Reload Window, more often as the file grows.Self-check script
Prints sizes and field names only, no file contents or paths, so the output is safe to paste:
Output on the session above:
I work under source-code disclosure restrictions, so file names above are genericized and no session
contents are included; everything here is byte counts, message counts, and field names from the
extension's own schema. Happy to run any additional measurement that would be useful and report the
numbers.
edelauna commentedon Sep 6, 2026
Second data point and heap-level confirmation of the mechanism described by @spiel2001 above.
Session
3.80.1, VS Code remote-SSH, Linux serverField breakdown (spiel2001's script, same session)
97.6% whole-file copies, 2.4% actual diff text. Same ratio as the larger session above, which rules out a session-specific outlier.
Heap timeline (pre-crash, taken while the renderer was still live)
Chrome DevTools heap timeline captured immediately before the crash: 181 MB self-size across 2.78 M nodes.
appliedDiffpayload stringsThe
appliedDiffcontent (~10.6 MB on disk) appears in the 30 MB string pool. The dominant single allocation site (160 MB, 2.5 M objects) traces to(V8 API)— native bindings including the WASM heap — with no JS-level attribution because the webview bundle is minified without source maps.181 MB is below the typical Chromium renderer limit, but the WASM baseline (73 MB fixed) plus JIT (35 MB) leaves less headroom than a raw number suggests. The Chrome breakpoint fires at 90% of the renderer process ceiling, which in VS Code's sandboxed webview may be lower than a normal browser window.
Virtualisation does not help here
react-virtuosois already in the codebase (ChatView.tsx,react-virtuoso@^4.7.13). The DOM is windowed — only visible messages are mounted. But the fullclineMessagesarray lives in React state regardless of what is rendered. Virtuoso has no effect on data-layer memory. The 11.8 MB array is in the heap whether one message or all 945 are visible.Interaction with #1541 (globalState write-through)
On
Developer: Reload Window(the recovery path),scheduleGlobalStateWriteThroughserialises the full task history over the VS Code RPC bridge before the webview reloads. The freeze from #1541 and the OOM described here compound: the renderer fails first, the extension host then freezes during the "recovery" write. Fixing #1541 does not address the webview OOM, but it does make the reload recovery faster.Suggestions that add to those above
originalContentbeforepostMessage.ClineProvider.postStateToWebview(and the lightweight variants) could droporiginalContentfromappliedDiffpayloads before sending. The full field is on disk; the webview only needsdiffanddiffStatsto render a diff view. This would have cut this session from 11.8 MB to ~0.4 MB over the wire.originalContent(e.g. for a side-by-side view), fetch it on demand from the extension host rather than loading it with every state push.edelauna commentedon Sep 7, 2026
Follow-up: webview VM confirmed, 5× string duplication, allocation entry point named
The previous comment used a timeline from an earlier profiling session. A second session taken from the webview renderer process adds two concrete findings.
5× string duplication (heap snapshot, 127 MB file, 337 MB total heap)
Every large message string appears as five separate string nodes in the snapshot. A 197 KB user message costs ~985 KB. A 135 KB tool-call JSON string that appears in both an assistant message and related tool-result messages appears 15×. The 188 MB string pool in this snapshot (vs 30 MB in session 1) reflects a longer conversation; the 5× multiplier is structural, not session-specific.
This is the React state machine retaining the full
clineMessagesarray in multiple simultaneous copies: the active render fiber, the previous fiber, a queued state update, the postMessage buffer, and at least one memo or selector cache.react-virtuosowindows the DOM. It does not reduce the number of string copies held in JS memory.Allocation entry point named (heap timeline, 131 MB file)
The allocation timeline captures
trace_node_idon every live object. The top call chains by self-size:(anon)@index.js:430from(root)G_n@index.js:2790 → dL@index.js:428synthesize/isTarget/n@index.js:2793HostMessaging.channel.port1.onmessage@index.html:299HostMessaging.channel.port1.onmessageis the cut point. It allocates 0.272 MB parsing the incoming payload, then dispatches a React state update. The scheduler fires asynchronously (from the event loop root, no extension host frames in scope), which is why the 5.5 MB React render appears attributed to(root)rather than toonmessage.Each full-array state push from the extension host costs approximately 6 MB in allocation pressure on the renderer. Under a long active session with frequent tool calls, that accumulates faster than GC can reclaim it.
What this changes in the fix
The fix needs to act before
port1.onmessagereceives the payload, or inside it before the state update is dispatched. The two suggestions from the previous comment (striporiginalContentinpostStateToWebview; lazy-fetch on expand) remain the right starting points. StrippingoriginalContentbefore the message is sent would reduce the per-push allocation proportionally and break the 5× duplication of the largest strings.