hermes-agent/apps/desktop/src/debug/perf-live.ts
hermes-seaeye[bot] 215ec101be
fmt(js): npm run fix on merge (#72522)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 07:00:22 +00:00

305 lines
8.7 KiB
TypeScript

// 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 {}