Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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.
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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()
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user