Skip to content

feat(webview): incremental clineMessages delivery for focused task #630

Open

Description

@edelauna

Context

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:

  1. New message type: { type: "clineMessageAppended", message: ClineMessage } — sent from addToClineMessages instead of a full state push
  2. Webview handler: ExtensionStateContext appends to existing clineMessages array instead of replacing it
  3. Full sync retained for: task switch, initial load, checkpoint restore, overwriteClineMessages
  4. 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
  • Works correctly with focus gating from 3.2c

Activity

  1. edelauna commented on Aug 9, 2026

    @edelauna
    ContributorAuthor

    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.

    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.

  2. spiel2001 commented on Aug 21, 2026

    @spiel2001

    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

    1. The chat panel turns entirely gray. No content, no error dialog.
    2. The agent stops. Any in-flight request never completes.
    3. Developer: Reload Window restores a working panel, and task history survives.
    4. 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

    1. 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.
    2. Persist only the diff. It is 1.4% of the payload and is presumably enough to render what
      changed.
    3. 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.
    4. Have context condensation also compact ui_messages.json, or document that it does not.
    5. 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.

    1. Open a workspace with large source files, 1500+ lines.

    2. 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.

    3. Watch the session file grow:

      ls -l ~/.vscode-server/data/User/globalStorage/zoocodeorganization.zoo-code/tasks/<taskId>/ui_messages.json
    4. 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, glob
    base = 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 = 0
    for 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 1
    print('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

    Output on the session above:

    size: 28.7 MB, appliedDiff messages: 181
      originalContent       24.25 MB   97.3%
      diff                   0.34 MB    1.4%
      content                0.31 MB    1.2%
      path                   0.01 MB    0.0%
      diffStats              0.00 MB    0.0%
      tool                   0.00 MB    0.0%
      isProtected            0.00 MB    0.0%
    whole-file copies: 24.55 MB (98.6%), diff text: 0.34 MB (1.4%)
    

    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.

  3. edelauna commented on Sep 6, 2026

    @edelauna
    ContributorAuthor

    Second data point and heap-level confirmation of the mechanism described by @spiel2001 above.

    Session

    • Extension 3.80.1, VS Code remote-SSH, Linux server
    • Workload: PR review session, iterative code edits to a single large TypeScript file (~5 000 lines)
    • Task ended with a Chrome "Paused before potential out-of-memory crash" breakpoint in the webview renderer

    Field breakdown (spiel2001's script, same session)

    size: 11.8 MB, appliedDiff messages: 87, total messages: 945
      originalContent        8.81 MB   95.6%
      diff                   0.22 MB    2.4%
      content                0.18 MB    1.9%
      path                   0.00 MB    0.0%
      diffStats              0.00 MB    0.0%
      tool                   0.00 MB    0.0%
      isProtected            0.00 MB    0.0%
    whole-file copies: 8.99 MB (97.6%), diff text: 0.22 MB (2.4%)
    

    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.
  4. edelauna commented on Sep 7, 2026

    @edelauna
    ContributorAuthor

    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 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
    1.678 MB G_n@index.js:2790 → dL@index.js:428 Syntax highlighting (shiki tokenizer)
    ~2.4 MB synthesize/isTarget/n@index.js:2793 Virtuoso scroll/range computation
    0.272 MB HostMessaging.channel.port1.onmessage@index.html:299 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions