diff --git a/apps/desktop/scripts/diag-submit.mjs b/apps/desktop/scripts/diag-submit.mjs new file mode 100644 index 00000000000..7b10218f364 --- /dev/null +++ b/apps/desktop/scripts/diag-submit.mjs @@ -0,0 +1,115 @@ +// Manual single-shot observer to find what's happening between Enter and clear +const list = await (await fetch('http://127.0.0.1:9222/json/list')).json() +const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http')) +const ws = new WebSocket(tgt.webSocketDebuggerUrl) +let id = 0 +const pending = new Map() +ws.addEventListener('message', ev => { + const m = JSON.parse(ev.data) + if (m.id != null && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +await new Promise(r => ws.addEventListener('open', r)) +const send = (m, p = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method: m, params: p })) + }) +const evalP = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) + return r.result.result.value +} + +// Type some text +const composerExists = await evalP(` + (() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return false + el.focus() + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges(); sel.addRange(range) + return true + })() +`) +console.log('composer focused:', composerExists) + +const text = 'cancel me ' + 'x'.repeat(30) +for (const c of text) { + await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 10)) +} +await new Promise(r => setTimeout(r, 200)) + +// Set up a deep observer that logs ALL state transitions in the composer subtree +await evalP(` + (() => { + window.__submitLog = [] + const composer = document.querySelector('[data-slot="composer-rich-input"]') + const root = composer?.closest('[data-slot="composer-root"]') || document.body + const startTime = performance.now() + const log = (kind, detail) => window.__submitLog.push({ t: performance.now() - startTime, kind, detail }) + log('start', { composerText: composer?.innerText?.length || 0, hasDataAuiEmpty: composer?.hasAttribute('data-aui-composer-empty') }) + + // Observe composer text changes via mutation + const composerObs = new MutationObserver(muts => { + const text = composer?.innerText ?? '' + log('composerMut', { textLen: text.length, head: text.slice(0, 30) }) + }) + composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true }) + + // Observe the busy state via aria-label / data-state on send button + const sendBtn = document.querySelector('[aria-label*="end"]') || document.querySelector('[aria-label*="top"]') + if (sendBtn) { + const btnObs = new MutationObserver(() => { + log('sendBtnMut', { aria: sendBtn.getAttribute('aria-label'), disabled: sendBtn.disabled }) + }) + btnObs.observe(sendBtn, { attributes: true }) + log('sendBtn', { aria: sendBtn.getAttribute('aria-label'), disabled: sendBtn.disabled }) + } + + // Observe thread message inserts + const threadRoot = document.querySelector('[data-slot="aui_thread-content"]') + const threadObs = threadRoot ? new MutationObserver(() => { + const c = threadRoot.querySelectorAll('[data-slot="aui_message"], [data-message-role]').length + log('threadMut', { count: c }) + }) : null + threadObs && threadObs.observe(threadRoot, { childList: true, subtree: true }) + + window.__obs = { composerObs, threadObs } + return true + })() +`) + +// Hit Enter +console.log('pressing Enter…') +await send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r' +}) +await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' }) + +// Wait, then dump +await new Promise(r => setTimeout(r, 3500)) + +const logs = await evalP('JSON.stringify(window.__submitLog)') +console.log('\n=== EVENT LOG ===') +for (const e of JSON.parse(logs || '[]')) { + console.log(` ${String(e.t.toFixed(1)).padStart(7)}ms ${e.kind.padEnd(15)} ${JSON.stringify(e.detail)}`) +} + +// Cancel the pending agent turn +await evalP(` + (() => { + for (const b of document.querySelectorAll('button')) { + if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stop-clicked' } + } + return 'no-stop' + })() +`).then(r => console.log('cancel:', r)) + +ws.close() diff --git a/apps/desktop/scripts/latency-under-stream.mjs b/apps/desktop/scripts/latency-under-stream.mjs new file mode 100644 index 00000000000..869e8fda058 --- /dev/null +++ b/apps/desktop/scripts/latency-under-stream.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// Measure typing latency WHILE the assistant is streaming a response. +// Submits a prompt, then immediately starts typing into the composer +// while tokens stream in. Records keypress→paint latency under load. +// +// Usage: node apps/desktop/scripts/latency-under-stream.mjs --chars=120 --cps=20 + +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 ?? 120) +const CPS = Number(args.cps ?? 20) + +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 }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function main() { + const tgt = await pickRenderer() + console.log('target', tgt.url) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + + // 1) Type a prompt + Enter + await evalP( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) + })()` + ) + + const prompt = 'write a short technical explanation of WebGL2 fragment shaders, just a paragraph please' + for (const c of prompt) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 8)) + } + await new Promise(r => setTimeout(r, 200)) + + await cdp.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r' + }) + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' }) + + // 2) Wait for the assistant to actually start streaming (look for streaming indicator) + console.log('waiting for stream to start…') + const streamStarted = await (async () => { + const deadline = Date.now() + 10000 + while (Date.now() < deadline) { + const text = await evalP( + cdp, + `(() => { + // Look for either streaming indicator OR new assistant message + const aiMsgs = document.querySelectorAll('[data-slot="aui_assistant-message-root"], [data-role="assistant"]') + return aiMsgs.length > 0 ? 'started' : null + })()` + ) + if (text === 'started') return true + await new Promise(r => setTimeout(r, 100)) + } + return false + })() + console.log('stream started:', streamStarted) + + if (!streamStarted) { + console.log('no streaming detected; aborting') + cdp.close() + return + } + + // Wait a moment to ensure stream is actively producing tokens + await new Promise(r => setTimeout(r, 800)) + + // 3) Refocus composer + install latency observer + await evalP( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + if (!el) return false + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) + window.__keypressTimings = [] + window.__pendingKey = null + const obs = new MutationObserver(() => { + const start = window.__pendingKey + if (start === null) return + const mut = performance.now() + window.__pendingKey = null + requestAnimationFrame(() => { + window.__keypressTimings.push({ + start, mut, paint: performance.now(), + mutLat: mut - start, paintLat: performance.now() - start + }) + }) + }) + obs.observe(el, { childList: true, subtree: true, characterData: true }) + window.__keystrokeObserver = obs + return true + })()` + ) + + // 4) Type while streaming + const text = + 'meanwhile typing into the composer while streaming runs — ' + + 'how does this feel as the assistant streams tokens above? ' + const slice = text.slice(0, CHARS) + const intervalMs = Math.max(1, Math.round(1000 / CPS)) + const t0 = Date.now() + for (let i = 0; i < slice.length; i++) { + await evalP(cdp, `window.__pendingKey = performance.now()`) + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: slice[i], unmodifiedText: slice[i] }) + const expected = t0 + (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)) + + // 5) Pull samples + const samples = await evalP(cdp, `JSON.stringify(window.__keypressTimings || [])`) + const arr = JSON.parse(samples) + console.log(`\n${arr.length} keystroke samples taken while streaming`) + + const paintLat = arr.map(s => s.paintLat).sort((a, b) => a - b) + const mutLat = arr.map(s => s.mutLat).sort((a, b) => a - b) + const stat = a => ({ + n: a.length, + min: a[0]?.toFixed(2), + p50: a[Math.floor(a.length * 0.5)]?.toFixed(2), + p90: a[Math.floor(a.length * 0.9)]?.toFixed(2), + p95: a[Math.floor(a.length * 0.95)]?.toFixed(2), + p99: a[Math.floor(a.length * 0.99)]?.toFixed(2), + max: a[a.length - 1]?.toFixed(2), + mean: a.length ? (a.reduce((s, x) => s + x, 0) / a.length).toFixed(2) : 0 + }) + console.log('\n=== keystroke→mutation latency (ms) while streaming ===') + console.log(' ', stat(mutLat)) + console.log('\n=== keystroke→paint latency (ms) while streaming ===') + console.log(' ', stat(paintLat)) + const slow = arr.filter(s => s.paintLat > 16).length + console.log(`\n${slow}/${arr.length} keystrokes > 16ms (dropped frame) while streaming`) + + // 6) Cancel the stream + await evalP( + cdp, + `(() => { + for (const b of document.querySelectorAll('button')) { + if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' } + } + return 'no-stop' + })()` + ).then(r => console.log('cancel:', r)) + + writeFileSync('/tmp/hermes-latency-under-stream.json', JSON.stringify(arr, null, 2)) + cdp.close() +} + +main().catch(e => { + console.error('fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/scripts/measure-submit.mjs b/apps/desktop/scripts/measure-submit.mjs index 6c89c44e34d..e7ccec9e6c0 100644 --- a/apps/desktop/scripts/measure-submit.mjs +++ b/apps/desktop/scripts/measure-submit.mjs @@ -85,66 +85,124 @@ async function focusAndType(cdp, text) { } 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) => { + // Install observers, await milestones. Returns a promise that we'll resolve + // after the page-side Promise resolves OR a CDP-side timeout (belt + braces). + const setup = await evalP( + cdp, + ` + (() => { 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) - } + window.__submitMilestones = { start: performance.now(), startMessageCount } + window.__submitDone = false + window.__submitResolve = null const composerObs = new MutationObserver(() => { - if (!milestones.composerClearedMs && composer && composer.innerText.length === 0) { - milestones.composerClearedMs = performance.now() - milestones.start + const m = window.__submitMilestones + if (!m) return + if (!m.composerClearedMs && composer && composer.innerText.length === 0) { + m.composerClearedMs = performance.now() - m.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') + const threadObs = threadRoot ? new MutationObserver(() => { + const m = window.__submitMilestones + if (!m || m.userMessageRenderedMs) return + const c = threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length + if (c > m.startMessageCount) { + m.userMessageRenderedMs = performance.now() - m.start + requestAnimationFrame(() => { + m.userMessagePaintMs = performance.now() - m.start + }) } - }, 100) - const timer = setTimeout(() => finish('timeout-overall'), ${timeoutMs}) + }) : null + threadObs && threadObs.observe(threadRoot, { childList: true, subtree: true }) - // 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) - }) - `) + window.__submitObservers = { composerObs, threadObs } + return true + })() + ` + ) + if (!setup) throw new Error('observer setup failed') + + // Send Enter via real keystroke channel (rawKeyDown + char + keyUp). + // React synthetic onKeyDown receives this exactly like a hardware Enter. + await cdp.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + key: 'Enter', + code: 'Enter', + text: '\r', + unmodifiedText: '\r' + }) + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + key: 'Enter', + code: 'Enter' + }) + + // Poll for the milestones from outside; cap at timeoutMs. + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const m = await evalP(cdp, 'JSON.stringify(window.__submitMilestones)') + const parsed = JSON.parse(m || '{}') + if (parsed.userMessagePaintMs != null) { + parsed.reason = 'paint' + await evalP(cdp, `(() => { + window.__submitObservers?.composerObs.disconnect() + window.__submitObservers?.threadObs?.disconnect() + })()`) + return parsed + } + await new Promise(r => setTimeout(r, 50)) + } + // Timed out + const m = await evalP(cdp, 'JSON.stringify(window.__submitMilestones)') + const parsed = JSON.parse(m || '{}') + parsed.reason = 'timeout-overall' + await evalP(cdp, `(() => { + window.__submitObservers?.composerObs.disconnect() + window.__submitObservers?.threadObs?.disconnect() + })()`) + return parsed +} + +async function tryCancel(cdp) { + // Find a Stop / Cancel button and click it. After submit, the composer + // turns into "cancel" mode; clicking it interrupts the agent turn so we + // don't burn tokens on these probes. + await evalP( + cdp, + ` + (() => { + // Common selectors: aria-label="Stop response", data-slot="composer-cancel", + // role=button with text "Stop" + const candidates = [ + '[aria-label="Stop response"]', + '[aria-label*="Stop"]', + '[aria-label*="Cancel"]', + '[data-slot*="cancel"]', + '[data-slot*="stop"]' + ] + for (const sel of candidates) { + const el = document.querySelector(sel) + if (el) { el.click(); return { clicked: sel } } + } + // Fallback: any button whose textContent includes "Stop" + for (const b of document.querySelectorAll('button')) { + if ((b.textContent || '').toLowerCase().includes('stop')) { b.click(); return { clicked: 'btn-text-Stop' } } + } + return { clicked: null } + })() + ` + ) } async function main() { @@ -165,8 +223,11 @@ async function main() { `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)) + // Interrupt the running agent turn so we don't burn tokens on these probes. + await new Promise(r => setTimeout(r, 200)) + await tryCancel(cdp) + // Wait long enough for the cancel to settle before next round. + await new Promise(r => setTimeout(r, 1500)) } writeFileSync('/tmp/hermes-submit-latency.json', JSON.stringify(samples, null, 2)) console.log('\nwrote /tmp/hermes-submit-latency.json') diff --git a/apps/desktop/scripts/profile-typing-lag.md b/apps/desktop/scripts/profile-typing-lag.md index 6d75b935be1..d157a8ff610 100644 --- a/apps/desktop/scripts/profile-typing-lag.md +++ b/apps/desktop/scripts/profile-typing-lag.md @@ -114,39 +114,56 @@ detached DOM, FiberNodes (unmounted), and listener growth. ## Findings -See commit message for `apps/desktop/src/app/chat/composer/index.tsx` -edits. Three changes: +See commit messages for the actual edits. Summary: -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. +1. **`src/app/chat/composer/index.tsx`** — four changes, biggest win is the + ~35 listener/round leak being gone: + - drop per-keystroke `scrollHeight` read used to decide composer expansion + - bucket measured composer height to 8 px before writing CSS vars on + `documentElement` (was firing per-px / per-char) + - remove the dead `$composerDraft` two-way sync (no external subscribers) + - `refreshTrigger` fast-bails when no `@`/`/` in draft (avoids O(n) + `range.toString()` walk) -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. +2. **`src/components/ui/fade-text.tsx`** — biggest win during streaming: + - drop the `useEffect([children])` that re-measured `scrollWidth` on + every parent re-render; `useResizeObserver` already handles the only + case where overflow state can legitimately change + - wrap the component in `memo` with a custom comparator that + short-circuits re-renders when scalar `children` (a string) is + unchanged -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). + Measured impact via `scripts/profile-under-stream.mjs` (typing 100 chars + into the composer while the assistant is streaming a 6-paragraph reply): -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. + - FadeText self time: **35.8 ms → 18.1 ms** (-50 %) + - Total active CPU (non-idle, non-GC): **~150 ms → ~50 ms** across the + same wall-clock window + - `tool-fallback.tsx` re-renders + `selectMessageRunning` selector both + dropped out of the top-5 self-time list -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 -## Submit / TTFT stall (open) +`scripts/measure-submit.mjs` measures Enter → composer-cleared → +user-message-rendered → first-paint. On a freshly loaded session, all five +rounds clear in ≤6 ms and paint in ≤322 ms (`clear=3ms userMsg=193ms +paint=316ms`). There's no UI-side stall on the submit path. Anything +felt as "stall after Enter" is gateway/agent first-token latency, not the +renderer. -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. +## Typing during streaming (the real complaint) + +`scripts/latency-under-stream.mjs` types into the composer while the +assistant is actively streaming. Before/after my patches: + +| | before | after | +|---|---|---| +| keystroke→paint p50 | 9.0 ms | 9-10 ms | +| keystroke→paint p90 | 14.9 ms | 14-15 ms | +| keystroke→paint p99 | 29.1 ms | 25-30 ms | +| dropped frames | 5/80 | 2-3/60 | + +Synthetic latency at 15 cps is similar; the CPU profile shows the per-token +work dropping by ~⅔, which means there's a lot more headroom for fast-burst +typing and complex token contents (long code blocks, math, etc.) — exactly +the case where the user-felt jank shows up. diff --git a/apps/desktop/scripts/profile-under-stream.mjs b/apps/desktop/scripts/profile-under-stream.mjs new file mode 100644 index 00000000000..232ecc14eeb --- /dev/null +++ b/apps/desktop/scripts/profile-under-stream.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// Capture a CPU profile while the assistant is streaming AND the user is +// typing into the composer. This is the scenario most likely to feel laggy +// in real use: follow-up typing while a prior turn is still streaming in. +// +// Output: /tmp/hermes-stream-type.cpuprofile + +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-stream-type-${Date.now()}`) +const CHARS = Number(args.chars ?? 100) +const CPS = Number(args.cps ?? 20) + +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 }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text) + return r.result.value +} + +async function main() { + const tgt = await pickRenderer() + console.log('target', tgt.url) + const cdp = await connect(tgt.webSocketDebuggerUrl) + await cdp.send('Runtime.enable') + await cdp.send('Profiler.enable') + + // Submit a meaty prompt + await evalP( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) + })()` + ) + const prompt = 'explain GPU memory bandwidth and the roofline model in detail with at least 6 paragraphs, no code' + for (const c of prompt) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c }) + await new Promise(r => setTimeout(r, 6)) + } + await new Promise(r => setTimeout(r, 200)) + await cdp.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r' + }) + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' }) + + // Wait for stream to begin + console.log('waiting for assistant…') + let streaming = false + for (let i = 0; i < 100; i++) { + const c = await evalP( + cdp, + `document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length` + ) + if (c > 0) { streaming = true; break } + await new Promise(r => setTimeout(r, 100)) + } + if (!streaming) { + console.error('no assistant message appeared') + cdp.close() + return + } + + // Wait for stream to produce some tokens + await new Promise(r => setTimeout(r, 800)) + + // Refocus, start profiler, type while streaming + await evalP( + cdp, + `(() => { + const el = document.querySelector('[data-slot="composer-rich-input"]') + el.focus() + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false) + window.getSelection().removeAllRanges(); window.getSelection().addRange(r) + })()` + ) + + await cdp.send('Profiler.setSamplingInterval', { interval: 1000 }) + await cdp.send('Profiler.start') + + const text = 'follow-up typing during streaming feels laggy when tokens flood in '.repeat(4).slice(0, CHARS) + const intervalMs = Math.max(1, Math.round(1000 / CPS)) + const t0 = 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 = t0 + (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 { profile } = await cdp.send('Profiler.stop') + writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile)) + console.log(`cpuprofile → ${OUT}.cpuprofile`) + + // Quick top-self summary + const total = (profile.endTime - profile.startTime) / 1000 + const intMs = total / 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 => ({ id: n.id, fn: n.callFrame.functionName || '(anon)', url: n.callFrame.url || '', line: n.callFrame.lineNumber, self: counts.get(n.id) ?? 0 })) + .sort((a, b) => b.self - a.self) + .slice(0, 25) + console.log(`\n=== ${total.toFixed(0)}ms wall, ${profile.samples?.length ?? 0} samples (${intMs.toFixed(2)}ms each) ===`) + for (const r of rows) { + if (r.self === 0) break + const url = r.url.replace(/^.*\/src\//, 'src/').replace(/\?.*$/, '').slice(0, 70) + console.log(` ${(r.self * intMs).toFixed(1).padStart(7)}ms (${String(r.self).padStart(4)} samp) ${r.fn.padEnd(45)} ${url}:${r.line}`) + } + + // Cancel stream + await evalP( + cdp, + `(() => { + for (const b of document.querySelectorAll('button')) { + if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' } + } + return 'no-stop' + })()` + ).then(r => console.log('cancel:', r)) + + cdp.close() +} + +main().catch(e => { + console.error('fatal:', e.stack ?? e.message) + process.exit(1) +}) diff --git a/apps/desktop/src/components/ui/fade-text.tsx b/apps/desktop/src/components/ui/fade-text.tsx index cc3b3bf1cac..e36b86edd42 100644 --- a/apps/desktop/src/components/ui/fade-text.tsx +++ b/apps/desktop/src/components/ui/fade-text.tsx @@ -1,5 +1,5 @@ import type { ComponentProps, CSSProperties } from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useRef, useState } from 'react' import { useResizeObserver } from '@/hooks/use-resize-observer' import { cn } from '@/lib/utils' @@ -22,8 +22,23 @@ interface FadeTextProps extends Omit, 'children'> { * background is — no need to know the surface color, no after-pseudo overlap. * The mask is only applied when the text is actually overflowing, so short * strings render as plain text without an unnecessary gradient on their tail. + * + * `memo` with a custom comparator skips re-renders entirely when the parent + * passed the same scalar `children` (e.g. a tool title string that didn't + * change between streaming frames). This matters during assistant streaming, + * where parents re-render on every token; without the memo+comparator, + * tool-fallback's title FadeTexts re-rendered for every token even though + * the title text was unchanged, and the `useResizeObserver` callback paid + * the `scrollWidth`/`clientWidth` cost (forced layout) on each one. + * + * The internal `useResizeObserver` fires the measure callback once on mount + * and whenever the host span's size changes; that covers initial render and + * any container resize. The previous explicit `useEffect([children, ...])` + * is redundant in that picture — RO already handles the only case where + * overflow state can legitimately change (host size changes) — and was the + * cause of the per-token forced-layout flushes. */ -export function FadeText({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) { +function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) { const ref = useRef(null) const [overflowing, setOverflowing] = useState(false) @@ -34,15 +49,13 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re return } - setOverflowing(el.scrollWidth - el.clientWidth > 1) + const overflow = el.scrollWidth - el.clientWidth > 1 + + setOverflowing(prev => (prev === overflow ? prev : overflow)) }, []) useResizeObserver(measureOverflow, ref) - useEffect(() => { - measureOverflow() - }, [children, measureOverflow]) - const maskStyle: CSSProperties = overflowing ? { maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`, @@ -62,3 +75,27 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re ) } + +function arePropsEqual(prev: FadeTextProps, next: FadeTextProps): boolean { + // Cheap scalar-children short-circuit — the hot path during streaming is + // re-rendering FadeText with the same string children every token tick. + // For non-string children we skip the optimization and fall through to + // React's default referential check (returning false re-renders, but + // crucially the inner `useResizeObserver` is still the only thing that + // can trigger a forced-layout pass). + if (prev.children !== next.children) { + if (typeof prev.children !== 'string' || typeof next.children !== 'string') { + return false + } + if (prev.children !== next.children) return false + } + + return ( + prev.className === next.className && + prev.fadeWidth === next.fadeWidth && + prev.style === next.style + ) +} + +export const FadeText = memo(FadeTextImpl, arePropsEqual) +