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 new file mode 100644 index 000000000000..d26fb7661c5e --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -0,0 +1,264 @@ +// 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. + * + * `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__ + ${record ? '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 + ${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 + 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: ${record ? 'rc.commits()' : '0'}, + top: ${record ? 'rc.report(10)' : '[]'} + }) + })() +` + +/** 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('[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 + 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.__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)) + } +`) + +/** 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 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. + 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, + dragTarget, + dragMoved, + 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/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. 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) {