Codex VS Code webview becomes progressively unresponsive during long-running conversations

## Summary

The Codex VS Code webview becomes progressively slower during a long-running

conversation. Reloading the complete VS Code browser tab temporarily restores

responsiveness.

Testing against extension `26.721.30844-linux-x64` identified four independent

renderer-side scaling problems:

1. Subagent lifecycle projection rescans the same parent conversation once per

source membership.

2. Turn-level virtualization does not bound the DOM when one turn contains

hundreds of normalized items, and a tail policy can accidentally force every

turn in that tail to remain mounted.

3. Every cumulative conversation snapshot is forwarded immediately into the UI

mirror during streaming, repeatedly waking selectors and React consumers.

4. Turn-height updates are applied synchronously inside a `ResizeObserver`

delivery callback, while recoverable delivery warnings are forwarded through

extension IPC and logging.

The persisted rollout JSONL is large in this reproduction, but it is not being

fully reread for every live event. Full history restoration remains relevant at

initial load or resume; live updates arrive through IPC and append to the open

rollout file.

## Environment and Reproduction Shape

- Codex VS Code extension: `26.721.30844-linux-x64`

- Host surface: code-server in Chromium

- Representative rollout: approximately 138 MiB and 56,546 valid JSONL records

- Parsed turn-context blocks: 185

- Blocks containing more than 200 render items: 80

- Largest single block: 842 render items

The slowdown is cumulative. A full browser-tab reload rebuilds a much smaller

initial DOM and clears accumulated renderer state, so the same conversation

temporarily feels fast again.

## Finding 1: Repeated Subagent Parent-History Scans

The subagent lifecycle selector projects references for each source membership.

When several memberships share a parent conversation, the current path

recursively scans that same parent’s complete turn history once per membership.

Cards that are completed or hidden are filtered after this projection, so the

cost is paid even when no subagent card is visible.

The effective cost is approximately:

```text

source membership count x parent history size

```

In one 10-second CPU profile, this projection path consumed approximately 7.7

seconds, or 62 percent of sampled time.

### Recommended source-level change

Group source memberships by parent conversation, project each distinct parent

once, and distribute the resulting references by conversation ID:

```ts

const membershipsByParent = groupBy(

sourceMemberships,

(membership) => getParentConversationId(membership),

);

const nestedReferences = new Map<string, AgentReference>();

for (const [parentId, memberships] of membershipsByParent) {

if (parentId == null) continue;

const references = projectSubagentLifecycle({

conversationTurns: getConversationTurns(parentId) ?? \[\],

getConversationTurns: () => null,

memberships,

sourceMemberships: \[\],

});

for (const [conversationId, reference] of references) {

nestedReferences.set(conversationId, reference);

}

}

```

The remaining lifecycle resolution can use `nestedReferences.get(id)` while

preserving the existing precedence rules.

### Measured result

The same named projection function fell from several seconds to approximately

11 ms in a later 10-second profile. A synthetic equivalence test instantiated

the original and optimized functions from the installed bundle, supplied the

same multi-parent topology, and required identical deterministically sorted

output maps.

## Finding 2: The Rendered Tail Is Not Strictly Bounded

The native virtualizer operates at turn granularity. This is insufficient when

one active turn contains hundreds of normalized items. Preserving the complete

turn that crosses a 200-item boundary allowed a single 842-item turn to bypass

the intended limit.

A second issue appeared after adding a tail window: forcing the complete tail

range into the virtualizer mounted every turn in that range, including

offscreen turns.

### Recommended source-level change

Keep complete conversation data in client state, but derive a render-only

window with these rules:

1. Count normalized `turn.items`, including the live `turnState`.

2. Find the newest `N` items, with `N = 200` as a conservative default.

3. For complete turns before the boundary, provide no items while the policy is

active.

4. If the boundary is inside a turn, provide an immutable

`items.slice(-boundaryKeepCount)` view to the row renderer.

5. Intersect the eligible tail turn range with the native viewport range.

6. Never mutate conversation state or rollout history.

7. Disable the item cutoff while the user is intentionally browsing older

content.

8. Returning to the latest region only arms the policy. The next new item

reactivates the cutoff, preventing a surprise jump while the user is reading.

9. Loading older history at the beginning must not be mistaken for a new tail

item.

Normalized exec, patch, and MCP activity items already contain their associated

result state in this renderer, so slicing between normalized items does not

separate a call from its result.

### Measured result

Before intersecting with the native viewport, the active tail mounted 17 turns

and approximately 3,903 DOM nodes. After the change, it mounted 4 turns and

approximately 773 nodes; 2 turns intersected the visible viewport.

During a later 8-second active-stream sample, layout consumed only about 37 ms.

This indicates that DOM size and layout were no longer the dominant remaining

cost.

## Finding 3: High-Frequency UI Mirror Notifications

After the first two fixes, live streaming still produced heavy script and

allocation churn. The source conversation store supplies cumulative snapshots,

and each snapshot was synchronously forwarded to the UI mirror. That wakes

subscribers, derived selectors, render grouping, and React reconciliation for

every fine-grained stream event.

One active 8-second diagnostic sample reported:

- `TaskDuration`: approximately +8.61 seconds

- `ScriptDuration`: approximately +4.80 seconds

- `LayoutDuration`: approximately +0.037 seconds

- JavaScript heap: approximately +60 MB during active arrival

- Nodes: +282

- Event-listener registrations: +1,118

The heap, node, and listener deltas were captured while new content was arriving.

They demonstrate high transient churn but are not, by themselves, proof of a

retained-memory leak.

Native `readAsArrayBuffer` samples came from the code-server workbench socket.

The observed hot path was transport delivery followed by conversation-state

application, not repeated full-file JSONL parsing.

### Recommended source-level change

Coalesce only the source-store-to-UI-mirror boundary. The authoritative source

store must continue to process every event immediately.

For each conversation:

- Retain the latest cumulative snapshot.

- Flush at a configurable bounded interval. The local diagnostic patch uses

200 ms after 50 ms still allowed renderer saturation under several concurrent

large streams.

- Union `entityKeys` invalidations received during the interval.

- Preserve `fullReset` if any queued update requires it.

- Treat deletion as a `fullReset`.

- Flush pending state before stream-role changes, turn completion, approval

requests, user-input requests, and recent-conversation metadata changes.

- Use a timer-backed trailing flush rather than relying only on

`requestAnimationFrame`, because background Chromium frames can heavily

throttle paint callbacks.

Illustrative logic:

```ts

type PendingUpdate = {

state: ConversationState | null;

invalidation: HistoryInvalidation;

};

const pending = new Map<string, PendingUpdate>();

function queueUiSnapshot(

conversationId: string,

state: ConversationState | null,

invalidation: HistoryInvalidation,

) {

const previous = pending.get(conversationId);

pending.set(conversationId, {

state,

invalidation: mergeInvalidations(previous?.invalidation, invalidation),

});

scheduleTrailingFlush(200);

}

function flushUiSnapshots() {

const batch = […pending];

pending.clear();

for (const [conversationId, update] of batch) {

uiMirror.applyConversationState(

  conversationId,

  update.state,

  update.state == null ? { type: "fullReset" } : update.invalidation,

);

}

}

```

This is safe only if the callback contract supplies cumulative snapshots. If a

source-level implementation forwards incremental patches instead, those patches

must be composed rather than dropped.

### Current validation status

The local implementation has unit-level coverage for:

- 1,000 updates collapsing to one latest snapshot

- `entityKeys` union

- `fullReset` precedence

- deletion semantics

- separate conversations in one batch

- immediate flush barriers before semantic events

- repeated empty flushes

- generated-bundle syntax and patch idempotence

It has been written to the installed extension but intentionally was not

activated with a forced reload while an agent was working. A post-reload runtime

profile is still required before calling this third fix fully runtime-validated.

## Finding 4: ResizeObserver Delivery Feedback and Log Amplification

During active conversation updates, Chromium repeatedly emitted the standard

warning:

```text

ResizeObserver loop completed with undelivered notifications.

```

The conversation virtualizer observes every mounted turn. Its callback

immediately applies measured turn heights and latest-content measurements.

Those updates can synchronously enter React rendering and scroll compensation,

change another observed size during the same delivery cycle, and cause Chromium

to defer the remaining notification.

The warning is normally recoverable. However, the webview’s global error

listener forwarded every occurrence through extension IPC as an error log. A

streaming burst could therefore combine repeated measurement/render work with

extension-host IPC and disk-log traffic.

### Recommended source-level change

- Accumulate turn-height and latest-content measurements for one animation

frame.

- Retain only the newest measurement for each turn within that frame.

- Apply the batch outside the active `ResizeObserver` delivery callback.

- Cancel a pending animation frame when the observer is disconnected.

- Suppress forwarding only the two exact standard warning strings:

`ResizeObserver loop completed with undelivered notifications.` and

`ResizeObserver loop limit exceeded`.

- Continue forwarding all other global errors and unhandled rejections.

The warning filter is only log-amplification containment. Frame-batching the

measurements is the part that addresses the synchronous feedback path.

### Current validation status

The local patch has transformation, batching, latest-measurement, cleanup,

warning-filter, ordinary-error passthrough, idempotence, and generated-bundle

syntax tests. It has been written to the installed extension but was not

activated with a forced reload while an agent was working. A post-reload

continuous-stream profile is still required.

## Required Tests for an Upstream Implementation

1. Projection equivalence with several child memberships sharing one parent,

nested parents, missing parents, interrupted agents, and mixed runtime

statuses.

2. A scan-count or benchmark assertion showing one history scan per distinct

parent rather than one per membership.

3. Initial empty-to-loaded hydration with a 200-item render window.

4. One turn containing more than 200 normalized items.

5. Live growth in both persisted `turn` and current `turnState`.

6. Loading older history while away from the latest region.

7. Returning to the latest region without immediately cutting content.

8. Reactivation on the next new item.

9. Native viewport intersection and repeated up/down scrolling without mounted

range accumulation.

10. Non-mutation of source conversation objects.

11. Conversation update coalescing with invalidation composition and semantic

event ordering.

12. Frame-level coalescing of repeated `ResizeObserver` measurements, retaining

the latest height per turn.

13. Cancellation of pending measurement work on observer cleanup.

14. Exact-match filtering of standard `ResizeObserver` delivery warnings

without hiding ordinary errors.

15. End-to-end profiling on a large synthetic conversation under continuous

streaming.

## Optional Feature: Goal Auto Play

`Auto Play` is optional and independent of all performance fixes.

It can be offered as an explicit per-conversation toggle beside the permissions

control. When enabled, it may retry an interrupted Goal every five minutes only

when:

- A Goal exists.

- Its status is explicitly retryable, such as `active` or `usageLimited`.

- No response is currently in progress.

- The current client owns the thread stream.

- The user has enabled the toggle for this conversation.

The implementation should use the existing Goal resume action, never inject a

synthetic user message. It should also check overdue work on focus, online,

page-show, and visibility changes because background Chromium timers can be

throttled. Paused, blocked, budget-limited, and complete Goals should not be

resumed automatically.

This feature requires separate product and retry-policy review. It should not

be bundled as a prerequisite for the renderer fixes.

The local diagnostic implementation has been ported to the current extension

and its enabled per-conversation control was observed after a full reload. This

does not replace product and retry-policy review for an upstream implementation.

## Optional Feature: Workspace-Scoped CODEX_HOME

Workspace-scoped `CODEX_HOME` is also optional, but it can be a powerful VS Code

integration for users who work across multiple independent projects.

A workspace-scoped setting could support a value such as:

```json

{

“chatgpt.codexHome”: “${workspaceFolder}/.codex”

}

```

Benefits:

- Each VS Code project can retain its own sessions and project-specific Codex

state.

- Session discovery does not scan or accidentally resume unrelated projects.

- Project-specific plugins and configuration can remain isolated.

- Moving between projects does not require manually exporting `CODEX_HOME`.

Recommended behavior:

- Keep the existing global/default behavior unless the user opts in at VS Code

Workspace scope.

- Resolve `${workspaceFolder}` explicitly and handle multi-root workspaces with

a selected folder rather than silently choosing the first root.

- Apply the resolved path consistently to CLI spawn, session discovery, and

plugin discovery.

- Honor VS Code Workspace Trust.

- Clearly warn that a full `CODEX_HOME` may contain credentials or other

sensitive files and must not be committed. Prefer OS/global secure storage

for authentication if the architecture allows session storage to be

separated from secrets.

This proposal is independent of the long-conversation performance fixes.

The current local installation forces the first active workspace root to

provide `/.codex` as `CODEX_HOME`, with the prior environment

fallback retained when no workspace is open. That diagnostic implementation

does not resolve the multi-root selection question described above and should

not be copied upstream unchanged.

## Production Readiness

The local patch scripts are suitable as reproducible diagnostic hotfixes:

- They discover structural anchors and fail closed if an expected target is not

found.

- They create backups.

- They are idempotent.

- They run policy or equivalence tests before writing.

- The installed bundles pass JavaScript syntax validation.

Directly editing generated, minified extension assets is not the recommended

upstream delivery form. The algorithms should be implemented in the original

TypeScript/React source modules with typed state, lifecycle cleanup, unit tests,

integration tests, and performance regression coverage.

The subagent projection and strict DOM-window changes have been runtime-profiled

after activation. A 50 ms UI update interval still allowed renderer saturation

with one 11 MB parent and four approximately 3.2 MB child streams, so the local

coalescer now uses 200 ms while retaining semantic flush barriers. That revised

interval and the `ResizeObserver` frame-batching change still need post-reload

runtime profiles. Therefore this report is ready to send as a bug report and

source-level patch proposal, but it should not claim that every local bundle

edit is already production-certified.