mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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.
115 lines
4.3 KiB
JavaScript
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()
|