Revert "perf(desktop): cut FadeText forced layouts during streaming"

This reverts commit 88e7d7537c.
This commit is contained in:
Brooklyn Nicholson 2026-05-21 18:54:24 -05:00
parent 0739588f48
commit 493dd5b660
5 changed files with 85 additions and 574 deletions

View file

@ -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)
})

View file

@ -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')

View file

@ -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.

View file

@ -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)
})

View file

@ -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<ComponentProps<'span'>, '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<HTMLSpanElement>(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
</span>
)
}
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)