mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge pull request #74665 from NousResearch/bb/palette-perf
perf(desktop): ⌘K opens instantly, whatever else the shell is doing
This commit is contained in:
commit
d437d3e54d
4 changed files with 505 additions and 151 deletions
146
apps/desktop/scripts/probe-command-palette.mjs
Normal file
146
apps/desktop/scripts/probe-command-palette.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
|
||||
//
|
||||
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
|
||||
//
|
||||
// Reports, per round, the time from the keydown the app actually receives to:
|
||||
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
|
||||
// means: the overlay owes you a frame immediately)
|
||||
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
|
||||
// plus any long tasks in the window, so a slow open is attributable.
|
||||
import { CDP, sleep } 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') ?? 8)
|
||||
|
||||
const cdp = await CDP.connect({ port })
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const INSTALL = `
|
||||
(() => {
|
||||
if (window.__CMDK__) window.__CMDK__.stop()
|
||||
|
||||
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
|
||||
|
||||
// Time from the keydown the APP receives — excludes CDP transport, so the
|
||||
// number is what a user's finger actually experiences.
|
||||
const onKey = e => {
|
||||
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
state.t0 = performance.now()
|
||||
state.armed = false
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
|
||||
const obs = new MutationObserver(() => {
|
||||
if (state.t0 === null) return
|
||||
if (state.frame === null && document.querySelector('[cmdk-input]')) {
|
||||
state.frame = performance.now() - state.t0
|
||||
}
|
||||
const n = document.querySelectorAll('[cmdk-item]').length
|
||||
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
|
||||
})
|
||||
|
||||
obs.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
const po = new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
|
||||
})
|
||||
|
||||
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
|
||||
|
||||
window.__CMDK__ = {
|
||||
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
|
||||
read: () => ({
|
||||
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
|
||||
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
|
||||
rows: state.rows,
|
||||
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
|
||||
}),
|
||||
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
|
||||
}
|
||||
|
||||
return true
|
||||
})()
|
||||
`
|
||||
|
||||
// Settle: frame painted AND rows stopped growing for two frames.
|
||||
const WAIT = `
|
||||
new Promise(resolve => {
|
||||
let stable = 0
|
||||
let last = -1
|
||||
const started = performance.now()
|
||||
const tick = () => {
|
||||
const r = window.__CMDK__.read()
|
||||
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
|
||||
if (++stable >= 2) { resolve(r); return }
|
||||
} else { stable = 0 }
|
||||
last = r.rows
|
||||
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
`
|
||||
|
||||
const key = async type =>
|
||||
cdp.send('Input.dispatchKeyEvent', {
|
||||
type,
|
||||
key: 'k',
|
||||
code: 'KeyK',
|
||||
windowsVirtualKeyCode: 75,
|
||||
nativeVirtualKeyCode: 75,
|
||||
modifiers: 4
|
||||
})
|
||||
|
||||
const esc = async () => {
|
||||
for (const type of ['keyDown', 'keyUp']) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
|
||||
}
|
||||
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
await cdp.eval(INSTALL)
|
||||
await esc()
|
||||
|
||||
const samples = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await sleep(250)
|
||||
await cdp.eval('window.__CMDK__.arm()')
|
||||
await key('rawKeyDown')
|
||||
await key('keyUp')
|
||||
const r = await cdp.eval(WAIT)
|
||||
samples.push(r)
|
||||
console.log(`round ${i}:`, r)
|
||||
await esc()
|
||||
}
|
||||
|
||||
await cdp.eval('window.__CMDK__.stop()')
|
||||
|
||||
const stat = k => {
|
||||
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
|
||||
|
||||
if (!v.length) return null
|
||||
|
||||
return {
|
||||
min: v[0],
|
||||
median: v[Math.floor(v.length / 2)],
|
||||
max: v[v.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
|
||||
console.log('keydown → rows painted (ms):', stat('rows_ms'))
|
||||
console.log('long-task time in window (ms):', stat('longtask_ms'))
|
||||
|
||||
cdp.close()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
|
|
@ -235,6 +235,70 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
|
|||
// theme lists under both Light and Dark). The id suffix disambiguates.
|
||||
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
|
||||
|
||||
const EMPTY_GROUPS: PaletteGroup[] = []
|
||||
|
||||
// Backstop only. The palette normally retires on the content's real
|
||||
// `animationend`, so the CSS owns the close duration; this just guarantees the
|
||||
// body can't stay mounted forever somewhere animations never run (jsdom,
|
||||
// `animation: none`). Deliberately longer than any plausible exit so it never
|
||||
// races the real signal and truncates the fade.
|
||||
const EXIT_FALLBACK_MS = 1000
|
||||
|
||||
/**
|
||||
* The palette's row list, split out so an OPENING palette paints before it
|
||||
* renders rows. This component mounts with the portal, so `useDeferredValue`'s
|
||||
* initial value applies per open: the first commit is the frame + input
|
||||
* (instant), and the several-hundred-row list arrives in an interruptible
|
||||
* follow-up render. Opening ⌘K must never wait on building the list.
|
||||
*/
|
||||
const PaletteGroups = memo(function PaletteGroups({
|
||||
bindings,
|
||||
groups,
|
||||
modHeld,
|
||||
noResultsLabel,
|
||||
onSelectItem,
|
||||
onSelectMods,
|
||||
search
|
||||
}: {
|
||||
bindings: Record<string, string[]>
|
||||
groups: PaletteGroup[]
|
||||
modHeld: boolean
|
||||
noResultsLabel: string
|
||||
onSelectItem: (item: PaletteItem) => void
|
||||
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
|
||||
search: string
|
||||
}) {
|
||||
const deferred = useDeferredValue(groups, EMPTY_GROUPS)
|
||||
// While the rows are still catching up, an empty list means "not rendered
|
||||
// yet", not "nothing matched" — don't flash the empty state on open.
|
||||
const pending = deferred !== groups
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
|
||||
(keyed to its internal filter count) would never fire. */}
|
||||
{deferred.length === 0 && !pending && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">{noResultsLabel}</div>
|
||||
)}
|
||||
{deferred.map((group, index) => (
|
||||
<CommandGroup className={HUD_HEADING} heading={group.heading} key={group.heading ?? `palette-group-${index}`}>
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
modHeld={modHeld}
|
||||
onSelectItem={onSelectItem}
|
||||
onSelectMods={onSelectMods}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const PaletteRow = memo(function PaletteRow({
|
||||
bindings,
|
||||
item,
|
||||
|
|
@ -377,9 +441,68 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
|||
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌘K is an overlay that is stateful to itself: pressing it must open a frame
|
||||
* immediately, and must not be held up by whatever else the shell is doing. So
|
||||
* the mounted cost of a CLOSED palette is one store subscription and nothing
|
||||
* else.
|
||||
*
|
||||
* Everything expensive — a dozen store subscriptions (connection, update
|
||||
* status/apply, keybinds, worktrees, projects, theme, i18n), three server
|
||||
* queries, and the group builders that assemble a few hundred rows — lives in
|
||||
* `CommandPaletteBody`, which only exists while the palette is on screen.
|
||||
* Before this split those hooks ran on every render of the always-mounted
|
||||
* component: an in-flight update rewrote `$updateApply` per progress line and
|
||||
* rebuilt the entire row set each time, for a surface nobody could see.
|
||||
*
|
||||
* `mounted` lags `open` by the close animation rather than tracking it exactly.
|
||||
* Unmounting the body the instant `open` flips false would rip the content out
|
||||
* of the tree before Radix could play `data-[state=closed]`, so the overlay
|
||||
* would vanish instead of closing. The body reports its own exit via
|
||||
* `onExited` (the content's real `animationend`), so nothing here has to know
|
||||
* how long that animation is — the CSS owns the duration.
|
||||
*
|
||||
* The `openCount` key remounts the body per open, which is what lets local
|
||||
* search/sub-page state reset without a close effect.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [openCount, setOpenCount] = useState(0)
|
||||
|
||||
const retire = useCallback(() => {
|
||||
// Only retire the body if the palette is still closed — a reopen mid-fade
|
||||
// must not unmount the fresh instance.
|
||||
if (!$commandPaletteOpen.get()) {
|
||||
setMounted(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setOpenCount(count => count + 1)
|
||||
setMounted(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Safety net for environments where the exit animation never runs (jsdom,
|
||||
// `animation: none`), so the body can't be stranded mounted. The real
|
||||
// unmount is `onExited` below; whichever fires first wins.
|
||||
const timer = setTimeout(retire, EXIT_FALLBACK_MS)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, retire])
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
{mounted && <CommandPaletteBody key={openCount} onExited={retire} />}
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteBody({ onExited }: { onExited: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const pendingPage = useStore($commandPalettePage)
|
||||
const bindings = useStore($bindings)
|
||||
const worktrees = useStore($repoWorktrees)
|
||||
|
|
@ -441,12 +564,6 @@ export function CommandPalette() {
|
|||
const [modHeld, setModHeld] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setModHeld(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey)
|
||||
const clear = () => setModHeld(false)
|
||||
|
||||
|
|
@ -459,26 +576,25 @@ export function CommandPalette() {
|
|||
window.removeEventListener('keyup', sync, { capture: true })
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [open])
|
||||
}, [])
|
||||
|
||||
// Server-backed sources for the type-to-search groups, fetched lazily while
|
||||
// the palette is open. react-query handles caching/dedup/staleness.
|
||||
// Server-backed sources for the type-to-search groups. This component only
|
||||
// exists while the palette is open, so the queries are inherently lazy — no
|
||||
// `enabled` gate needed. react-query handles caching/dedup/staleness, so a
|
||||
// reopen paints from cache and revalidates in the background.
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['command-palette', 'config'],
|
||||
queryFn: getHermesConfigRecord,
|
||||
enabled: open
|
||||
queryFn: getHermesConfigRecord
|
||||
})
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude')
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only')
|
||||
})
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
|
|
@ -492,21 +608,16 @@ export function CommandPalette() {
|
|||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
|
||||
|
||||
// Reset the query/sub-page on close so it reopens clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
setPage(null)
|
||||
}
|
||||
}, [open])
|
||||
// Search/sub-page are local to a mount, and this component remounts per open
|
||||
// (keyed by open count), so each open starts clean without a reset effect.
|
||||
|
||||
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
|
||||
useEffect(() => {
|
||||
if (open && pendingPage) {
|
||||
if (pendingPage) {
|
||||
setPage(pendingPage)
|
||||
$commandPalettePage.set(null)
|
||||
}
|
||||
}, [open, pendingPage])
|
||||
}, [pendingPage])
|
||||
|
||||
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
|
||||
|
||||
|
|
@ -1101,102 +1212,92 @@ export function CommandPalette() {
|
|||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||
)}
|
||||
// The close animation finishing is what retires this whole subtree —
|
||||
// the CSS owns the duration, not a hardcoded timer. Guarded on the
|
||||
// content itself (descendants animate too) and on the closed state, so
|
||||
// an OPEN animation never unmounts the palette we just opened.
|
||||
onAnimationEnd={event => {
|
||||
if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') {
|
||||
onExited()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop shouldFilter={false}>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={goBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
<span>{t.commandCenter.back}</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop shouldFilter={false}>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={goBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
<span>{t.commandCenter.back}</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||
</button>
|
||||
<CommandInput
|
||||
className={HUD_TEXT}
|
||||
onKeyDown={event => {
|
||||
// Capture modifiers before cmdk's Enter fires onSelect (which
|
||||
// swipes the inviting MouseEvent and hands us nothing).
|
||||
noteSelectMods(event)
|
||||
|
||||
if (!activePage) {
|
||||
return
|
||||
}
|
||||
|
||||
// In a submenu: Esc and empty-input Backspace step back out
|
||||
// instead of closing the whole palette.
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage
|
||||
onGenerate={() => {
|
||||
closeCommandPalette()
|
||||
openPetGenerate()
|
||||
}}
|
||||
search={search}
|
||||
/>
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<PaletteGroups
|
||||
bindings={bindings}
|
||||
groups={visibleGroups}
|
||||
modHeld={modHeld}
|
||||
noResultsLabel={t.commandCenter.noResults}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
<CommandInput
|
||||
className={HUD_TEXT}
|
||||
onKeyDown={event => {
|
||||
// Capture modifiers before cmdk's Enter fires onSelect (which
|
||||
// swipes the inviting MouseEvent and hands us nothing).
|
||||
noteSelectMods(event)
|
||||
|
||||
if (!activePage) {
|
||||
return
|
||||
}
|
||||
|
||||
// In a submenu: Esc and empty-input Backspace step back out
|
||||
// instead of closing the whole palette.
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage
|
||||
onGenerate={() => {
|
||||
closeCommandPalette()
|
||||
openPetGenerate()
|
||||
}}
|
||||
search={search}
|
||||
/>
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<>
|
||||
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
|
||||
(keyed to its internal filter count) would never fire. */}
|
||||
{visibleGroups.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
|
||||
)}
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
modHeld={modHeld}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
|
||||
import {
|
||||
buildGroups,
|
||||
firstVisibleGroupIndex,
|
||||
LIVE_TAIL_MIN_GROUPS,
|
||||
LIVE_TAIL_PARTS,
|
||||
liveTailStart,
|
||||
type MessageGroup
|
||||
} from './list'
|
||||
|
||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
||||
// selector in list.tsx).
|
||||
|
|
@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('isVirtualizedGroup', () => {
|
||||
it('never virtualizes the newest turns (the live tail)', () => {
|
||||
const count = 20
|
||||
describe('liveTailStart', () => {
|
||||
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
|
||||
|
||||
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
}
|
||||
it('keeps the newest turns rendered until the parts budget is spent', () => {
|
||||
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
|
||||
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
|
||||
const start = liveTailStart(groups)
|
||||
|
||||
expect(start).toBeGreaterThan(0)
|
||||
expect(start).toBeLessThan(groups.length)
|
||||
|
||||
// Everything from `start` onward is the live tail...
|
||||
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
|
||||
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
|
||||
|
||||
// ...and dropping its oldest member puts it back under budget, i.e. the
|
||||
// tail is minimal rather than sprawling.
|
||||
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
|
||||
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
|
||||
})
|
||||
|
||||
it('virtualizes older turns that sit before the live tail', () => {
|
||||
const count = 20
|
||||
it('virtualizes the old bulk of a long agent transcript', () => {
|
||||
// The regression this guards: heavy tool turns. A turn-count tail (6) left
|
||||
// NOTHING virtualized on transcripts like this, so every Radix overlay open
|
||||
// paid a whole-document style recalc.
|
||||
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
|
||||
|
||||
expect(isVirtualizedGroup(0, count)).toBe(true)
|
||||
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
|
||||
// Only the min-group floor stays rendered; the other 38 turns skip.
|
||||
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
|
||||
})
|
||||
|
||||
it('never virtualizes below the min-group floor, however heavy the turns', () => {
|
||||
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
|
||||
|
||||
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
|
||||
})
|
||||
|
||||
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
|
||||
const count = LIVE_TAIL_GROUPS
|
||||
const groups = [group('a', 5), group('b', 5), group('c', 5)]
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
expect(liveTailStart(groups)).toBe(0)
|
||||
})
|
||||
|
||||
it('handles an empty transcript', () => {
|
||||
expect(liveTailStart([])).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a custom budget', () => {
|
||||
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
|
||||
|
||||
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
|
||||
// here, so the parts budget wins.
|
||||
expect(liveTailStart(groups, 3)).toBe(6)
|
||||
})
|
||||
|
||||
it('never renders more than the old turn-count tail did, on any shape', () => {
|
||||
// Guards the one way a parts budget can regress: a long transcript of tiny
|
||||
// turns, where walking back 40 parts reaches further than 6 turns would.
|
||||
const shapes = [
|
||||
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
|
||||
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
|
||||
Array.from({ length: 12 }, () => 6),
|
||||
[80, 120, 60, 150, 90, 200, 70], // real agent tile
|
||||
[30, 45]
|
||||
]
|
||||
|
||||
for (const weights of shapes) {
|
||||
const groups = weights.map((weight, i) => group(`g${i}`, weight))
|
||||
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
|
||||
|
||||
const oldStart = Math.max(0, groups.length - 6)
|
||||
|
||||
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a custom tail size', () => {
|
||||
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
|
||||
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
|
|||
// stick-to-bottom lock drifts and the view creeps up over older turns — the
|
||||
// "long session eventually shows old responses" glitch.
|
||||
//
|
||||
// Keep the newest N turns always-rendered so a turn is only ever virtualized
|
||||
// Keep the newest turns always-rendered so a turn is only ever virtualized
|
||||
// once its layout has settled at its final size (remembered == real → skipping
|
||||
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
|
||||
// recalc win on long transcripts is preserved (that scales with the hundreds of
|
||||
// old turns, not this small live tail).
|
||||
export const LIVE_TAIL_GROUPS = 6
|
||||
// recalc win on long transcripts is preserved.
|
||||
//
|
||||
// The tail is budgeted in PARTS, not turns, because that is what the cost
|
||||
// actually scales with — the same currency as RENDER_BUDGET / FIRST_PAINT_BUDGET.
|
||||
// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
|
||||
// turn is 50-200 parts, so a 6-TURN tail exempted the entire visible transcript
|
||||
// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
|
||||
// per tile): zero content-visibility containers were active, and every Radix
|
||||
// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
|
||||
//
|
||||
// 40 parts ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
|
||||
// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
|
||||
// doesn't fall out of the tail as it settles).
|
||||
export const LIVE_TAIL_PARTS = 40
|
||||
// Floor: always exempt at least this many turns regardless of weight, so a
|
||||
// transcript of very heavy turns still keeps the streaming one unvirtualized.
|
||||
export const LIVE_TAIL_MIN_GROUPS = 2
|
||||
// Ceiling: never exempt more than this many turns, however light they are. On a
|
||||
// long transcript of tiny turns a parts-only budget would walk back further
|
||||
// than the old turn-count tail did and virtualize LESS — this keeps the new
|
||||
// policy a strict improvement on every shape.
|
||||
export const LIVE_TAIL_MAX_GROUPS = 6
|
||||
|
||||
/** True when a visible group is old enough to virtualize (outside the live tail). */
|
||||
export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
|
||||
return indexInVisible < visibleCount - liveTail
|
||||
/**
|
||||
* Index of the newest group that still virtualizes — everything at or after it
|
||||
* is the live tail and stays rendered. Walks newest-first accumulating parts,
|
||||
* so the tail covers a viewport's worth of content rather than a fixed number
|
||||
* of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
|
||||
*/
|
||||
export function liveTailStart(
|
||||
groups: readonly MessageGroup[],
|
||||
tailParts = LIVE_TAIL_PARTS,
|
||||
minGroups = LIVE_TAIL_MIN_GROUPS,
|
||||
maxGroups = LIVE_TAIL_MAX_GROUPS
|
||||
): number {
|
||||
let parts = 0
|
||||
let start = groups.length
|
||||
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
parts += groups[i]?.weight ?? 1
|
||||
start = i
|
||||
|
||||
if (parts > tailParts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
|
||||
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
|
||||
// from sprawling past what the old turn-count policy rendered.
|
||||
const floor = Math.max(0, groups.length - minGroups)
|
||||
const ceiling = Math.max(0, groups.length - maxGroups)
|
||||
|
||||
return Math.min(floor, Math.max(ceiling, start))
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
|
|
@ -278,6 +325,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
|
||||
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
// Where the always-rendered live tail begins. Derived from the WEIGHTED
|
||||
// groups (parts, not turns) so the tail is a viewport's worth of content —
|
||||
// see liveTailStart. Computed once here rather than per row.
|
||||
const tailStart = useMemo(
|
||||
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
|
||||
[weightedGroups, hiddenCount]
|
||||
)
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||
|
|
@ -436,12 +490,11 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
// The live tail (newest turns) is exempt: virtualizing a turn
|
||||
// whose final size hasn't been remembered yet snaps it to a stale
|
||||
// height when it scrolls off, drifting stick-to-bottom up over old
|
||||
// turns. See isVirtualizedGroup.
|
||||
// turns. See liveTailStart.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
indexInVisible < tailStart && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
|
|
@ -461,7 +514,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)),
|
||||
[visibleGroups, components, structuralSignature]
|
||||
[visibleGroups, components, structuralSignature, tailStart]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue