perf(sessions): faster task open, less render churn and memory#3063
Merged
Conversation
splitMarkdownBlocks ran unmemoized in the render body, re-scanning the whole message string on every render (~10-20/sec while text smooths in). Memoize on content so the linear re-scan only happens when the text actually changes. Finding #6.
The elapsed-time interval fired every 50ms — 20 state updates/sec for the whole duration a prompt is pending (minutes for cloud tasks). The display shows tenths of a second, so 100ms updates every rendered digit while halving the re-render rate. Finding #8.
DIFFS_HIGHLIGHTER_OPTIONS set only the theme. A minified or single-giant- line file in a tool-call diff makes the diff worker tokenize the entire line, stalling that diff. Cap at 1000 chars, matching the guard the diffs library exposes for exactly this case. Finding #13.
Each streamed ACP event ran handleSessionEvent immediately in the tRPC onData callback. Electron IPC delivers each event as its own task, so a fast turn produced one processing pass — and roughly one React commit — per token. Buffer events per taskRunId and flush on a ~16ms timer, replaying the existing per-event handler in arrival order. Logic and ordering are unchanged; only when a burst is processed changes, coalescing it into one pass. The buffer is flushed synchronously before a permission request is handled and on channel teardown/reset so nothing observes a stale transcript or drops trailing events. Finding #1.
highlightSyntax parses with Lezer on the main thread. The only cache was each HighlightedCode instance's useMemo, so a code block scrolled out of and back into the virtualized transcript re-parsed every time. Add a bounded module-level LRU keyed on (theme, language, content) so remounts reuse the parsed segments. Finding #4.
react-resizable-panels fires onLayout every frame during a divider drag, and persist serialized the whole layout tree to localStorage on each one. Panels are uncontrolled (defaultSize) and in-memory state is untouched, so debouncing only the write keeps live resize instant while collapsing a drag's ~60 synchronous writes into one. Pending writes flush on pagehide. Finding #12.
useSidebarData consumed the whole sessions record via useSessions(), which immer replaces on every appended event. Because the sidebar is root-mounted, that re-rendered the tree on every token during a turn — even though deriveTaskData only reads isPromptPending, pendingPermissions.size, cloudStatus and cloudOutput.pr_url. Add computeSidebarSessionSignature (a primitive digest of just those fields) and useSidebarSessionMap, which subscribes with a signature-based equality so the taskId to session map only changes when a rendered field does. A render-count test covers the streamed-token case. Part of Finding #2 (sidebar instance).
immer autofreezes produced state, which for the append-only events array meant walking a growing array on every streamed event. Freezing each event at creation (hydration factory) and on append lets immer stop at the first frozen node, so per-append cost no longer grows with transcript length. Verified no code mutates a stored event (full core + builder suites pass with frozen events). Part of Finding #3 (autofreeze cost).
Two existing tests asserted synchronous side effects that are now deferred: - sessionServiceHost steer-echo routing asserted appendEvents synchronously after an onData event, which #1 now batches onto a frame flush. - panelLayoutStore persistence read localStorage synchronously after a write, which #12 now debounces. Flush the frame timer / pagehide to observe the same behavior. Follow-up to #1 and #12.
A session's events array only grew and stayed resident after you navigated away, so several big chats open at once climbed the renderer heap toward the memory-eviction crash reason. Free a transcript ~20s after its view unmounts and reload it from disk on return (useSessionEventsResidency + SessionService.ensureEventsLoaded / scheduleEventEviction). Only disconnected, idle sessions are eligible, so no streamed event can append to an evicted transcript, and rehydration only restores when the transcript is still empty (a reconnect that refilled it wins). Reuses the existing log fetch/parse; state is cleared on reset. Part of Finding #3 (unbounded memory).
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
Contributor
|
Reviews (1): Last reviewed commit: "perf(sessions): evict backgrounded trans..." | Re-trigger Greptile |
On the idle transition the builder discarded its state and re-parsed every event via buildConversationItems. Finalize the persistent builder in place instead — append the remaining events, then run the same finalization — so the cost is proportional to what's new, not the whole transcript. Falls back to the full rebuild when the append-only prefix is no longer valid or events are out of ts-order (a full rebuild sorts; the incremental builder processes in arrival order), keeping output identical. New tests stream then finalize in place across every scenario, incl. resume-after-finalize. Finding #7.
ReviewPage/CloudReviewPage were static-imported by TaskDetail and TabContentRenderer, pulling the whole code-review UI (ReviewShell, diff rows, comment UI, review hooks) into the initial bundle. Route both through a Suspense-wrapped lazy import so that code splits into its own chunk, loaded on first review open. The shared diff/highlight libraries stay eager (the always-visible transcript uses them), so this splits the review-specific UI, not those libs. Finding #14.
ChatThread (the transcript container), SessionView, and the queued-message hook each read a single field (isCloud / handoffInProgress) off the whole session via useSessionForTask, which has no equality fn and returns a new reference on every streamed event — re-rendering those components every frame. Add primitive useSessionIsCloud / useSessionHandoffInProgress selectors (same pattern as the existing useAdapterForTask) so they only re-render when that field actually changes. Part of Finding #2 (coarse selector), remaining consumers.
Contributor
|
Reviews (2): Last reviewed commit: "perf(sessions): narrow hot single-field ..." | Re-trigger Greptile |
Address review feedback on the perf changes: - Highlight cache: store the code alongside the segments and compare it on lookup, so a 32-bit hash collision re-parses instead of returning another snippet's segments (which would render the wrong code text). - Transcript rehydration: clear the evicted flag only once the transcript is actually populated. fetchSessionLogs swallows read errors and returns empty rather than throwing, so deleting the flag up front stranded the transcript empty on a transient failure; now an empty read stays evicted and a later visit retries. - Extract the duplicated DIFFS_HIGHLIGHTER_OPTIONS into a shared module. Adds a residency test for the retry-after-failed-read path.
Measured follow-up to the pre-freeze change. Pre-freezing events barely helped (~1%): immer autofreeze re-walks the whole events array on every append regardless of whether the elements are already frozen, so appending 10k events took ~2.3s. Disabling autofreeze drops that to ~56ms (41x). The per-event Object.freeze stays (O(1) each) so events remain immutable. Autofreeze is a dev-time mutation guard with no runtime value; full core (1859) and ui (1135) suites pass with it off. Refines Finding #3.
readLocalLogsTail(taskRunId, maxBytes) reads only the last maxBytes of a session's ndjson log (dropping the partial first line) so a big transcript can open by reading a small tail instead of the whole file. Wired end to end: LocalLogsService -> workspace-server + host-router routers -> apps/code LOGS_SERVICE forward -> core trpc deps (optional; core feature-detects). Not yet used; the open-path switch follows. Part of fast task open.
Opening a task read + transferred + parsed the entire ndjson log before any paint — measured ~540ms for an 18.5MB log, dominated by disk read + IPC transfer (both scale with log bytes; parse was ~30ms). Now the open path paints the last ~1.5MB of the log immediately, so the latest turns show in tens of ms, and the existing full read + reconnect replaces it with the authoritative session (correct processed-line tracking + live connect). Safe by construction: the full reconnect path is unchanged and always wins; tail-first only adds an earlier throwaway paint. Falls back cleanly when the host lacks the tail read or there's no local log. Part of fast task open.
The autofreeze note carried before/after benchmark numbers that read as change-commentary; the sessionEvents freeze comment credited an immer deep-freeze walk that no longer runs now that the store disables autofreeze.
charlesvien
approved these changes
Jul 2, 2026
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.
Problem
The agent session transcript is the app's hot path, and the renderer did redundant per-token work and grew memory unbounded over a session:
eventsarray on every append.Changes
Each fix is its own commit:
useSessionIsCloud,useSessionHandoffInProgress) so the transcript container and session view stop re-rendering every event to read one flag.eventsarray on every append (O(n) per event); events stay individually frozen for immutability, which is O(1) each.readLocalLogsTailRPC + a throwaway fast-paint; the full connect path is unchanged.Overlaps with, and is intended to supersede, @KarloAldrete's open PRs #2957 (pre-freeze + eviction) and #2710 (sidebar).
Considered and left out: the active-turn row-context split — largely subsumed by event batching (rows now re-render per frame, not per token).
How did you test this?
Unit tests added: event batching (order + flush-on-teardown), sidebar render-count + signature purity, highlight-cache reuse, debounced storage, evict/rehydrate residency (incl. rehydrate-from-disk and retry-after-failed-read), finalize-in-place equivalence across every scenario, the log tail read, and the tail-first paint.
Full suites pass:
@posthog/core1859,@posthog/ui1135; all 22 packages typecheck.Measured impact — isolated deterministic micro-benchmarks (main behavior vs branch), plus an on-app before/after over CDP:
Notes: fast-open v1 improves perceived latency (the tail paints at 114ms) — the full read still completes behind it (~3.5s cold), so total work isn't reduced yet (tail-only + backfill is the follow-up). The batching figure is best-case for a full in-frame burst; sparse streams coalesce less. Idle transcript scroll held 120fps / 0 dropped frames.
Automatic notifications