diff --git a/apps/desktop/scripts/probe-model-picker.mjs b/apps/desktop/scripts/probe-model-picker.mjs new file mode 100644 index 000000000000..d5585a368351 --- /dev/null +++ b/apps/desktop/scripts/probe-model-picker.mjs @@ -0,0 +1,80 @@ +// Model picker open latency probe: click the composer model pill, time until +// the dropdown/dialog content paints, repeat. Run with --cpuprofile via the +// harness runner once promoted; standalone for iteration. +// node scripts/perf/probe-model-picker.mjs [--port 9222] [--rounds 5] +import { CDP } from './perf/lib/cdp.mjs' + +const args = process.argv.slice(2) +const flag = name => { + const i = args.indexOf(`--${name}`) + return i >= 0 ? args[i + 1] : undefined +} +const port = Number(flag('port') ?? 9222) +const rounds = Number(flag('rounds') ?? 5) +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +const cdp = await CDP.connect({ port }) +await cdp.send('Runtime.enable') + +// Find the pill (dropdown path) — the composer model selector button. +const PILL = `(() => { + const btns = [...document.querySelectorAll('button[aria-label]')] + const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '') && b.closest('[data-slot]')) + return pill ? (pill.getAttribute('aria-label') || 'found') : null +})()` + +console.log('pill:', await cdp.eval(PILL)) + +const MEASURE = ` + (async () => { + const btns = [...document.querySelectorAll('button[aria-label]')] + const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '')) + if (!pill) return JSON.stringify({ error: 'no pill' }) + + const t0 = performance.now() + pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + pill.click() + + // Wait for menu/dialog content to exist AND paint (double rAF after found). + const found = await new Promise(resolve => { + const deadline = performance.now() + 5000 + const check = () => { + const menu = document.querySelector('[role="menu"], [role="dialog"] [cmdk-list]') + if (menu && menu.childElementCount > 0) { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now()))) + return + } + if (performance.now() > deadline) { resolve(null); return } + requestAnimationFrame(check) + } + check() + }) + + const openMs = found ? found - t0 : null + const rows = document.querySelectorAll('[role="menu"] [role="menuitem"], [cmdk-item]').length + + // Close: Escape. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + const menu = document.querySelector('[role="menu"], [role="dialog"]') + menu?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await new Promise(r => setTimeout(r, 300)) + + return JSON.stringify({ openMs, rows }) + })() +` + +const samples = [] + +for (let i = 0; i < rounds; i++) { + const raw = await cdp.eval(MEASURE, { awaitPromise: true }) + const r = JSON.parse(raw) + console.log(`round ${i}:`, r) + if (typeof r.openMs === 'number') samples.push(r.openMs) + await sleep(500) +} + +samples.sort((a, b) => a - b) +console.log('\nopen latency ms — min/median/max:', + Math.round(samples[0]), '/', Math.round(samples[Math.floor(samples.length / 2)]), '/', Math.round(samples.at(-1))) +cdp.close() diff --git a/apps/desktop/scripts/profile-model-picker.mjs b/apps/desktop/scripts/profile-model-picker.mjs new file mode 100644 index 000000000000..26e16d6e7c9e --- /dev/null +++ b/apps/desktop/scripts/profile-model-picker.mjs @@ -0,0 +1,60 @@ +// CPU-profile one model-picker open. +// node scripts/profile-model-picker.mjs [--port 9222] +import { writeFileSync } from 'node:fs' + +import { CDP } from './perf/lib/cdp.mjs' +import { cpuProfileTopSelf } from './perf/lib/stats.mjs' + +const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222) +const cdp = await CDP.connect({ port }) +await cdp.send('Runtime.enable') +await cdp.send('Profiler.enable') +await cdp.send('Profiler.setSamplingInterval', { interval: 100 }) + +const OPEN = ` + (async () => { + // Reset: close any open menu first. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await new Promise(r => setTimeout(r, 250)) + + const btns = [...document.querySelectorAll('button[aria-label]')] + const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '')) + if (!pill) return -1 + const t0 = performance.now() + pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + pill.click() + const found = await new Promise(resolve => { + const deadline = performance.now() + 8000 + const check = () => { + const menu = document.querySelector('[role="menu"]') + if (menu && menu.childElementCount > 0) { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now()))) + return + } + if (performance.now() > deadline) { resolve(-1); return } + requestAnimationFrame(check) + } + check() + }) + return found < 0 ? -1 : found - t0 + })() +` + +await cdp.send('Profiler.start') +const openMs = await cdp.eval(OPEN) +const { profile } = await cdp.send('Profiler.stop') + +console.log('openMs:', Math.round(openMs)) +const out = `/tmp/model-picker-open.cpuprofile` +writeFileSync(out, JSON.stringify(profile)) +console.log('wrote', out) +console.log('top self-time (ms):') + +for (const r of cpuProfileTopSelf(profile, 20)) { + console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(44)} ${r.url.split('/').slice(-2).join('/')}:${r.line}`) +} + +// Close the menu again. +await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`) +cdp.close() diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 53c419937ffc..417edd4d222a 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -96,7 +96,20 @@ interface ModelEditSubmenuProps { requestGateway: (method: string, params?: Record) => Promise } -export function ModelEditSubmenu({ +export function ModelEditSubmenu(props: ModelEditSubmenuProps) { + // The panel mounts one of these per model row; only the hovered row's + // submenu is ever open. Keep this wrapper hook-free and render the body as + // a CHILD of SubContent so Radix's Presence gate leaves it unrendered until + // the sub actually opens — eagerly running the body's hooks/JSX for every + // row made opening the menu itself lag on large catalogs. + return ( + + + + ) +} + +function ModelEditSubmenuBody({ effort, fastControl, isActive, @@ -213,50 +226,46 @@ export function ModelEditSubmenu({ const hasFast = fastControl.kind !== 'none' const fastOn = fastControl.kind === 'none' ? false : fastControl.on - return ( - - {!hasFast && !reasoning ? ( -
{copy.noOptions}
- ) : ( + return !hasFast && !reasoning ? ( +
{copy.noOptions}
+ ) : ( + <> + {copy.options} + {reasoning ? ( + event.preventDefault()}> + {copy.thinking} + void patchReasoning(checked ? effortValue || defaultEffort : 'none')} + size="xs" + /> + + ) : null} + {hasFast ? ( + event.preventDefault()}> + {copy.fast} + + + ) : null} + {reasoning ? ( <> - {copy.options} - {reasoning ? ( - event.preventDefault()}> - {copy.thinking} - void patchReasoning(checked ? effortValue || defaultEffort : 'none')} - size="xs" - /> - - ) : null} - {hasFast ? ( - event.preventDefault()}> - {copy.fast} - - - ) : null} - {reasoning ? ( - <> - - {copy.effort} - void patchReasoning(value)} value={effortValue}> - {REASONING_EFFORTS.map(value => ( - event.preventDefault()} - value={value} - > - {copy[value]} - - ))} - - - ) : null} + + {copy.effort} + void patchReasoning(value)} value={effortValue}> + {REASONING_EFFORTS.map(value => ( + event.preventDefault()} + value={value} + > + {copy[value]} + + ))} + - )} -
+ ) : null} + ) } diff --git a/apps/desktop/src/components/ui/glyph-spinner.tsx b/apps/desktop/src/components/ui/glyph-spinner.tsx index bf42e587640a..52e82412c881 100644 --- a/apps/desktop/src/components/ui/glyph-spinner.tsx +++ b/apps/desktop/src/components/ui/glyph-spinner.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { cn } from '@/lib/utils' export type { SpinnerName } @@ -43,13 +44,20 @@ interface GlyphSpinnerProps { export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) { const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille! const [frame, setFrame] = useState(0) + // Pause when this surface is a hidden (kept-alive) tab: N mounted tabs each + // ticking a setInterval + setState burn CPU for pixels nobody can see. + const visible = usePaneVisible() useEffect(() => { + if (!visible) { + return + } + setFrame(0) const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) return () => window.clearInterval(id) - }, [spin]) + }, [spin, visible]) return (