From 493dd5b660c2165db0fec702b4565a971d76bd53 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 21 May 2026 18:54:24 -0500 Subject: [PATCH] Revert "perf(desktop): cut FadeText forced layouts during streaming" This reverts commit 88e7d7537cdab87200405edf298e38cb37e0a950. --- apps/desktop/scripts/latency-under-stream.mjs | 204 ------------------ apps/desktop/scripts/measure-submit.mjs | 159 +++++--------- apps/desktop/scripts/profile-typing-lag.md | 75 +++---- apps/desktop/scripts/profile-under-stream.mjs | 170 --------------- apps/desktop/src/components/ui/fade-text.tsx | 51 +---- 5 files changed, 85 insertions(+), 574 deletions(-) delete mode 100644 apps/desktop/scripts/latency-under-stream.mjs delete mode 100644 apps/desktop/scripts/profile-under-stream.mjs diff --git a/apps/desktop/scripts/latency-under-stream.mjs b/apps/desktop/scripts/latency-under-stream.mjs deleted file mode 100644 index 869e8fda058..00000000000 --- a/apps/desktop/scripts/latency-under-stream.mjs +++ /dev/null @@ -1,204 +0,0 @@ -#!/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 e7ccec9e6c0..6c89c44e34d 100644 --- a/apps/desktop/scripts/measure-submit.mjs +++ b/apps/desktop/scripts/measure-submit.mjs @@ -85,124 +85,66 @@ async function focusAndType(cdp, text) { } async function submitAndMeasure(cdp, timeoutMs = 5000) { - // 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, - ` - (() => { + // 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 : '' - window.__submitMilestones = { start: performance.now(), startMessageCount } - window.__submitDone = false - window.__submitResolve = null + 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(() => { - const m = window.__submitMilestones - if (!m) return - if (!m.composerClearedMs && composer && composer.innerText.length === 0) { - m.composerClearedMs = performance.now() - m.start + if (!milestones.composerClearedMs && composer && composer.innerText.length === 0) { + milestones.composerClearedMs = performance.now() - milestones.start } }) composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true }) - 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 - }) + 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') } - }) : null - threadObs && threadObs.observe(threadRoot, { childList: true, subtree: true }) + }, 100) + const timer = setTimeout(() => finish('timeout-overall'), ${timeoutMs}) - 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 } - })() - ` - ) + // 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() { @@ -223,11 +165,8 @@ async function main() { `paint=${(result.userMessagePaintMs ?? -1).toFixed?.(0) ?? '?'}ms ` + `reason=${result.reason}` ) - // 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)) + // 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') diff --git a/apps/desktop/scripts/profile-typing-lag.md b/apps/desktop/scripts/profile-typing-lag.md index 51b18b54d4d..ecbab9d5475 100644 --- a/apps/desktop/scripts/profile-typing-lag.md +++ b/apps/desktop/scripts/profile-typing-lag.md @@ -117,56 +117,39 @@ detached DOM, FiberNodes (unmounted), and listener growth. ## Findings -See commit messages for the actual edits. Summary: +See commit message for `apps/desktop/src/app/chat/composer/index.tsx` +edits. Three changes: -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) +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. **`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 +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. - Measured impact via `scripts/profile-under-stream.mjs` (typing 100 chars - into the composer while the assistant is streaming a 6-paragraph reply): +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). - - 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 +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. -## Submit / TTFT stall +The biggest win is the listener leak in (3) — without it, each round of +typing leaked ~35 event listeners until a steady state. -`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. +## Submit / TTFT stall (open) -## 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. +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-under-stream.mjs b/apps/desktop/scripts/profile-under-stream.mjs deleted file mode 100644 index 232ecc14eeb..00000000000 --- a/apps/desktop/scripts/profile-under-stream.mjs +++ /dev/null @@ -1,170 +0,0 @@ -#!/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 e36b86edd42..cc3b3bf1cac 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 { memo, useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useResizeObserver } from '@/hooks/use-resize-observer' import { cn } from '@/lib/utils' @@ -22,23 +22,8 @@ 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. */ -function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) { +export function FadeText({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) { const ref = useRef(null) const [overflowing, setOverflowing] = useState(false) @@ -49,13 +34,15 @@ function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest return } - const overflow = el.scrollWidth - el.clientWidth > 1 - - setOverflowing(prev => (prev === overflow ? prev : overflow)) + setOverflowing(el.scrollWidth - el.clientWidth > 1) }, []) useResizeObserver(measureOverflow, ref) + useEffect(() => { + measureOverflow() + }, [children, measureOverflow]) + const maskStyle: CSSProperties = overflowing ? { maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`, @@ -75,27 +62,3 @@ function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest ) } - -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) -