From b7b378e3a43f94b9f4a1a34155707c6301c0fd87 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 21 May 2026 18:54:32 -0500 Subject: [PATCH] Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer" This reverts commit bff1b3261d18a2427ac6c345c99f8312728346dd. --- apps/desktop/scripts/click-session.mjs | 51 ---- apps/desktop/scripts/dev-no-hmr.mjs | 22 -- 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 | 155 ----------- apps/desktop/scripts/profile-typing.mjs | 260 ------------------- apps/desktop/scripts/reload-renderer.mjs | 25 -- apps/desktop/src/app/chat/composer/index.tsx | 107 ++------ 10 files changed, 24 insertions(+), 1219 deletions(-) delete mode 100644 apps/desktop/scripts/click-session.mjs delete mode 100644 apps/desktop/scripts/dev-no-hmr.mjs delete mode 100644 apps/desktop/scripts/leak-typing.mjs delete mode 100644 apps/desktop/scripts/measure-latency.mjs delete mode 100644 apps/desktop/scripts/measure-submit.mjs delete mode 100644 apps/desktop/scripts/probe-renderer.mjs delete mode 100644 apps/desktop/scripts/profile-typing-lag.md delete mode 100644 apps/desktop/scripts/profile-typing.mjs delete mode 100644 apps/desktop/scripts/reload-renderer.mjs diff --git a/apps/desktop/scripts/click-session.mjs b/apps/desktop/scripts/click-session.mjs deleted file mode 100644 index 77983f51d68..00000000000 --- a/apps/desktop/scripts/click-session.mjs +++ /dev/null @@ -1,51 +0,0 @@ -// 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 deleted file mode 100644 index 9647e973811..00000000000 --- a/apps/desktop/scripts/dev-no-hmr.mjs +++ /dev/null @@ -1,22 +0,0 @@ -#!/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/leak-typing.mjs b/apps/desktop/scripts/leak-typing.mjs deleted file mode 100644 index d43a8478278..00000000000 --- a/apps/desktop/scripts/leak-typing.mjs +++ /dev/null @@ -1,222 +0,0 @@ -#!/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 deleted file mode 100644 index c3f3da1302c..00000000000 --- a/apps/desktop/scripts/measure-latency.mjs +++ /dev/null @@ -1,184 +0,0 @@ -#!/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 deleted file mode 100644 index 6c89c44e34d..00000000000 --- a/apps/desktop/scripts/measure-submit.mjs +++ /dev/null @@ -1,179 +0,0 @@ -#!/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 deleted file mode 100644 index fb0633b73b8..00000000000 --- a/apps/desktop/scripts/probe-renderer.mjs +++ /dev/null @@ -1,38 +0,0 @@ -// 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 deleted file mode 100644 index ecbab9d5475..00000000000 --- a/apps/desktop/scripts/profile-typing-lag.md +++ /dev/null @@ -1,155 +0,0 @@ -# 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 per round, clears, force-GCs, captures -`Performance.getMetrics` deltas. Reveals leaked event listeners, heap -drift, document node growth, and forced-layout counts. - -```bash -# After clicking into a real session (e.g. via click-session.mjs): -node apps/desktop/scripts/leak-typing.mjs --rounds=8 --chars=200 --cps=50 -``` - -**Real-session numbers (Phaser thread, 8 rounds × 200 chars):** - -| | unpatched (HEAD~2) | patched (HEAD) | -|---|---|---| -| jsListeners growth/round | +0 | +0 | -| DOM nodes growth/round | +0 | +0 | -| heap growth/round | ~0 (V8 housekeeping) | ~0 | -| **forced layouts/char** | **7.02** | **2.35** (3× fewer) | - -The forced-layout count is the load-bearing number — typing into a real -session was triggering ~7 layouts per character on the unpatched build -(scrollHeight reads + per-px CSS var writes + FadeText scrollWidth reads -all stacking up). After the patches it's down to ~2.35/char, which is -Blink's natural cost for a 1px/char-growing contentEditable and can't -be lowered further without architectural changes. - -The initial "+35 listeners/round leak" I called out on the first -unpatched run turned out to be transient warm-up (popovers initializing, -etc.); steady-state listener growth was 0 both before and after. - -### 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 deleted file mode 100644 index f57cb40adf6..00000000000 --- a/apps/desktop/scripts/profile-typing.mjs +++ /dev/null @@ -1,260 +0,0 @@ -#!/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 deleted file mode 100644 index f1f57462dcd..00000000000 --- a/apps/desktop/scripts/reload-renderer.mjs +++ /dev/null @@ -1,25 +0,0 @@ -// 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 3c58c41848f..eb3eb507eba 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -23,8 +23,10 @@ import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $composerAttachments, + $composerDraft, clearComposerAttachments, - type ComposerAttachment + type ComposerAttachment, + reconcileComposerTerminalSelections } from '@/store/composer' import { $queuedPromptsBySession, @@ -216,16 +218,10 @@ 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 @@ -234,20 +230,22 @@ 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) @@ -259,22 +257,13 @@ export function ChatBar({ return } - if (draft.includes('\n') || draft.length > 60) { + const wraps = (editorRef.current?.scrollHeight ?? 0) > 56 + + if (draft.includes('\n') || wraps) { 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 @@ -287,30 +276,15 @@ export function ChatBar({ const root = document.documentElement if (width > 0) { - const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX - - if (nextTight !== lastTightRef.current) { - lastTightRef.current = nextTight - setTight(nextTight) - } + setTight(width < COMPOSER_STACK_BREAKPOINT_PX) } if (height > 0) { - const bucket = Math.round(height / 8) * 8 - - if (bucket !== lastBucketedHeightRef.current) { - lastBucketedHeightRef.current = bucket - root.style.setProperty('--composer-measured-height', `${bucket}px`) - } + root.style.setProperty('--composer-measured-height', `${Math.round(height)}px`) } if (surfaceHeight && surfaceHeight > 0) { - const bucket = Math.round(surfaceHeight / 8) * 8 - - if (bucket !== lastBucketedSurfaceHeightRef.current) { - lastBucketedSurfaceHeightRef.current = bucket - root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) - } + root.style.setProperty('--composer-surface-measured-height', `${Math.round(surfaceHeight)}px`) } }, []) @@ -407,28 +381,12 @@ 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 ?? text) + const detected = detectTrigger(before ?? composerPlainText(editor)) setTrigger(detected) setTriggerActive(0) - }, [trigger]) + }, []) const handleEditorInput = (event: FormEvent<HTMLDivElement>) => { const editor = event.currentTarget @@ -1032,24 +990,7 @@ export function ChatBar({ role="textbox" suppressContentEditableWarning /> - {/* 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> + <ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} /> </div> )