perf(desktop): pre-warm profile pool backends on hover intent

A cold profile switch pays the full pool-backend spawn — Python boot,
port announcement, readiness probe, token adoption — before the
profile's gateway can even open. Measured with the new CDP harness
(scripts/measure-profile-switch.mjs, same family as
profile-session-switch.mjs): click → WS open is ~2.5-2.9s on a cold
profile, ~3-3.6s to a settled sidebar; a warm profile settles in
~0.5-0.8s. The pointer entering a profile square telegraphs the switch
hundreds of ms before the click lands, so start the spawn then.

- store/profile: prewarmProfileBackend(name) — fires the existing
  hermesDesktop.getConnection IPC, which is idempotent (ensureBackend
  returns the pooled connectionPromise), so the real switch joins the
  in-flight spawn instead of starting it. Skips the active gateway
  profile, throttles per profile (60s) so drive-by hovers can't spam
  spawn attempts, and swallows failures — error UX belongs to the real
  switch. No new IPC surface; the pool's existing LRU cap + idle reaper
  still bound resource use, and the LRU guard never evicts a
  keepalive-fresh backend for a hover spawn.
- sidebar/use-profile-prewarm: pointerenter/pointerleave handlers with
  a 120ms dwell so sweeping the pointer across the rail or a
  mixed-profile session list doesn't spawn a backend per element
  crossed.
- Wired at the three switch surfaces: rail ProfileSquare, the condensed
  ProfileDropdown items (extracted ProfileDropdownItem so each row owns
  its dwell timer), and SidebarSessionRow (covers cross-profile resumes
  from the all-profiles view; same-profile rows no-op inside the guard).

Measured E2E over CDP: synthetic hover on a cold profile square spawns
its backend in the background; the subsequent click settles in ~519ms
vs ~3.0-3.6s unhovered — and any hover shorter than the spawn still
shaves its dwell off the click's wait.

Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 212 files / 1777 passed (new prewarm guard/throttle
tests in store/profile.test.ts); eslint + prettier clean.
This commit is contained in:
Brooklyn Nicholson 2026-07-17 10:23:49 -04:00
parent 71252f0dcb
commit e0390c0f70
6 changed files with 315 additions and 21 deletions

View file

@ -0,0 +1,159 @@
// Measure a profile switch end-to-end: click a profile square in the rail,
// then break the wall time into the phases the renderer can observe:
// - getConnection IPC (Electron: pool backend spawn / reuse + readiness)
// - gateway WS connect
// - swap-target clear ($gatewaySwapTarget → sidebar loader gone)
// - sidebar session rows for the new profile painted
//
// Instruments window.hermesDesktop.getConnection + WebSocket to timestamp the
// phases without touching app code.
//
// Usage:
// node apps/desktop/scripts/measure-profile-switch.mjs <profileName> [settleTimeoutMs]
const CDP_HTTP = 'http://127.0.0.1:9222'
const PROFILE = process.argv[2]
const SETTLE_TIMEOUT = Number(process.argv[3] || 60000)
if (!PROFILE) {
console.error('usage: measure-profile-switch.mjs <profileName>')
process.exit(1)
}
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
static async open(url) {
const ws = new WebSocket(url)
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
const cdp = new CDP(ws)
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data.toString())
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) reject(new Error(m.error.message))
else resolve(m.result)
}
})
ws.addEventListener('close', () => {
for (const { reject } of cdp.pending.values()) reject(new Error('CDP socket closed'))
cdp.pending.clear()
})
return cdp
}
send(method, params) {
const id = ++this.id
return new Promise((res, rej) => {
this.pending.set(id, { resolve: res, reject: rej })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
return r.result.value
}
close() { this.ws.close() }
}
async function main() {
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
const cdp = await CDP.open(target.webSocketDebuggerUrl)
// Instrument getConnection + WebSocket once.
await cdp.eval(`(() => {
if (window.__PROFILE_SWITCH_OBS__) return 'already'
const obs = { events: [] }
const mark = (name, extra) => obs.events.push({ name, t: performance.now(), ...(extra || {}) })
window.__PROFILE_SWITCH_OBS__ = obs
window.__psMark = mark
const desktop = window.hermesDesktop
if (desktop && desktop.getConnection) {
const orig = desktop.getConnection.bind(desktop)
desktop.getConnection = async (profile) => {
mark('getConnection:start', { profile })
try {
const res = await orig(profile)
mark('getConnection:done', { profile })
return res
} catch (e) {
mark('getConnection:error', { profile, error: String(e).slice(0, 120) })
throw e
}
}
}
const OrigWS = window.WebSocket
window.WebSocket = function (url, ...rest) {
const ws = new OrigWS(url, ...rest)
if (String(url).includes('/api/ws')) {
mark('ws:new', { url: String(url).replace(/token=[^&]+/, 'token=…').slice(0, 90) })
ws.addEventListener('open', () => mark('ws:open'))
}
return ws
}
window.WebSocket.prototype = OrigWS.prototype
Object.assign(window.WebSocket, OrigWS)
return 'installed'
})()`)
const before = await cdp.eval(`(() => {
const rail = document.querySelector('[data-slot="profile-rail"]')
return {
railButtons: rail ? [...rail.querySelectorAll('[role="tab"], button')].map(b => (b.getAttribute('aria-label') || b.title || b.textContent || '').slice(0, 30)) : [],
sessions: document.querySelectorAll('[data-slot="sidebar-session-row"], [data-session-id]').length
}
})()`)
console.log('rail buttons:', JSON.stringify(before.railButtons))
const clicked = await cdp.eval(`(() => {
window.__psMark('click', { profile: ${JSON.stringify(PROFILE)} })
const rail = document.querySelector('[data-slot="profile-rail"]')
if (!rail) return 'no-rail'
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || '')).toLowerCase().includes(${JSON.stringify(PROFILE.toLowerCase())}))
if (!target) return 'not-found'
target.click()
return 'clicked'
})()`)
console.log('click:', clicked)
if (clicked !== 'clicked') { cdp.close(); process.exit(2) }
// Poll until the swap settles: loader gone + session rows painted (or empty
// list settled) + active profile pill shows the target.
const t0 = Date.now()
let settled = null
while (Date.now() - t0 < SETTLE_TIMEOUT) {
await new Promise((r) => setTimeout(r, 100))
const s = await cdp.eval(`(() => {
// The swap overlay stays mounted at opacity-0 after the swap — check the
// computed opacity of the container that holds the "Waking up …" label.
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
return {
t: performance.now(),
overlayVisible,
sessions: document.querySelectorAll('[data-slot="row-button"]').length
}
})()`)
if (!s.overlayVisible && s.sessions > 0) { settled = s; break }
}
await new Promise((r) => setTimeout(r, 400))
const obs = await cdp.eval('window.__PROFILE_SWITCH_OBS__')
const events = obs.events
const click = events.find((e) => e.name === 'click' && e.profile === PROFILE)
console.log('\n=== PHASES (ms after click) ===')
for (const e of events) {
if (e.t < click.t - 5) continue
console.log(`${(e.t - click.t).toFixed(0).padStart(7)} ${e.name}${e.profile ? ' [' + e.profile + ']' : ''}${e.error ? ' ' + e.error : ''}${e.url ? ' ' + e.url : ''}`)
}
console.log(settled ? `\nsettled (loader gone + rows painted) at ~${Date.now() - t0} ms wall` : '\nTIMEOUT waiting for settle')
cdp.close()
}
main().catch((e) => { console.error(e); process.exit(1) })

View file

@ -66,6 +66,8 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
import { useProfilePrewarm } from './use-profile-prewarm'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// Past this many profiles the strip of colored squares stops scaling (tiny
@ -457,30 +459,40 @@ function ProfileDropdown({
<SelectValue placeholder={p.title} />
</SelectTrigger>
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
{profiles.map(profile => {
const color = resolveProfileColor(profile.name, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
return (
<SelectItem key={profile.name} value={profile.name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{profile.name}</span>
</span>
</SelectItem>
)
})}
{profiles.map(profile => (
<ProfileDropdownItem
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
name={profile.name}
/>
))}
</SelectContent>
</Select>
)
}
// One dropdown row per profile — its own component so each row can own a
// hover-intent prewarm timer (see useProfilePrewarm).
function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) {
const hue = color ?? 'var(--ui-text-quaternary)'
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
return (
<SelectItem onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{name}</span>
</span>
</SelectItem>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).
@ -548,6 +560,9 @@ function ProfileSquare({
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
// Hovering a square telegraphs the switch — start that profile's backend
// spawn now so a cold click doesn't pay the full boot.
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(label)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label,
@ -637,7 +652,11 @@ function ProfileSquare({
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerLeave={clearPress}
onPointerEnter={startPrewarm}
onPointerLeave={() => {
clearPress()
cancelPrewarm()
}}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}

View file

@ -20,6 +20,7 @@ import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
@ -68,6 +69,7 @@ export function SidebarSessionRow({
}: SidebarSessionRowProps) {
const { t } = useI18n()
const r = t.sidebar.row
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(session.profile)
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
@ -161,6 +163,12 @@ export function SidebarSessionRow({
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
}}
// Hovering a row from another profile (the all-profiles view) telegraphs
// a cross-profile resume — start that backend's spawn now so the click
// doesn't pay the full cold boot. Same-profile rows no-op inside
// prewarmProfileBackend.
onPointerEnter={startPrewarm}
onPointerLeave={cancelPrewarm}
ref={ref}
style={style}
{...rest}

View file

@ -0,0 +1,38 @@
import { useCallback, useEffect, useRef } from 'react'
import { prewarmProfileBackend } from '@/store/profile'
// Dwell before firing: long enough that sweeping the pointer across the rail
// or a mixed-profile session list doesn't spawn a backend for every element
// passed through, short enough to beat the click by hundreds of ms.
const PREWARM_DWELL_MS = 120
/**
* pointerenter/pointerleave handlers that pre-warm `profile`'s pool backend
* after a short hover dwell (see prewarmProfileBackend in store/profile).
* Consumers merge these with their own pointer handlers.
*/
export function useProfilePrewarm(profile: string | null | undefined) {
const timer = useRef<null | number>(null)
const profileRef = useRef(profile)
profileRef.current = profile
const cancelPrewarm = useCallback(() => {
if (timer.current != null) {
clearTimeout(timer.current)
timer.current = null
}
}, [])
useEffect(() => cancelPrewarm, [cancelPrewarm])
const startPrewarm = useCallback(() => {
cancelPrewarm()
timer.current = window.setTimeout(() => {
timer.current = null
prewarmProfileBackend(profileRef.current || 'default')
}, PREWARM_DWELL_MS)
}, [cancelPrewarm])
return { cancelPrewarm, startPrewarm }
}

View file

@ -18,7 +18,9 @@ vi.mock('@/hermes', () => ({
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, refreshProfiles } = await import('./profile')
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, prewarmProfileBackend, refreshProfiles } =
await import('./profile')
const { $connection } = await import('./session')
const { queryClient } = await import('@/lib/query-client')
const { getProfiles } = await import('@/hermes')
@ -115,6 +117,42 @@ describe('profile-scoped cache invalidation', () => {
})
})
describe('prewarmProfileBackend (hover-intent pool spawn)', () => {
it('kicks getConnection for a non-active profile', () => {
getConnection.mockResolvedValue(localConn())
prewarmProfileBackend('warm-basic')
expect(getConnection).toHaveBeenCalledWith('warm-basic')
})
it('skips the profile the gateway is already on', () => {
$activeGatewayProfile.set('warm-active')
prewarmProfileBackend('warm-active')
expect(getConnection).not.toHaveBeenCalled()
})
it('throttles repeat pre-warms for the same profile within the interval', () => {
getConnection.mockResolvedValue(localConn())
prewarmProfileBackend('warm-throttle-a')
prewarmProfileBackend('warm-throttle-a')
prewarmProfileBackend('warm-throttle-b')
const calls = getConnection.mock.calls.map(([name]) => name)
expect(calls.filter(name => name === 'warm-throttle-a')).toHaveLength(1)
expect(calls.filter(name => name === 'warm-throttle-b')).toHaveLength(1)
})
it('swallows spawn failures — error UX belongs to the real switch', () => {
getConnection.mockRejectedValue(new Error('spawn failed'))
expect(() => prewarmProfileBackend('warm-failing')).not.toThrow()
})
})
describe('refreshProfiles shared rail list (#49289)', () => {
it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => {
$profiles.set([profile('default', true), profile('test1')])

View file

@ -189,6 +189,38 @@ $activeGatewayProfile.subscribe(value => {
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
export const $gatewaySwapTarget = atom<string | null>(null)
// ── Hover-intent backend pre-warm ───────────────────────────────────────────
// A cold switch to a profile whose pool backend isn't running pays the full
// spawn (Python boot + port announce + readiness probe — measured ~2.5-3s)
// before its gateway can even open. The pointer entering a profile square in
// the rail signals the switch a few hundred ms before the click lands, so we
// start the spawn then. `getConnection` → `ensureBackend` in the Electron main
// is idempotent (a pooled profile returns its existing connectionPromise), so
// the real switch's getConnection joins the in-flight spawn instead of
// starting it — and a pre-warm for an already-live backend is a cheap no-op.
// Throttled per profile so drive-by hovers can't spam spawn attempts; failures
// stay silent here and surface on the real switch, which owns error UX.
const PREWARM_MIN_INTERVAL_MS = 60_000
const prewarmedAt = new Map<string, number>()
export function prewarmProfileBackend(name: string): void {
const key = normalizeProfileKey(name)
if (key === normalizeProfileKey($activeGatewayProfile.get())) {
return
}
const now = Date.now()
if (now - (prewarmedAt.get(key) ?? 0) < PREWARM_MIN_INTERVAL_MS) {
return
}
prewarmedAt.set(key, now)
window.hermesDesktop?.getConnection?.(key).catch(() => undefined)
}
let gatewaySwitch: Promise<void> | null = null
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with