perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins

Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:

  style recalc 2736ms | script 1027ms | layout 89ms
  top callsite: pin @ fallback.tsx — 927ms

Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).

Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.

Measured on the live app, same drag, before -> after:
  fps      11.5 -> 59-60
  p95      101ms -> 18ms
  slow>33  60/60 -> 1/60

Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
  boundaries + row identity) and WEIGHT (part counts — budget only), and
  memoize groups + row JSX. A streamed part-append re-rendered every
  turn's boundary via its resetKey prop; explain() measured 540-865
  wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
  ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
  measured cost of the last one (33ms floor, 250ms cap), so multi-stream
  load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
  wrappers, committing the store ONCE on release (fixed-zone sides get
  flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
  with changed-hook indices, diag-real-loop/key-latency/switch-trace
  probes that drive the real app over CDP.

Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 01:07:48 -05:00
parent af1cc1c245
commit 2c867b05ce
17 changed files with 1013 additions and 58 deletions

View File

@ -0,0 +1,25 @@
// Is the tree-split preview path actually active in the running renderer?
// Checks the served source (what vite compiled) rather than guessing.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const out = await cdp.eval(`(async () => {
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
const src = await res.text()
return JSON.stringify({
previewShift: src.includes('previewShift'),
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
})
})()`)
console.log(out)
} finally {
teardown?.()
}

View File

@ -0,0 +1,47 @@
// Typing latency, isolated: keystroke -> next paint, with and without an
// active stream. Distinguishes "input is slow" from "the frame budget is
// consumed by streaming flushes" — the fix differs completely.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const perKey = []
for (let i = 0; i < 30; i++) {
const ch = 'abcdefghij'[i % 10]
const t0 = performance.now()
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
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 => requestAnimationFrame(r))
perKey.push(performance.now() - t0)
// Human-ish 80ms cadence so streaming flushes interleave realistically.
await new Promise(r => setTimeout(r, 80))
}
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const sorted = [...perKey].sort((a, b) => a - b)
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
return JSON.stringify({
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
over16: perKey.filter(f => f > 16.7).length,
over33: perKey.filter(f => f > 33).length,
streamingParts: busy
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}

View File

@ -0,0 +1,21 @@
// Quick state probe of the running hgui instance via CDP.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const state = await cdp.eval(`(() => {
const rc = !!window.__RENDER_COUNTS__
const pl = !!window.__PERF_LIVE__
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
})()`)
console.log(state)
} finally {
teardown?.()
}

View File

@ -0,0 +1,149 @@
// The real-app perf loop: drive HER hgui instance (real profile, real
// sessions) through the three interactions that matter — session switch,
// sidebar drag, composer typing — and report honest single-clock numbers.
//
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
//
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
// switching is measured as the user feels it: click -> transcript painted.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const SWITCHES = Number(arg('switches', 6))
const { cdp, teardown } = await attach({ port })
// ---------------------------------------------------------------------------
// Session switch: click a sidebar session row, await the transcript settling.
// Measures click -> first paint of the new transcript AND click -> settled
// (two rAFs with no further DOM mutation in the thread viewport).
// ---------------------------------------------------------------------------
const SWITCH = swaps => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
const results = []
for (let i = 0; i < ${swaps}; i++) {
const row = rows[i % Math.min(rows.length, 4)]
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
const t0 = performance.now()
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
row.click()
// First paint: next two rAFs after the click.
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
const firstPaint = performance.now() - t0
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
let lastMutation = performance.now()
const target = viewport() ?? document.body
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(target, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 120) break
}
mo.disconnect()
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
await new Promise(r => setTimeout(r, 250))
}
return JSON.stringify(results)
})()
`
// ---------------------------------------------------------------------------
// Drag the first visible sash, single-clock frames.
// ---------------------------------------------------------------------------
const DRAG = `
(async () => {
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
if (!handle) 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 o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const frames = []
let last = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 2 : -2)
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
}
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length
})
})()
`
// ---------------------------------------------------------------------------
// Type into the composer, single-clock frames (one mark per keystroke frame).
// ---------------------------------------------------------------------------
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const frames = []
let last = performance.now()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
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 => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
await new Promise(r => setTimeout(r, 20))
}
// Clear what we typed.
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const moving = frames
const total = moving.reduce((a, b) => a + b, 0)
const sorted = [...moving].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: moving.filter(f => f > 33).length
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
console.log(await cdp.eval(SWITCH(SWITCHES)))
await sleep(500)
console.log('\n== SIDEBAR DRAG ==')
console.log(await cdp.eval(DRAG))
await sleep(500)
console.log('\n== COMPOSER TYPING ==')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}

View File

@ -0,0 +1,27 @@
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const out = await cdp.eval(`(() => {
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
if (!sidebar) return '(no sidebar el)'
// Find clickable rows: anchors or buttons with text, depth-limited sample.
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
const rows = clickables.map(el => ({
tag: el.tagName.toLowerCase(),
slot: el.getAttribute('data-slot') ?? '',
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
text: (el.textContent ?? '').trim().slice(0, 30),
visible: !!el.offsetParent
})).filter(r => r.text)
return JSON.stringify(rows.slice(0, 30), null, 1)
})()`)
console.log(out)
} finally {
teardown?.()
}

View File

@ -0,0 +1,74 @@
// What happens during a SLOW session switch? Click a heavy row with tracing
// on, dump the style/layout/script split plus top callsites.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const CLICK_HEAVIEST = `
(() => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return 'need rows'
// Alternate between the first two rows so every run actually switches.
const current = location.hash
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
target.click()
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
})()
`
const events = []
let complete = false
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
cdp.on('Tracing.tracingComplete', () => {
complete = true
})
try {
await cdp.send('Runtime.enable')
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: ['devtools.timeline'] }
})
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
await cdp.send('Tracing.end')
for (let w = 0; !complete && w < 10000; w += 200) {
await sleep(200)
}
const totals = new Map()
const byFn = new Map()
for (const e of events) {
if (e.ph !== 'X' || typeof e.dur !== 'number') {
continue
}
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
if (e.name === 'FunctionCall') {
const d = e.args?.data ?? {}
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
}
}
const style = totals.get('UpdateLayoutTree') ?? 0
const layout = totals.get('Layout') ?? 0
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
console.log('\nTOP CALLSITES:')
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
}
} finally {
teardown?.()
}

View File

@ -29,7 +29,7 @@ import { setSessionTodos } from '@/store/todos'
import type { ClientSessionState } from '../../../types'
import { useGatewayEventHandler } from './gateway-event'
import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils'
import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils'
interface MessageStreamOptions {
activeGatewayProfile?: string
@ -184,6 +184,9 @@ export function useMessageStream({
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
// What the previous flush cost on the main thread — drives the adaptive
// flush floor in scheduleDeltaFlush so multi-stream load yields to input.
const lastFlushCostRef = useRef<number>(0)
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(new Set())
@ -243,12 +246,28 @@ export function useMessageStream({
// length. With this floor, slower streams still coalesce ~2 tokens per
// commit and the synthetic harness shows longtask counts drop from ~5/5s
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
//
// ADAPTIVE: the floor scales with what the last flush actually cost.
// With several sessions streaming at once (split tiles), one flush carries
// every stream's commit + markdown re-parse; when that work approaches or
// exceeds the fixed 33ms budget, back-to-back flushes leave the main
// thread no idle frames and every interaction (typing, resize, hover)
// stutters even though no render is wasted. Yielding 3x the measured cost
// keeps the thread ~75% idle for input at any load: cheap flushes stay at
// 30fps of text growth, expensive multi-stream flushes degrade text fps
// instead of interactivity — capped so text never updates slower than 4/s.
const sinceLast = performance.now() - lastFlushAtRef.current
const adaptiveFloor = Math.min(
Math.max(STREAM_DELTA_FLUSH_MS, lastFlushCostRef.current * 3),
MAX_STREAM_FLUSH_GAP_MS
)
const runFlush = () => {
flushHandleRef.current = null
lastFlushAtRef.current = performance.now()
const startedAt = performance.now()
lastFlushAtRef.current = startedAt
flushQueuedDeltas()
lastFlushCostRef.current = performance.now() - startedAt
}
// Always a timer, never requestAnimationFrame. Chromium pauses rAF for a
@ -265,7 +284,7 @@ export function useMessageStream({
// for) while guaranteeing delivery without user interaction. Timers are
// clamped in background renderers rather than suspended, and
// disable-background-timer-throttling already opts out of that clamp.
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast))
}, [flushQueuedDeltas])
const queueDelta = useCallback(

View File

@ -66,6 +66,12 @@ export function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boole
// `scripts/profile-typing-lag.md` for the measurement work behind this.
export const STREAM_DELTA_FLUSH_MS = 33
// Ceiling for the ADAPTIVE flush gap (see scheduleDeltaFlush). Under heavy
// multi-stream load the gap stretches to 3x the measured flush cost so the
// main thread stays responsive to input; this cap guarantees streaming text
// still visibly updates at least ~4x per second no matter the load.
export const MAX_STREAM_FLUSH_GAP_MS = 250
// Gateway/provider failures sometimes arrive as message.complete text instead
// of an explicit error event. Treat matches as inline assistant errors so they
// persist like real error events and don't get erased by hydrate fallback.

View File

@ -13,8 +13,13 @@ const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
// Changes whenever the message list mutates STRUCTURALLY (ids/roles/count);
// remounting clears the caught error so the next consistent render recovers
// silently. Deliberately NOT the per-token signature: this prop reaches
// every turn's boundary, so a value that ticks with content length would
// re-render every boundary — and reconcile every turn's subtree — on every
// streamed token (measured: 540 wasted Block renders per explain() sample
// with two threads streaming).
resetKey: string
children: ReactNode
}

View File

@ -9,6 +9,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
@ -141,14 +142,25 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
.join('\n')
// TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count)
// changes only when messages are added/removed/swapped — it keys the error
// boundaries and the row identity. The WEIGHT one (per-message part counts)
// ticks while a streaming turn appends parts — it feeds only the render
// budget. Folding weights into the structural key handed every boundary a
// new resetKey per appended part, which reconciled every turn's subtree on
// every tick (measured: 540 wasted Block renders per explain() sample with
// two threads streaming).
const structuralSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const weightSignature = useAuiState(s => s.thread.messages.map(message => message.content?.length ?? 1).join(','))
const { t } = useI18n()
const groups = buildGroups(messageSignature)
// Row structure is memoized on the STRUCTURAL signature only, so streaming
// part-appends can't churn group identity (that would defeat the rows memo
// below on every tick). Weights are folded in separately for the budget.
const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
@ -213,7 +225,22 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
return () => cancelAnimationFrame(rafId)
}, [renderBudget])
const hiddenCount = firstVisibleGroupIndex(groups, renderBudget)
// Weights (per-message part counts) fold into the BUDGET only. Group
// identity stays structural, so a streaming append re-runs this cheap sum —
// not the row JSX. Weighted the same way the old combined signature was.
const weightedGroups = useMemo(() => {
const weights = weightSignature.split(',').map(w => Number(w) || 1)
return groups.map(group => ({
...group,
weight:
group.kind === 'turn'
? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0)
: (weights[group.index] ?? 1)
}))
}, [groups, weightSignature])
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
@ -331,6 +358,59 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
}
}, [scrollRef, renderBudget])
// The row array is memoized on the inputs the rows actually read. This
// component re-renders on every isAtBottom flip — and use-stick-to-bottom
// flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per
// frame. Without the memo, the inline .map() rebuilt every row's JSX each
// time, and rebuilt children re-render their whole subtree even when nothing
// changed (measured live: 865 wasted Block renders in one drag, walked to
// "MessageRenderBoundary (children only)" by explain()). With it, React
// bails out on element identity and a scroll flip re-renders nothing below.
const rows = useMemo(
() =>
visibleGroups.map((group, indexInVisible) => (
// content-visibility:auto — off-screen turns skip style recalc,
// layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
// reads getComputedStyle) forces a whole-document style recalc,
// measured ~650-730ms per open on a 1300-message session and
// ~100-200ms with this on. contain-intrinsic-size keeps a
// placeholder height for never-rendered turns (auto: remembered
// real size once rendered), so scrollbar/anchoring stay stable.
// Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
// turns. See isVirtualizedGroup.
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
<MessageRenderBoundary resetKey={structuralSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)),
[visibleGroups, components, structuralSignature]
)
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
@ -380,46 +460,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
{t.assistant.thread.showEarlier}
</button>
)}
{visibleGroups.map((group, indexInVisible) => (
// content-visibility:auto — off-screen turns skip style recalc,
// layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
// reads getComputedStyle) forces a whole-document style recalc,
// measured ~650-730ms per open on a 1300-message session and
// ~100-200ms with this on. contain-intrinsic-size keeps a
// placeholder height for never-rendered turns (auto: remembered
// real size once rendered), so scrollbar/anchoring stay stable.
// Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
// turns. See isVirtualizedGroup.
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
))}
{rows}
{loadingIndicator}
{clampToComposer && (
<div

View File

@ -87,8 +87,20 @@ const ThinkingDisclosure: FC<{
return
}
const pin = () => {
el.scrollTop = el.scrollHeight
// Height-gated: the observer also fires when the container's WIDTH changes
// (sidebar sash drag resizes every message), and pinning there forces a
// scrollHeight read+write per preview per frame. Only actual content
// growth needs the pin; the height rides the RO entry, reflow-free.
let lastHeight = -1
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
if (grew) {
el.scrollTop = el.scrollHeight
}
}
// No sync pin(): the observer's guaranteed initial delivery runs it with

View File

@ -755,7 +755,25 @@ function useToolWindow(enabled: boolean) {
return
}
const pin = () => {
// Track the content's HEIGHT and only pin when it grows. The observer also
// fires for width changes — a sidebar sash drag resizes every tool window
// once per frame — and pinning there is (a) pointless, the list didn't
// grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it
// back, a write->read forced reflow per tool group per frame. Measured on
// a real session while dragging the sash: 927ms of `pin` script plus
// 2.7s of style recalc across one 60-frame drag. Reading the height off
// the RO entry keeps the check reflow-free.
let lastHeight = -1
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
if (!grew) {
return
}
if (stickRef.current) {
el.scrollTop = el.scrollHeight
}

View File

@ -158,6 +158,31 @@ function sameRect(a: Rect | null, b: Rect | null) {
// Workspace-edge CSS vars
// ---------------------------------------------------------------------------
// --- Sash-drag deferral ------------------------------------------------------
// The tree sash sets this for the duration of a resize gesture (pointerdown to
// pointerup). While set, `publishWorkspaceGeometry` skips its :root custom
// property writes: each one invalidates computed style for the whole document,
// and the sash's ResizeObserver fires per frame. Measured live (LoAF, real
// session): drag frames of ~68ms with style+layout=67ms and no script ≥5ms;
// suppressing the writes recovered 14fps → 51fps. The vars only align titlebar
// chrome — republishing once on release is visually identical.
let sashDragDepth = 0
let onSashDragEnd: null | (() => void) = null
export function beginSashDrag() {
sashDragDepth += 1
}
export function endSashDrag() {
sashDragDepth = Math.max(0, sashDragDepth - 1)
if (sashDragDepth === 0) {
onSashDragEnd?.()
}
}
const sashDragging = () => sashDragDepth > 0
/**
* Publish the workspace zone's viewport edges as root CSS vars:
* --workspace-left : px from the viewport's left to the main zone
@ -177,6 +202,12 @@ export function publishWorkspaceGeometry(): () => void {
const ro = new ResizeObserver(() => measure())
const measure = () => {
// DEFER during a sash drag (see beginSashDrag above) — republished once on
// release via the onSashDragEnd hook registered below.
if (sashDragging()) {
return
}
const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]')
if (next !== el) {
@ -216,11 +247,14 @@ export function publishWorkspaceGeometry(): () => void {
// frame later, after the DOM committed. RO covers width changes (sash drags,
// side collapses); window resize covers the rest.
const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure))
// Drag released → publish the final geometry the deferral above skipped.
onSashDragEnd = () => requestAnimationFrame(measure)
window.addEventListener('resize', measure)
measure()
return () => {
unsubTree()
onSashDragEnd = null
window.removeEventListener('resize', measure)
ro.disconnect()
root.style.removeProperty('--workspace-left')

View File

@ -9,6 +9,7 @@
import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
import { beginSashDrag, endSashDrag } from '@/components/pane-shell/geometry'
import { useContributions } from '@/contrib/react/use-contributions'
import { rafCoalesce } from '@/lib/raf-coalesce'
import { cn } from '@/lib/utils'
@ -234,9 +235,33 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
// Suppress :root geometry-var writes for the gesture (see geometry.ts —
// each one restyles the whole document; they republish on release).
beginSashDrag()
// pointermove outpaces 60fps and each write relayouts the whole pane tree,
// so coalesce to one apply per frame (rafCoalesce commits on cleanup).
//
// During the gesture the store is NOT written. setTreeSplitWeights /
// setPaneWidthOverride each mint a new tree/pane-state object, and the
// resulting commit walks every mounted pane — measured live on a real
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
// store is written ONCE on release; during the drag the seam is
// previewed with inline styles on the same wrappers React sizes.
//
// Preview rules (learned the hard way — a wrong shape here left a
// phantom gap where a hidden sidebar lived):
// - a FIXED side gets ONLY a flex-basis override. Its wrapper renders
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
// shrink stay React's. Crucially the flex partner is left untouched,
// so it keeps absorbing the remainder and no leftover gap can open.
// - a flex-vs-flex seam pins both sides to `0 1 <px>`. Their combined
// px is constant, so sibling flex tracks see the same leftover.
// - cleanup: a real drag commits the store once, and React's re-render
// rewrites the `flex` shorthand, which clears the overrides (writing
// the shorthand resets the longhands). A no-movement click restores
// the captured style attribute instead, since nothing re-renders.
const applyShift = (shiftPx: number) => {
if (a.fixed) {
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
@ -255,14 +280,58 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
}
}
const resize = rafCoalesce(applyShift)
const styleA = kidA.getAttribute('style')
const styleB = kidB.getAttribute('style')
const previewSide = (el: HTMLElement, fixed: boolean, px: number) => {
if (fixed) {
el.style.flexBasis = `${px}px`
} else if (!a.fixed && !b.fixed) {
el.style.flex = `0 1 ${px}px`
}
// Mixed seam, flex side: untouched — it absorbs what the fixed side
// gives up, exactly as the track model would render it.
}
const previewShift = (shiftPx: number) => {
previewSide(kidA, a.fixed, a0px + shiftPx)
previewSide(kidB, b.fixed, b0px - shiftPx)
}
const resize = rafCoalesce(previewShift)
let lastShift: null | number = null
const onMove = (ev: PointerEvent) => {
resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)))
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
resize.push(lastShift)
}
const cleanup = () => {
resize.finish()
if (lastShift !== null) {
// One store commit; the re-render rewrites `flex` and clears the
// preview overrides.
applyShift(lastShift)
} else {
// Click without movement: nothing will re-render, so put the
// wrappers' inline styles back exactly as React last wrote them.
if (styleA === null) {
kidA.removeAttribute('style')
} else {
kidA.setAttribute('style', styleA)
}
if (styleB === null) {
kidB.removeAttribute('style')
} else {
kidB.setAttribute('style', styleB)
}
}
// Geometry vars re-enable AFTER the final store commit above, so the
// release publishes exactly one fresh measurement.
endSashDrag()
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect

View File

@ -25,6 +25,10 @@
// with no changed input, and stores that published a value equal to the last.
import './render-counter'
// Live interaction profiler — arms on real resize/typing so we can measure the
// app under REAL sessions instead of a synthetic scenario's toy transcripts.
// window.__PERF_LIVE__.on() in the console, then just use the app.
import './perf-live'
import { watchSessionAtoms } from './watched-atoms'

View File

@ -0,0 +1,306 @@
// Live interaction profiler — for driving the app by hand and seeing what the
// app actually does under YOUR sessions, not a synthetic scenario's.
//
// The synthetic scenarios seed toy transcripts (short prose, no tool calls, no
// code blocks). A real session is heavier in ways that matter, so a scenario
// can report 57fps on a gesture that visibly lags in the real app. This closes
// that gap by measuring the real thing.
//
// It arms itself on any pointerdown on a resize handle and on composer typing,
// records frames + render attribution for the duration of the interaction, and
// prints a table when the interaction ends. Idle cost is zero: nothing is
// observed until an interaction starts.
//
// window.__PERF_LIVE__.on() start watching (also: ?perflive=1)
// window.__PERF_LIVE__.off()
// window.__PERF_LIVE__.last() the most recent report as an object
//
// Dev-only; the whole debug/ graph is aliased out of production builds.
interface Sample {
kind: string
ms: number
frames: number
fps: number
p95: number
worst: number
slow33: number
commits: number
top: Array<{ name: string; renders: number; wasted: number; totalMs: number }>
longFrames: LongFrame[]
}
/** One Long Animation Frame, attributed. `styleMs` is the engine's style+layout
* time inside the frame; `scripts` names who ran JS and for how long. This is
* the half the render counter cannot see a frame can cost 900ms with almost
* no React in it, and only LoAF says whether that was layout, a ResizeObserver
* callback loop, or some timer. */
interface LongFrame {
ms: number
styleMs: number
blockingMs: number
scripts: Array<{ invoker: string; ms: number; src: string }>
}
const RESIZE_SELECTOR = '[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]'
const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]'
// A gesture is "over" once this long passes with no further input events.
const IDLE_END_MS = 350
// Don't report trivial blips (a click, a single keypress).
const MIN_FRAMES = 6
let watching = false
let active: null | {
kind: string
startedAt: number
frames: number[]
last: number
raf: number
endTimer: ReturnType<typeof setTimeout> | null
} = null
let lastReport: null | Sample = null
// Long Animation Frames observed while a gesture is active. LoAF entries name
// the scripts inside each long frame and split out style/layout time — the
// half of a frame the render counter cannot see.
let longFrames: LongFrame[] = []
const loafObserver =
typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
? new PerformanceObserver(list => {
if (!active) {
return
}
for (const entry of list.getEntries()) {
const e = entry as PerformanceEntry & {
blockingDuration?: number
styleAndLayoutStart?: number
renderStart?: number
scripts?: Array<{
duration: number
invoker?: string
invokerType?: string
sourceURL?: string
sourceFunctionName?: string
}>
}
longFrames.push({
blockingMs: Math.round(e.blockingDuration ?? 0),
ms: Math.round(e.duration),
scripts: (e.scripts ?? [])
.filter(s => s.duration >= 5)
.map(s => ({
invoker: `${s.invokerType ?? ''}:${s.invoker ?? s.sourceFunctionName ?? '?'}`,
ms: Math.round(s.duration),
src: (s.sourceURL ?? '').split('/').pop() ?? ''
})),
// styleAndLayoutStart -> frame end is the engine's style+layout tail.
styleMs: e.styleAndLayoutStart
? Math.round(e.startTime + e.duration - e.styleAndLayoutStart)
: 0
})
}
})
: null
const pct = (sorted: number[], p: number) =>
sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
function finish() {
if (!active) {
return
}
const { kind, startedAt, frames, raf } = active
active = null
cancelAnimationFrame(raf)
loafObserver?.disconnect()
const capturedLongFrames = longFrames
longFrames = []
const counter = window.__RENDER_COUNTS__
const commits = counter?.commits() ?? 0
const top = (counter?.report(12) ?? []).map(r => ({
name: r.name,
renders: r.renders,
wasted: r.wasted,
totalMs: r.totalMs
}))
counter?.stop()
if (frames.length < MIN_FRAMES) {
return
}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const report: Sample = {
commits,
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
frames: frames.length,
kind,
longFrames: capturedLongFrames,
ms: Math.round(performance.now() - startedAt),
p95: Math.round(pct(sorted, 0.95) * 10) / 10,
slow33: frames.filter(f => f > 33).length,
top,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10
}
lastReport = report
const headline =
`%c${kind}%c ${report.fps}fps · ${report.frames} frames in ${report.ms}ms · ` +
`p95 ${report.p95}ms worst ${report.worst}ms · ${report.slow33} slow · ${commits} commits`
console.log(
headline,
`background:${report.fps < 45 ? '#c0392b' : '#27ae60'};color:#fff;padding:1px 6px;border-radius:3px`,
'color:inherit'
)
if (top.length) {
console.table(top)
}
// The frame-engine side: what each long frame actually spent its time on.
for (const lf of capturedLongFrames.slice(0, 8)) {
const scripts = lf.scripts.map(s => `${s.invoker}@${s.src} ${s.ms}ms`).join(' | ') || '(no script ≥5ms)'
console.log(` ⏱ longframe ${lf.ms}ms style+layout ${lf.styleMs}ms block ${lf.blockingMs}ms → ${scripts}`)
}
}
function begin(kind: string) {
if (active) {
// Same gesture continuing — just push the end deadline out.
if (active.endTimer) {
clearTimeout(active.endTimer)
}
active.endTimer = setTimeout(finish, IDLE_END_MS)
return
}
window.__RENDER_COUNTS__?.start()
try {
// Buffered so a long frame already in flight when the gesture starts is
// still attributed to it.
loafObserver?.observe({ buffered: true, type: 'long-animation-frame' })
} catch {
// Older runtime without LoAF — headline still works, attribution is empty.
}
const now = performance.now()
const state = {
endTimer: setTimeout(finish, IDLE_END_MS) as ReturnType<typeof setTimeout> | null,
frames: [] as number[],
kind,
last: now,
raf: 0,
startedAt: now
}
const tick = () => {
if (active !== state) {
return
}
const t = performance.now()
state.frames.push(t - state.last)
state.last = t
state.raf = requestAnimationFrame(tick)
}
active = state
state.raf = requestAnimationFrame(tick)
}
function onPointerDown(event: PointerEvent) {
const target = event.target as Element | null
if (target?.closest?.(RESIZE_SELECTOR)) {
begin('resize')
}
}
function onPointerMove() {
if (active?.kind === 'resize') {
begin('resize')
}
}
function onKeyDown(event: KeyboardEvent) {
const target = event.target as Element | null
// Ignore pure modifiers and navigation — we want real text entry.
if (event.key.length !== 1 && event.key !== 'Backspace') {
return
}
if (target?.closest?.(TYPING_SELECTOR)) {
begin('typing')
}
}
function on() {
if (watching) {
return 'already watching'
}
watching = true
window.addEventListener('pointerdown', onPointerDown, true)
window.addEventListener('pointermove', onPointerMove, true)
window.addEventListener('keydown', onKeyDown, true)
console.log(
'%cperf-live%c armed — resize a pane or type in the composer; a report prints when you stop.',
'background:#5a7db0;color:#fff;padding:1px 6px;border-radius:3px',
'color:inherit'
)
return 'watching'
}
function off() {
watching = false
window.removeEventListener('pointerdown', onPointerDown, true)
window.removeEventListener('pointermove', onPointerMove, true)
window.removeEventListener('keydown', onKeyDown, true)
finish()
return 'stopped'
}
declare global {
interface Window {
__PERF_LIVE__?: {
on: () => string
off: () => string
last: () => null | Sample
watching: () => boolean
}
}
}
if (typeof window !== 'undefined' && !window.__PERF_LIVE__) {
window.__PERF_LIVE__ = { last: () => lastReport, off, on, watching: () => watching }
// Opt in for a whole session with ?perflive=1 so a reload keeps measuring.
if (new URLSearchParams(window.location.search).get('perflive') === '1') {
on()
}
}
export {}

View File

@ -46,6 +46,12 @@ const counts = new Map<string, RenderRecord>()
let commits = 0
let recording = false
// explain() state: while set, every wasted render of this component walks up
// the fiber tree to find the ancestor whose props/state/context actually
// changed — the origin of the cascade.
let explainTarget: null | string = null
const explainCauses = new Map<string, number>()
const blank = (): RenderRecord => ({
contextChanged: 0,
propsChanged: 0,
@ -76,19 +82,51 @@ function propsChanged(fiber: Fiber): boolean {
/** Did any hook's memoizedState change? Covers useState, useSyncExternalStore
* (so nanostores `useStore`), useMemo, and useReducer alike. */
function stateChanged(fiber: Fiber): boolean {
return changedHookIndices(fiber).length > 0
}
/** Indices (source order) of the hooks whose memoizedState changed. The index
* maps straight onto the component's hook call order, so "hook #3 changed"
* identifies the exact useStore/useState line without guessing. */
function changedHookIndices(fiber: Fiber): number[] {
let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState
let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState
const changed: number[] = []
let index = 0
while (next && prev) {
if (!Object.is(next.memoizedState, prev.memoizedState)) {
return true
changed.push(index)
}
next = next.next
prev = prev.next
index += 1
}
return false
return changed
}
/** Names of the props whose identity changed the cascade origin's smoking
* gun. Used by explain() so the answer is "Streamdown (props: children)" and
* not just "Streamdown (props)". */
function changedPropKeys(fiber: Fiber): string[] {
const prev = fiber.alternate?.memoizedProps as Record<string, unknown> | null | undefined
const next = fiber.memoizedProps as Record<string, unknown> | null | undefined
if (!prev || !next) {
return []
}
const keys: string[] = []
for (const key of Object.keys(next)) {
if (!Object.is(prev[key], next[key])) {
keys.push(key)
}
}
return keys
}
/** Did any consumed context value change? A `memo()` cannot block a re-render
@ -139,6 +177,46 @@ function record(fiber: Fiber) {
if (!props && !state && !context) {
entry.wasted += 1
// explain() support: walk UP from a wasted render to the TOP of the
// cascade — the highest ancestor that also rendered this commit. That
// fiber is the origin; its own changed props/state is the reason.
// Stopping at the first ancestor with changed props is wrong: JSX rebuilt
// by a parent makes every intermediate node report "children changed",
// which is the symptom cascading down, not the cause.
if (explainTarget && name === explainTarget) {
let origin: Fiber = fiber
let cursor = fiber.return
let hops = 0
while (cursor && hops < 80) {
if (isCompositeFiber(cursor) && didFiberRender(cursor)) {
origin = cursor
}
cursor = cursor.return
hops += 1
}
const originName = getDisplayName(origin) ?? '?'
const changed = changedPropKeys(origin).filter(k => k !== 'children')
const why =
origin === fiber
? 'self'
: stateChanged(origin)
? `state (hooks #${changedHookIndices(origin).slice(0, 5).join(',#')})`
: changed.length
? `props: ${changed.slice(0, 4).join(',')}`
: contextChanged(origin)
? 'context'
: changedPropKeys(origin).length
? 'children only'
: 'no visible change (external store?)'
const key = `${originName} (${why})`
explainCauses.set(key, (explainCauses.get(key) ?? 0) + 1)
}
}
counts.set(name, entry)
@ -168,6 +246,12 @@ declare global {
report: (limit?: number) => Array<RenderRecord & { name: string }>
/** Attribution for one component by display name. */
get: (name: string) => RenderRecord | undefined
/**
* Name a component (its displayName, e.g. 'Block'), interact, then call
* with no argument to get the tally of which CHANGED ancestor each of
* its wasted renders cascaded from. The origin, walked not guessed.
*/
explain: (name?: null | string) => Record<string, number> | string
}
}
}
@ -195,6 +279,20 @@ if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) {
},
commits: () => commits,
counts,
explain: name => {
if (name !== undefined) {
explainTarget = name
explainCauses.clear()
if (name && !recording) {
recording = true
}
return name ? `explaining ${name} — interact, then call explain() to read` : 'explain off'
}
return Object.fromEntries([...explainCauses.entries()].sort((x, y) => y[1] - x[1]))
},
get: name => counts.get(name),
recording: () => recording,
report,