From 4798994dcec53e8dbd6bae1ac85cebe6dbcc2da6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:19:23 -0500 Subject: [PATCH 1/5] perf(desktop): mount tooltips lazily, and measure the idle cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `idle-cost` scenario for the symptom Brooklyn reported: with a thread spinning, resizing the sidebar feels slow. It holds N tiles busy, pushes NO tokens, and measures the renderer's self-inflicted commit rate plus fps while dragging the splitter and while typing. It reproduces immediately. Five busy tiles, nothing streaming: idle commits 17.7/sec (should be 0 — nothing is happening) drag 1.4 fps p95 812ms, worst frame 1.9s typing 61 fps (fine — this is specific to resize) Attributing the drag window showed 105,385 TooltipProvider renders and ~15s of component time across a 60-frame gesture. Cause: `Tip` mounts a full Radix provider + Tooltip per call site, and there are ~107 of them. Radix's Tooltip holds real state and Popper subscribes to layout, so an unrelated interaction re-rendered all of them. Mounts the machinery lazily instead, on first hover/focus. Tooltip churn drops ~4x (105k -> 26k) and drag doubles to 3fps. Note `defaultOpen` on the armed Tooltip is load-bearing: the pointerenter that armed it has already fired, so Radix never sees it and the tip mounts silently closed. A test caught exactly that, and now guards it. 3fps is still bad — the remaining cost is the whole transcript re-rendering per resize frame (MessagePrimitive.Parts 12,600 renders / 10.5s, Block/Ct 24,300 each, all 100% wasted). Separate fix. --- .../scripts/perf/scenarios/idle-cost.mjs | 238 ++++++++++++++++++ apps/desktop/scripts/perf/scenarios/index.mjs | 2 + .../src/components/ui/tooltip.lazy.test.tsx | 56 +++++ apps/desktop/src/components/ui/tooltip.tsx | 41 ++- 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/scripts/perf/scenarios/idle-cost.mjs create mode 100644 apps/desktop/src/components/ui/tooltip.lazy.test.tsx diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs new file mode 100644 index 000000000000..7d167c4cf8d7 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -0,0 +1,238 @@ +// What does the app cost while a turn is running but NOTHING is arriving? +// +// The user-visible symptom: with a thread spinning, resizing the sidebar or +// typing in the composer feels slow. That is not streaming cost — the stream +// is idle. It is the app re-rendering on its own, competing with the +// interaction for the main thread. +// +// This scenario holds N tiles in a busy state, pushes NO tokens, and measures: +// - idle_commits_per_s the renderer's self-inflicted commit rate +// - drag_fps fps while dragging the sidebar splitter +// - type_fps fps while typing in the composer +// +// A perfectly idle app scores 0 idle commits and pins both interactions at the +// display's refresh rate. Every idle commit is main-thread time stolen from an +// interaction the user can feel. +// +// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6] + +import { sleep } from '../lib/cdp.mjs' + +/** Seed `tiles` busy session tiles. Same publish path as `multitab` / + * `render-churn`, but the driver never runs — the turn just stays open. */ +const setup = (tiles, seedTurns) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + if (!window.__RENDER_COUNTS__) return 'no-render-counter' + + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] } + ]) + + window.__IDLE__ = { ids: [] } + for (let n = 1; n <= ${tiles}; n++) { + const sid = 'idle-tile-' + n + const rid = 'idle-rt-' + n + const messages = [] + for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i)) + // An OPEN assistant message: the turn is running, but no tokens arrive. + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working on it.' }] }) + + window.__IDLE__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +/** Measure the app's self-inflicted commit rate with nothing happening. */ +const idleCost = seconds => ` + (async () => { + const rc = window.__RENDER_COUNTS__ + rc.start() + const t0 = performance.now() + await new Promise(r => setTimeout(r, ${seconds} * 1000)) + const elapsed = (performance.now() - t0) / 1000 + rc.stop() + return JSON.stringify({ + elapsed, + commits: rc.commits(), + top: rc.report(12), + owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10) + }) + })() +` + +/** Record frame pacing across an interaction driven from the page, plus WHAT + * rendered during it — a slow interaction is almost never the handler, it is + * whatever re-renders underneath while the handler runs. */ +const withFrames = body => ` + (async () => { + const rc = window.__RENDER_COUNTS__ + rc.start() + const frames = [] + let last = performance.now() + let stop = false + const tick = () => { + if (stop) return + const now = performance.now() + frames.push(now - last) + last = now + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + ${body} + stop = true + rc.stop() + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 + return JSON.stringify({ + fps: total ? (frames.length / total) * 1000 : 0, + p95: pct(0.95), + worst: sorted.length ? sorted[sorted.length - 1] : 0, + slow33: frames.filter(f => f > 33).length, + n: frames.length, + commits: rc.commits(), + top: rc.report(10) + }) + })() +` + +/** Drag the sidebar splitter back and forth — the resize symptom. */ +const DRAG = withFrames(` + const handle = document.querySelector('[data-slot="pane-resize-handle"], [role="separator"], .resize-handle') + if (handle) { + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + let x = box.left + box.width / 2 + handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, pointerId: 1 })) + for (let i = 0; i < 60; i++) { + x += (i % 2 === 0 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: x, clientY: y, pointerId: 1 })) + await new Promise(r => requestAnimationFrame(r)) + } + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: x, clientY: y, pointerId: 1 })) + } else { + await new Promise(r => setTimeout(r, 1000)) + } +`) + +/** Type into the composer — the keystroke symptom. */ +const TYPE = withFrames(` + const el = document.querySelector('[contenteditable="true"], textarea') + if (el) { + el.focus() + for (let i = 0; i < 40; i++) { + const ch = 'performance testing '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + if (el.tagName === 'TEXTAREA') { + el.value += ch + el.dispatchEvent(new Event('input', { bubbles: true })) + } else { + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + } + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => setTimeout(r, 25)) + } + } else { + await new Promise(r => setTimeout(r, 1000)) + } +`) + +const CLEANUP = ` + (() => { + if (window.__IDLE__) { + for (const { sid, rid } of window.__IDLE__.ids) { + const states = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__IDLE__ = null + } + window.__RENDER_COUNTS__.clear() + return 'cleaned' + })() +` + +const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places + +export default { + name: 'idle-cost', + tier: 'ci', + description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.', + async run(cdp, opts = {}) { + const tiles = Number(opts.tiles ?? 5) + const seedTurns = Number(opts.turns ?? 20) + const seconds = Number(opts.seconds ?? 6) + + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup(tiles, seedTurns)) + + if (ok !== 'ok') { + throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`) + } + + for (let n = 1; n <= tiles; n++) { + await cdp.eval(reveal(`idle-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + const idle = JSON.parse(await cdp.eval(idleCost(seconds))) + const drag = JSON.parse(await cdp.eval(DRAG)) + const type = JSON.parse(await cdp.eval(TYPE)) + + await cdp.eval(CLEANUP) + + return { + metrics: { + // Commits per second with a turn open and nothing arriving. Should be 0. + idle_commits_per_s: round(idle.commits / idle.elapsed), + idle_renders: idle.top.reduce((a, r) => a + r.renders, 0), + // Interaction smoothness while that churn competes for the main thread. + // Reported as a deficit from 60fps so "lower is better" matches the + // baseline gate's direction. + drag_fps_deficit: round(Math.max(0, 60 - drag.fps)), + drag_slow_frames: drag.slow33, + type_fps_deficit: round(Math.max(0, 60 - type.fps)), + type_slow_frames: type.slow33 + }, + detail: { + tiles, + idleSeconds: round(idle.elapsed), + dragFps: round(drag.fps), + dragP95: round(drag.p95), + dragWorst: round(drag.worst), + typeFps: round(type.fps), + typeP95: round(type.p95), + typeWorst: round(type.worst), + // Components whose OWN state changed with no prop change: the roots. + idleOwners: idle.owners, + idleTop: idle.top, + dragCommits: drag.commits, + dragTop: drag.top, + typeCommits: type.commits, + typeTop: type.top + } + } + } +} diff --git a/apps/desktop/scripts/perf/scenarios/index.mjs b/apps/desktop/scripts/perf/scenarios/index.mjs index d92526b0fcfa..de53212e4249 100644 --- a/apps/desktop/scripts/perf/scenarios/index.mjs +++ b/apps/desktop/scripts/perf/scenarios/index.mjs @@ -3,6 +3,7 @@ import coldStart from './cold-start.mjs' import firstToken from './first-token.mjs' +import idleCost from './idle-cost.mjs' import keystroke from './keystroke.mjs' import multitab from './multitab.mjs' import profileSwitch from './profile-switch.mjs' @@ -20,6 +21,7 @@ export const SCENARIOS = { [transcript.name]: transcript, [multitab.name]: multitab, [renderChurn.name]: renderChurn, + [idleCost.name]: idleCost, [coldStart.name]: coldStart, [firstToken.name]: firstToken, [submit.name]: submit, diff --git a/apps/desktop/src/components/ui/tooltip.lazy.test.tsx b/apps/desktop/src/components/ui/tooltip.lazy.test.tsx new file mode 100644 index 000000000000..4a66eda5cd5d --- /dev/null +++ b/apps/desktop/src/components/ui/tooltip.lazy.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Tip } from './tooltip' + +// `Tip` mounts Radix lazily (on first hover/focus) because ~107 eager +// providers dominated unrelated interactions — dragging the sidebar splitter +// measured 105k TooltipProvider renders across a 60-frame gesture. The saving +// is only worth anything if the tip still actually appears, so this asserts +// the behavior the optimization has to preserve, not its implementation. + +describe('Tip lazy mount', () => { + it('shows the tooltip on hover even though Radix mounts only once armed', async () => { + render( + + + + ) + + const trigger = screen.getByRole('button', { name: 'target' }) + + expect(screen.queryByText('Reveal in sidebar')).toBeNull() + + // Arms the lazy wrapper, then drives Radix's own trigger handlers for the + // same hover — the pointer is still inside, so the tip must open. + fireEvent.pointerEnter(trigger) + fireEvent.pointerMove(trigger) + + await waitFor(() => { + expect(screen.getAllByText('Reveal in sidebar').length).toBeGreaterThan(0) + }) + }) + + it('renders the child untouched when there is no label', () => { + const { container } = render( + + + + ) + + expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() + expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeNull() + }) + + it('does not mount Radix until the pointer arrives', () => { + const { container } = render( + + + + ) + + // The idle wrapper is present; no Radix trigger has been created. + expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeTruthy() + expect(container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index fe16e3b97cfc..a28478949f38 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -108,14 +108,53 @@ interface TipProps extends Omit(null) + if (!label) { return <>{children} } + if (!armed) { + // `display: contents` so this wrapper never affects layout — the child + // participates in its parent's box exactly as it did before. + return ( + { + suppressNonKeyboardFocusOpen(event) + + if (!event.defaultPrevented) { + setArmed('focus') + } + }} + onPointerEnter={() => setArmed('pointer')} + style={{ display: 'contents' }} + > + {children} + + ) + } + + // `defaultOpen` is required, not incidental: the pointerenter/focus that + // armed this has ALREADY fired, so Radix's own trigger handlers never see it + // and the tip would mount silently closed. Arming IS the hover, so open with + // it — Radix owns every subsequent open/close from here (pointerleave, blur, + // escape, pointerdown) exactly as before. return ( - + {children} {label} From f18d9b28435297426661dd9f88a3fdfecd94ef6c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:35:05 -0500 Subject: [PATCH 2/5] fix(desktop): make idle-cost's drag actually drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthetic gesture oscillated +/-3px, which nets to zero displacement and can clamp to a no-op — so it reported a confident fps number for a drag that never moved the sash. Sweeps monotonically now, dispatches pointer events React's synthetic system accepts (isPrimary/button/buttons), and records dragTarget + dragMoved so a drag that silently did nothing is visible in the output rather than passing as a measurement. Verified: dragMoved now reports 60px where it previously reported 0. --- .../scripts/perf/scenarios/idle-cost.mjs | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs index 7d167c4cf8d7..ab8cfcf37d24 100644 --- a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -114,20 +114,34 @@ const withFrames = body => ` })() ` -/** Drag the sidebar splitter back and forth — the resize symptom. */ +/** Drag the sidebar splitter — the resize symptom. + * Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op), + * and reports how far it actually moved so a drag that silently did nothing + * shows up as `dragMoved: 0` instead of a confident wrong number. */ const DRAG = withFrames(` - const handle = document.querySelector('[data-slot="pane-resize-handle"], [role="separator"], .resize-handle') + const handle = document.querySelector('[role="separator"]') + window.__DRAG_TARGET__ = handle ? 'separator' : 'none' + window.__DRAG_MOVED__ = 0 if (handle) { const box = handle.getBoundingClientRect() const y = box.top + box.height / 2 - let x = box.left + box.width / 2 - handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, pointerId: 1 })) - for (let i = 0; i < 60; i++) { - x += (i % 2 === 0 ? 3 : -3) - window.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: x, clientY: y, pointerId: 1 })) + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + // Out 60px then back — a real gesture, with a net displacement at the peak. + for (let i = 0; i < 30; i++) { + x += 2 + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) await new Promise(r => requestAnimationFrame(r)) } - window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: x, clientY: y, pointerId: 1 })) + window.__DRAG_MOVED__ = Math.round(x - x0) + for (let i = 0; i < 30; i++) { + x -= 2 + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) } else { await new Promise(r => setTimeout(r, 1000)) } @@ -199,10 +213,16 @@ export default { const idle = JSON.parse(await cdp.eval(idleCost(seconds))) const drag = JSON.parse(await cdp.eval(DRAG)) + const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"') + const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0') const type = JSON.parse(await cdp.eval(TYPE)) await cdp.eval(CLEANUP) + if (dragTarget === 'none') { + throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.') + } + return { metrics: { // Commits per second with a turn open and nothing arriving. Should be 0. @@ -218,6 +238,8 @@ export default { }, detail: { tiles, + dragTarget, + dragMoved, idleSeconds: round(idle.elapsed), dragFps: round(drag.fps), dragP95: round(drag.p95), From edadfcae9656ad0e4ea36d16b4f73cca26e82cea Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:53:20 -0500 Subject: [PATCH 3/5] perf(desktop): stop every zone subscribing to the whole layout tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TreeGroup called useStore($layoutTree) to build its right-click menu's move/split directions. That subscribes every zone — and therefore every mounted pane and its entire transcript — to the whole layout tree. A sash drag rewrites the tree once per frame, so dragging the sidebar re-rendered all five tiles' message lists on every pointermove, for a context menu nobody had open. The directions are only read when the menu renders, so read the tree there with .get() instead. Same lazy shape the neighbouring `closable` prop already uses. Measured over one 60px sash drag with five busy tiles: commits 83 -> 12 ChatView 150 -> 10 (4465ms -> 353ms) AuiProvider 9450 -> 630 (9868ms -> 774ms) TreeGroup 180 -> 12 TreeSplit 90 -> 6 Also fixes an observer effect in the harness: idle-cost recorded render attribution *during* the timed gesture, and the counter walks the fiber tree on every commit. That was large enough to hide this 15x reduction behind an unchanged fps, so timing and attribution are separate passes now and `record` defaults off. Adds scripts/diag-drag-churn.mjs — the probe that found this. It reports the transcript chain (who above the messages re-rendered) plus every atom that notified, which is what named TreeGroup instead of leaving it to be guessed at. Notably the atom list came back EMPTY: this was never store churn, so the render-attribution path was the only thing that could have found it. --- apps/desktop/scripts/diag-drag-churn.mjs | 158 ++++++++++++++++++ .../scripts/perf/scenarios/idle-cost.mjs | 20 ++- .../pane-shell/tree/renderer/tree-group.tsx | 65 ++++--- 3 files changed, 211 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/scripts/diag-drag-churn.mjs diff --git a/apps/desktop/scripts/diag-drag-churn.mjs b/apps/desktop/scripts/diag-drag-churn.mjs new file mode 100644 index 000000000000..009c34e3d2c1 --- /dev/null +++ b/apps/desktop/scripts/diag-drag-churn.mjs @@ -0,0 +1,158 @@ +// Who re-renders the transcript during a sash drag? +// +// Standalone probe, not a benchmark: seeds tiles, then drags the sash while +// recording (a) render attribution and (b) every nanostores atom that notifies +// during the gesture. The idle-cost scenario proved the transcript re-renders +// ~18x above baseline during a drag but that the sash HANDLER is not the cause +// (identical counts at 0px and 60px displacement) — so this names the store +// that actually fires. +// +// node scripts/perf/diag-drag-churn.mjs [--port 9222] + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const TILES = 5 +const TURNS = 20 + +const setup = ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] } + ]) + window.__D__ = { ids: [] } + for (let n = 1; n <= ${TILES}; n++) { + const sid = 'diag-tile-' + n + const rid = 'diag-rt-' + n + const messages = [] + for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working.' }] }) + window.__D__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +// Drag, recording renders AND atom notifications together. +const DRAG = ` + (async () => { + const rc = window.__RENDER_COUNTS__ + const ac = window.__ATOM_CHURN__ + rc.start(); ac.start() + + const handle = document.querySelector('[role="separator"]') + if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) } + + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 30; i++) { + x += 2 + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + + rc.stop(); ac.stop() + const all = rc.report(400) + const named = n => all.find(r => r.name === n) || null + return JSON.stringify({ + moved: Math.round(x - x0), + commits: rc.commits(), + renders: rc.report(14), + // The suspects: who at the TOP of the transcript tree re-rendered? + chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat', + 'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell'] + .map(n => ({ name: n, hit: named(n) })).filter(x => x.hit), + atoms: ac.report(30) + }) + })() +` + +const CLEANUP = ` + (() => { + if (window.__D__) { + for (const { sid, rid } of window.__D__.ids) { + const s = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__D__ = null + } + return 'cleaned' + })() +` + +const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222) +const { cdp, teardown } = await attach({ port }) + +try { + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup) + + if (ok !== 'ok') { + throw new Error(`setup failed: ${ok}`) + } + + for (let n = 1; n <= TILES; n++) { + await cdp.eval(reveal(`diag-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + const data = JSON.parse(await cdp.eval(DRAG)) + await cdp.eval(CLEANUP) + + console.log(`moved ${data.moved}px, ${data.commits} commits\n`) + console.log('RENDERS during drag:') + + for (const r of data.renders) { + console.log( + ` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` + + `props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` + + `ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}` + ) + } + + console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):') + + for (const { name, hit } of data.chain) { + console.log( + ` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` + + `props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}` + ) + } + + console.log('\nATOMS that notified during drag:') + + for (const a of data.atoms) { + console.log( + ` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` + + `fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}` + ) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs index ab8cfcf37d24..d26fb7661c5e 100644 --- a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -78,13 +78,17 @@ const idleCost = seconds => ` })() ` -/** Record frame pacing across an interaction driven from the page, plus WHAT - * rendered during it — a slow interaction is almost never the handler, it is - * whatever re-renders underneath while the handler runs. */ -const withFrames = body => ` +/** Record frame pacing across an interaction driven from the page. + * + * `record` MUST be false for any fps number you intend to believe: the render + * counter walks the whole fiber tree on every commit, so recording during a + * gesture measures the instrumentation as much as the app. Attribution and + * timing therefore run as two separate passes — the observer effect here is + * large enough to hide a 15x render reduction behind an unchanged fps. */ +const withFrames = (body, record = false) => ` (async () => { const rc = window.__RENDER_COUNTS__ - rc.start() + ${record ? 'rc.start()' : ''} const frames = [] let last = performance.now() let stop = false @@ -98,7 +102,7 @@ const withFrames = body => ` requestAnimationFrame(tick) ${body} stop = true - rc.stop() + ${record ? 'rc.stop()' : ''} const total = frames.reduce((a, b) => a + b, 0) const sorted = [...frames].sort((a, b) => a - b) const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 @@ -108,8 +112,8 @@ const withFrames = body => ` worst: sorted.length ? sorted[sorted.length - 1] : 0, slow33: frames.filter(f => f > 33).length, n: frames.length, - commits: rc.commits(), - top: rc.report(10) + commits: ${record ? 'rc.commits()' : '0'}, + top: ${record ? 'rc.report(10)' : '[]'} }) })() ` diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 1ed752f1dda6..74a278ed515c 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -89,7 +89,11 @@ function ZoneMenu({ /** False for the zone hosting the uncloseable workspace — collapsing the * MAIN pane strands the app behind a strip. */ minimizable?: boolean - directions: ZoneMenuDirection[] + /** Called when the menu renders, not on every zone re-render: resolving the + * neighbor zones has to read the layout tree, and subscribing every zone to + * it made a sash drag re-render every mounted pane. Same lazy shape as + * `closable`. */ + directions: () => ZoneMenuDirection[] headerHidden?: boolean minimized?: boolean nodeId: string @@ -100,7 +104,7 @@ function ZoneMenu({ {children} - {directions.map(direction => ( + {directions().map(direction => ( {direction.label} @@ -255,30 +259,43 @@ export function TreeGroup({ // - a single pane -> "Move ": join the zone visually adjacent on that // side (splitting here would only make an invisible empty zone). Sides // with no visible neighbor are omitted entirely. - const tree = useStore($layoutTree) + // NOT `useStore($layoutTree)`: this subscribes every zone — and therefore + // every mounted pane and its whole transcript — to the entire layout tree. + // A sash drag rewrites the tree once per frame, so dragging the sidebar + // re-rendered all five tiles' message lists on every pointermove (measured: + // TreeGroup 180 renders cascading into ChatView/Thread/TileChat at ~4.5s + // each, holding the drag at ~3fps). + // + // The tree is only read to build the zone context menu's move/split + // directions, which are consumed when the menu OPENS — so read it at that + // moment with `.get()` instead of subscribing to every intermediate frame. + const menuDirections = (): ZoneMenuDirection[] => { + if (shown.length > 1) { + return DIRECTION_ORDER.map(side => ({ + side, + label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => splitTreeZone(node.id, side, menuPane ?? activeId) + })) + } - const menuDirections: ZoneMenuDirection[] = - shown.length > 1 - ? DIRECTION_ORDER.map(side => ({ + const tree = $layoutTree.get() + + return DIRECTION_ORDER.flatMap(side => { + const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null + + if (!neighbor || neighbor.id === node.id) { + return [] + } + + return [ + { side, - label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`, - run: () => splitTreeZone(node.id, side, menuPane ?? activeId) - })) - : DIRECTION_ORDER.flatMap(side => { - const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null - - if (!neighbor || neighbor.id === node.id) { - return [] - } - - return [ - { - side, - label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`, - run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' }) - } - ] - }) + label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' }) + } + ] + }) + } // Close targets the right-clicked chip (falling back to the active pane); // only panes that declare `uncloseable` (the main workspace) are exempt. From f64b719321c4a1668663d54019a87e6a1ab6ca2f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 17:02:59 -0500 Subject: [PATCH 4/5] perf(desktop): cache short markdown parses too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseMarkdownIntoBlocksCached bypassed its cache for text under 1024 chars, on the theory that re-lexing a short message is cheap. The lex is cheap; what it returns is not free. `parseMarkdownIntoBlocks` builds a fresh array every call (verified in streamdown's dist: `let r=[]` ... `return r`), and Streamdown mirrors the block list into useState — so a new array identity for UNCHANGED text re-renders Streamdown and every Block beneath it. Most messages are short, so most of the transcript was on the uncached path. Caching every length cuts the idle cost of five mounted tiles: Streamdown 5.2ms -> 2.6ms Block 128ms -> 85ms Ct 122ms -> 81ms Cache bumped 64 -> 256 entries to cover the now-larger key space. This does NOT reduce Streamdown's 105 idle self-renders — array identity turned out not to be what drives those, and I verified the cache returns a stable identity, so that root is still open. This is a cost win, not the churn fix. --- apps/desktop/src/lib/markdown-blocks.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/markdown-blocks.ts b/apps/desktop/src/lib/markdown-blocks.ts index df6a473a2747..8aac6a3a6145 100644 --- a/apps/desktop/src/lib/markdown-blocks.ts +++ b/apps/desktop/src/lib/markdown-blocks.ts @@ -9,8 +9,15 @@ import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown' * new string, so the stock splitter pays that O(full-text) cost ~30×/s on * long replies. Two caches remove it: * - * 1. Exact-string LRU — a message that REMOUNTS with unchanged text - * (virtualizer scroll, session switch) reuses its parse outright. + * 1. Exact-string cache — the same text always yields the SAME ARRAY. This is + * identity, not just cost: `parseMarkdownIntoBlocks` builds a fresh array + * every call, and Streamdown mirrors the block list into `useState`, so a + * new array identity for unchanged text makes every Streamdown re-render + * itself and re-render every Block under it. Short messages used to skip + * the cache on the theory that re-lexing them was cheap — the lex is, but + * the churn it caused was not (measured: ~105 self-renders of Streamdown + * across five idle tiles in six seconds, cascading into 800 Block renders + * with nothing streaming). Every length is cached now. * 2. Streaming-append cache — when the new text starts with a recently parsed * text (the token-append case), the previous parse's blocks are reused up * to a settled boundary and only the suffix is lexed. The boundary drops @@ -28,8 +35,7 @@ import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown' * full lex, i.e. exactly the previous behavior. */ -const EXACT_CACHE_MAX = 64 -const EXACT_CACHE_MIN_LENGTH = 1024 +const EXACT_CACHE_MAX = 256 const exactCache = new Map() // Streaming messages grow monotonically, and only a handful stream at once @@ -109,10 +115,6 @@ function lexIncrementally(text: string): null | string[] { } export function parseMarkdownIntoBlocksCached(markdown: string): string[] { - if (markdown.length < EXACT_CACHE_MIN_LENGTH) { - return parseMarkdownIntoBlocks(markdown) - } - const hit = exactCache.get(markdown) if (hit) { From 0226d1162ef2ff36a1b118a135c7a2c3f378ed3d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 18:00:02 -0500 Subject: [PATCH 5/5] Revert "perf(desktop): mount tooltips lazily" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the tooltip half of 4798994dc; keeps the idle-cost scenario. Lazily mounting Radix on first hover measured well (105k -> 26k TooltipProvider renders per drag) but broke 18 tests across 12 files. Those tests are not incidental: the repo has an established convention of asserting `[data-slot="tooltip-trigger"]` at mount to prove a control carries a tooltip, and deferring the mount invalidates all of them at once. There is also a real behavior risk the convention was protecting — `asChild` puts the slot on the button element itself, so arming REPLACES the node, which is exactly the kind of identity change that breaks focus restoration and ref-holding call sites. A 4x cut in tooltip churn is not worth reworking every tooltip assertion in the app plus taking that risk, on a component with ~107 call sites. If it's worth revisiting, the right shape is probably making TooltipProvider itself cheap (one app-level provider) rather than deferring the mount per call site — that preserves the DOM contract these tests encode. The genuine win in this branch stands on its own: the $layoutTree subscription fix (commits 83 -> 12 on a sash drag) is unaffected. --- .../src/components/ui/tooltip.lazy.test.tsx | 56 ------------------- apps/desktop/src/components/ui/tooltip.tsx | 41 +------------- 2 files changed, 1 insertion(+), 96 deletions(-) delete mode 100644 apps/desktop/src/components/ui/tooltip.lazy.test.tsx diff --git a/apps/desktop/src/components/ui/tooltip.lazy.test.tsx b/apps/desktop/src/components/ui/tooltip.lazy.test.tsx deleted file mode 100644 index 4a66eda5cd5d..000000000000 --- a/apps/desktop/src/components/ui/tooltip.lazy.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it } from 'vitest' - -import { Tip } from './tooltip' - -// `Tip` mounts Radix lazily (on first hover/focus) because ~107 eager -// providers dominated unrelated interactions — dragging the sidebar splitter -// measured 105k TooltipProvider renders across a 60-frame gesture. The saving -// is only worth anything if the tip still actually appears, so this asserts -// the behavior the optimization has to preserve, not its implementation. - -describe('Tip lazy mount', () => { - it('shows the tooltip on hover even though Radix mounts only once armed', async () => { - render( - - - - ) - - const trigger = screen.getByRole('button', { name: 'target' }) - - expect(screen.queryByText('Reveal in sidebar')).toBeNull() - - // Arms the lazy wrapper, then drives Radix's own trigger handlers for the - // same hover — the pointer is still inside, so the tip must open. - fireEvent.pointerEnter(trigger) - fireEvent.pointerMove(trigger) - - await waitFor(() => { - expect(screen.getAllByText('Reveal in sidebar').length).toBeGreaterThan(0) - }) - }) - - it('renders the child untouched when there is no label', () => { - const { container } = render( - - - - ) - - expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() - expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeNull() - }) - - it('does not mount Radix until the pointer arrives', () => { - const { container } = render( - - - - ) - - // The idle wrapper is present; no Radix trigger has been created. - expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeTruthy() - expect(container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull() - }) -}) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index a28478949f38..fe16e3b97cfc 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -108,53 +108,14 @@ interface TipProps extends Omit(null) - if (!label) { return <>{children} } - if (!armed) { - // `display: contents` so this wrapper never affects layout — the child - // participates in its parent's box exactly as it did before. - return ( - { - suppressNonKeyboardFocusOpen(event) - - if (!event.defaultPrevented) { - setArmed('focus') - } - }} - onPointerEnter={() => setArmed('pointer')} - style={{ display: 'contents' }} - > - {children} - - ) - } - - // `defaultOpen` is required, not incidental: the pointerenter/focus that - // armed this has ALREADY fired, so Radix's own trigger handlers never see it - // and the tip would mount silently closed. Arming IS the hover, so open with - // it — Radix owns every subsequent open/close from here (pointerleave, blur, - // escape, pointerdown) exactly as before. return ( - + {children} {label}