Skip to content

perf(desktop): two-phase transcript render budget for fast session switches - #363

Merged
Kyzcreig merged 4 commits into
mainfrom
fix/switch-paint-two-phase
Jul 16, 2026
Merged

Kyzcreig merged 4 commits into
mainfrom
fix/switch-paint-two-phase

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

Symptom

Session switches on the MBP felt frozen — clicking a heavy session held the renderer's main thread for seconds.

Profile (CDP-driven, live app, MBP 2026-07-15)

Switch→settled-paint, before: 7,984ms on a 1,294-msg session; 1.0-3.0s on mid-size ones. V8 sampling profile attributed the time to (1) the sticky user-bubble clamp-measure ResizeObserver cascade, (2) synchronous Shiki tokenization of every visible code fence, (3) reflow/GC churn — all inside the single 300-part switch commit, in 350-640ms long tasks. Network/gateway exonerated (transcript REST fetch of the same session: 0.11s).

Fix

Two-phase render budget (use-two-phase-render-budget.ts):

  • Switch commit renders the newest 80 parts (thread pins to bottom, so this is exactly the visible tail).
  • Idle-time steps (+110/parts, requestIdleCallback w/ 2s timeout, setTimeout fallback) raise it back to 300. Old groups keep keys → each step mounts only the new turns.
  • Scroll geometry recorded before each step and restored through the existing 'Show earlier' restore effect — no viewport jumps.
  • Never lowers a budget grown via 'Show earlier'; mid-raise switch cancels cleanly.

Tests

7 new hook tests (slim start, stepwise raise, onBeforeRaise ordering, switch reset, show-earlier guard, setTimeout fallback, unmount cancel). Full desktop suite: 163 files / 1,317 tests green. tsc clean.

E2E

Before/after switch-latency numbers from the same CDP loop on the MBP to follow in comments.

…itches

Session switches into heavy transcripts froze the renderer for multiple
seconds (profiled on a 1,294-message session: ~8s to settled paint, in
350-640ms main-thread long tasks). The switch commit rendered the full
300-part budget synchronously — every sticky user-bubble clamp measure,
every Shiki tokenization, and the resulting reflow/GC churn all landed in
one commit.

Split the budget into two phases: the switch commit renders only the
newest 80 parts (the visible tail — the thread pins to the bottom anyway),
then idle-time steps of 110 parts raise it back to 300. Old groups keep
their keys across steps so each raise only mounts the newly visible
turns. Before each step the scroll distance-from-bottom is recorded and
restored through the same mechanism 'Show earlier' uses, so the viewport
never jumps. The raise never lowers a budget the user grew via 'Show
earlier', and a mid-raise session switch cancels cleanly.

Measured via CDP-driven switch loop on the MBP: see PR body.
@greptile-apps

greptile-apps Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR introduces a two-phase render budget for the desktop thread list to cut session-switch main-thread hold times: the first commit after a switch renders only the newest 80 parts (SWITCH_RENDER_BUDGET), then requestIdleCallback steps raise it to the full 300-part steady state in chunks of 110, with scroll geometry preserved via the existing restoreFromBottomRef restore effect.

  • use-two-phase-render-budget.ts: New hook encapsulating the slim-start / idle-raise lifecycle. A liveKeyRef guard prevents a stale idle callback from the old session raising the new session's freshly reset budget in the gap between the layout-effect reset and the effect cleanup. A setTimeout fallback (400 ms) covers environments where requestIdleCallback is absent.
  • list.tsx: Replaces the plain useState(RENDER_BUDGET) with useTwoPhaseRenderBudget; restoreFromBottomRef is moved before the hook call so the onBeforeRaise closure captures the stable scroll ref. The session-switch useLayoutEffect that previously called setRenderBudget(RENDER_BUDGET) is simplified now that the hook owns the reset.
  • use-two-phase-render-budget.test.tsx: Seven tests cover the five documented contract points (slim start, stepwise raise, onBeforeRaise ordering, switch reset, Show-earlier guard, setTimeout fallback, unmount cancel), using a synchronous idle-callback mock and a correctly finally-guarded fake-timer block.

Confidence Score: 5/5

Safe to merge. The optimisation is well-scoped, the scroll-restore path is shared with the existing Show-earlier feature, and the liveKeyRef guard correctly neutralises stale idle callbacks on session switch.

The hook logic is correct across all exercised paths: idle raises, setTimeout fallback, Show-earlier guard, session-switch reset, and unmount cleanup. The liveKeyRef mechanism is the right defence against the layout-effect / effect-cleanup ordering gap. Tests are comprehensive and the suite is green.

No files require special attention. The subtle dual-write on budgetRef is safe given JS single-threading, and the one trailing idle callback per session is intentional and tested.

Important Files Changed

Filename Overview
apps/desktop/src/components/assistant-ui/thread/use-two-phase-render-budget.ts New hook implementing the two-phase render budget with liveKeyRef guard for stale-callback safety, setTimeout fallback, and Show-earlier guard; logic is correct and well-commented.
apps/desktop/src/components/assistant-ui/thread/use-two-phase-render-budget.test.tsx Seven focused tests covering all five contract points; idle mock, finally-guarded fake-timer teardown, and bounded flush loops are all correct.
apps/desktop/src/components/assistant-ui/thread/list.tsx Cleanly integrates the hook; restoreFromBottomRef moved before the hook call so the onBeforeRaise closure captures the stable refs correctly, and the existing restore layout effect reuses the same ref for both Show-earlier and idle raises.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as "User: session switch"
    participant LE as useLayoutEffect
    participant RIC as requestIdleCallback
    participant S as step
    participant R as "React render"
    participant RE as "restore useLayoutEffect"

    U->>LE: sessionKey changes
    LE->>LE: "liveKeyRef.current = newKey, budgetRef = 80"
    LE->>R: "setRenderBudget(80), commit budget=80"
    LE->>LE: stopScroll, pin scrollTop to bottom
    note over LE: Old effect cleanup runs: cancelled=true, cancelIdleCallback
    LE->>RIC: "new useEffect schedules step (ownKey=newKey)"
    note over RIC: idle fires (or 2s timeout)
    RIC->>S: step fires
    S->>S: "liveKeyRef check passes, budgetRef 80 < 300"
    S->>RE: onBeforeRaise records scrollHeight-scrollTop
    S->>R: "setRenderBudget(190), commit budget=190"
    S->>RIC: schedule(step)
    R->>RE: renderBudget changed, restore scrollTop
    note over RIC: next idle slot
    RIC->>S: step fires
    S->>RE: onBeforeRaise records position
    S->>R: "setRenderBudget(300), commit budget=300"
    S->>RIC: "schedule(step) - one extra"
    R->>RE: restore scrollTop
    RIC->>S: extra step fires
    S->>S: "budgetRef 300 >= 300, return early, no reschedule"
    note over S: chain terminates
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as "User: session switch"
    participant LE as useLayoutEffect
    participant RIC as requestIdleCallback
    participant S as step
    participant R as "React render"
    participant RE as "restore useLayoutEffect"

    U->>LE: sessionKey changes
    LE->>LE: "liveKeyRef.current = newKey, budgetRef = 80"
    LE->>R: "setRenderBudget(80), commit budget=80"
    LE->>LE: stopScroll, pin scrollTop to bottom
    note over LE: Old effect cleanup runs: cancelled=true, cancelIdleCallback
    LE->>RIC: "new useEffect schedules step (ownKey=newKey)"
    note over RIC: idle fires (or 2s timeout)
    RIC->>S: step fires
    S->>S: "liveKeyRef check passes, budgetRef 80 < 300"
    S->>RE: onBeforeRaise records scrollHeight-scrollTop
    S->>R: "setRenderBudget(190), commit budget=190"
    S->>RIC: schedule(step)
    R->>RE: renderBudget changed, restore scrollTop
    note over RIC: next idle slot
    RIC->>S: step fires
    S->>RE: onBeforeRaise records position
    S->>R: "setRenderBudget(300), commit budget=300"
    S->>RIC: "schedule(step) - one extra"
    R->>RE: restore scrollTop
    RIC->>S: extra step fires
    S->>S: "budgetRef 300 >= 300, return early, no reschedule"
    note over S: chain terminates
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into fix/switch-pain..." | Re-trigger Greptile

@Kyzcreig
Kyzcreig enabled auto-merge (squash) July 16, 2026 05:26
@Kyzcreig
Kyzcreig merged commit b8228c2 into main Jul 16, 2026
24 checks passed
@Kyzcreig
Kyzcreig deleted the fix/switch-paint-two-phase branch July 16, 2026 05:32
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

E2E on the MBP (CDP-driven, new build b8228c2b installed + relaunched)

First-paint after switch (marker → first turn-pair in DOM) — the metric that matches the feel:

Session Before (settled paint, old build) After (first paint, new build)
f48256 (1,294 msgs) 7,984ms 179ms
6dbc21 (581 msgs) 1,624ms 131ms
c0b032 (920KB) 3,042ms 956ms
b74e98 2,715ms 1,310ms
798cf7 1,011ms 1,063ms

Worst main-thread block during a switch+backfill window: 503–1,175ms (the backfill steps still tokenize Shiki fences — that's the remaining lever, tracked separately). The heaviest sessions went from ~8s frozen to sub-200ms first paint with history backfilling on idle.

Methodology: Performance.now() bracket around location.hash switch, polling [data-slot=aui_turn-pair] at 100ms; longtask PerformanceObserver captures the switch+4s window. Debug port closed and app relaunched clean after measurement.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant