Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/desktop/e2e/large-session-resume.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import { RealSessionBuilder } from './real-session-builder'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const SESSION_TITLE = 'E2E large persisted session'
const EXPECTED_TEXT = 'E2E persisted user message 52'
// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
// baseline count taken before that backfill sees a clipped transcript and
// falsely reports duplicates once the full list mounts. Waiting for this
// oldest row means the baseline reflects the fully-mounted transcript.
const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
const HISTORY_TURNS = Array.from(
{ length: 27 },
Expand Down Expand Up @@ -210,6 +216,16 @@ test.describe('large session resume', () => {
await waitForAppReady(fixture, 120_000)

await openSeededSession(fixture.page)
// The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
// and backfills older turns in a rAF. Wait for the oldest seeded row to
// mount before taking the baseline so it reflects the full transcript —
// otherwise a clipped baseline makes the backfilled rows look like
// duplicates of the completed reply.
await fixture.page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
OLDEST_SEEDED_TEXT,
{ timeout: 30_000 },
)
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
await fixture.mock.waitForHeldStream()
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/scripts/diag-code-live.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Is the tree-split preview path actually active in the running renderer?
// Checks the served source (what vite compiled) rather than guessing.
import { attach } from './perf/lib/launch.mjs'

const { cdp, teardown } = await attach({ port: 9222 })

try {
await cdp.send('Runtime.enable')

const out = await cdp.eval(`(async () => {
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
const src = await res.text()
return JSON.stringify({
previewShift: src.includes('previewShift'),
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
})
})()`)

console.log(out)
} finally {
teardown?.()
}
47 changes: 47 additions & 0 deletions apps/desktop/scripts/diag-key-latency.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Typing latency, isolated: keystroke -> next paint, with and without an
// active stream. Distinguishes "input is slow" from "the frame budget is
// consumed by streaming flushes" — the fix differs completely.
import { attach } from './perf/lib/launch.mjs'

const { cdp, teardown } = await attach({ port: 9222 })

const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const perKey = []
for (let i = 0; i < 30; i++) {
const ch = 'abcdefghij'[i % 10]
const t0 = performance.now()
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
perKey.push(performance.now() - t0)
// Human-ish 80ms cadence so streaming flushes interleave realistically.
await new Promise(r => setTimeout(r, 80))
}
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const sorted = [...perKey].sort((a, b) => a - b)
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
return JSON.stringify({
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
over16: perKey.filter(f => f > 16.7).length,
over33: perKey.filter(f => f > 33).length,
streamingParts: busy
})
})()
`

try {
await cdp.send('Runtime.enable')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}
21 changes: 21 additions & 0 deletions apps/desktop/scripts/diag-live-state.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Quick state probe of the running hgui instance via CDP.
import { attach } from './perf/lib/launch.mjs'

const { cdp, teardown } = await attach({ port: 9222 })

try {
await cdp.send('Runtime.enable')

const state = await cdp.eval(`(() => {
const rc = !!window.__RENDER_COUNTS__
const pl = !!window.__PERF_LIVE__
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
})()`)

console.log(state)
} finally {
teardown?.()
}
149 changes: 149 additions & 0 deletions apps/desktop/scripts/diag-real-loop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// The real-app perf loop: drive HER hgui instance (real profile, real
// sessions) through the three interactions that matter — session switch,
// sidebar drag, composer typing — and report honest single-clock numbers.
//
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
//
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
// switching is measured as the user feels it: click -> transcript painted.

import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'

const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)

return i === -1 ? fallback : process.argv[i + 1]
}

const port = Number(arg('port', 9222))
const SWITCHES = Number(arg('switches', 6))

const { cdp, teardown } = await attach({ port })

// ---------------------------------------------------------------------------
// Session switch: click a sidebar session row, await the transcript settling.
// Measures click -> first paint of the new transcript AND click -> settled
// (two rAFs with no further DOM mutation in the thread viewport).
// ---------------------------------------------------------------------------
const SWITCH = swaps => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })

const results = []
for (let i = 0; i < ${swaps}; i++) {
const row = rows[i % Math.min(rows.length, 4)]
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
const t0 = performance.now()
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
row.click()

// First paint: next two rAFs after the click.
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
const firstPaint = performance.now() - t0

// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
let lastMutation = performance.now()
const target = viewport() ?? document.body
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(target, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 120) break
}
mo.disconnect()
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
await new Promise(r => setTimeout(r, 250))
}
return JSON.stringify(results)
})()
`

// ---------------------------------------------------------------------------
// Drag the first visible sash, single-clock frames.
// ---------------------------------------------------------------------------
const DRAG = `
(async () => {
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
if (!handle) return JSON.stringify({ error: 'no sash' })
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const frames = []
let last = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 2 : -2)
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
}
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length
})
})()
`

// ---------------------------------------------------------------------------
// Type into the composer, single-clock frames (one mark per keystroke frame).
// ---------------------------------------------------------------------------
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const frames = []
let last = performance.now()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
await new Promise(r => setTimeout(r, 20))
}
// Clear what we typed.
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const moving = frames
const total = moving.reduce((a, b) => a + b, 0)
const sorted = [...moving].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: moving.filter(f => f > 33).length
})
})()
`

try {
await cdp.send('Runtime.enable')

console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
console.log(await cdp.eval(SWITCH(SWITCHES)))

await sleep(500)
console.log('\n== SIDEBAR DRAG ==')
console.log(await cdp.eval(DRAG))

await sleep(500)
console.log('\n== COMPOSER TYPING ==')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}
27 changes: 27 additions & 0 deletions apps/desktop/scripts/diag-sidebar-dom.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
import { attach } from './perf/lib/launch.mjs'

const { cdp, teardown } = await attach({ port: 9222 })

try {
await cdp.send('Runtime.enable')

const out = await cdp.eval(`(() => {
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
if (!sidebar) return '(no sidebar el)'
// Find clickable rows: anchors or buttons with text, depth-limited sample.
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
const rows = clickables.map(el => ({
tag: el.tagName.toLowerCase(),
slot: el.getAttribute('data-slot') ?? '',
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
text: (el.textContent ?? '').trim().slice(0, 30),
visible: !!el.offsetParent
})).filter(r => r.text)
return JSON.stringify(rows.slice(0, 30), null, 1)
})()`)

console.log(out)
} finally {
teardown?.()
}
52 changes: 52 additions & 0 deletions apps/desktop/scripts/diag-switch-autopsy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Session-switch autopsy: click between the two heaviest rows repeatedly,
// recording per-switch (a) settled ms, (b) React commits, (c) top rendered
// components — so slow switches name themselves.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'

const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)

return i === -1 ? fallback : process.argv[i + 1]
}

const port = Number(arg('port', 9222))
const ROUNDS = Number(arg('rounds', 8))

const { cdp, teardown } = await attach({ port })

const SWITCH_ONE = index => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return JSON.stringify({ error: 'rows' })
const row = rows[${index} % 2]
const rc = window.__RENDER_COUNTS__
rc.start()
const t0 = performance.now()
row.click()
let lastMutation = performance.now()
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(document.body, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 4000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 150) break
}
mo.disconnect()
rc.stop()
const settled = Math.round(performance.now() - t0 - 150)
const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)')
return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report })
})()
`

try {
await cdp.send('Runtime.enable')

for (let i = 0; i < ROUNDS; i++) {
console.log(await cdp.eval(SWITCH_ONE(i)))
await sleep(400)
}
} finally {
teardown?.()
}
Loading
Loading