hermes-agent/apps/desktop/scripts/diag-submit.mjs
Brooklyn Nicholson 88e7d7537c perf(desktop): cut FadeText forced layouts during streaming
The slowest user-felt path is typing into the composer while the
assistant is streaming. Profile (scripts/profile-under-stream.mjs):

  FadeText measureOverflow self time:  35.8 ms → 18.1 ms  (-50%)
  total active CPU during 7s window:   ~150 ms → ~50 ms

Two changes in src/components/ui/fade-text.tsx:

1. Drop the `useEffect([children])` that re-ran `measureOverflow`
   (reads scrollWidth + clientWidth — forced layout) on every parent
   re-render. `useResizeObserver` already fires the same callback on
   mount and whenever the host span's box size changes; that covers
   the only case where overflow state can legitimately change. The
   previous explicit useEffect was a forced-layout flush on every
   parent render, which during streaming meant every token tick.

2. Wrap the component in `memo` with a custom comparator that
   short-circuits the entire render when scalar string `children` and
   the className/fadeWidth/style props are unchanged. The hot path
   was tool-fallback's title chips being re-rendered by parent
   streaming updates even though their text was stable; memo+
   comparator skips that.

Also adds two harness scripts under apps/desktop/scripts/:
  - latency-under-stream.mjs (key→paint latency while a turn streams)
  - profile-under-stream.mjs (CPU profile while a turn streams)

Updates profile-typing-lag.md with the streaming numbers and confirms
the Enter→paint submit path is already fast (≤320ms on the populated
session; the 2s "stall after Enter" the user noticed once was a
one-time cold-start, not reproducible at the UI layer).

I'd guess the felt jank in real use is fast-burst typing during a
long-form streaming reply (code blocks + markdown lists multiply the
per-token render cost). The CPU savings here scale linearly with
token volume.
2026-05-21 16:09:44 -05:00

115 lines
4.3 KiB
JavaScript

// Manual single-shot observer to find what's happening between Enter and clear
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (m, p = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method: m, params: p }))
})
const evalP = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
return r.result.result.value
}
// Type some text
const composerExists = await evalP(`
(() => {
const el = document.querySelector('[data-slot="composer-rich-input"]')
if (!el) return false
el.focus()
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges(); sel.addRange(range)
return true
})()
`)
console.log('composer focused:', composerExists)
const text = 'cancel me ' + 'x'.repeat(30)
for (const c of text) {
await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
await new Promise(r => setTimeout(r, 10))
}
await new Promise(r => setTimeout(r, 200))
// Set up a deep observer that logs ALL state transitions in the composer subtree
await evalP(`
(() => {
window.__submitLog = []
const composer = document.querySelector('[data-slot="composer-rich-input"]')
const root = composer?.closest('[data-slot="composer-root"]') || document.body
const startTime = performance.now()
const log = (kind, detail) => window.__submitLog.push({ t: performance.now() - startTime, kind, detail })
log('start', { composerText: composer?.innerText?.length || 0, hasDataAuiEmpty: composer?.hasAttribute('data-aui-composer-empty') })
// Observe composer text changes via mutation
const composerObs = new MutationObserver(muts => {
const text = composer?.innerText ?? ''
log('composerMut', { textLen: text.length, head: text.slice(0, 30) })
})
composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true })
// Observe the busy state via aria-label / data-state on send button
const sendBtn = document.querySelector('[aria-label*="end"]') || document.querySelector('[aria-label*="top"]')
if (sendBtn) {
const btnObs = new MutationObserver(() => {
log('sendBtnMut', { aria: sendBtn.getAttribute('aria-label'), disabled: sendBtn.disabled })
})
btnObs.observe(sendBtn, { attributes: true })
log('sendBtn', { aria: sendBtn.getAttribute('aria-label'), disabled: sendBtn.disabled })
}
// Observe thread message inserts
const threadRoot = document.querySelector('[data-slot="aui_thread-content"]')
const threadObs = threadRoot ? new MutationObserver(() => {
const c = threadRoot.querySelectorAll('[data-slot="aui_message"], [data-message-role]').length
log('threadMut', { count: c })
}) : null
threadObs && threadObs.observe(threadRoot, { childList: true, subtree: true })
window.__obs = { composerObs, threadObs }
return true
})()
`)
// Hit Enter
console.log('pressing Enter…')
await send('Input.dispatchKeyEvent', {
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
})
await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
// Wait, then dump
await new Promise(r => setTimeout(r, 3500))
const logs = await evalP('JSON.stringify(window.__submitLog)')
console.log('\n=== EVENT LOG ===')
for (const e of JSON.parse(logs || '[]')) {
console.log(` ${String(e.t.toFixed(1)).padStart(7)}ms ${e.kind.padEnd(15)} ${JSON.stringify(e.detail)}`)
}
// Cancel the pending agent turn
await evalP(`
(() => {
for (const b of document.querySelectorAll('button')) {
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stop-clicked' }
}
return 'no-stop'
})()
`).then(r => console.log('cancel:', r))
ws.close()