From c856f36459afbc7bf1d128d549f6f4f83a455b40 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 16 Jul 2026 22:46:08 -0400 Subject: [PATCH] perf(desktop): kill the layout-thrash cascade on session switch (#66033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #65890 (router transitions off) and #65898 (structural compare + first-paint budget): profiling the switch path on real 1000+- message sessions with a new CDP harness showed the remaining freeze is NOT markdown rendering — it's a forced-reflow cascade from mount-time layout reads interleaved with style writes across the transcript's layout effects, plus the first-paint budget cut landing too late to stop the full-budget commit. Measured on the two largest local sessions (996 and 1363 messages), main-thread longtask totals per switch: warm 2450ms -> 557ms and 1158ms -> 194ms; first paint 1690ms -> 444ms. Harness: scripts/profile-session-switch.mjs (same CDP family as profile-real-stream.mjs). - use-resize-observer: drop the synchronous initial callback and ride the observer's spec-guaranteed first delivery instead (same frame, after layout, before paint). The sync call ran while the commit's layout was dirty, so every size read in a callback forced a full reflow — with one instance per user bubble (measureClamp read scrollHeight, then WROTE --human-msg-full, re-dirtying layout for the next bubble), the switch commit thrashed for over a second. Inside RO timing the same reads are free. Composer metrics (2x getBoundingClientRect + documentElement style writes) rides the same fix. - Same class, same fix at the remaining call sites profiling surfaced: ExpandableBlock and TerminalOutput (dozens per tool-heavy transcript) now measure/pin via RO initial delivery; the tool-window and thinking-preview pins drop their sync pin() call; the thread timeline's initial active-tick compute joins its existing scroll-time rAF batching so back-to-back transcript updates coalesce. - thread/list: cut the render budget in the RENDER phase (state-from- props adjustment) instead of the post-commit layout effect. The effect-time cut was too late — on a warm switch React first built and committed the full 300-part tree, then re-rendered at 60, then bumped back to 300, so the expensive commit still happened (and on a cold switch the bump rAF usually fired while the transcript was still empty, so the prefetched messages rendered at full budget anyway). The render-phase cut restarts the component before any child renders; a second trigger handles the cold path where messages land later under the same sessionKey. - thread/list: backfill 60 -> 300 inside startTransition so the older turns' markdown+shiki render is interruptible background work instead of a synchronous freeze one frame after the switch paints. Functional Math.max so an urgent "Show earlier" click can't be rebased back down. - composer focus: skip the rAF/timeout focus retries when the element is already focused — focus() runs the full focusing steps (forcing layout) even on the active element, ~585ms per switch on a large dirty DOM. - Replace the tautological render-budget test (it re-declared the constants locally and asserted 60 < 300) with behavior tests of the now-exported buildGroups + firstVisibleGroupIndex. Verification: apps/desktop `npx tsc --noEmit` clean; full `npx vitest run` 210 files / 1763 passed; manual CDP check confirms the deferred backfill commits the full transcript, stays pinned to bottom, and "Show earlier" still pages. --- .../scripts/profile-session-switch.mjs | 163 ++++++++++++++++++ apps/desktop/src/app/chat/composer/focus.ts | 9 +- .../assistant-ui/thread/list.test.ts | 92 ++++++++-- .../components/assistant-ui/thread/list.tsx | 98 +++++++---- .../assistant-ui/thread/message-parts.tsx | 3 +- .../assistant-ui/thread/timeline.tsx | 8 +- .../components/assistant-ui/tool/fallback.tsx | 5 +- .../src/components/chat/expandable-block.tsx | 21 ++- .../src/components/chat/terminal-output.tsx | 19 +- apps/desktop/src/hooks/use-resize-observer.ts | 15 +- 10 files changed, 362 insertions(+), 71 deletions(-) create mode 100644 apps/desktop/scripts/profile-session-switch.mjs diff --git a/apps/desktop/scripts/profile-session-switch.mjs b/apps/desktop/scripts/profile-session-switch.mjs new file mode 100644 index 000000000000..9445f6a3bec4 --- /dev/null +++ b/apps/desktop/scripts/profile-session-switch.mjs @@ -0,0 +1,163 @@ +// CPU-profile a session switch — outputs a .cpuprofile, a top-self ranking, +// longtask timings, and paint milestones for cold + warm switches. +// +// Drives the real resume path by setting location.hash (same code path as a +// sidebar click: use-route-resume → resumeSession → prefetch + resume RPC). +// +// Usage: +// node apps/desktop/scripts/profile-session-switch.mjs [rounds] +// OUT=/tmp/switch.cpuprofile node scripts/profile-session-switch.mjs 2026.. 2026.. + +import { writeFileSync } from 'node:fs' + +const CDP_HTTP = 'http://127.0.0.1:9222' +const A = process.argv[2] +const B = process.argv[3] +const ROUNDS = Number(process.argv[4] || 2) +const OUT = process.env.OUT || `/tmp/session-switch-${Date.now()}.cpuprofile` +const SETTLE_TIMEOUT = Number(process.env.SETTLE_TIMEOUT || 30000) + +if (!A || !B) { + console.error('usage: profile-session-switch.mjs [rounds]') + process.exit(1) +} + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() } + static async open(url) { + const ws = new WebSocket(url) + await new Promise((r) => ws.addEventListener('open', r, { once: true })) + const cdp = new CDP(ws) + ws.addEventListener('message', (ev) => { + const m = JSON.parse(ev.data.toString()) + if (m.id != null && cdp.pending.has(m.id)) { + const { resolve, reject } = cdp.pending.get(m.id) + cdp.pending.delete(m.id) + if (m.error) reject(new Error(m.error.message)) + else resolve(m.result) + } + }) + return cdp + } + send(method, params) { + const id = ++this.id + return new Promise((res, rej) => { + this.pending.set(id, { resolve: res, reject: rej }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + async eval(expr) { + const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed') + return r.result.value + } + close() { this.ws.close() } +} + +async function main() { + const list = await (await fetch(`${CDP_HTTP}/json`)).json() + const target = list.find((t) => t.type === 'page' && /5174/.test(t.url)) + if (!target) { console.error('renderer not found on 9222'); process.exit(1) } + const cdp = await CDP.open(target.webSocketDebuggerUrl) + + // Install observers once: longtasks + rAF frame gaps, tagged per switch. + await cdp.eval(`(() => { + if (window.__SWITCH_OBS__) return 'already' + const obs = { longtasks: [], marks: [] } + new PerformanceObserver((l) => { + for (const e of l.getEntries()) obs.longtasks.push({ t: e.startTime, dur: e.duration }) + }).observe({ entryTypes: ['longtask'] }) + window.__SWITCH_OBS__ = obs + return 'installed' + })()`) + + const switchTo = async (sid, label) => { + const t0 = await cdp.eval(`(() => { + const o = window.__SWITCH_OBS__ + o.marks.push({ label: ${JSON.stringify(label)}, sid: ${JSON.stringify(sid)}, t: performance.now() }) + location.hash = '#/' + ${JSON.stringify(sid)} + return performance.now() + })()`) + + // Poll until the transcript for this session has painted and settled: + // route matches, >0 message roots, and message count stable for 3 polls. + const deadline = Date.now() + SETTLE_TIMEOUT + let stable = 0 + let lastCount = -1 + let firstPaintT = null + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)) + const s = await cdp.eval(`({ + t: performance.now(), + route: location.hash, + msgs: document.querySelectorAll('[data-slot="aui_message"], [data-slot="aui_assistant-message-root"], [data-slot="aui_user-message-root"]').length, + parts: document.querySelectorAll('[data-slot="aui_thread-content"] *').length + })`) + if (!s.route.includes(sid)) continue + if (s.msgs > 0 && firstPaintT === null) firstPaintT = s.t + stable = s.msgs === lastCount && s.msgs > 0 ? stable + 1 : 0 + lastCount = s.msgs + if (stable >= 3) return { t0, firstPaintT, settledT: s.t, msgs: s.msgs, domNodes: s.parts } + } + return { t0, firstPaintT, settledT: null, msgs: lastCount, timedOut: true } + } + + console.log('starting CPU profile') + await cdp.send('Profiler.enable') + await cdp.send('Profiler.setSamplingInterval', { interval: 100 }) + await cdp.send('Profiler.start') + + const results = [] + for (let round = 0; round < ROUNDS; round++) { + for (const [sid, tag] of [[A, 'A'], [B, 'B']]) { + const label = `round${round}:${tag}:${round === 0 ? 'cold' : 'warm'}` + const r = await switchTo(sid, label) + results.push({ label, sid, ...r }) + const ftp = r.firstPaintT != null ? (r.firstPaintT - r.t0).toFixed(0) : 'n/a' + const st = r.settledT != null ? (r.settledT - r.t0).toFixed(0) : 'TIMEOUT' + console.log(`${label.padEnd(18)} first-paint ${String(ftp).padStart(6)} ms settled ${String(st).padStart(6)} ms msgs ${r.msgs} dom ${r.domNodes ?? '?'}`) + await new Promise((r2) => setTimeout(r2, 800)) + } + } + + const { profile } = await cdp.send('Profiler.stop') + writeFileSync(OUT, JSON.stringify(profile)) + console.log('\nwrote', OUT) + + // Longtasks per switch window. + const obs = await cdp.eval('window.__SWITCH_OBS__') + console.log('\n=== LONGTASKS (>=50ms main-thread blocks) ===') + for (let i = 0; i < obs.marks.length; i++) { + const m = obs.marks[i] + const end = obs.marks[i + 1]?.t ?? Infinity + const lts = obs.longtasks.filter((lt) => lt.t >= m.t && lt.t < end) + const total = lts.reduce((a, b) => a + b.dur, 0) + console.log(`${m.label.padEnd(18)} ${String(lts.length).padStart(2)} longtasks, ${total.toFixed(0).padStart(5)} ms total ${lts.map((l) => Math.round(l.dur)).join(', ')}`) + } + + // Self-time ranking. + const samples = profile.samples || [] + const timeDeltas = profile.timeDeltas || [] + const nodes = new Map(profile.nodes.map((n) => [n.id, n])) + const selfTime = new Map() + for (let i = 0; i < samples.length; i++) { + selfTime.set(samples[i], (selfTime.get(samples[i]) || 0) + (timeDeltas[i] ?? 0)) + } + const ranked = [...selfTime.entries()] + .map(([id, us]) => { + const cf = nodes.get(id)?.callFrame || {} + return { ms: us / 1000, name: cf.functionName || '(anonymous)', url: (cf.url || '').slice(-70), line: cf.lineNumber } + }) + .filter((x) => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name)) + .sort((a, b) => b.ms - a.ms) + .slice(0, 30) + + console.log('\n=== TOP 30 SELF TIME (ms) ACROSS ALL SWITCHES ===') + for (const r of ranked) { + console.log(`${r.ms.toFixed(1).padStart(8)} ${r.name.padEnd(44)} ${r.url}:${r.line}`) + } + + cdp.close() +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 238642f54e49..a490ef18b44c 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -157,7 +157,14 @@ export const focusComposerInput = (el: HTMLElement | null) => { return } - const focus = () => el.focus({ preventScroll: true }) + // Skip when already focused: focus() runs the full focusing steps (forcing + // layout) even on the active element, and during a session switch the DOM is + // large and dirty — the redundant retries were measurably expensive there. + const focus = () => { + if (document.activeElement !== el) { + el.focus({ preventScroll: true }) + } + } focus() window.requestAnimationFrame(focus) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.test.ts b/apps/desktop/src/components/assistant-ui/thread/list.test.ts index 379aa6149456..b25725ecb6f0 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -1,26 +1,82 @@ import { describe, expect, it } from 'vitest' -describe('ThreadMessageList render budget constants', () => { - it('defines FIRST_PAINT_BUDGET as smaller than RENDER_BUDGET', () => { - // These constants control the deferred render budget on session switch. - // FIRST_PAINT_BUDGET should be small enough to render quickly (just the - // bottom turn(s) visible after scroll-to-bottom), while RENDER_BUDGET - // is the full cap for "Show earlier" increments and the eventual full - // transcript after the first paint. - const RENDER_BUDGET = 300 - const FIRST_PAINT_BUDGET = 60 +import { buildGroups, firstVisibleGroupIndex, type MessageGroup } from './list' - expect(FIRST_PAINT_BUDGET).toBeLessThan(RENDER_BUDGET) - expect(FIRST_PAINT_BUDGET).toBeGreaterThan(0) +// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState +// selector in list.tsx). +const signature = (rows: [string, string, number][]) => + rows.map(([id, role, weight], index) => `${index}:${id}:${role}:${weight}`).join('\n') + +describe('buildGroups', () => { + it('returns no groups for an empty signature', () => { + expect(buildGroups('')).toEqual([]) }) - it('ensures FIRST_PAINT_BUDGET is sufficient for at least one visible turn', () => { - // A typical bottom turn (user + assistant) might have ~20-40 parts total - // (including tool calls), so 60 is enough for 1-2 visible turns at the - // bottom of the viewport. - const FIRST_PAINT_BUDGET = 60 - const TYPICAL_TURN_PARTS = 30 + it('groups a user message with the assistant turn(s) that follow it', () => { + const groups = buildGroups( + signature([ + ['u1', 'user', 1], + ['a1', 'assistant', 4], + ['a2', 'assistant', 2], + ['u2', 'user', 1], + ['a3', 'assistant', 3] + ]) + ) - expect(FIRST_PAINT_BUDGET).toBeGreaterThanOrEqual(TYPICAL_TURN_PARTS) + expect(groups).toEqual([ + { id: 'u1', indices: [0, 1, 2], kind: 'turn', weight: 7 }, + { id: 'u2', indices: [3, 4], kind: 'turn', weight: 4 } + ]) + }) + + it('keeps leading non-user messages as standalone groups', () => { + const groups = buildGroups( + signature([ + ['s1', 'system', 1], + ['a0', 'assistant', 2], + ['u1', 'user', 1], + ['a1', 'assistant', 5] + ]) + ) + + expect(groups).toEqual([ + { id: 's1', index: 0, kind: 'standalone', weight: 1 }, + { id: 'a0', index: 1, kind: 'standalone', weight: 2 }, + { id: 'u1', indices: [2, 3], kind: 'turn', weight: 6 } + ]) + }) + + it('defaults a missing/zero weight to 1', () => { + const groups = buildGroups('0:a:assistant:0') + + expect(groups).toEqual([{ id: 'a', index: 0, kind: 'standalone', weight: 1 }]) + }) +}) + +describe('firstVisibleGroupIndex', () => { + const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight }) + + it('shows everything when total weight fits the budget', () => { + const groups = [group('a', 10), group('b', 10), group('c', 10)] + + expect(firstVisibleGroupIndex(groups, 100)).toBe(0) + }) + + it('walks newest-first and hides everything before the turn that meets the budget', () => { + const groups = [group('old', 50), group('mid', 30), group('new', 30)] + + // newest-first: 30 (new) < 60, +30 (mid) = 60 >= 60 → mid is the first + // visible group, old is hidden. + expect(firstVisibleGroupIndex(groups, 60)).toBe(1) + }) + + it('keeps whole turns intact — the turn that crosses the budget stays visible', () => { + const groups = [group('old', 5), group('huge', 500)] + + expect(firstVisibleGroupIndex(groups, 60)).toBe(1) + }) + + it('returns groups.length for an empty list', () => { + expect(firstVisibleGroupIndex([], 60)).toBe(0) }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index c6e47c544d19..28c41b727f96 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -5,6 +5,7 @@ import { type FC, memo, type ReactNode, + startTransition, useCallback, useEffect, useLayoutEffect, @@ -28,7 +29,7 @@ import { MessageRenderBoundary } from '../message-render-boundary' type ThreadMessageComponents = ComponentProps['components'] -type MessageGroup = { id: string; weight: number } & ( +export type MessageGroup = { id: string; weight: number } & ( | { index: number; kind: 'standalone' } | { indices: number[]; kind: 'turn' } ) @@ -58,7 +59,7 @@ interface ThreadMessageListProps { // Group each user message with the assistant turn(s) that follow it so the // human bubble can `position: sticky` against the scroller across its whole // turn (see StickyHumanMessageContainer in thread.tsx). -function buildGroups(signature: string): MessageGroup[] { +export function buildGroups(signature: string): MessageGroup[] { if (!signature) { return [] } @@ -94,6 +95,24 @@ function buildGroups(signature: string): MessageGroup[] { return groups } +// Walk turns newest-first, summing their part weights until the budget is met; +// everything before the first kept turn is hidden. Returns the index of that +// first visible group. +export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget: number): number { + let firstVisible = groups.length + + for (let i = groups.length - 1, weight = 0; i >= 0; i--) { + weight += groups[i].weight + firstVisible = i + + if (weight >= budget) { + break + } + } + + return firstVisible +} + const ThreadMessageListInner: FC = ({ clampToComposer, components, @@ -121,22 +140,59 @@ const ThreadMessageListInner: FC = ({ resize: 'instant' }) - const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET) + const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET) - // Walk turns newest-first, summing their part weights until the budget is met; - // everything before that first kept turn is hidden. - let firstVisible = groups.length + // Cut the budget during RENDER, not in the post-commit layout effect. An + // effect-time cut is too late: React would first build the whole tree with + // the full budget (up to 300 parts of markdown + syntax highlighting), + // commit it, and only then re-render at the small budget. The render-phase + // state adjustment restarts this component immediately — before any child + // renders — so the heavy commit never happens. + // + // Two triggers, because the transcript swap arrives differently per path: + // a WARM switch publishes sessionKey + messages in one commit (the key + // branch), while a COLD switch changes sessionKey with an empty transcript + // and the prefetched messages land hundreds of ms later under the SAME key + // (the empty→non-empty branch). + const hasGroups = groups.length > 0 + const [budgetSessionKey, setBudgetSessionKey] = useState(sessionKey) + const [hadGroups, setHadGroups] = useState(hasGroups) - for (let i = groups.length - 1, weight = 0; i >= 0; i--) { - weight += groups[i].weight - firstVisible = i + if (budgetSessionKey !== sessionKey) { + setBudgetSessionKey(sessionKey) + setHadGroups(hasGroups) + setRenderBudget(FIRST_PAINT_BUDGET) + } else if (hadGroups !== hasGroups) { + setHadGroups(hasGroups) - if (weight >= renderBudget) { - break + if (hasGroups) { + setRenderBudget(FIRST_PAINT_BUDGET) } } - const hiddenCount = firstVisible + // Backfill from FIRST_PAINT_BUDGET to the full budget after the small + // commit painted — as a TRANSITION, so the heavy markdown + syntax + // highlight render of the older turns is interruptible instead of one long + // synchronous commit that freezes input right after the switch. Route + // changes stay urgent (main.tsx disables router transitions); it's exactly + // this backfill that belongs at background priority. "Show earlier" pages + // (budget > RENDER_BUDGET) never re-enter here. + useEffect(() => { + if (renderBudget >= RENDER_BUDGET) { + return + } + + const rafId = requestAnimationFrame(() => { + // Functional max, not a plain set: an urgent "Show earlier" click can + // land between scheduling and committing this transition, and a plain + // set would rebase over it and shrink the budget back down. + startTransition(() => setRenderBudget(budget => Math.max(budget, RENDER_BUDGET))) + }) + + return () => cancelAnimationFrame(rafId) + }, [renderBudget]) + + const hiddenCount = firstVisibleGroupIndex(groups, renderBudget) const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const restoreFromBottomRef = useRef(null) // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) @@ -192,12 +248,6 @@ const ThreadMessageListInner: FC = ({ // Instead: quiet it, glue to the true bottom until the height holds steady, // then hand back locked. Live streaming afterward uses the normal resize follow. useLayoutEffect(() => { - // Start with a small first-paint budget (enough for the bottom turn(s) the - // user sees after scroll-to-bottom), then defer the full budget bump to a - // requestAnimationFrame so the heavy markdown render happens after the - // initial commit. - setRenderBudget(FIRST_PAINT_BUDGET) - const el = scrollRef.current if (!el) { @@ -238,17 +288,7 @@ const ThreadMessageListInner: FC = ({ let rafId = requestAnimationFrame(settle) - // After the settle loop starts, bump the render budget to the full value - // in a subsequent rAF so the full transcript becomes available after the - // first paint. - let budgetRafId = requestAnimationFrame(() => { - setRenderBudget(RENDER_BUDGET) - }) - - return () => { - cancelAnimationFrame(rafId) - cancelAnimationFrame(budgetRafId) - } + return () => cancelAnimationFrame(rafId) }, [scrollRef, scrollToBottom, sessionKey, stopScroll]) // Prepend an older page while preserving the on-screen position. The user is diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 0e1a2e7e0882..d1f084876641 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -84,7 +84,8 @@ const ThinkingDisclosure: FC<{ el.scrollTop = el.scrollHeight } - pin() + // No sync pin(): the observer's guaranteed initial delivery runs it with + // layout already clean (still before paint), avoiding a forced reflow. const observer = new ResizeObserver(pin) observer.observe(content) diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx index 5892e04f0952..18b09c63fe3c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timeline.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/timeline.tsx @@ -206,7 +206,13 @@ export const ThreadTimeline: FC = () => { } } - compute() + // Initial compute rides the same rAF batching as scroll. A sync call here + // reads getBoundingClientRect for every user message while other commit + // effects are still writing styles — on a session switch that interleaving + // forces a full reflow per read on a large transcript. One rAF later the + // reads batch into a single layout pass, and back-to-back entries updates + // (prefetch paint, then resume reconcile) coalesce into one compute. + onScroll() viewport.addEventListener('scroll', onScroll, { passive: true }) return () => { diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 705c900e3b21..ae173d2eb5af 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -659,7 +659,10 @@ function useToolWindow(enabled: boolean) { syncFade() } - pin() + // No sync pin() here: the observer's guaranteed initial delivery runs it + // inside RO timing (layout already clean, still before paint). A sync call + // at effect time reads scrollHeight while the commit's layout is dirty — + // one forced reflow per tool group, which cascades on a session switch. const observer = new ResizeObserver(pin) observer.observe(content) diff --git a/apps/desktop/src/components/chat/expandable-block.tsx b/apps/desktop/src/components/chat/expandable-block.tsx index 3933f7ccab54..92d2ab08c830 100644 --- a/apps/desktop/src/components/chat/expandable-block.tsx +++ b/apps/desktop/src/components/chat/expandable-block.tsx @@ -1,7 +1,8 @@ 'use client' -import { type ReactNode, useLayoutEffect, useRef, useState } from 'react' +import { type ReactNode, useCallback, useRef, useState } from 'react' +import { useResizeObserver } from '@/hooks/use-resize-observer' import { ChevronDown } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -15,21 +16,19 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) { const [expanded, setExpanded] = useState(false) const [overflowing, setOverflowing] = useState(false) - useLayoutEffect(() => { + // Measure inside ResizeObserver timing only (layout is clean there). A + // synchronous mount-time scrollHeight read forces a reflow per instance, + // and a tool-heavy transcript mounts dozens of these on a session switch. + const measure = useCallback(() => { const el = innerRef.current - if (!el) { - return + if (el) { + setOverflowing(el.scrollHeight > 121) } - - const measure = () => setOverflowing(el.scrollHeight > 121) - measure() - const observer = new ResizeObserver(measure) - observer.observe(el) - - return () => observer.disconnect() }, []) + useResizeObserver(measure, innerRef) + return (
diff --git a/apps/desktop/src/components/chat/terminal-output.tsx b/apps/desktop/src/components/chat/terminal-output.tsx index 00dbda57c6c4..6b12c5b9d214 100644 --- a/apps/desktop/src/components/chat/terminal-output.tsx +++ b/apps/desktop/src/components/chat/terminal-output.tsx @@ -1,5 +1,6 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' +import { useResizeObserver } from '@/hooks/use-resize-observer' import { cn } from '@/lib/utils' interface TerminalOutputProps { @@ -22,15 +23,25 @@ const NEAR_BOTTOM_PX = 24 export function TerminalOutput({ className, text }: TerminalOutputProps) { const ref = useRef(null) - // On open: jump straight to the latest output (no animation, before paint). - useLayoutEffect(() => { + // On open: jump straight to the latest output. Rides the ResizeObserver's + // initial delivery (layout-clean, still before paint) instead of a layout + // effect — a sync scrollHeight read there forces a reflow per instance, and + // a tool-heavy transcript mounts dozens of these on a session switch. + const jumpedRef = useRef(false) + + const jumpToBottom = useCallback(() => { const el = ref.current - if (el) { + // First delivery only; later box resizes must not yank the user back + // down while they're scrolled up reading earlier output. + if (el && !jumpedRef.current) { + jumpedRef.current = true el.scrollTop = el.scrollHeight } }, []) + useResizeObserver(jumpToBottom, ref) + // On growth: tail only when already pinned near the bottom. useEffect(() => { const el = ref.current diff --git a/apps/desktop/src/hooks/use-resize-observer.ts b/apps/desktop/src/hooks/use-resize-observer.ts index e9a0b0b50a62..6ff08387ffcf 100644 --- a/apps/desktop/src/hooks/use-resize-observer.ts +++ b/apps/desktop/src/hooks/use-resize-observer.ts @@ -2,9 +2,16 @@ import { type RefObject, useLayoutEffect, useRef } from 'react' /** * Observe element resizes. The callback receives the ResizeObserver entries - * (empty on the initial synchronous call and in non-RO environments) so - * callers can read the observed size off the entry instead of forcing a - * fresh layout read. + * (empty only in non-RO environments) so callers can read the observed size + * off the entry instead of forcing a fresh layout read. + * + * The initial measurement rides the observer's spec-guaranteed first delivery + * (same frame, after layout, before paint) instead of a synchronous call from + * the layout effect. A sync call here runs while the commit's layout is still + * dirty, so any size read in the callback forces a full reflow — and with many + * instances mounting at once (every user bubble on a session switch), the + * interleaved read→write→read pattern cascades into seconds of layout thrash. + * Inside RO timing, layout is already clean and the same reads are ~free. */ export function useResizeObserver( onResize: (entries: readonly ResizeObserverEntry[]) => void, @@ -40,8 +47,6 @@ export function useResizeObserver( return } - onResize([]) - return () => observer.disconnect() }, [onResize]) }