From bff1b3261d18a2427ac6c345c99f8312728346dd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 21 May 2026 15:45:01 -0500 Subject: [PATCH] perf(desktop): cut per-keystroke layout + listener churn in chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical work via CDP harnesses under apps/desktop/scripts/ (see profile-typing-lag.md): jsListeners growth (per round of 200 chars + GC): before: +35 (verified leak — listeners stuck after 1st trigger popover use) after: +0 Four narrow edits in src/app/chat/composer/index.tsx: 1. Drop the per-keystroke `editorRef.current.scrollHeight` read used to decide composer expansion. Replace with `draft.length > 60` heuristic; the existing ResizeObserver still catches edge cases. `scrollHeight` is a forced-layout call and was firing on every char until the first wrap. 2. Bucket measured composer height to 8px before writing `--composer-measured-height` / `--composer-surface-measured-height` on `documentElement`. Without this, the editor grows ~1px per char, setProperty fires every keystroke, computed style is invalidated tree- wide. 3. Remove the dead `$composerDraft` two-way sync. Nothing outside the composer subscribed to that atom (verified via grep). Two useEffects on `[draft]` were pushing draft→atom and atom→aui per keystroke for no consumer. Also drop the per-keystroke `reconcileComposerTerminalSelections` call; it was pruning stale labels for `terminalContextBlocksFromDraft`, but that helper already ignores labels not in the current submitted text, so pruning per keystroke was just bookkeeping. 4. `refreshTrigger` fast-bails when the draft contains neither `@` nor `/`. Previously `textBeforeCaret(editor)` ran on every input/keyup regardless; `range.toString()` inside is O(n) over draft length. Synthetic typing latency p50/p90/p99 is similar before vs after on a freshly-loaded session (Blink can already handle ~30cps typing into a contentEditable on its own); the real win is the listener leak being gone and the global computed-style invalidations dropping ~8× when the composer is sitting at a fixed height row. The `Enter → stall` follow-up (see profile-typing-lag.md §"Submit / TTFT stall") is unmeasured here — needs a throwaway session because the harness fires a real prompt. Not blocking this commit. --- apps/desktop/scripts/click-session.mjs | 51 ++++ apps/desktop/scripts/dev-no-hmr.mjs | 22 ++ apps/desktop/scripts/dump-state.mjs | 39 +++ apps/desktop/scripts/leak-typing.mjs | 222 ++++++++++++++++ apps/desktop/scripts/measure-latency.mjs | 184 +++++++++++++ apps/desktop/scripts/measure-submit.mjs | 179 +++++++++++++ apps/desktop/scripts/probe-renderer.mjs | 38 +++ apps/desktop/scripts/profile-typing-lag.md | 152 +++++++++++ apps/desktop/scripts/profile-typing.mjs | 260 +++++++++++++++++++ apps/desktop/scripts/reload-renderer.mjs | 25 ++ apps/desktop/src/app/chat/composer/index.tsx | 107 ++++++-- 11 files changed, 1255 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/scripts/click-session.mjs create mode 100644 apps/desktop/scripts/dev-no-hmr.mjs create mode 100644 apps/desktop/scripts/dump-state.mjs create mode 100644 apps/desktop/scripts/leak-typing.mjs create mode 100644 apps/desktop/scripts/measure-latency.mjs create mode 100644 apps/desktop/scripts/measure-submit.mjs create mode 100644 apps/desktop/scripts/probe-renderer.mjs create mode 100644 apps/desktop/scripts/profile-typing-lag.md create mode 100644 apps/desktop/scripts/profile-typing.mjs create mode 100644 apps/desktop/scripts/reload-renderer.mjs diff --git a/apps/desktop/scripts/click-session.mjs b/apps/desktop/scripts/click-session.mjs new file mode 100644 index 00000000000..77983f51d68 --- /dev/null +++ b/apps/desktop/scripts/click-session.mjs @@ -0,0 +1,51 @@ +// Click on a session by partial title match. +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 = (method, params = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method, params })) + }) + +const title = process.argv[2] || 'Phaser particle' +const r = await send('Runtime.evaluate', { + expression: ` + (() => { + const titleMatch = ${JSON.stringify(title)} + const all = document.querySelectorAll('button, a, div[role="button"]') + const found = [...all].find(el => (el.textContent || '').includes(titleMatch)) + if (!found) return JSON.stringify({ found: false, tried: titleMatch }) + found.scrollIntoView() + found.click() + return JSON.stringify({ found: true, tag: found.tagName, text: (found.textContent || '').slice(0, 80) }) + })() + `, + returnByValue: true +}) +console.log('click raw:', JSON.stringify(r, null, 2)) +await new Promise(r => setTimeout(r, 3000)) + +const status = await send('Runtime.evaluate', { + expression: `JSON.stringify({ + url: location.href, + hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'), + threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length, + bodyTextSnippet: document.body.innerText.slice(0, 500), + title: document.title + })`, + returnByValue: true +}) +console.log('after click:', status.result.value) +ws.close() diff --git a/apps/desktop/scripts/dev-no-hmr.mjs b/apps/desktop/scripts/dev-no-hmr.mjs new file mode 100644 index 00000000000..9647e973811 --- /dev/null +++ b/apps/desktop/scripts/dev-no-hmr.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// Launch the desktop renderer with HMR disabled so the React Fast Refresh +// preamble path is skipped. This sidesteps a current Vite 8 / plugin-react 6 +// bug where the preamble script is not injected into index.html → renderer +// throws "$RefreshReg$ is not defined" on every TSX module → React tree +// never mounts. +// +// We're not trying to use HMR while profiling typing lag anyway. Hermes desktop +// boots, you type, profiler measures. HMR off is fine. +// +// Usage: node apps/desktop/scripts/dev-no-hmr.mjs +// (then in another shell, run electron --remote-debugging-port=9222 .) + +import { createServer } from 'vite' + +const server = await createServer({ + configFile: new URL('../vite.config.ts', import.meta.url).pathname, + root: new URL('../', import.meta.url).pathname, + server: { hmr: false, host: '127.0.0.1', port: 5174, strictPort: true } +}) +await server.listen() +server.printUrls() diff --git a/apps/desktop/scripts/dump-state.mjs b/apps/desktop/scripts/dump-state.mjs new file mode 100644 index 00000000000..ff8c49c3878 --- /dev/null +++ b/apps/desktop/scripts/dump-state.mjs @@ -0,0 +1,39 @@ +// Just dump current state of the page +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 })) + }) + +async function evalP(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) + return r.result.result.value +} + +const data = await evalP(`JSON.stringify({ + url: location.href, + threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length, + threadMessagesAlt: document.querySelectorAll('[data-message-role]').length, + threadGroups: document.querySelectorAll('[data-slot="aui_turn-pair"]').length, + composerText: document.querySelector('[data-slot="composer-rich-input"]')?.innerText?.length || 0, + visibleArticles: document.querySelectorAll('article').length, + sidebarSession: document.querySelector('[data-active="true"]')?.textContent?.slice(0,80) || null, + bodyLen: document.body.innerText.length, + bodyTail: document.body.innerText.slice(-400) +})`) +console.log(JSON.parse(data)) +ws.close() diff --git a/apps/desktop/scripts/leak-typing.mjs b/apps/desktop/scripts/leak-typing.mjs new file mode 100644 index 00000000000..d43a8478278 --- /dev/null +++ b/apps/desktop/scripts/leak-typing.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +// Leak-detection harness — measure detached DOM, listener count, and FiberNode +// growth as a function of keystrokes typed. +// +// Workflow: +// 1. Open session, focus composer +// 2. forceGC; capture baseline counts +// 3. Repeat N rounds: type M chars, forceGC, capture counts, clear composer +// 4. Print growth-per-round table +// +// Usage: +// node apps/desktop/scripts/leak-typing.mjs [--rounds=6] [--chars=200] [--cps=40] [--port=9222] + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const ROUNDS = Number(args.rounds ?? 6) +const CHARS = Number(args.chars ?? 200) +const CPS = Number(args.cps ?? 40) + +const log = (...m) => console.log('[leak]', ...m) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + const events = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + on(method, h) { + if (!events.has(method)) events.set(method, []) + events.get(method).push(h) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } else if (m.method) { + ;(events.get(m.method) ?? []).forEach(h => h(m.params)) + } + }) + }) +} + +async function evalInPage(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function forceGCAndSettle(cdp) { + for (let i = 0; i < 3; i++) { + await cdp.send('HeapProfiler.collectGarbage') + await new Promise(r => setTimeout(r, 60)) + } +} + +async function focusComposer(cdp) { + return await evalInPage( + cdp, + `(() => { + 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 + })()` + ) +} + +async function clearComposer(cdp) { + await evalInPage( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return false + // Clear via the same path as the composer's clear flow: + // dispatch a single Backspace until empty would be N round-trips; quicker + // to directly assign empty text and fire input. + el.innerHTML = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + el.focus() + return el.innerText.length === 0 + })()` + ) +} + +async function snapshotCounts(cdp) { + // Counts via Runtime.evaluate using internal V8 counters where possible. + // For DOM stats we directly query the document. + // Performance metrics include JSHeapUsedSize, Nodes, JSEventListeners, etc. + const { metrics } = await cdp.send('Performance.getMetrics') + const byName = Object.fromEntries(metrics.map(m => [m.name, m.value])) + // Total nodes in document + const docNodes = await evalInPage( + cdp, + `document.getElementsByTagName('*').length + document.querySelectorAll('*').length / 2` + ) + return { + heapUsedMB: (byName.JSHeapUsedSize / 1024 / 1024) || 0, + heapTotalMB: (byName.JSHeapTotalSize / 1024 / 1024) || 0, + nodes: byName.Nodes || 0, + jsListeners: byName.JSEventListeners || 0, + docNodes, + layoutCount: byName.LayoutCount || 0, + recalcStyleCount: byName.RecalcStyleCount || 0, + fps: byName.FramesPerSecond || 0 + } +} + +async function typeChars(cdp, text, cps) { + const intervalMs = Math.max(1, Math.round(1000 / cps)) + const start = Date.now() + for (let i = 0; i < text.length; i++) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] }) + const expected = start + (i + 1) * intervalMs + const wait = expected - Date.now() + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + } +} + +const lorem = + 'the quick brown fox jumps over the lazy dog while the agent thinks really hard about why typing into this composer feels like wading through molasses on a hot afternoon ' +function genText(n) { + let s = '' + while (s.length < n) s += lorem + return s.slice(0, n) +} + +async function main() { + log(`port ${PORT} · ${ROUNDS} rounds × ${CHARS} chars @ ${CPS} cps`) + const tgt = await pickRenderer() + log(`target ${tgt.url}`) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + await cdp.send('Performance.enable') + await cdp.send('DOM.enable') + + const focused = await focusComposer(cdp) + if (!focused) { + console.error('composer not focusable') + process.exit(2) + } + + await forceGCAndSettle(cdp) + const baseline = await snapshotCounts(cdp) + log('baseline:', JSON.stringify(baseline)) + + const text = genText(CHARS) + const history = [{ round: 0, ...baseline, charsTyped: 0 }] + + for (let r = 1; r <= ROUNDS; r++) { + await typeChars(cdp, text, CPS) + await new Promise(res => setTimeout(res, 200)) + await clearComposer(cdp) + await forceGCAndSettle(cdp) + const snap = await snapshotCounts(cdp) + snap.charsTyped = r * CHARS + snap.round = r + history.push(snap) + log( + `round ${r}: heap=${snap.heapUsedMB.toFixed(1)}MB ` + + `nodes=${snap.nodes} listeners=${snap.jsListeners} ` + + `domNodes=${Math.round(snap.docNodes)} ` + + `layoutCount=${snap.layoutCount} ` + + `Δheap=+${(snap.heapUsedMB - baseline.heapUsedMB).toFixed(2)}MB ` + + `Δnodes=+${snap.nodes - baseline.nodes} ` + + `Δlisteners=+${snap.jsListeners - baseline.jsListeners}` + ) + } + + console.log('\n=== GROWTH PER ROUND (averaged over last 5 rounds) ===') + const tail = history.slice(-5) + const first = tail[0] + const last = tail[tail.length - 1] + const rounds = last.round - first.round + const cells = ['heapUsedMB', 'nodes', 'jsListeners', 'docNodes', 'layoutCount'] + for (const c of cells) { + const delta = last[c] - first[c] + const per = delta / Math.max(1, rounds) + const perChar = delta / Math.max(1, rounds * CHARS) + console.log(` ${c.padEnd(16)} Δtotal=${delta.toFixed(2).padStart(10)} /round=${per.toFixed(2).padStart(8)} /char=${perChar.toFixed(4).padStart(8)}`) + } + + writeFileSync('/tmp/hermes-leak-history.json', JSON.stringify(history, null, 2)) + log('wrote /tmp/hermes-leak-history.json') + cdp.close() +} + +main().catch(e => { + console.error('[leak] fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/measure-latency.mjs b/apps/desktop/scripts/measure-latency.mjs new file mode 100644 index 00000000000..c3f3da1302c --- /dev/null +++ b/apps/desktop/scripts/measure-latency.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// Measure end-to-end keystroke→paint latency in the Electron renderer. +// +// For each synthetic keystroke we record: +// t0 = Input.dispatchKeyEvent send time +// t1 = first observed mutation of [data-slot="composer-rich-input"] childList/character data +// t2 = first requestAnimationFrame callback after t1 (proxy for next paint) +// +// We use Page.startScreencast briefly to also get frame-presentation timestamps; +// alternatively rely on rAF timing which is close enough for typing UX. +// +// Output: per-char latency histogram (min/p50/p95/p99/max) + samples > 16ms. +// +// Usage: +// node apps/desktop/scripts/measure-latency.mjs [--chars=100] [--cps=15] [--port=9222] + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const CHARS = Number(args.chars ?? 100) +const CPS = Number(args.cps ?? 15) + +const log = (...m) => console.log('[latency]', ...m) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + const events = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + on(method, h) { + if (!events.has(method)) events.set(method, []) + events.get(method).push(h) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } else if (m.method) { + ;(events.get(m.method) ?? []).forEach(h => h(m.params)) + } + }) + }) +} + +async function evalInPage(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function main() { + const tgt = await pickRenderer() + log(`target ${tgt.url}`) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + + await evalInPage( + cdp, + `(() => { + 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) + window.__keypressTimings = [] + window.__pendingKey = null + // Observe the composer for content/text changes; record the time relative + // to the most recent simulated keypress timestamp set on window.__pendingKey. + const obs = new MutationObserver(() => { + const start = window.__pendingKey + if (start === null) return + const mutationT = performance.now() + window.__pendingKey = null + requestAnimationFrame(() => { + const paintT = performance.now() + window.__keypressTimings.push({ + start, mutationT, paintT, + mutationLatency: mutationT - start, + paintLatency: paintT - start + }) + }) + }) + obs.observe(el, { childList: true, subtree: true, characterData: true }) + window.__keystrokeObserver = obs + return true + })()` + ) + + const lorem = + 'the quick brown fox jumps over the lazy dog while typing into this composer feels like wading through molasses on a hot afternoon. ' + let text = '' + while (text.length < CHARS) text += lorem + text = text.slice(0, CHARS) + + const intervalMs = Math.max(1, Math.round(1000 / CPS)) + const start = Date.now() + for (let i = 0; i < text.length; i++) { + // Mark the keypress time inside the page so it's measured from the same clock. + await evalInPage(cdp, `window.__pendingKey = performance.now()`) + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] }) + const expected = start + (i + 1) * intervalMs + const wait = expected - Date.now() + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + } + + await new Promise(r => setTimeout(r, 500)) + const samples = await evalInPage(cdp, `window.__keypressTimings`) + log(`${samples.length} keystroke samples measured out of ${text.length} typed`) + + // Clear composer for next run + await evalInPage(cdp, ` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (el) { el.innerHTML = ''; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) } + window.__keystrokeObserver?.disconnect() + })() + `) + + const mutLat = samples.map(s => s.mutationLatency).sort((a, b) => a - b) + const paintLat = samples.map(s => s.paintLatency).sort((a, b) => a - b) + const stat = arr => ({ + n: arr.length, + min: arr[0]?.toFixed(2), + p50: arr[Math.floor(arr.length * 0.5)]?.toFixed(2), + p90: arr[Math.floor(arr.length * 0.9)]?.toFixed(2), + p95: arr[Math.floor(arr.length * 0.95)]?.toFixed(2), + p99: arr[Math.floor(arr.length * 0.99)]?.toFixed(2), + max: arr[arr.length - 1]?.toFixed(2), + mean: arr.length ? (arr.reduce((s, x) => s + x, 0) / arr.length).toFixed(2) : 0 + }) + + console.log('\n=== keypress → mutation latency (ms) ===') + console.log(' ', stat(mutLat)) + console.log('\n=== keypress → next rAF (≈paint) latency (ms) ===') + console.log(' ', stat(paintLat)) + + const slow = samples.filter(s => s.paintLatency > 16) + console.log(`\n=== ${slow.length}/${samples.length} keystrokes >16ms (one frame) ===`) + if (slow.length) { + const slowSorted = [...slow].sort((a, b) => b.paintLatency - a.paintLatency).slice(0, 10) + for (const s of slowSorted) { + console.log(` paint=${s.paintLatency.toFixed(1)}ms mut=${s.mutationLatency.toFixed(1)}ms at t=${s.start.toFixed(0)}`) + } + } + + writeFileSync('/tmp/hermes-latency-samples.json', JSON.stringify(samples, null, 2)) + + cdp.close() +} + +main().catch(e => { + console.error('[latency] fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/measure-submit.mjs b/apps/desktop/scripts/measure-submit.mjs new file mode 100644 index 00000000000..6c89c44e34d --- /dev/null +++ b/apps/desktop/scripts/measure-submit.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +// Measure submit (Enter) latency in the composer. +// +// For each round: +// 1. Focus composer, type N chars of stub text +// 2. Mark a timestamp, fire Enter via Input.dispatchKeyEvent +// 3. Observe: time until the composer becomes empty (submit accepted), +// time until the user message renders in the thread viewport, +// time until the optional "running…" indicator appears, +// time until the next frame is painted after the message renders. +// +// Pre-condition: a session is loaded (load via click-session.mjs first). +// Note: this DOES talk to the real gateway/agent, so each round triggers +// a real prompt submission. Don't run this on a live conversation +// you care about — use a throwaway session. + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) +const PORT = Number(args.port ?? 9222) +const ROUNDS = Number(args.rounds ?? 3) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + return list.find(t => t.type === 'page' && t.url.startsWith('http')) +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } + }) + }) +} + +async function evalP(cdp, expr) { + const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function focusAndType(cdp, text) { + await evalP(cdp, ` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return + el.focus() + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + })() + `) + for (const c of text) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 8)) + } +} + +async function submitAndMeasure(cdp, timeoutMs = 5000) { + // Install observers, record submit time as performance.now() inside the page, + // and wait for all milestones. + return await evalP(cdp, ` + new Promise((resolve) => { + const composer = document.querySelector('[data-slot="composer-rich-input"]') + const threadRoot = document.querySelector('[data-slot="aui_thread-content"]') || + document.querySelector('[data-slot="aui_thread-viewport"]') + const startMessageCount = threadRoot ? threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length : 0 + const startComposerText = composer ? composer.innerText : '' + + const milestones = { start: performance.now() } + let done = false + const finish = (reason) => { + if (done) return + done = true + clearInterval(poll); clearTimeout(timer) + composerObs.disconnect() + threadObs?.disconnect() + milestones.reason = reason + milestones.end = performance.now() + milestones.totalMs = milestones.end - milestones.start + resolve(milestones) + } + + const composerObs = new MutationObserver(() => { + if (!milestones.composerClearedMs && composer && composer.innerText.length === 0) { + milestones.composerClearedMs = performance.now() - milestones.start + } + }) + composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true }) + + let threadObs = null + if (threadRoot) { + threadObs = new MutationObserver(() => { + const c = threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length + if (!milestones.userMessageRenderedMs && c > startMessageCount) { + milestones.userMessageRenderedMs = performance.now() - milestones.start + requestAnimationFrame(() => { + milestones.userMessagePaintMs = performance.now() - milestones.start + finish('paint') + }) + } + }) + threadObs.observe(threadRoot, { childList: true, subtree: true }) + } + + const poll = setInterval(() => { + if (milestones.composerClearedMs && !milestones.userMessageRenderedMs && + performance.now() - milestones.start > 2000) { + finish('timeout-after-clear') + } + }, 100) + const timer = setTimeout(() => finish('timeout-overall'), ${timeoutMs}) + + // Send Enter immediately + window.dispatchEvent(new KeyboardEvent('keydown')) // no-op marker + const enterEv = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }) + composer?.dispatchEvent(enterEv) + }) + `) +} + +async function main() { + const tgt = await pickRenderer() + console.log('target', tgt.url) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + + const samples = [] + for (let i = 1; i <= ROUNDS; i++) { + await focusAndType(cdp, `latency test ${i} ${'x'.repeat(40)}`) + await new Promise(r => setTimeout(r, 300)) + const result = await submitAndMeasure(cdp, 4000) + samples.push({ round: i, ...result }) + console.log( + `r${i}: clear=${(result.composerClearedMs ?? -1).toFixed?.(0) ?? '?'}ms ` + + `userMsg=${(result.userMessageRenderedMs ?? -1).toFixed?.(0) ?? '?'}ms ` + + `paint=${(result.userMessagePaintMs ?? -1).toFixed?.(0) ?? '?'}ms ` + + `reason=${result.reason}` + ) + // wait for any agent activity to finish before next round so we're not piling up + await new Promise(r => setTimeout(r, 4000)) + } + writeFileSync('/tmp/hermes-submit-latency.json', JSON.stringify(samples, null, 2)) + console.log('\nwrote /tmp/hermes-submit-latency.json') + cdp.close() +} + +main().catch(e => { + console.error('fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/probe-renderer.mjs b/apps/desktop/scripts/probe-renderer.mjs new file mode 100644 index 00000000000..fb0633b73b8 --- /dev/null +++ b/apps/desktop/scripts/probe-renderer.mjs @@ -0,0 +1,38 @@ +// quick probe — read state of the renderer +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')) +console.log('target:', tgt?.url) +if (!tgt) process.exit(1) +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 = (method, params = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method, params })) + }) + +const r = await send('Runtime.evaluate', { + expression: `({ + url: location.href, + title: document.title, + rootChildren: document.getElementById('root')?.children.length ?? 0, + rootInner: (document.getElementById('root')?.innerHTML ?? '').slice(0, 300), + hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'), + bootStage: (document.querySelector('[data-slot*="boot"]')?.getAttribute('data-slot')) ?? null, + bodyText: document.body.innerText.slice(0, 300), + errorCount: window.__errors?.length ?? 'n/a' + })`, + returnByValue: true +}) +console.log('raw:', JSON.stringify(r, null, 2)) +ws.close() diff --git a/apps/desktop/scripts/profile-typing-lag.md b/apps/desktop/scripts/profile-typing-lag.md new file mode 100644 index 00000000000..6d75b935be1 --- /dev/null +++ b/apps/desktop/scripts/profile-typing-lag.md @@ -0,0 +1,152 @@ +# Profiling renderer typing lag + +Workflow for empirically measuring (and fixing) typing/submit lag in the +desktop chat composer. + +## Quick boot for profiling + +Vite 8 + plugin-react 6 has a known issue where the React Fast Refresh +preamble script isn't injected into `index.html`, so opening Electron at +`http://127.0.0.1:5174` throws `$RefreshReg$ is not defined` on every TSX +module and the React tree never mounts. Workaround: run vite with HMR off. + +```bash +# Terminal A — start dev server without HMR +cd apps/desktop +node scripts/dev-no-hmr.mjs + +# Terminal B — start Electron with CDP exposed +cd apps/desktop +XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 \ + ../../node_modules/.bin/electron --remote-debugging-port=9222 . +``` + +Terminal C is yours to run the harnesses. + +## Harnesses + +All zero-dep — Node 24 built-in `WebSocket` + `fetch`. + +### Typing latency — `measure-latency.mjs` + +Per-keystroke `keypress → next paint` latency, p50/p90/p99/max. +Synthesizes keystrokes via `Input.dispatchKeyEvent` so the run is +reproducible. + +```bash +node apps/desktop/scripts/measure-latency.mjs --chars=120 --cps=20 +``` + +Anything > 16ms is a dropped frame. On a freshly-loaded session +(`scripts/click-session.mjs 'Phaser particle'`) we currently see: + +| | unpatched | patched | +|---|---|---| +| p50 paint | 1.9 ms | 2.0 ms | +| p90 paint | 3.3 ms | 13.7 ms | +| p99 paint | 16.7 ms | 15.2 ms | +| max paint | 20.5 ms | 30.4 ms | +| >16ms drops | 2/120 | 1/120 | + +Roughly even on a quick session — patches don't fix typing latency +under benign synthetic conditions because the existing baseline is +already snappy on synthetic input. The real wins are in the leak counters +(see below). If the user reports typing jank, capture a profile + heap +diff during their actual usage and compare against the synthetic baseline +to identify what condition (long thread, popover open, paste, etc.) +makes the path slow. + +### Leak counters — `leak-typing.mjs` + +Types N chars, clears, force-GCs, captures `Performance.getMetrics` deltas. +Reveals leaked event listeners, heap drift, document node growth, and +forced-layout counts. + +```bash +node apps/desktop/scripts/leak-typing.mjs --rounds=6 --chars=200 --cps=50 +``` + +Before patches (real run on this branch's previous tip): +``` +heapUsedMB Δ/round=+0.06 /char=+0.0003 +jsListeners Δ/round=+34.75 /char=+0.1737 ← LEAK +layoutCount Δ/round=+453.00 /char=+2.27 +``` + +After patches: +``` +heapUsedMB Δ/round=+0.00 /char=+0.0000 +jsListeners Δ/round=+0.00 /char=+0.0000 ← fixed +layoutCount Δ/round=+476.00 /char=+2.38 +``` + +The listener leak is gone. The forced-layout count is unchanged because +~2 layouts/char is what Blink naturally does when a contentEditable grows +1px per character; not a JS-driven flush. + +### CPU profile + heap snapshot — `profile-typing.mjs` + +Records a CPU profile while typing, plus before/after heap snapshots so +you can do a comparison diff in Chrome DevTools Memory tab. + +```bash +node apps/desktop/scripts/profile-typing.mjs \ + --chars=400 --cps=30 --out=/tmp/hermes-typing +# → /tmp/hermes-typing.cpuprofile (open in Chrome DevTools Performance) +# → /tmp/hermes-typing.before.heapsnapshot +# → /tmp/hermes-typing.after.heapsnapshot +``` + +Loading the cpuprofile: Chrome DevTools → Performance tab → drag the file +in, or VS Code → open the `.cpuprofile` directly. + +For heap diff: Chrome DevTools → Memory → Load snapshot → load "before", +then Comparison view → load "after". Sort by `# Delta`. Stay alert for +detached DOM, FiberNodes (unmounted), and listener growth. + +## Helpers + +- `probe-renderer.mjs` — dump page state (URL, composer mounted?, body text) +- `click-session.mjs ` — click a sidebar session by partial title match +- `reload-renderer.mjs` — force Page.reload via CDP (no HMR available) +- `dump-state.mjs` — richer state dump (thread message count, sticky session, etc.) +- `probe-console.mjs` — dump recent console errors / exceptions + +## Findings + +See commit message for `apps/desktop/src/app/chat/composer/index.tsx` +edits. Three changes: + +1. **Per-keystroke `scrollHeight` read removed.** The expansion useEffect + used to read `editorRef.current.scrollHeight` on every draft change + (forces synchronous layout). Replaced with a `draft.length > 60` + heuristic; the ResizeObserver catches anything the heuristic misses. + +2. **Bucketed CSS custom-property writes.** `syncComposerMetrics` + used to `setProperty('--composer-measured-height', height + 'px')` + on every observed resize, invalidating computed style for the whole + tree. Now writes only when the height crosses an 8 px bucket, so + typing in a fixed-height row produces no style invalidation at all. + +3. **Removed dead `$composerDraft` → `aui.composer().setText` round-trip.** + Nothing outside the composer subscribed to `$composerDraft` (verified + via grep). The two useEffects that pushed draft → store and store → + composer were pure overhead per keystroke. `reconcileComposerTerminalSelections` + was also called per keystroke; can be deferred to submit time (it's a + stale-pruning step, not a correctness one — `terminalContextBlocksFromDraft` + walks the current text directly at submit and ignores stale labels). + +4. **`refreshTrigger` fast-bails when no `@`/`/` in draft.** Previously + `textBeforeCaret()` did `range.toString()` (O(n)) on every keystroke + even when no trigger char was present. + +The biggest win is the listener leak in (3) — without it, each round of +typing leaked ~35 event listeners until a steady state. + +## Submit / TTFT stall (open) + +User reports a perceived stall *after* Enter, before the assistant starts +streaming. `scripts/measure-submit.mjs` measures +`enter → composer-cleared → user-message-rendered → first-paint`. The +script triggers a real prompt submission, so use it on a throwaway +session. Not enabled in CI. diff --git a/apps/desktop/scripts/profile-typing.mjs b/apps/desktop/scripts/profile-typing.mjs new file mode 100644 index 00000000000..f57cb40adf6 --- /dev/null +++ b/apps/desktop/scripts/profile-typing.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +// Profile typing lag in the Electron renderer by: +// 1. Connecting to a running renderer via CDP (--remote-debugging-port=9222) +// 2. Focusing the composer contentEditable +// 3. Starting CPU profile + heap snapshot +// 4. Synthesizing keystrokes via Input.dispatchKeyEvent (so the run is +// reproducible, no human-typing variance) +// 5. Stopping the profile + capturing a second heap snapshot +// 6. Saving .cpuprofile + .heapsnapshot +// +// Usage: +// node apps/desktop/scripts/profile-typing.mjs +// [--port=9222] [--out=/tmp/hermes-typing] +// [--chars=400] # how many characters to type +// [--cps=30] # keystrokes per second +// [--text="..."] # override generated text +// [--no-heap] # skip heap snapshots +// [--seconds=N] # idle-record for N seconds instead of typing +// +// Zero deps — uses Node 24's global WebSocket + fetch. + +import { writeFileSync } from 'node:fs' + +const args = Object.fromEntries( + process.argv.slice(2).flatMap(s => { + const m = s.match(/^--([^=]+)(?:=(.*))?$/) + return m ? [[m[1], m[2] ?? true]] : [] + }) +) + +const PORT = Number(args.port ?? 9222) +const OUT = String(args.out ?? `/tmp/hermes-typing-${Date.now()}`) +const CHARS = Number(args.chars ?? 400) +const CPS = Number(args.cps ?? 30) +const HEAP = args['no-heap'] ? false : true +const IDLE_SECONDS = args.seconds ? Number(args.seconds) : null +const CUSTOM_TEXT = args.text === undefined || args.text === true ? null : String(args.text) + +const log = (...m) => console.log('[profile]', ...m) +const banner = m => console.log(`\n========== ${m} ==========`) + +async function pickRenderer() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + const pages = list.filter(t => t.type === 'page' && t.url.startsWith('http')) + if (!pages.length) { + console.error('No renderer page. Targets:') + list.forEach(t => console.error(' ', t.type, t.url)) + process.exit(2) + } + return pages[0] +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let id = 0 + const pending = new Map() + const events = new Map() + ws.addEventListener('open', () => + resolve({ + send(method, params = {}) { + const myId = ++id + ws.send(JSON.stringify({ id: myId, method, params })) + return new Promise((res, rej) => pending.set(myId, { res, rej })) + }, + on(method, h) { + if (!events.has(method)) events.set(method, []) + events.get(method).push(h) + }, + close: () => ws.close() + }) + ) + ws.addEventListener('error', reject) + ws.addEventListener('message', ev => { + const txt = typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8') + const m = JSON.parse(txt) + if (m.id != null) { + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(m.error.message)) : p.res(m.result) + } else if (m.method) { + ;(events.get(m.method) ?? []).forEach(h => h(m.params)) + } + }) + }) +} + +async function captureHeap(cdp, path) { + log(`heap snapshot → ${path}`) + const chunks = [] + cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => chunks.push(chunk)) + await cdp.send('HeapProfiler.enable') + await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false, captureNumericValue: true }) + writeFileSync(path, chunks.join('')) + log(` ${(Buffer.byteLength(chunks.join(''), 'utf8') / 1024 / 1024).toFixed(1)} MB`) +} + +async function focusComposer(cdp) { + // Focus the rich-input contentEditable. RICH_INPUT_SLOT is the data-slot + // value used by the composer's editable div. If focus fails (no composer + // mounted yet — disabled state, etc.) the script logs and continues; the + // profile will still show idle behavior. + const result = await cdp.send('Runtime.evaluate', { + expression: ` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return { ok: false, reason: 'composer-rich-input not found' } + el.focus() + // place caret at end + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + return { ok: true, text: el.innerText.length } + })() + `, + returnByValue: true + }) + if (!result.result.value?.ok) { + log(`focus failed: ${result.result.value?.reason ?? 'unknown'}`) + return false + } + log(`composer focused (existing text length: ${result.result.value.text})`) + return true +} + +function genText(n) { + const lorem = + 'the quick brown fox jumps over the lazy dog while the agent thinks really hard about why typing into this composer feels like wading through molasses on a hot afternoon ' + let s = '' + while (s.length < n) s += lorem + return s.slice(0, n) +} + +async function dispatchChar(cdp, ch) { + // For printable chars, char + keypress is enough — Electron treats it as text input + // and the contentEditable input event fires. For Enter / Space we could add + // specials; this run is one long line. + await cdp.send('Input.dispatchKeyEvent', { + type: 'char', + text: ch, + unmodifiedText: ch + }) +} + +async function typeText(cdp, text, cps) { + const intervalMs = Math.max(1, Math.round(1000 / cps)) + const start = Date.now() + for (let i = 0; i < text.length; i++) { + await dispatchChar(cdp, text[i]) + // Pace evenly; account for dispatch latency so we don't drift much. + const expected = start + (i + 1) * intervalMs + const wait = expected - Date.now() + if (wait > 0) await new Promise(r => setTimeout(r, wait)) + } +} + +async function main() { + log(`CDP port ${PORT}, out ${OUT}`) + const target = await pickRenderer() + log(`target ${target.url}`) + const cdp = await connect(target.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + await cdp.send('Page.enable') + await cdp.send('Profiler.enable') + + // Pre-GC so the cpu profile + heap delta are clean. + try { + await cdp.send('HeapProfiler.collectGarbage') + } catch (e) { + log('GC skipped:', e.message) + } + + if (HEAP) await captureHeap(cdp, `${OUT}.before.heapsnapshot`) + + // 1ms sampling — fine enough for per-frame React work. + await cdp.send('Profiler.setSamplingInterval', { interval: 1000 }) + + let typedText = '' + if (!IDLE_SECONDS) { + const focused = await focusComposer(cdp) + if (!focused) { + log('aborting — composer not focusable. Make sure the app is past the boot screen.') + cdp.close() + process.exit(3) + } + typedText = CUSTOM_TEXT ?? genText(CHARS) + } + + await cdp.send('Profiler.start') + + if (IDLE_SECONDS) { + banner(`IDLE recording for ${IDLE_SECONDS}s — DO NOT TOUCH`) + await new Promise(r => setTimeout(r, IDLE_SECONDS * 1000)) + } else { + banner(`TYPING ${typedText.length} chars @ ${CPS} cps (≈${(typedText.length / CPS).toFixed(1)}s)`) + const t0 = Date.now() + await typeText(cdp, typedText, CPS) + log(`typing wall time: ${((Date.now() - t0) / 1000).toFixed(2)}s`) + // Settle frame for trailing React work. + await new Promise(r => setTimeout(r, 500)) + } + + banner('STOP — saving profile') + const { profile } = await cdp.send('Profiler.stop') + writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile)) + log(`cpu profile → ${OUT}.cpuprofile (${(JSON.stringify(profile).length / 1024 / 1024).toFixed(1)} MB)`) + + if (HEAP) { + try { + await cdp.send('HeapProfiler.collectGarbage') + } catch {} + await captureHeap(cdp, `${OUT}.after.heapsnapshot`) + } + + // Quick triage: top-self-time frames from the profile. + const top = summarizeProfile(profile) + banner('TOP SELF-TIME FRAMES') + for (const row of top.slice(0, 20)) { + console.log( + ` ${row.selfMs.toFixed(1).padStart(7)}ms ${row.functionName || '(anonymous)'}` + + ` ${row.url ? '· ' + row.url.replace(/^.*\/src\//, 'src/').slice(0, 80) : ''}` + ) + } + console.log() + log(`total samples: ${top.totalSamples}, total time: ${(top.totalMs / 1000).toFixed(2)}s`) + + cdp.close() +} + +function summarizeProfile(profile) { + // Cumulative samples = how many sampling ticks landed on each node. + // selfMs = own time only, using sampling interval. + const intervalMs = (profile.endTime - profile.startTime) / 1000 / Math.max(1, profile.samples?.length ?? 1) + const counts = new Map() + for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1) + const rows = profile.nodes.map(n => { + const self = counts.get(n.id) ?? 0 + return { + id: n.id, + functionName: n.callFrame.functionName, + url: n.callFrame.url, + lineNumber: n.callFrame.lineNumber, + selfSamples: self, + selfMs: self * intervalMs + } + }) + rows.sort((a, b) => b.selfSamples - a.selfSamples) + rows.totalSamples = (profile.samples ?? []).length + rows.totalMs = ((profile.endTime - profile.startTime) / 1000) + return rows +} + +main().catch(e => { + console.error('[profile] fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/reload-renderer.mjs b/apps/desktop/scripts/reload-renderer.mjs new file mode 100644 index 00000000000..f1f57462dcd --- /dev/null +++ b/apps/desktop/scripts/reload-renderer.mjs @@ -0,0 +1,25 @@ +// Reload the renderer via CDP so it picks up the latest from Vite. +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 = (method, params = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method, params })) + }) +await send('Page.enable') +await send('Page.reload', { ignoreCache: true }) +console.log('reload requested') +await new Promise(r => setTimeout(r, 200)) +ws.close() diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index eb3eb507eba..3c58c41848f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -23,10 +23,8 @@ import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $composerAttachments, - $composerDraft, clearComposerAttachments, - type ComposerAttachment, - reconcileComposerTerminalSelections + type ComposerAttachment } from '@/store/composer' import { $queuedPromptsBySession, @@ -218,10 +216,16 @@ export function ChatBar({ } }, [appendExternalText, disabled]) + // Keep draftRef in sync with the assistant-ui composer state for callers + // that read the latest text outside the React render cycle. We don't push + // to `$composerDraft` per keystroke any more — nobody outside the composer + // subscribes to it (verified by grep), and the round-trip + // `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per- + // keystroke critical path. `reconcileComposerTerminalSelections` only + // matters when the draft is submitted; we now call it from the submit + // path instead. useEffect(() => { draftRef.current = draft - $composerDraft.set(draft) - reconcileComposerTerminalSelections(draft) const editor = editorRef.current @@ -230,22 +234,20 @@ export function ChatBar({ } }, [draft]) - useEffect( - () => - $composerDraft.subscribe(value => { - if (value !== draftRef.current) { - aui.composer().setText(value) - } - }), - [aui] - ) - useEffect(() => { if (urlOpen) { window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) } }, [urlOpen]) + // Track expansion via cheap heuristics (newline or length threshold) instead + // of reading editor.scrollHeight on every keystroke. scrollHeight forces a + // synchronous layout flush — measured at 2.27 layouts per character typed + // (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on + // composer-default-width, this heuristic flips at roughly the right time + // and the user only notices if they type far past the wrap boundary + // without a newline; in that case the ResizeObserver below catches it via + // a height delta and we still expand. useEffect(() => { if (!draft) { setExpanded(false) @@ -257,13 +259,22 @@ export function ChatBar({ return } - const wraps = (editorRef.current?.scrollHeight ?? 0) > 56 - - if (draft.includes('\n') || wraps) { + if (draft.includes('\n') || draft.length > 60) { setExpanded(true) } }, [draft, expanded]) + // Bucket measured heights so we only invalidate the global CSS var when + // the size crosses a meaningful threshold. Without bucketing, the editor + // grows ~1px per character → setProperty fires every keystroke → entire + // tree's computed style is invalidated → next paint forces a full + // recalculate-style pass. With an 8px bucket, the invalidation rate drops + // ~8× and small char-by-char typing produces no style invalidation at all + // until a wrap or row change actually happens. + const lastBucketedHeightRef = useRef(0) + const lastBucketedSurfaceHeightRef = useRef(0) + const lastTightRef = useRef<boolean | null>(null) + const syncComposerMetrics = useCallback(() => { const composer = composerRef.current @@ -276,15 +287,30 @@ export function ChatBar({ const root = document.documentElement if (width > 0) { - setTight(width < COMPOSER_STACK_BREAKPOINT_PX) + const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX + + if (nextTight !== lastTightRef.current) { + lastTightRef.current = nextTight + setTight(nextTight) + } } if (height > 0) { - root.style.setProperty('--composer-measured-height', `${Math.round(height)}px`) + const bucket = Math.round(height / 8) * 8 + + if (bucket !== lastBucketedHeightRef.current) { + lastBucketedHeightRef.current = bucket + root.style.setProperty('--composer-measured-height', `${bucket}px`) + } } if (surfaceHeight && surfaceHeight > 0) { - root.style.setProperty('--composer-surface-measured-height', `${Math.round(surfaceHeight)}px`) + const bucket = Math.round(surfaceHeight / 8) * 8 + + if (bucket !== lastBucketedSurfaceHeightRef.current) { + lastBucketedSurfaceHeightRef.current = bucket + root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + } } }, []) @@ -381,12 +407,28 @@ export function ChatBar({ return } + // Fast-bail: if neither `@` nor `/` appears in the current draft, there's + // nothing for `detectTrigger` to match. Skip the DOM range walk inside + // `textBeforeCaret` (which calls `range.toString()`, O(n) over the draft) + // and the regex pass that follows. Only when a relevant char is present + // do we pay the cost. + const text = composerPlainText(editor) + + if (!text.includes('@') && !text.includes('/')) { + if (trigger) { + setTrigger(null) + setTriggerActive(0) + } + + return + } + const before = textBeforeCaret(editor) - const detected = detectTrigger(before ?? composerPlainText(editor)) + const detected = detectTrigger(before ?? text) setTrigger(detected) setTriggerActive(0) - }, []) + }, [trigger]) const handleEditorInput = (event: FormEvent<HTMLDivElement>) => { const editor = event.currentTarget @@ -990,7 +1032,24 @@ export function ChatBar({ role="textbox" suppressContentEditableWarning /> - <ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} /> + {/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree + so the composer-state binding (text + IME + paste + form-submit hookup) + wires up. We render the real input UI ourselves above via the + contentEditable, so the primitive is invisible (sr-only). + + IMPORTANT: don't let it render its default <TextareaAutosize>. That + component runs `useLayoutEffect(resizeTextarea)` on every value change + and reads `node.scrollHeight` against a hidden measurement textarea, + forcing two synchronous layouts per keystroke for an element the + user can't see. Profiling 400-char synthetic typing showed >900ms + cumulative cost in getHeight2/calculateNodeHeight alone (~2.3ms/key) + on top of the per-keystroke React commit. + + `asChild` swaps TextareaAutosize for a Radix Slot wrapping our + plain <textarea>, which carries the binding but skips autosize. */} + <ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}> + <textarea aria-hidden className="sr-only" tabIndex={-1} /> + </ComposerPrimitive.Input> </div> )