diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs
new file mode 100644
index 000000000000..7d167c4cf8d7
--- /dev/null
+++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs
@@ -0,0 +1,238 @@
+// What does the app cost while a turn is running but NOTHING is arriving?
+//
+// The user-visible symptom: with a thread spinning, resizing the sidebar or
+// typing in the composer feels slow. That is not streaming cost — the stream
+// is idle. It is the app re-rendering on its own, competing with the
+// interaction for the main thread.
+//
+// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
+// - idle_commits_per_s the renderer's self-inflicted commit rate
+// - drag_fps fps while dragging the sidebar splitter
+// - type_fps fps while typing in the composer
+//
+// A perfectly idle app scores 0 idle commits and pins both interactions at the
+// display's refresh rate. Every idle commit is main-thread time stolen from an
+// interaction the user can feel.
+//
+// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
+
+import { sleep } from '../lib/cdp.mjs'
+
+/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
+ * `render-churn`, but the driver never runs — the turn just stays open. */
+const setup = (tiles, seedTurns) => `
+ (() => {
+ const hook = window.__HERMES_SESSION_TILES__
+ if (!hook) return 'no-hook'
+ if (!window.__RENDER_COUNTS__) return 'no-render-counter'
+
+ const turn = (sid, i) => ([
+ { id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
+ parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
+ { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
+ parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
+ ])
+
+ window.__IDLE__ = { ids: [] }
+ for (let n = 1; n <= ${tiles}; n++) {
+ const sid = 'idle-tile-' + n
+ const rid = 'idle-rt-' + n
+ const messages = []
+ for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
+ // An OPEN assistant message: the turn is running, but no tokens arrive.
+ messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
+ parts: [{ type: 'text', text: 'Working on it.' }] })
+
+ window.__IDLE__.ids.push({ sid, rid })
+ hook.open(sid, 'center')
+ hook.patch(sid, { runtimeId: rid })
+ hook.publish(rid, {
+ storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
+ reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
+ busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
+ pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
+ needsInput: false, turnStartedAt: Date.now(), usage: null
+ })
+ }
+ return 'ok'
+ })()
+`
+
+const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
+
+/** Measure the app's self-inflicted commit rate with nothing happening. */
+const idleCost = seconds => `
+ (async () => {
+ const rc = window.__RENDER_COUNTS__
+ rc.start()
+ const t0 = performance.now()
+ await new Promise(r => setTimeout(r, ${seconds} * 1000))
+ const elapsed = (performance.now() - t0) / 1000
+ rc.stop()
+ return JSON.stringify({
+ elapsed,
+ commits: rc.commits(),
+ top: rc.report(12),
+ owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
+ })
+ })()
+`
+
+/** Record frame pacing across an interaction driven from the page, plus WHAT
+ * rendered during it — a slow interaction is almost never the handler, it is
+ * whatever re-renders underneath while the handler runs. */
+const withFrames = body => `
+ (async () => {
+ const rc = window.__RENDER_COUNTS__
+ rc.start()
+ const frames = []
+ let last = performance.now()
+ let stop = false
+ const tick = () => {
+ if (stop) return
+ const now = performance.now()
+ frames.push(now - last)
+ last = now
+ requestAnimationFrame(tick)
+ }
+ requestAnimationFrame(tick)
+ ${body}
+ stop = true
+ rc.stop()
+ const total = frames.reduce((a, b) => a + b, 0)
+ const sorted = [...frames].sort((a, b) => a - b)
+ const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
+ return JSON.stringify({
+ fps: total ? (frames.length / total) * 1000 : 0,
+ p95: pct(0.95),
+ worst: sorted.length ? sorted[sorted.length - 1] : 0,
+ slow33: frames.filter(f => f > 33).length,
+ n: frames.length,
+ commits: rc.commits(),
+ top: rc.report(10)
+ })
+ })()
+`
+
+/** Drag the sidebar splitter back and forth — the resize symptom. */
+const DRAG = withFrames(`
+ const handle = document.querySelector('[data-slot="pane-resize-handle"], [role="separator"], .resize-handle')
+ if (handle) {
+ const box = handle.getBoundingClientRect()
+ const y = box.top + box.height / 2
+ let x = box.left + box.width / 2
+ handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, pointerId: 1 }))
+ for (let i = 0; i < 60; i++) {
+ x += (i % 2 === 0 ? 3 : -3)
+ window.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: x, clientY: y, pointerId: 1 }))
+ await new Promise(r => requestAnimationFrame(r))
+ }
+ window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: x, clientY: y, pointerId: 1 }))
+ } else {
+ await new Promise(r => setTimeout(r, 1000))
+ }
+`)
+
+/** Type into the composer — the keystroke symptom. */
+const TYPE = withFrames(`
+ const el = document.querySelector('[contenteditable="true"], textarea')
+ if (el) {
+ el.focus()
+ for (let i = 0; i < 40; i++) {
+ const ch = 'performance testing '[i % 20]
+ el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
+ if (el.tagName === 'TEXTAREA') {
+ el.value += ch
+ el.dispatchEvent(new Event('input', { bubbles: true }))
+ } else {
+ 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 => setTimeout(r, 25))
+ }
+ } else {
+ await new Promise(r => setTimeout(r, 1000))
+ }
+`)
+
+const CLEANUP = `
+ (() => {
+ if (window.__IDLE__) {
+ for (const { sid, rid } of window.__IDLE__.ids) {
+ const states = window.__HERMES_SESSION_TILES__.states()
+ window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
+ window.__HERMES_SESSION_TILES__.close(sid)
+ }
+ window.__IDLE__ = null
+ }
+ window.__RENDER_COUNTS__.clear()
+ return 'cleaned'
+ })()
+`
+
+const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
+
+export default {
+ name: 'idle-cost',
+ tier: 'ci',
+ description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
+ async run(cdp, opts = {}) {
+ const tiles = Number(opts.tiles ?? 5)
+ const seedTurns = Number(opts.turns ?? 20)
+ const seconds = Number(opts.seconds ?? 6)
+
+ await cdp.send('Runtime.enable')
+
+ const ok = await cdp.eval(setup(tiles, seedTurns))
+
+ if (ok !== 'ok') {
+ throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
+ }
+
+ for (let n = 1; n <= tiles; n++) {
+ await cdp.eval(reveal(`idle-tile-${n}`))
+ await sleep(300)
+ }
+
+ await sleep(1500)
+
+ const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
+ const drag = JSON.parse(await cdp.eval(DRAG))
+ const type = JSON.parse(await cdp.eval(TYPE))
+
+ await cdp.eval(CLEANUP)
+
+ return {
+ metrics: {
+ // Commits per second with a turn open and nothing arriving. Should be 0.
+ idle_commits_per_s: round(idle.commits / idle.elapsed),
+ idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
+ // Interaction smoothness while that churn competes for the main thread.
+ // Reported as a deficit from 60fps so "lower is better" matches the
+ // baseline gate's direction.
+ drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
+ drag_slow_frames: drag.slow33,
+ type_fps_deficit: round(Math.max(0, 60 - type.fps)),
+ type_slow_frames: type.slow33
+ },
+ detail: {
+ tiles,
+ idleSeconds: round(idle.elapsed),
+ dragFps: round(drag.fps),
+ dragP95: round(drag.p95),
+ dragWorst: round(drag.worst),
+ typeFps: round(type.fps),
+ typeP95: round(type.p95),
+ typeWorst: round(type.worst),
+ // Components whose OWN state changed with no prop change: the roots.
+ idleOwners: idle.owners,
+ idleTop: idle.top,
+ dragCommits: drag.commits,
+ dragTop: drag.top,
+ typeCommits: type.commits,
+ typeTop: type.top
+ }
+ }
+ }
+}
diff --git a/apps/desktop/scripts/perf/scenarios/index.mjs b/apps/desktop/scripts/perf/scenarios/index.mjs
index d92526b0fcfa..de53212e4249 100644
--- a/apps/desktop/scripts/perf/scenarios/index.mjs
+++ b/apps/desktop/scripts/perf/scenarios/index.mjs
@@ -3,6 +3,7 @@
import coldStart from './cold-start.mjs'
import firstToken from './first-token.mjs'
+import idleCost from './idle-cost.mjs'
import keystroke from './keystroke.mjs'
import multitab from './multitab.mjs'
import profileSwitch from './profile-switch.mjs'
@@ -20,6 +21,7 @@ export const SCENARIOS = {
[transcript.name]: transcript,
[multitab.name]: multitab,
[renderChurn.name]: renderChurn,
+ [idleCost.name]: idleCost,
[coldStart.name]: coldStart,
[firstToken.name]: firstToken,
[submit.name]: submit,
diff --git a/apps/desktop/src/components/ui/tooltip.lazy.test.tsx b/apps/desktop/src/components/ui/tooltip.lazy.test.tsx
new file mode 100644
index 000000000000..4a66eda5cd5d
--- /dev/null
+++ b/apps/desktop/src/components/ui/tooltip.lazy.test.tsx
@@ -0,0 +1,56 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { Tip } from './tooltip'
+
+// `Tip` mounts Radix lazily (on first hover/focus) because ~107 eager
+// providers dominated unrelated interactions — dragging the sidebar splitter
+// measured 105k TooltipProvider renders across a 60-frame gesture. The saving
+// is only worth anything if the tip still actually appears, so this asserts
+// the behavior the optimization has to preserve, not its implementation.
+
+describe('Tip lazy mount', () => {
+ it('shows the tooltip on hover even though Radix mounts only once armed', async () => {
+ render(
+
+
+
+ )
+
+ const trigger = screen.getByRole('button', { name: 'target' })
+
+ expect(screen.queryByText('Reveal in sidebar')).toBeNull()
+
+ // Arms the lazy wrapper, then drives Radix's own trigger handlers for the
+ // same hover — the pointer is still inside, so the tip must open.
+ fireEvent.pointerEnter(trigger)
+ fireEvent.pointerMove(trigger)
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Reveal in sidebar').length).toBeGreaterThan(0)
+ })
+ })
+
+ it('renders the child untouched when there is no label', () => {
+ const { container } = render(
+
+
+
+ )
+
+ expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy()
+ expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeNull()
+ })
+
+ it('does not mount Radix until the pointer arrives', () => {
+ const { container } = render(
+
+
+
+ )
+
+ // The idle wrapper is present; no Radix trigger has been created.
+ expect(container.querySelector('[data-slot="tooltip-idle"]')).toBeTruthy()
+ expect(container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx
index fe16e3b97cfc..a28478949f38 100644
--- a/apps/desktop/src/components/ui/tooltip.tsx
+++ b/apps/desktop/src/components/ui/tooltip.tsx
@@ -108,14 +108,53 @@ interface TipProps extends Omit(null)
+
if (!label) {
return <>{children}>
}
+ if (!armed) {
+ // `display: contents` so this wrapper never affects layout — the child
+ // participates in its parent's box exactly as it did before.
+ return (
+ {
+ suppressNonKeyboardFocusOpen(event)
+
+ if (!event.defaultPrevented) {
+ setArmed('focus')
+ }
+ }}
+ onPointerEnter={() => setArmed('pointer')}
+ style={{ display: 'contents' }}
+ >
+ {children}
+
+ )
+ }
+
+ // `defaultOpen` is required, not incidental: the pointerenter/focus that
+ // armed this has ALREADY fired, so Radix's own trigger handlers never see it
+ // and the tip would mount silently closed. Arming IS the hover, so open with
+ // it — Radix owns every subsequent open/close from here (pointerleave, blur,
+ // escape, pointerdown) exactly as before.
return (
-
+
{children}
{label}