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