diff --git a/apps/desktop/scripts/measure-profile-switch.mjs b/apps/desktop/scripts/measure-profile-switch.mjs new file mode 100644 index 000000000000..2f221f00de4c --- /dev/null +++ b/apps/desktop/scripts/measure-profile-switch.mjs @@ -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 [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 ') + 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) }) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index f3cb814e694a..52fa8de19c0a 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -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({ - {profiles.map(profile => { - const color = resolveProfileColor(profile.name, colors) - const hue = color ?? 'var(--ui-text-quaternary)' - - return ( - - - - {profile.name} - - - ) - })} + {profiles.map(profile => ( + + ))} ) } +// 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 ( + + + + {name} + + + ) +} + 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) 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) || '?'} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 9b15d388222c..574408fab15f 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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} diff --git a/apps/desktop/src/app/chat/sidebar/use-profile-prewarm.ts b/apps/desktop/src/app/chat/sidebar/use-profile-prewarm.ts new file mode 100644 index 000000000000..4aecf5cad525 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/use-profile-prewarm.ts @@ -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) + 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 } +} diff --git a/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts index 1306139151f7..30f097a45b54 100644 --- a/apps/desktop/src/store/profile.test.ts +++ b/apps/desktop/src/store/profile.test.ts @@ -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')]) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 79a9c5301381..e0b1cee1001b 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -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(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() + +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 | null = null // Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with