Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
# Dev-only state diagnostics
Two counters that answer, for any interaction: **what re-rendered, why, and
which store pushed it?**
```
window.__RENDER_COUNTS__ what re-rendered, attributed to props / state / parent
window.__ATOM_CHURN__ which store published it, and whether it mattered
```
Both are inert until `start()`, so the idle cost is one no-op branch per commit
and per notify. Neither ships: `vite.config.ts` aliases `@/debug/dev-only` to a
no-op module for any build that isn't the dev server (or `VITE_PERF_PROBE=1`).
## Using it
From the devtools console, while an agent streams:
```js
__RENDER_COUNTS__.start(); __ATOM_CHURN__.start()
// ...let it run for a few seconds...
__RENDER_COUNTS__.stop(); __ATOM_CHURN__.stop()
console.table(__RENDER_COUNTS__.report())
console.table(__ATOM_CHURN__.report())
```
The `wasted` column in each is the fix list:
- **render `wasted`** — re-rendered with neither changed props nor changed hook
state, i.e. purely because a parent did. A `memo()` or a narrower subscription
removes it.
- **atom `wasted`** — published a value deep-equal to the previous one.
`@nanostores/react` bails out on *reference* equality only, so this
re-renders every subscriber for nothing. This is the
"preserve reference identity on no-ops" rule in `apps/desktop/AGENTS.md`.
Or as a gated measurement, driving 5 concurrent streaming tabs synthetically
(no backend, no credits):
```bash
node scripts/perf/run.mjs render-churn --spawn --tiles 5 --tokens 240
```
## Why bippy, and not the obvious choices
**React 19.2 removed `injectProfilingHooks` from react-dom.** Verified:
`grep -c injectProfilingHooks node_modules/react-dom/cjs/react-dom-client.development.js`
→ `0`. Only `onCommitFiberRoot` / `onPostCommitFiberRoot` remain. The entire
`mark*` profiling family (`component-render-start`, `state-update`,
`render-scheduled`) is dead on this stack — anything built on it is out.
**`<Profiler>` cannot answer "did the sidebar re-render?"** React invokes
`onRender` for *every* Profiler in a committed tree, including subtrees that
bailed out. Counting those callbacks "proves" a re-render that never happened.
`actualDuration` is not a discriminator either: a bailed-out subtree still
reports a small nonzero duration, so there's no safe threshold. `didFiberRender`
is the honest signal. (Note `app/chat/perf-probe.tsx` exports a `PerfProbe`
Profiler wrapper that is used nowhere — that's why.)
**react-scan** ships the right idea in its undocumented `react-scan/lite`
subpath, but `lite` is a thin wrapper whose only imports are `bippy` and
`bippy/source`. The package pulls ~217 transitive deps (babel, preact) and
floats `react-grab` / `react-doctor` on `latest`, so installs aren't
reproducible, and its main entry currently breaks Vite with a JSON
import-attribute error (upstream issues #448, #467, both open). We take bippy
directly: MIT, zero dependencies.
## The import-order constraint
`main.tsx` imports `@/debug/dev-only` **statically, above `react-dom`**. This is
load-bearing, not stylistic.
react-dom captures the devtools hook at **module init**, not at `createRoot`.
Installing afterwards leaves a hook object in place but `bippy._renderers`
empty, and every commit goes unseen. Verified both directions:
| order | `_renderers` | commits |
|---|---|---|
| bippy installs first | 1 | 1 |
| react-dom evaluates first | 0 | 0 |
A dynamic `import()` behind an `import.meta.env.DEV` guard would resolve a
microtask *after* main.tsx's static graph (react-dom included) had evaluated —
too late. Hence a static import, with production exclusion handled by the
build-time alias instead of tree-shaking.
If you add a test that imports these counters, note the `ui` vitest project's
`setupFiles` pulls in `@testing-library/react` (and thus react-dom) before any
test body runs, so the hook can never install there. Use a config without
`setupFiles`.
## Baseline (5 tabs × 240 tokens, dev renderer, darwin-arm64)
```
sidebar_renders 6
sidebar_wasted 0
wasted_renders 10,432
total_renders 78,385
commits 1,566
wasted_notifies 0
```
The sidebar hypothesis is **refuted**: 6 renders across the whole run, all
attributable to hook state on genuine busy/needsInput edges, none wasted. The
`stableArray` guards on `$workingSessionIds` / `$attentionSessionIds`
(`store/session-states.ts:236-259`) are doing their job.
The real cost is elsewhere — see `topRenders` in the scenario's `detail`.
`$sessionStates` notified 1,200 times with **10 listeners** (fan-out 12,000)
and zero wasted notifies, so the publishing side is honest; the waste is in
components that re-render on a parent commit without their own inputs changing.
+132
View File
@@ -0,0 +1,132 @@
// Dev-only nanostores churn counter — the state-side companion to
// `render-counter.ts`. Render counts tell you WHAT re-rendered; this tells you
// WHICH ATOM pushed the update, and whether the push was worth making.
//
// The headline metric is `wasted`: notifications whose new value is deep-equal
// to the old one. `@nanostores/react`'s `useStore` bails out on REFERENCE
// equality only (`snapshotRef.current === value`), so publishing a fresh array
// or object with identical contents re-renders every subscriber for nothing.
// That is the exact failure `apps/desktop/AGENTS.md` names: "Preserve reference
// identity on no-ops."
//
// `listeners` is read from the store's own `lc` (listener count) at notify
// time, because `onNotify` fires even when a store has zero subscribers — a
// raw notify count over-reports. `notifies x listeners` is the real fan-out.
import { onNotify, type Store } from 'nanostores'
export interface AtomChurn {
/** Times the store notified its listeners. */
notifies: number
/**
* ...of those, how many pushed a value deep-equal to the previous one.
* Pure waste: every subscriber re-rendered and nothing actually changed.
*/
wasted: number
/** Peak listener count seen at notify time (`store.lc`). */
peakListeners: number
/** Sum of listeners across notifications — the true re-render fan-out. */
fanout: number
}
const churn = new Map<string, AtomChurn>()
const unsubscribes: Array<() => void> = []
let recording = false
const blank = (): AtomChurn => ({ fanout: 0, notifies: 0, peakListeners: 0, wasted: 0 })
/** Structural equality, depth-capped so a long transcript array doesn't make
* the instrumentation itself the bottleneck. Beyond the cap we compare by
* reference, which under-reports waste rather than inventing it. */
function equal(a: unknown, b: unknown, depth = 0): boolean {
if (Object.is(a, b)) {
return true
}
if (depth > 3 || typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false
}
if (Array.isArray(a) !== Array.isArray(b)) {
return false
}
const ka = Object.keys(a as object)
const kb = Object.keys(b as object)
if (ka.length !== kb.length) {
return false
}
return ka.every(k => equal((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], depth + 1))
}
/** Watch one store. Call at module scope for each atom you want attributed. */
export function watchAtom(name: string, store: Store) {
const off = onNotify(store, ({ oldValue }) => {
if (!recording) {
return
}
const entry = churn.get(name) ?? blank()
// `lc` is nanostores' listener count — see node_modules/nanostores/atom/index.js.
const listeners = (store as unknown as { lc?: number }).lc ?? 0
const next = (store as unknown as { value?: unknown }).value
entry.notifies += 1
entry.fanout += listeners
entry.peakListeners = Math.max(entry.peakListeners, listeners)
if (equal(oldValue, next)) {
entry.wasted += 1
}
churn.set(name, entry)
})
unsubscribes.push(off)
return off
}
/** Rows sorted by wasted notifications, then fan-out — the fix list, in order. */
function report(limit = 40) {
return [...churn.entries()]
.map(([name, c]) => ({ name, ...c }))
.sort((a, b) => b.wasted - a.wasted || b.fanout - a.fanout)
.slice(0, limit)
}
declare global {
interface Window {
__ATOM_CHURN__?: {
churn: Map<string, AtomChurn>
clear: () => void
start: () => void
stop: () => void
recording: () => boolean
report: (limit?: number) => Array<AtomChurn & { name: string }>
get: (name: string) => AtomChurn | undefined
/** Names of every watched store, whether or not it has notified. */
watched: () => string[]
}
}
}
if (typeof window !== 'undefined' && !window.__ATOM_CHURN__) {
window.__ATOM_CHURN__ = {
churn,
clear: () => churn.clear(),
get: name => churn.get(name),
recording: () => recording,
report,
start: () => {
churn.clear()
recording = true
},
stop: () => {
recording = false
},
watched: () => [...churn.keys()]
}
}
+8
View File
@@ -0,0 +1,8 @@
// Production stand-in for `dev-only.ts`. `vite.config.ts` aliases the dev
// entry to this module for any build that isn't the dev server, so the
// diagnostics graph — and bippy with it — never reaches a shipped renderer.
//
// Keep this file free of imports. It exists precisely so the production
// bundle contains nothing from `debug/`.
export {}
+19
View File
@@ -0,0 +1,19 @@
// Dev-only diagnostics entry. Statically imported from `main.tsx` ABOVE the
// `react-dom` import — that ordering is load-bearing and non-negotiable.
//
// react-dom captures the devtools hook at MODULE INIT, not at `createRoot`.
// Verified: installing bippy after react-dom has evaluated yields
// `renderers=0` and `commits=0` — the hook object exists but react-dom never
// registered with it. A dynamic `import()` here would resolve a microtask
// after main.tsx's static graph (react-dom included) had already evaluated,
// so it must be a plain static import chain, whose evaluation order ESM
// guarantees.
//
// Production builds don't tree-shake this away (a static side-effect import
// can't be eliminated) — instead `vite.config.ts` aliases this module to
// `dev-only.noop.ts` whenever the build isn't serving dev, so neither bippy
// nor the counters reach a shipped renderer.
import './index'
export {}
+36
View File
@@ -0,0 +1,36 @@
// Dev-only state diagnostics: one import, two counters.
//
// window.__RENDER_COUNTS__ — what re-rendered, and why (props/state/parent)
// window.__ATOM_CHURN__ — which store published it, and whether it mattered
//
// Imported FIRST in `main.tsx`, before `react-dom`. That ordering is
// load-bearing: react-dom decides at module-init whether a devtools hook
// exists, so bippy must install during THIS module's evaluation. A dynamic
// `import()` from main.tsx would resolve a microtask too late and every commit
// would go unseen — hence a plain static import chain, whose evaluation order
// ESM guarantees.
//
// Both counters are inert until `start()` is called, so the idle cost in dev is
// a single no-op branch per commit / per notify.
//
// Typical session, from the devtools console:
//
// __RENDER_COUNTS__.start(); __ATOM_CHURN__.start()
// // ...let an agent stream for a few seconds...
// __RENDER_COUNTS__.stop(); __ATOM_CHURN__.stop()
// console.table(__RENDER_COUNTS__.report())
// console.table(__ATOM_CHURN__.report())
//
// The `wasted` column in each is the fix list: components that re-rendered
// 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 './right-pane-probe'
import { watchSessionAtoms } from './watched-atoms'
watchSessionAtoms()
+306
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 {}
+308
View File
@@ -0,0 +1,308 @@
// Dev-only render counter — answers "what actually re-rendered, and why?".
//
// Loaded from `main.tsx` BEFORE `react-dom` (see the import-order note there).
// That ordering is load-bearing: react-dom decides at module-init whether a
// devtools hook exists, so installing after it has already initialised leaves
// `bippy._renderers` empty and every commit goes unseen.
//
// Why not `<Profiler>`: React invokes `onRender` for EVERY Profiler in a
// committed tree, including subtrees that bailed out. Counting those callbacks
// "proves" the sidebar re-rendered when it did not. `actualDuration` is not a
// discriminator either — a bailed-out subtree still reports a small nonzero
// duration. `didFiberRender` is the honest signal.
//
// Why not react-scan: its `lite` subpath is a thin wrapper over bippy, while
// the package pulls ~217 transitive deps (babel, preact) and floats
// `react-grab`/`react-doctor` on `latest`, which makes installs
// non-reproducible and breaks Vite with a JSON import-attribute error.
import { didFiberRender, type Fiber, getDisplayName, instrument, isCompositeFiber, traverseRenderedFibers } from 'bippy'
/** Why a component re-rendered, attributed per commit. */
export interface RenderRecord {
/** Commits in which this component actually re-rendered (mount excluded). */
renders: number
/** ...of those, how many had at least one changed prop reference. */
propsChanged: number
/** ...of those, how many had changed hook state (useState/useMemo/store). */
stateChanged: number
/**
* ...of those, how many consumed a context whose value changed. `memo()`
* cannot block these — the fix is to narrow or split the provider, not to
* add a memo boundary.
*/
contextChanged: number
/**
* ...of those, how many had NEITHER changed props, changed state, nor a
* changed context. These re-rendered purely because a parent did — the
* wasted work a `memo()` or a narrower store subscription would eliminate.
*/
wasted: number
/** Sum of `actualDuration` across counted renders, in ms. */
totalMs: number
}
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,
renders: 0,
stateChanged: 0,
totalMs: 0,
wasted: 0
})
/** Did any prop's reference identity change between the two fiber versions? */
function propsChanged(fiber: Fiber): boolean {
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 false
}
for (const key of Object.keys(next)) {
if (!Object.is(prev[key], next[key])) {
return true
}
}
return Object.keys(prev).length !== Object.keys(next).length
}
/** 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)) {
changed.push(index)
}
next = next.next
prev = prev.next
index += 1
}
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
* caused by context, so distinguishing this from a parent-driven render is
* the difference between "add a memo" and "split the provider". */
function contextChanged(fiber: Fiber): boolean {
let dep = fiber.dependencies?.firstContext
while (dep) {
const context = dep.context as { _currentValue?: unknown } | undefined
if (context && 'memoizedValue' in dep && !Object.is(dep.memoizedValue, context._currentValue)) {
return true
}
dep = dep.next
}
return false
}
function record(fiber: Fiber) {
const name = getDisplayName(fiber)
if (!name) {
return
}
const entry = counts.get(name) ?? blank()
const props = propsChanged(fiber)
const state = stateChanged(fiber)
const context = contextChanged(fiber)
entry.renders += 1
entry.totalMs += fiber.actualDuration ?? 0
if (props) {
entry.propsChanged += 1
}
if (state) {
entry.stateChanged += 1
}
if (context) {
entry.contextChanged += 1
}
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)
}
/** Rows sorted by wasted renders, then total renders — the fix list, in order. */
function report(limit = 40) {
return [...counts.entries()]
.map(([name, r]) => ({ name, ...r, totalMs: Math.round(r.totalMs * 100) / 100 }))
.sort((a, b) => b.wasted - a.wasted || b.renders - a.renders)
.slice(0, limit)
}
declare global {
interface Window {
__RENDER_COUNTS__?: {
/** Per-component render attribution since the last `clear()`. */
counts: Map<string, RenderRecord>
/** Commits observed since the last `clear()`. */
commits: () => number
clear: () => void
/** Start counting. Cheap no-op until called — zero cost while idle. */
start: () => void
stop: () => void
recording: () => boolean
/** Sorted worst-offenders table; `console.table`-friendly. */
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
}
}
}
if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) {
instrument({
onCommitFiberRoot(_id, root) {
if (!recording) {
return
}
commits += 1
traverseRenderedFibers(root, fiber => {
if (isCompositeFiber(fiber) && didFiberRender(fiber)) {
record(fiber)
}
})
}
})
window.__RENDER_COUNTS__ = {
clear: () => {
counts.clear()
commits = 0
},
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,
start: () => {
counts.clear()
commits = 0
recording = true
},
stop: () => {
recording = false
}
}
}
@@ -0,0 +1,25 @@
export type RightPanePerfEvent =
'project-tree-render' | 'project-tree-row-render' | 'terminal-fit-active' | 'terminal-fit-hidden' | 'terminal-measure'
export interface RightPanePerfSnapshot {
counts: Record<RightPanePerfEvent, number>
details: Partial<Record<RightPanePerfEvent, Record<string, number>>>
rows: Record<string, number>
}
declare global {
interface Window {
__RIGHT_PANE_PERF__?: {
clear: () => void
mark: (event: RightPanePerfEvent, detail?: string) => void
snapshot: () => RightPanePerfSnapshot
start: () => void
stop: () => void
}
}
}
/** Tiny production-safe callsite; the recorder exists only in dev/perf builds. */
export function markRightPanePerf(event: RightPanePerfEvent, detail?: string): void {
window.__RIGHT_PANE_PERF__?.mark(event, detail)
}
@@ -0,0 +1,58 @@
import type { RightPanePerfEvent, RightPanePerfSnapshot } from './right-pane-events'
const eventNames: RightPanePerfEvent[] = [
'project-tree-render',
'project-tree-row-render',
'terminal-fit-active',
'terminal-fit-hidden',
'terminal-measure'
]
const blankCounts = (): Record<RightPanePerfEvent, number> =>
Object.fromEntries(eventNames.map(name => [name, 0])) as Record<RightPanePerfEvent, number>
if (typeof window !== 'undefined' && !window.__RIGHT_PANE_PERF__) {
let recording = false
let counts = blankCounts()
let details: RightPanePerfSnapshot['details'] = {}
let rows: Record<string, number> = {}
window.__RIGHT_PANE_PERF__ = {
clear: () => {
counts = blankCounts()
details = {}
rows = {}
},
mark: (event, detail) => {
if (!recording) {
return
}
counts[event] += 1
if (detail) {
const eventDetails = details[event] ?? {}
eventDetails[detail] = (eventDetails[detail] ?? 0) + 1
details[event] = eventDetails
if (event === 'project-tree-row-render') {
rows[detail] = (rows[detail] ?? 0) + 1
}
}
},
snapshot: (): RightPanePerfSnapshot => ({
counts: { ...counts },
details: Object.fromEntries(Object.entries(details).map(([event, eventDetails]) => [event, { ...eventDetails }])),
rows: { ...rows }
}),
start: () => {
counts = blankCounts()
details = {}
rows = {}
recording = true
},
stop: () => {
recording = false
}
}
}
+81
View File
@@ -0,0 +1,81 @@
// Dev-only: registers the stores worth attributing during a streaming turn.
//
// Deliberately NOT every atom in the app — a churn counter that reports 200
// rows is as useless as none. These are the stores on or adjacent to the
// streaming hot path, plus the sidebar's inputs, so a recording answers one
// question directly: while an agent is typing, what is being published, and
// who re-renders because of it?
import { $projects, $projectTree } from '@/store/projects'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$cronSessions,
$currentCwd,
$currentUsage,
$gatewayState,
$messages,
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$sessionsLoading
} from '@/store/session'
import {
$attentionSessionIds,
$focusedRuntimeId,
$focusedSessionState,
$focusedStoredSessionId,
$sessionStates,
$sessionTiles,
$stalledSessionIds,
$workingSessionIds
} from '@/store/session-states'
import { watchAtom } from './atom-churn'
/** Streaming hot path — written per token / per delta flush. */
const HOT = {
$awaitingResponse,
$busy,
// The global mirror the workspace pane paints from.
$messages,
// Republished on EVERY delta: the per-session source of truth.
$sessionStates
}
/** Derived from the hot path. These SHOULD stay quiet during a turn — any
* notification here is a candidate for the "wasted" column. */
const DERIVED = {
$attentionSessionIds,
$currentUsage,
$focusedRuntimeId,
$focusedSessionState,
$focusedStoredSessionId,
$stalledSessionIds,
$workingSessionIds
}
/** Sidebar inputs. Expected to be cold during a turn — if any of these notify
* while an agent is typing, that is the bug. */
const SIDEBAR = {
$activeSessionId,
$cronSessions,
$currentCwd,
$gatewayState,
$messagingSessions,
$projects,
$projectTree,
$selectedStoredSessionId,
$sessions,
$sessionsLoading,
$sessionTiles
}
export function watchSessionAtoms() {
for (const group of [HOT, DERIVED, SIDEBAR]) {
for (const [name, store] of Object.entries(group)) {
watchAtom(name, store)
}
}
}