Improve agent picker updates and menu pointer handoffs (#36874)

* Optimize agent picker menu updates and pointer highlighting

- Keep catalog hydration re-renders scoped to open menu content
- Preserve unchanged picker rows and fix menu highlights after scrolling or submenu flicks

* Improve menu pointer handoffs and picker update performance

- Preserve focus during scrolling and portaled setup interactions
- Open rested submenu targets without an extra pointer move
- Avoid unnecessary picker row updates while keeping translations current

* Fix inventory subscriber assertions to identify agents

* Defer launch picker inventory updates to passive effects

* Add menu pointer highlight component fixtures and tests

* Fix checkbox menu item test accessibility state

* Reset retained menu pointer tracking on reopen

* Keep menu highlights in sync while scrolling submenus

* Remove synthetic submenu pointer handoff

- Preserve Radix hover and keyboard navigation without triggering clicks
- Keep wheel-scroll highlight synchronization responsive to current cursor position

* Preserve submenu pointer grace after wheel scrolling

* Preserve prevented menu pointer movement during scrolling

* Fix menu pointer tracking observer cleanup

* Recognize component fixture specs as Playwright tests
This commit is contained in:
Jinjing
2026-11-01 01:39:06 -07:00
committed by GitHub
parent 57e287b555
commit 30ffe6e297
26 changed files with 1377 additions and 102 deletions
@@ -22,6 +22,12 @@ const NON_UNIT_TEST_RUNNERS = [
sources: ['tests/playwright.config.ts'],
mirrors: ["testDir: './e2e'", "testMatch: '**/*.spec.ts'"]
},
{
name: 'component fixtures Playwright',
include: ['tests/component-fixtures/**/*.spec.ts'],
sources: ['tests/component-fixtures/playwright.config.ts'],
mirrors: ["testDir: '.'", "testMatch: '*.spec.ts'"]
},
{
name: 'computer-use e2e',
include: ['tests/e2e/computer-*.e2e.ts'],
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { TooltipProvider } from '@/components/ui/tooltip'
import { i18n } from '@/i18n/i18n'
import es from '@/i18n/locales/es.json'
import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
it('refreshes translated row labels on a language change with unchanged props', async () => {
const language = i18n.language
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
const entry: AgentPickerEntry = {
agent: 'codex',
label: 'Codex',
rows: [{ agent: 'codex', kind: 'chat-unavailable', reason: 'ssh' }],
surfaces: ['terminal'],
defaultSurface: 'terminal',
terminal: {},
structured: {},
catalog: { origin: 'probe', fetchedAt: 1, models: [] }
}
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
try {
i18n.addResourceBundle('es', 'translation', es)
await i18n.changeLanguage('en')
act(() =>
root.render(
<TooltipProvider>
<DropdownMenu defaultOpen modal={false}>
<DropdownMenuTrigger>New tab</DropdownMenuTrigger>
<DropdownMenuContent>
<AgentLaunchPickerMenu entries={[entry]} runLaunch={vi.fn()} />
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
)
)
const row = document.querySelector<HTMLElement>('[role="menuitem"]')!
expect(row.textContent).toContain('No chat over SSH')
await act(async () => {
await i18n.changeLanguage('es')
})
expect(row.textContent).toContain(es.auto.components.tab.bar.QuickLaunchButton.chatNotOverSsh)
expect(row.textContent).not.toContain('No chat over SSH')
} finally {
act(() => root.unmount())
host.remove()
await i18n.changeLanguage(language)
vi.unstubAllGlobals()
}
})
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'react'
import { AgentLaunchSetupButton } from './AgentLaunchSetupButton'
import { AgentLaunchPickerMenu } from './AgentLaunchPickerMenu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
vi.mock('./AgentLaunchSetupButton', () => ({
AgentLaunchSetupButton: vi.fn((props: ComponentProps<typeof AgentLaunchSetupButton>) =>
props.renderAnchor?.(<button>Setup</button>)
)
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('skips unchanged search rows on unrelated inventory updates and launches current choices', () => {
const codex: AgentPickerEntry = {
agent: 'codex',
label: 'Codex',
rows: [{ agent: 'codex', kind: 'cli' }],
surfaces: ['terminal'],
defaultSurface: 'terminal',
terminal: {},
structured: {},
catalog: {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: 'host-model',
label: 'Host model',
isDefault: true,
efforts: [{ value: 'low', label: 'Low' }]
}
]
}
}
const claude: AgentPickerEntry = {
...codex,
agent: 'claude',
label: 'Claude',
rows: [{ agent: 'claude', kind: 'cli' }],
catalog: { origin: 'probe', fetchedAt: 1, models: [] }
}
const launch = vi.fn()
const menu = (entries: AgentPickerEntry[]) => (
<DropdownMenu defaultOpen modal={false}>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<AgentLaunchPickerMenu entries={entries} runLaunch={launch} />
</DropdownMenuContent>
</DropdownMenu>
)
const view = render(menu([codex, claude]))
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'host low' } })
expect(screen.getByRole('menuitem').textContent).toContain('Codex · Host model · Low')
expect(vi.mocked(AgentLaunchSetupButton).mock.lastCall?.[0].initial).toEqual({
agent: 'codex',
surface: 'terminal',
choices: { model: 'host-model', options: { effort: 'low' } }
})
vi.mocked(AgentLaunchSetupButton).mockClear()
view.rerender(
menu([codex, { ...claude, catalog: { origin: 'probe', fetchedAt: 2, models: [] } }])
)
expect(AgentLaunchSetupButton).not.toHaveBeenCalled()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'host' } })
expect(AgentLaunchSetupButton).toHaveBeenCalled()
fireEvent.click(screen.getByRole('menuitem'))
expect(launch).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'codex', kind: 'cli' }),
{ agent: 'codex', surface: 'terminal', choices: { model: 'host-model' } },
codex
)
})
@@ -1,11 +1,16 @@
import { useId, useRef, useState, type PointerEvent } from 'react'
import { memo, useId, useRef, useState, type ComponentProps, type PointerEvent } from 'react'
import { Input } from '@/components/ui/input'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { AgentIcon } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import { AgentLaunchSetupButton } from './AgentLaunchSetupButton'
import { chatUnavailableLabel } from '../tab-bar/tab-agent-row-presentation'
import type { NewTabAgentMenuRow } from '../../../../shared/agent-launch-picker-rows'
import {
useAgentPickerInventoryEntries,
type AgentPickerInventorySource
} from './use-agent-launch-picker-inventory'
import {
searchAgentPickerSetups,
type AgentPickerDraft,
@@ -92,11 +97,8 @@ export function AgentLaunchPickerMenu({
openSetup={openSetup}
onSetupOpenChange={setOpenSetup}
label={setup.label}
initial={{
agent: setup.entry.agent,
surface: setup.entry.defaultSurface,
choices: setup.choices
}}
initialModel={setup.choices.model}
initialEffort={setup.choices.options?.effort}
onSelectAgent={onSelectAgent}
disabled={disabled}
runLaunch={runLaunch}
@@ -126,12 +128,25 @@ export function AgentLaunchPickerMenu({
)
}
function AgentPickerMenuRow({
// Subscribe here so inventory updates do not re-render the tab bar.
export function SubscribedAgentLaunchPickerMenu({
inventory,
...props
}: Omit<ComponentProps<typeof AgentLaunchPickerMenu>, 'entries'> & {
inventory: AgentPickerInventorySource
}) {
const entries = useAgentPickerInventoryEntries(inventory)
return <AgentLaunchPickerMenu {...props} entries={entries} />
}
// Why: catalog and seed reads land one agent at a time after the menu opens; only that row re-renders.
const AgentPickerMenuRow = memo(function AgentPickerMenuRow({
entry,
openSetup,
onSetupOpenChange,
label = entry.label,
initial,
initialModel,
initialEffort,
disabled,
runLaunch,
shortcut,
@@ -141,12 +156,24 @@ function AgentPickerMenuRow({
openSetup: string | null
onSetupOpenChange: (id: string | null) => void
label?: string
initial?: AgentPickerDraft
initialModel?: string
initialEffort?: string
disabled?: boolean
runLaunch: Launch
shortcut?: string
onSelectAgent?: (agent: AgentPickerEntry['agent']) => void
}) {
useUiLanguageRevision()
const initial: AgentPickerDraft | undefined = initialModel
? {
agent: entry.agent,
surface: entry.defaultSurface,
choices: {
model: initialModel,
...(initialEffort ? { options: { effort: initialEffort } } : {})
}
}
: undefined
const setupId = useId()
const item = useRef<HTMLDivElement>(null)
const configured = useRef<AgentPickerDraft | undefined>(undefined)
@@ -212,4 +239,4 @@ function AgentPickerMenuRow({
)}
/>
)
}
})
@@ -1,3 +1,4 @@
import { shallow } from 'zustand/shallow'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -81,7 +82,7 @@ class AgentPickerAcquisition {
return defaults.map((entry, i) => {
const read = this.supports.get(i)
const current = read && !(reopen && read.state === 'settled')
return {
const next = {
...entry,
catalog: this.entries[i].catalog,
...(read
@@ -94,6 +95,7 @@ class AgentPickerAcquisition {
}
: {})
}
return shallow(this.entries[i], next) ? this.entries[i] : next
})
}
@@ -3,7 +3,10 @@ import { act, fireEvent, render } from '@testing-library/react'
import { expect, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import { useAgentLaunchPickerInventory } from './use-agent-launch-picker-inventory'
import {
useAgentLaunchPickerInventorySource,
useAgentPickerInventoryEntries
} from './use-agent-launch-picker-inventory'
import { agentPickerAcquisitionDiagnostics } from './agent-launch-picker-acquisition'
import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model'
import * as picker from '../../../../shared/agent-launch-picker'
@@ -48,7 +51,9 @@ export async function exerciseAgentPickerFreshness({
const snapshots: picker.AgentPickerEntry[] = []
const current: { entry?: picker.AgentPickerEntry } = {}
function Setup({ value }: { value: typeof menu }) {
const [entry] = useAgentLaunchPickerInventory('wt', value, true)
const [entry] = useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt', value, true)
)
current.entry = entry
snapshots.push(entry)
return createElement(
@@ -18,7 +18,10 @@ import * as picker from '../../../../shared/agent-launch-picker'
import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
import type { NewTabAgentMenuModel } from '../tab-bar/new-tab-agent-menu-model'
import { useAgentLaunchPickerInventory } from './use-agent-launch-picker-inventory'
import {
useAgentLaunchPickerInventorySource,
useAgentPickerInventoryEntries
} from './use-agent-launch-picker-inventory'
import { agentPickerAcquisitionDiagnostics } from './agent-launch-picker-acquisition'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
@@ -132,7 +135,9 @@ it('publishes only committed selection to physical catalog admission after a sus
const committed = vi.fn()
type Selection = { selected: picker.AgentPickerEntry['agent']; suspend: boolean }
function Inventory({ selected, suspend }: Selection) {
const entries = useAgentLaunchPickerInventory('wt', menu, true, selected)
const entries = useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt', menu, true, selected)
)
rendered(selected, suspend)
useLayoutEffect(() => {
committed(selected)
@@ -226,7 +231,9 @@ it('reads host Arguments and remembered chat choices without leaking desktop def
const menu = model()
const hook = renderHook(
({ selected }: { selected?: 'codex' }) =>
useAgentLaunchPickerInventory('wt', menu, true, selected),
useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt', menu, true, selected)
),
{ initialProps: {} }
)
expect(hook.result.current[0]).toMatchObject({
@@ -265,7 +272,9 @@ it('uses existing legacy settings and a missing optional seed on an older paired
mocks.session.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog' ? catalog : { supported: true }
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
const hook = renderHook(() =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true))
)
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
@@ -288,9 +297,13 @@ it('bounds open-time catalog hydration to two agents and discards responses from
mocks.session.mockImplementation((_target, method) =>
method === 'agentSession.modelCatalog' ? old.promise : Promise.resolve({ supported: true })
)
const hook = renderHook(({ menu }) => useAgentLaunchPickerInventory('wt', menu, true), {
initialProps: { menu: model('alpha', ['codex', 'claude', 'gemini']) }
})
const hook = renderHook(
({ menu }) =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)),
{
initialProps: { menu: model('alpha', ['codex', 'claude', 'gemini']) }
}
)
await act(async () => {})
const catalogs = () =>
mocks.session.mock.calls.filter((call) => call[1] === 'agentSession.modelCatalog')
@@ -308,6 +321,40 @@ it('bounds open-time catalog hydration to two agents and discards responses from
expect(hook.result.current[0].rows[0].executionHostId).toBe('runtime:beta')
})
it('lands catalog reads in subscribers only, replacing just the entry whose catalog changed', async () => {
const reads = new Map<string, PromiseWithResolvers<AgentSessionModelCatalogResult>>()
mocks.session.mockImplementation((_target, method, params: { agent: string }) => {
if (method !== 'agentSession.modelCatalog') {
return Promise.resolve({ supported: true })
}
const read = Promise.withResolvers<AgentSessionModelCatalogResult>()
reads.set(params.agent, read)
return read.promise
})
const menu = model('alpha', ['codex', 'claude'])
let ownerRenders = 0
const owner = renderHook(() => {
ownerRenders += 1
return useAgentLaunchPickerInventorySource('wt', menu, true)
})
const subscriber = renderHook(() => useAgentPickerInventoryEntries(owner.result.current))
await act(async () => {})
const ownerRendersBeforeReads = ownerRenders
const before = subscriber.result.current
await act(async () => reads.get('codex')!.resolve(catalog))
const afterCodex = subscriber.result.current
expect(afterCodex.find((entry) => entry.agent === 'codex')?.catalog).toEqual(catalog)
const beforeClaude = before.find((entry) => entry.agent === 'claude')
expect(beforeClaude).toBeDefined()
expect(afterCodex.find((entry) => entry.agent === 'claude')).toBe(beforeClaude)
// A failed read changes nothing, so it must not publish a new snapshot.
await act(async () => reads.get('claude')!.reject(new Error('listing unavailable')))
expect(subscriber.result.current).toBe(afterCodex)
expect(ownerRenders).toBe(ownerRendersBeforeReads)
})
it.each(['slow', 'failed'] as const)(
'publishes known terminal defaults independently of %s optional catalog/support reads',
async (kind) => {
@@ -319,7 +366,9 @@ it.each(['slow', 'failed'] as const)(
? Promise.reject(new Error('unavailable'))
: Promise.resolve({ seedOptions: { model: 'host-model', permissionMode: 'ask' } })
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
const hook = renderHook(() =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true))
)
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
@@ -350,7 +399,9 @@ it('retains legacy remembered values when createSupport does not supply optional
mocks.session.mockImplementation(async (_target, method) =>
method === 'agentSession.modelCatalog' ? catalog : { supported: true }
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
const hook = renderHook(() =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true))
)
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
@@ -363,7 +414,9 @@ it('keeps Chat default controls pending until their independent seed read settle
mocks.session.mockImplementation((_target, method) =>
method === 'agentSession.modelCatalog' ? Promise.resolve(catalog) : support.promise
)
const hook = renderHook(() => useAgentLaunchPickerInventory('wt', model(), true))
const hook = renderHook(() =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', model(), true))
)
await act(async () => {})
expect(hook.result.current[0]).toMatchObject({
defaultsPending: false,
@@ -400,7 +453,10 @@ it('acquires each host agent once per open batch at N and 10N, independently of
.map((agent) => ({ agent, kind: 'chat', executionHostId: 'runtime:alpha' }))
menu.detection.detectedIds = agents.slice(0, count)
const hook = renderHook(
({ open, selected }) => useAgentLaunchPickerInventory('wt', menu, open, selected),
({ open, selected }) =>
useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt', menu, open, selected)
),
{ initialProps: { open: true, selected: agents[0] } }
)
await act(async () => {})
@@ -433,7 +489,7 @@ it('updates terminal availability when detection changes while the Chat rows sta
}
const hook = renderHook(
({ value }: { value: Extract<NewTabAgentMenuModel, { state: 'settled' }> }) =>
useAgentLaunchPickerInventory('wt', value, true),
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', value, true)),
{ initialProps }
)
await act(async () => {})
@@ -447,7 +503,7 @@ it('reorders entries when the default agent changes while the rows stay the same
const menu = model('default-order', ['codex', 'claude'])
const hook = renderHook(
({ value }: { value: Extract<NewTabAgentMenuModel, { state: 'settled' }> }) =>
useAgentLaunchPickerInventory('wt', value, true),
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', value, true)),
{ initialProps: { value: { ...menu, defaultTuiAgent: 'claude' } } }
)
await act(async () => {})
@@ -475,9 +531,13 @@ it.each([3, 30])(
})
const menu = model(`shared-${count}`, ['claude', 'codex', 'gemini'])
const mount = (open = true) =>
renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), {
initialProps: { open }
})
renderHook(
({ open }) =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, open)),
{
initialProps: { open }
}
)
const one = mount(),
two = mount()
for (let i = 0; i < count; i++) {
@@ -548,9 +608,13 @@ it.each([
mocks.settings.mockResolvedValue(null)
mocks.rpc.mockResolvedValue({ settings: payload })
const menu = model(`invalid-${JSON.stringify(payload)}`)
const hook = renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), {
initialProps: { open: true }
})
const hook = renderHook(
({ open }) =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, open)),
{
initialProps: { open: true }
}
)
await act(async () => {})
expect(hook.result.current[0].defaultsPending).toBe(true)
expect(mocks.rpc).toHaveBeenCalledTimes(1)
@@ -568,9 +632,13 @@ it.each([
it('revokes same-key read authority synchronously, retains failure as unknown, then accepts a late valid current answer', async () => {
const menu = model('authority')
const hook = renderHook(({ open }) => useAgentLaunchPickerInventory('wt', menu, open), {
initialProps: { open: true }
})
const hook = renderHook(
({ open }) =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, open)),
{
initialProps: { open: true }
}
)
await act(async () => {})
expect(hook.result.current[0].defaultsPending).toBe(false)
hook.rerender({ open: false })
@@ -603,9 +671,13 @@ it('lets a re-paired owner progress independently of held local-style calls and
mocks.settings.mockReturnValueOnce(oldSettings.promise)
mocks.session.mockReturnValueOnce(oldOptional.promise).mockReturnValueOnce(oldOptional.promise)
const menu = model('re-pair')
const hook = renderHook(({ menu }) => useAgentLaunchPickerInventory('wt', menu, true), {
initialProps: { menu }
})
const hook = renderHook(
({ menu }) =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true)),
{
initialProps: { menu }
}
)
if (menu.state !== 'settled' || !menu.pickerContext) {
throw new Error('fixture')
}
@@ -636,7 +708,10 @@ it.each([3, 30])(
throw new Error('fixture')
}
menu.pickerContext.executionHostId = 'local'
const mount = () => renderHook(() => useAgentLaunchPickerInventory('wt', menu, true))
const mount = () =>
renderHook(() =>
useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true))
)
const surviving = mount()
for (let i = 0; i < count; i++) {
const transient = mount()
@@ -684,7 +759,7 @@ it('updates a mounted setup from current host rows without making ordinary actio
hostChatAnswer: () => answer,
chatProgram: () => 'available'
}).map((row) => ({ ...row, executionHostId: 'runtime:late-offer' as const }))
current = useAgentLaunchPickerInventory('wt', menu, true)
current = useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true))
return createElement(
TooltipProvider,
null,
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { readHostAgentLaunchSettings } from '../settings/agent-launch-settings-transport'
import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows'
import { useAppStore } from '@/store'
@@ -25,12 +25,57 @@ import {
type AgentPickerEntry
} from '../../../../shared/agent-launch-picker'
export function useAgentLaunchPickerInventory(
/**
* Live picker entries for one (inventory key, open) generation. Catalog and seed reads land one
* at a time for several seconds after the menu opens; holding them here instead of in the
* owner's React state lets only the components that render entries re-render as they land.
*/
export type AgentPickerInventorySource = {
getSnapshot: () => AgentPickerEntry[]
subscribe: (listener: () => void) => () => void
}
type WritableAgentPickerInventorySource = AgentPickerInventorySource & {
publish: (entries: readonly AgentPickerEntry[]) => void
}
function createAgentPickerInventorySource(
initial: AgentPickerEntry[]
): WritableAgentPickerInventorySource {
let entries = initial
const listeners = new Set<() => void>()
return {
getSnapshot: () => entries,
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
publish: (next) => {
// Why: a read that changed nothing (e.g. an unavailable catalog) must not re-render the menu.
if (next.length === entries.length && next.every((entry, i) => entry === entries[i])) {
return
}
entries = [...next]
for (const listener of listeners) {
listener()
}
}
}
}
export function useAgentPickerInventoryEntries(
source: AgentPickerInventorySource
): AgentPickerEntry[] {
return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot)
}
/** Owns acquisition for the menu without subscribing its caller to the entries it publishes. */
export function useAgentLaunchPickerInventorySource(
worktreeId: string | undefined,
model: NewTabAgentMenuModel,
open: boolean,
selected?: AgentPickerEntry['agent']
): AgentPickerEntry[] {
): AgentPickerInventorySource {
const selectedRef = useRef(selected)
useLayoutEffect(() => {
selectedRef.current = selected
@@ -70,11 +115,6 @@ export function useAgentLaunchPickerInventory(
]),
[context, worktreeId, executionHostId, model, settings]
)
const generation = useMemo(() => ({ key, open }), [key, open])
const [snapshot, setSnapshot] = useState<{
generation: object
entries: AgentPickerEntry[]
} | null>(null)
const build = (defaults: AgentPickerDefaultSettings | null | undefined) =>
model.state === 'settled'
? agentPickerEntriesFromRows(
@@ -101,6 +141,14 @@ export function useAgentLaunchPickerInventory(
// oxlint-disable-next-line react-hooks/exhaustive-deps -- The semantic key covers rows, detection, target and defaults without restarting for object identity.
[key]
)
// A new generation starts from the shared acquisition (or the fallback) until it publishes.
const source = useMemo(
() =>
createAgentPickerInventorySource(
(open ? currentAgentPickerAcquisition(key) : undefined) ?? fallback
),
[key, open, fallback]
)
useEffect(() => {
if (!open || !target || !worktreeId || model.state !== 'settled') {
return
@@ -212,12 +260,10 @@ export function useAgentLaunchPickerInventory(
},
{
selected: () => selectedRef.current,
publish: (entries) => setSnapshot({ generation, entries: [...entries] })
publish: source.publish
}
)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only semantic inputs and visibility own observation; selected priority publishes independently after commit.
}, [key, open])
return snapshot?.generation === generation
? snapshot.entries
: ((open ? currentAgentPickerAcquisition(key) : undefined) ?? fallback)
}, [key, open, source])
return source
}
@@ -393,7 +393,7 @@ it('gives static/search no agent actions while loading, retaining settings and g
onNewBrowserTab: vi.fn()
})
)
expect(controller.result.current.agentLaunchOptions).toEqual([])
expect(controller.result.current.agentPickerInventory.getSnapshot()).toEqual([])
expect(
controller.result.current.createMenuOptions.some((option) => option.kind === 'new-terminal')
).toBe(true)
@@ -1,4 +1,4 @@
import { AgentLaunchPickerMenu } from '@/components/agent/AgentLaunchPickerMenu'
import { SubscribedAgentLaunchPickerMenu } from '@/components/agent/AgentLaunchPickerMenu'
import {
agentPickerLaunchInputs,
type AgentPickerEntry,
@@ -22,6 +22,7 @@ import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { useAgentMenuShortcutLabels } from './agent-menu-shortcut-labels'
import type { AgentPickerInventorySource } from '../agent/use-agent-launch-picker-inventory'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import { translate } from '@/i18n/i18n'
@@ -174,14 +175,14 @@ export function ProjectedAgentMenuItems({
disabled = false,
model,
runLaunch,
pickerEntries,
pickerInventory,
onSelectPickerAgent,
hidePickerSearch
}: {
disabled?: boolean
model: NewTabAgentMenuModel
runLaunch: (row: NewTabAgentMenuRow, draft?: AgentPickerDraft) => void
pickerEntries?: AgentPickerEntry[]
pickerInventory?: AgentPickerInventorySource
onSelectPickerAgent?: (agent: AgentPickerEntry['agent']) => void
hidePickerSearch?: boolean
}): React.JSX.Element {
@@ -189,7 +190,7 @@ export function ProjectedAgentMenuItems({
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
const shortcutLabels = useAgentMenuShortcutLabels(pickerEntries ? model : undefined)
const shortcutLabels = useAgentMenuShortcutLabels(pickerInventory ? model : undefined)
const openAgentSettings = useCallback(() => {
openSettingsTarget({ pane: 'agents', repoId: null })
openSettingsPage()
@@ -250,10 +251,10 @@ export function ProjectedAgentMenuItems({
)}
</DropdownMenuItem>
) : null}
{pickerEntries ? (
<AgentLaunchPickerMenu
{pickerInventory ? (
<SubscribedAgentLaunchPickerMenu
shortcutLabels={shortcutLabels}
entries={pickerEntries}
inventory={pickerInventory}
runLaunch={runLaunch}
onSelectAgent={onSelectPickerAgent}
hideSearch={hidePickerSearch}
@@ -0,0 +1,25 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import {
useAgentPickerInventoryEntries,
type AgentPickerInventorySource
} from '../agent/use-agent-launch-picker-inventory'
import { buildAgentPickerLaunchOptions } from './tab-agent-launch-options'
import TabBarCreateEntry from './TabBarCreateEntry'
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
// Subscribe inside the menu so inventory updates do not re-render the tab bar.
export function TabBarAgentPickerEntry({
inventory,
...props
}: Omit<TabBarCreateEntryProps, 'agentPickerEntries' | 'agentOptions'> & {
inventory: AgentPickerInventorySource
}): React.JSX.Element {
const entries = useAgentPickerInventoryEntries(inventory)
const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides)
const launchOptions = useMemo(
() => buildAgentPickerLaunchOptions(entries, commandOverrides),
[entries, commandOverrides]
)
return <TabBarCreateEntry {...props} agentPickerEntries={entries} agentOptions={launchOptions} />
}
@@ -27,7 +27,10 @@ import {
ensureAgentLaunchPreferences,
readAgentLaunchPreferencesForWorktree
} from '../settings/use-host-agent-launch-preferences'
import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory'
import {
useAgentLaunchPickerInventorySource,
useAgentPickerInventoryEntries
} from '../agent/use-agent-launch-picker-inventory'
import { useAgentMenuObservation, useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
it('projects one capable source through cold, deadline, late, failed-refresh and empty answers', async () => {
@@ -270,7 +273,9 @@ it.each([20, 200])(
environmentId: 'alpha'
})
renders++
return useAgentLaunchPickerInventory('wt', menu, false)
return useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt', menu, false)
)
})
)
await act(async () => vi.advanceTimersByTimeAsync(3_000))
@@ -1,5 +1,6 @@
import type { NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import type { AgentPickerInventorySource } from '../agent/use-agent-launch-picker-inventory'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
import type { useTabAgentMenuLaunch } from './QuickLaunchButton'
import type { TabCreateMenuOption } from './tab-create-menu-options'
@@ -7,8 +8,8 @@ import type { WindowsShellMenuEntry } from './tab-bar-windows-shell-options'
export type TabBarCreateMenuController = {
agentMenu: NewTabAgentMenuModel
agentLaunchOptions: TabAgentLaunchOption[]
agentPickerEntries: AgentPickerEntry[]
/** Read through `useAgentPickerInventoryEntries` in the component that renders the entries. */
agentPickerInventory: AgentPickerInventorySource
selectPickerAgent: (agent: AgentPickerEntry['agent']) => void
launchAgentRow: ReturnType<typeof useTabAgentMenuLaunch>
newTabMenuOpen: boolean
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ProjectedAgentMenuItems } from './QuickLaunchButton'
import TabBarCreateEntry from './TabBarCreateEntry'
import { TabBarAgentPickerEntry } from './TabBarAgentPickerEntry'
import { TabStripScrollIndicator } from './TabStripScrollIndicator'
import { TabStripTooltipProvider } from './TabStripTooltipProvider'
import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics'
@@ -75,8 +75,7 @@ export function renderTabBarSurface({
} = runtime
const {
agentMenu,
agentLaunchOptions,
agentPickerEntries,
agentPickerInventory,
selectPickerAgent,
launchAgentRow,
newTabMenuOpen,
@@ -234,13 +233,12 @@ export function renderTabBarSurface({
>
{!terminalOnly && onOpenEntry ? (
<>
<TabBarCreateEntry
<TabBarAgentPickerEntry
inventory={agentPickerInventory}
worktreeId={worktreeId}
groupId={resolvedGroupId}
menuOpen={newTabMenuOpen}
menuOptions={createMenuOptions}
agentOptions={agentLaunchOptions}
agentPickerEntries={agentPickerEntries}
onLaunchAgentOption={launchAgentFromNewTabEntry}
onOpenDefaultTerminal={() => {
queueNewActiveTerminalFocusAfterNewTabMenuClose()
@@ -283,7 +281,7 @@ export function renderTabBarSurface({
<DropdownMenuSeparator />
<ProjectedAgentMenuItems
model={agentMenu}
pickerEntries={agentPickerEntries}
pickerInventory={agentPickerInventory}
onSelectPickerAgent={selectPickerAgent}
hidePickerSearch={!terminalOnly && Boolean(onOpenEntry)}
runLaunch={launchAgentRow}
@@ -26,7 +26,10 @@ import {
readAgentLaunchPreferencesForWorktree,
readAgentLaunchPreferencesForWorkspace
} from '../settings/use-host-agent-launch-preferences'
import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory'
import {
useAgentLaunchPickerInventorySource,
useAgentPickerInventoryEntries
} from '../agent/use-agent-launch-picker-inventory'
import { agentMenuShortcutLabels } from './agent-menu-shortcut-labels'
import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
@@ -386,7 +389,9 @@ it.each(['pairing', 'runtime'] as const)(
it('updates actual inventory for native presentation and local permission changes', async () => {
const hook = renderHook(() => {
const menu = useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
const entries = useAgentLaunchPickerInventory('wt', menu, true)
const entries = useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt', menu, true)
)
return { menu, entries }
})
await act(async () => {})
@@ -407,7 +412,9 @@ it('updates actual inventory for native presentation and local permission change
})
const local = renderHook(() => {
const menu = useNewTabAgentMenuRows('folder:local', true, { kind: 'local' })
return useAgentLaunchPickerInventory('folder:local', menu, true)
return useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('folder:local', menu, true)
)
})
await act(async () => {})
await act(async () => {
@@ -465,10 +472,12 @@ it.each([30, 300])(
const hook = renderHook(
({ supplied, query }) => {
void query
return useAgentLaunchPickerInventory(
id,
supplied ? model : { ...model, pickerContext: undefined },
false
return useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource(
id,
supplied ? model : { ...model, pickerContext: undefined },
false
)
)
},
{ initialProps: { supplied: true, query: '' } }
@@ -553,12 +562,14 @@ it('invalidates mounted picker seeds only for their effective authored-default s
const local = renderHook(() => {
const menu = useNewTabAgentMenuRows('folder:local', true, { kind: 'local' })
localRenders++
return useAgentLaunchPickerInventory('folder:local', menu, true)
return useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('folder:local', menu, true)
)
})
const host = renderHook(() => {
const menu = useNewTabAgentMenuRows('wt', true, { kind: 'runtime', environmentId: 'alpha' })
hostRenders++
return useAgentLaunchPickerInventory('wt', menu, true)
return useAgentPickerInventoryEntries(useAgentLaunchPickerInventorySource('wt', menu, true))
})
await act(async () => {})
const settled = { local: localRenders, host: hostRenders }
@@ -655,7 +666,10 @@ it('reprojects a supplied local authored map once without host acquisition', asy
const before = compositions.mock.calls.length
const profileBefore = profiles.mock.calls.length
const hook = renderHook(
({ supplied }) => useAgentLaunchPickerInventory(undefined, supplied, false),
({ supplied }) =>
useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource(undefined, supplied, false)
),
{ initialProps: { supplied: model } }
)
await act(async () => {})
@@ -1,6 +1,6 @@
import type { TabBarCreateMenuController } from './tab-bar-create-menu-contract'
export type { TabBarCreateMenuController } from './tab-bar-create-menu-contract'
import { useAgentLaunchPickerInventory } from '../agent/use-agent-launch-picker-inventory'
import { useAgentLaunchPickerInventorySource } from '../agent/use-agent-launch-picker-inventory'
import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker'
import { useEffect, useMemo, useRef, useState } from 'react'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
@@ -11,7 +11,6 @@ import { requestNativeChatComposerFocus } from '@/components/native-chat/use-nat
import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows'
import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
import { buildAgentPickerLaunchOptions } from './tab-agent-launch-options'
import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options'
import { resolveWindowsShellLaunchTarget } from './windows-shell-launch'
import { buildWindowsShellMenuEntries } from './tab-bar-windows-shell-options'
@@ -76,14 +75,13 @@ export function useTabBarCreateMenuController({
{ wakeOnOpen: true }
)
const [selectedPickerAgent, selectPickerAgent] = useState<AgentPickerEntry['agent']>()
const agentPickerEntries = useAgentLaunchPickerInventory(
// Why: subscribed inside the open menu only; catalog reads landing here would re-render the tab bar.
const agentPickerInventory = useAgentLaunchPickerInventorySource(
worktreeId,
agentMenu,
newTabMenuOpen && showAgentLaunchItems,
selectedPickerAgent
)
const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides)
const agentLaunchOptions = buildAgentPickerLaunchOptions(agentPickerEntries, commandOverrides)
const pendingNewTabMenuFocusRef = useRef<{ focus: () => void; synchronous?: true } | null>(null)
const pendingNewTabMenuFocusAnimationRef = useRef<number | null>(null)
const pendingNewTabMenuFocusRetryRef = useRef<number | null>(null)
@@ -277,8 +275,7 @@ export function useTabBarCreateMenuController({
}, [newTabMenuOpen])
return {
agentMenu,
agentLaunchOptions,
agentPickerEntries,
agentPickerInventory,
selectPickerAgent,
launchAgentRow,
newTabMenuOpen,
@@ -6,6 +6,7 @@ import * as ContextMenuPrimitive from 'radix-ui/context-menu'
import { cn } from '@/lib/utils'
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
import { keepFocusWhileMenuCloses } from './menu-item-closing-focus'
import { useMenuPointerHighlight } from './menu-pointer-highlight'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" modal={false} {...props} />
@@ -70,6 +71,7 @@ function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent> &
VariantProps<typeof contentAnimationVariants>) {
const pointerHighlight = useMenuPointerHighlight(props)
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
@@ -84,6 +86,7 @@ function ContextMenuSubContent({
// Portal submenus outside the scrollable parent to prevent overflow clipping.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</ContextMenuPrimitive.Portal>
@@ -97,6 +100,7 @@ function ContextMenuContent({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> &
VariantProps<typeof contentAnimationVariants>) {
const pointerHighlight = useMenuPointerHighlight(props)
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
@@ -109,6 +113,7 @@ function ContextMenuContent({
// Disable native dragging so overlapping titlebar regions cannot intercept menu clicks.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</ContextMenuPrimitive.Portal>
@@ -5,6 +5,7 @@ import * as DropdownMenuPrimitive from 'radix-ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape'
import { keepFocusWhileMenuCloses } from './menu-item-closing-focus'
import { useMenuPointerHighlight } from './menu-pointer-highlight'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
@@ -28,6 +29,7 @@ function DropdownMenuContent({
style,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
const pointerHighlight = useMenuPointerHighlight(props)
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
@@ -40,6 +42,7 @@ function DropdownMenuContent({
// Disable native dragging: titlebar drag regions intercept menu clicks regardless of z-index.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</DropdownMenuPrimitive.Portal>
@@ -215,6 +218,7 @@ function DropdownMenuSubContent({
alignOffset = -5,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
const pointerHighlight = useMenuPointerHighlight(props)
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
@@ -228,6 +232,7 @@ function DropdownMenuSubContent({
// Disable native dragging where submenu content overlaps the titlebar.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</DropdownMenuPrimitive.Portal>
@@ -0,0 +1,49 @@
import { defineFixture } from '../../component-fixtures/component-fixture'
import * as Dropdown from './dropdown-menu'
import * as Context from './context-menu'
function MenuPointerFixture({ kind }: { kind: 'dropdown' | 'context' }) {
const Menu = kind === 'dropdown' ? Dropdown.DropdownMenu : Context.ContextMenu
const Trigger = kind === 'dropdown' ? Dropdown.DropdownMenuTrigger : Context.ContextMenuTrigger
const Content = kind === 'dropdown' ? Dropdown.DropdownMenuContent : Context.ContextMenuContent
const Item = kind === 'dropdown' ? Dropdown.DropdownMenuItem : Context.ContextMenuItem
const Sub = kind === 'dropdown' ? Dropdown.DropdownMenuSub : Context.ContextMenuSub
const SubTrigger =
kind === 'dropdown' ? Dropdown.DropdownMenuSubTrigger : Context.ContextMenuSubTrigger
const SubContent =
kind === 'dropdown' ? Dropdown.DropdownMenuSubContent : Context.ContextMenuSubContent
return (
<Menu modal={false}>
<Trigger>Open menu</Trigger>
<Content className="max-h-60 w-64">
{['First', 'Second'].map((name) => (
<Sub key={name}>
<SubTrigger>{name}</SubTrigger>
<SubContent>
<Item>{name} child</Item>
{Array.from({ length: 6 }, (_, index) => (
<Item key={index}>
{name} option {index + 1}
</Item>
))}
</SubContent>
</Sub>
))}
{Array.from({ length: 40 }, (_, index) => (
<Item key={index}>Row {index + 1}</Item>
))}
</Content>
</Menu>
)
}
export const dropdown = defineFixture({
width: 800,
render: () => <MenuPointerFixture kind="dropdown" />
})
export const context = defineFixture({
width: 800,
render: () => <MenuPointerFixture kind="context" />
})
@@ -0,0 +1,386 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useMenuPointerHighlight } from './menu-pointer-highlight'
let root: Root | null = null
let hit: Element | null = null
let frames: FrameRequestCallback[] = []
beforeEach(() => {
hit = null
frames = []
document.elementFromPoint = vi.fn(() => hit)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => frames.push(cb))
})
afterEach(() => {
act(() => root?.unmount())
root = null
document.body.innerHTML = ''
vi.restoreAllMocks()
})
type MenuProps = Pick<React.HTMLAttributes<HTMLDivElement>, 'onKeyDown' | 'onKeyDownCapture'> & {
open?: boolean
forceMount?: boolean
}
function Menu({ open = true, forceMount = false, ...props }: MenuProps): React.JSX.Element | null {
const handlers = useMenuPointerHighlight(props)
if (!open && !forceMount) {
return null
}
return (
<div
role="menu"
data-state={open ? 'open' : 'closed'}
tabIndex={-1}
id="menu"
{...props}
{...handlers}
>
<div role="menuitem" tabIndex={-1} id="a">
<span id="a-label">A</span>
</div>
<div role="menuitemcheckbox" aria-checked="false" tabIndex={-1} id="b" />
<div role="menuitem" tabIndex={-1} id="c" data-disabled="" />
<div role="separator" id="sep" />
</div>
)
}
function render(
props: MenuProps = {}
): Record<'menu' | 'a' | 'aLabel' | 'b' | 'c' | 'sep', HTMLElement> {
const container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
act(() => root!.render(<Menu {...props} />))
const get = (id: string): HTMLElement => {
const el = document.getElementById(id)
if (!el) {
throw new Error(`missing #${id}`)
}
return el
}
return {
menu: get('menu'),
a: get('a'),
aLabel: get('a-label'),
b: get('b'),
c: get('c'),
sep: get('sep')
}
}
/** happy-dom's WheelEvent drops MouseEvent coordinates, which Chromium's WheelEvent carries. */
function wheelAt(menu: HTMLElement, clientX: number, clientY: number): void {
const event = new WheelEvent('wheel', { bubbles: true })
Object.defineProperties(event, { clientX: { value: clientX }, clientY: { value: clientY } })
act(() => {
menu.dispatchEvent(event)
})
}
function scroll(menu: HTMLElement): void {
act(() => {
menu.dispatchEvent(new Event('scroll'))
frames.splice(0).forEach((cb) => cb(0))
})
}
function wheelScroll(menu: HTMLElement): void {
wheelAt(menu, 0, 0)
scroll(menu)
}
it('moves the highlight to the row under the resting cursor on every wheel-scroll frame', () => {
const { menu, a, aLabel, b } = render()
a.focus()
wheelAt(menu, 10, 40)
hit = b
scroll(menu)
expect(document.activeElement).toBe(b)
expect(document.elementFromPoint).toHaveBeenLastCalledWith(10, 40)
hit = aLabel
scroll(menu)
expect(document.activeElement).toBe(a)
})
it('uses the current cursor position when it moves during wheel scrolling', () => {
const { menu, a, b } = render()
a.focus()
wheelAt(menu, 10, 40)
act(() => {
b.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
clientX: 20,
clientY: 60
})
)
})
hit = b
scroll(menu)
expect(document.elementFromPoint).toHaveBeenLastCalledWith(20, 60)
expect(document.activeElement).toBe(b)
})
it('preserves prevented pointer movement across further wheel events and queued frames', () => {
const { menu, a, b } = render()
a.focus()
wheelAt(menu, 10, 40)
act(() => menu.dispatchEvent(new Event('scroll')))
const preventMove = (event: Event) => event.preventDefault()
b.addEventListener('pointermove', preventMove)
act(() => {
b.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
cancelable: true,
pointerType: 'mouse',
clientX: 20,
clientY: 60
})
)
})
hit = b
scroll(menu)
expect(document.activeElement).toBe(a)
wheelAt(menu, 20, 60)
scroll(menu)
act(() => {
b.dispatchEvent(
new PointerEvent('pointerover', {
bubbles: true,
pointerType: 'mouse',
clientX: 20,
clientY: 60
})
)
})
expect(document.activeElement).toBe(a)
b.removeEventListener('pointermove', preventMove)
act(() => {
b.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
clientX: 10,
clientY: 60
})
)
})
wheelAt(menu, 10, 60)
scroll(menu)
expect(document.activeElement).toBe(b)
})
it('leaves moving-pointer boundary events to Radix after wheel scrolling', () => {
const { menu, a, b } = render()
a.focus()
wheelAt(menu, 10, 40)
hit = b
act(() => {
b.dispatchEvent(
new PointerEvent('pointerover', {
bubbles: true,
pointerType: 'mouse',
clientX: 20,
clientY: 60
})
)
})
expect(document.activeElement).toBe(a)
})
it('leaves keyboard-driven scrolling alone', () => {
const { menu, a, b } = render()
wheelAt(menu, 10, 40)
a.focus()
act(() => {
a.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
hit = b
scroll(menu)
expect(document.activeElement).toBe(a)
})
it.each([false, true])(
'resets wheel tracking when retained wrappers reopen their content (forceMount: %s)',
async (forceMount) => {
const { menu, a, b } = render({ forceMount })
a.focus()
wheelAt(menu, 10, 40)
hit = b
scroll(menu)
expect(document.activeElement).toBe(b)
act(() => menu.dispatchEvent(new Event('scroll')))
await act(async () => root!.render(<Menu open={false} forceMount={forceMount} />))
await act(async () => root!.render(<Menu forceMount={forceMount} />))
const first = document.getElementById('a')!
const next = document.getElementById('b')!
const reopened = document.getElementById('menu')!
first.focus()
hit = next
act(() => {
next.dispatchEvent(
new PointerEvent('pointerover', {
bubbles: true,
pointerType: 'mouse',
clientX: 10,
clientY: 40
})
)
})
scroll(reopened)
expect(document.activeElement).toBe(first)
wheelAt(reopened, 10, 40)
scroll(reopened)
expect(document.activeElement).toBe(next)
}
)
it.each([false, true])(
'preserves keyboard focus when an input stops keydown propagation (queued frame: %s)',
(queuedFrame) => {
const { menu, a, b } = render()
const input = document.createElement('input')
menu.prepend(input)
input.addEventListener('keydown', (event) => {
event.stopPropagation()
a.focus()
})
input.focus()
wheelAt(menu, 10, 40)
hit = b
if (queuedFrame) {
act(() => menu.dispatchEvent(new Event('scroll')))
expect(frames).toHaveLength(1)
}
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
scroll(menu)
expect(document.activeElement).toBe(a)
wheelAt(menu, 10, 40)
scroll(menu)
expect(document.activeElement).toBe(b)
}
)
it('preserves consumer capture and bubble keydown handlers', () => {
const calls: string[] = []
const { a } = render({
onKeyDownCapture: (event) => calls.push(`capture:${event.key}`),
onKeyDown: (event) => calls.push(`bubble:${event.key}`)
})
act(() => {
a.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
})
expect(calls).toEqual(['capture:ArrowDown', 'bubble:ArrowDown'])
})
it('does not let a queued wheel frame override subsequent keyboard navigation', () => {
const { menu, a, b } = render()
wheelAt(menu, 10, 40)
hit = b
act(() => menu.dispatchEvent(new Event('scroll')))
expect(frames).toHaveLength(1)
act(() => {
a.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
a.focus()
frames.splice(0).forEach((cb) => cb(0))
})
expect(document.activeElement).toBe(a)
wheelAt(menu, 10, 40)
scroll(menu)
expect(document.activeElement).toBe(b)
})
it.each(['input', 'textarea'])(
'preserves a portaled setup %s while wheel synchronization targets another row',
(tag) => {
const { menu, b } = render()
const input = document.createElement(tag)
document.body.append(input)
input.focus()
hit = b
wheelAt(menu, 10, 40)
scroll(menu)
expect(document.activeElement).toBe(input)
}
)
it('clears the highlight over disabled rows and separators, follows scrolling past submenus, and skips closed menus', () => {
const { menu, a, b, c, sep } = render()
a.focus()
hit = c
wheelScroll(menu)
expect(document.activeElement).toBe(menu)
a.focus()
hit = sep
wheelScroll(menu)
expect(document.activeElement).toBe(menu)
hit = b
a.setAttribute('aria-haspopup', 'menu')
a.setAttribute('data-state', 'open')
wheelScroll(menu)
expect(document.activeElement).toBe(b)
a.removeAttribute('data-state')
menu.setAttribute('data-state', 'closed')
wheelScroll(menu)
expect(document.activeElement).toBe(b)
})
it("ignores a cursor that is not over the menu, such as over a row's portaled popover", () => {
const { menu, a } = render()
const popover = document.createElement('div')
document.body.append(popover)
a.focus()
hit = popover
wheelScroll(menu)
expect(document.activeElement).toBe(a)
})
it('does not pull focus out of a search field inside the menu', () => {
const { menu, b } = render()
const input = document.createElement('input')
menu.prepend(input)
input.focus()
hit = b
wheelScroll(menu)
expect(document.activeElement).toBe(input)
})
it.each(['button', 'div'])(
'preserves focus on a non-editable setup %s when scrolling over its owning row',
(tag) => {
const { menu, a } = render()
const trigger = document.createElement('button')
trigger.setAttribute('data-slot', 'popover-trigger')
trigger.setAttribute('aria-expanded', 'true')
a.append(trigger)
const control = document.createElement(tag)
control.tabIndex = 0
document.body.append(control)
control.focus()
hit = a
wheelAt(menu, 10, 40)
scroll(menu)
expect(document.activeElement).toBe(control)
}
)
@@ -0,0 +1,168 @@
import * as React from 'react'
import { useComposedRefs } from 'radix-ui/internal'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"]'
const OPEN_POPOVER_TRIGGER_SELECTOR = '[data-slot="popover-trigger"][aria-expanded="true"]'
type Point = { x: number; y: number }
type MenuPointerHighlightState = {
/** Tracked from wheel until keyboard navigation or pointer exit. */
pointer: Point | null
pointerMovePrevented: boolean
frame: number
}
// Setup inputs can be portaled outside the menu content.
function isEditableFocused(): boolean {
const active = document.activeElement
return active instanceof Element && active.matches(EDITABLE_SELECTOR)
}
/**
* Move the menu highlight to the row under a resting cursor while the menu wheel-scrolls.
* Radix highlights only on pointermove, and Chromium defers the hover boundary events a scroll
* causes until the scroll settles, so without this the highlight trails the scroll.
*/
function syncMenuHighlightAtPoint(content: HTMLElement, x: number, y: number): void {
if (
content.getAttribute('data-state') === 'closed' ||
isEditableFocused() ||
content.querySelector(OPEN_POPOVER_TRIGGER_SELECTOR)
) {
return
}
const hit = document.elementFromPoint(x, y)
// Why: React bubbles wheel/scroll from portaled children (e.g. a row's setup popover); a cursor
// that is not over this menu says nothing about which of its rows should be highlighted.
if (!hit || !content.contains(hit)) {
return
}
const active = document.activeElement
const item = hit.closest(ITEM_SELECTOR)
if (
item instanceof HTMLElement &&
content.contains(item) &&
!item.hasAttribute('data-disabled')
) {
if (!item.contains(active)) {
item.focus({ preventScroll: true })
}
return
}
// Mirror Radix's pointerleave: nothing highlightable under the cursor clears the highlight.
if (
active instanceof Element &&
active !== content &&
content.contains(active) &&
active.matches(ITEM_SELECTOR)
) {
content.focus({ preventScroll: true })
}
}
type Handlers = {
onWheel?: React.WheelEventHandler<HTMLDivElement>
onPointerMove?: React.PointerEventHandler<HTMLDivElement>
onPointerOver?: React.PointerEventHandler<HTMLDivElement>
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>
onKeyDownCapture?: React.KeyboardEventHandler<HTMLDivElement>
onScrollCapture?: React.UIEventHandler<HTMLDivElement>
}
function resetPointerTracking(state: MenuPointerHighlightState): void {
cancelAnimationFrame(state.frame)
state.pointer = null
state.pointerMovePrevented = false
state.frame = 0
}
/** Content-level handlers that keep the menu highlight on the row under the cursor. */
export function useMenuPointerHighlight(
props: Handlers & { ref?: React.Ref<HTMLDivElement> }
): Required<Handlers> & { ref: React.RefCallback<HTMLDivElement> } {
const stateRef = React.useRef<MenuPointerHighlightState>({
pointer: null,
pointerMovePrevented: false,
frame: 0
})
const [content, setContent] = React.useState<HTMLDivElement | null>(null)
const trackContent = React.useCallback((content: HTMLDivElement | null) => {
resetPointerTracking(stateRef.current)
setContent(content)
}, [])
React.useEffect(() => {
if (!content) {
return
}
const state = stateRef.current
// A retained content node can reopen before its closing animation unmounts it.
const observer = new MutationObserver(() => resetPointerTracking(state))
observer.observe(content, { attributes: true, attributeFilter: ['data-state'] })
return () => {
observer.disconnect()
resetPointerTracking(state)
}
}, [content])
const ref = useComposedRefs(props.ref, trackContent)
return {
ref,
onWheel: (event) => {
props.onWheel?.(event)
stateRef.current.pointer = { x: event.clientX, y: event.clientY }
},
onPointerMove: (event) => {
props.onPointerMove?.(event)
const state = stateRef.current
if (event.pointerType !== 'mouse') {
return
}
// Keep Radix's submenu grace decision across wheel events and deferred scroll frames.
state.pointerMovePrevented = event.defaultPrevented
if (!state.pointer || state.pointerMovePrevented) {
return
}
state.pointer = { x: event.clientX, y: event.clientY }
},
// Re-sync scroll's deferred boundary events; actual pointer movement belongs to Radix.
onPointerOver: (event) => {
props.onPointerOver?.(event)
const { pointer, pointerMovePrevented } = stateRef.current
if (
!pointerMovePrevented &&
event.pointerType === 'mouse' &&
pointer?.x === event.clientX &&
pointer.y === event.clientY
) {
syncMenuHighlightAtPoint(event.currentTarget, event.clientX, event.clientY)
}
},
onPointerLeave: (event) => {
props.onPointerLeave?.(event)
resetPointerTracking(stateRef.current)
},
// Inputs can stop propagation when transferring keyboard focus to a menu item.
onKeyDownCapture: (event) => {
resetPointerTracking(stateRef.current)
props.onKeyDownCapture?.(event)
},
// Capture so menus that scroll an inner list (not the content itself) are covered too.
onScrollCapture: (event) => {
props.onScrollCapture?.(event)
const state = stateRef.current
if (!state.pointer || state.pointerMovePrevented || state.frame) {
return
}
const content = event.currentTarget
state.frame = requestAnimationFrame(() => {
state.frame = 0
if (state.pointer && !state.pointerMovePrevented) {
syncMenuHighlightAtPoint(content, state.pointer.x, state.pointer.y)
}
})
}
}
}
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import * as Dropdown from './dropdown-menu'
import * as Context from './context-menu'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
})
it.each(['dropdown', 'context'] as const)(
'leaves prevented %s submenu hover to Radix and opens with the keyboard without clicking',
(kind) => {
const Menu = kind === 'dropdown' ? Dropdown.DropdownMenu : Context.ContextMenu
const Trigger = kind === 'dropdown' ? Dropdown.DropdownMenuTrigger : Context.ContextMenuTrigger
const Content = kind === 'dropdown' ? Dropdown.DropdownMenuContent : Context.ContextMenuContent
const Sub = kind === 'dropdown' ? Dropdown.DropdownMenuSub : Context.ContextMenuSub
const SubTrigger =
kind === 'dropdown' ? Dropdown.DropdownMenuSubTrigger : Context.ContextMenuSubTrigger
const SubContent =
kind === 'dropdown' ? Dropdown.DropdownMenuSubContent : Context.ContextMenuSubContent
const Item = kind === 'dropdown' ? Dropdown.DropdownMenuItem : Context.ContextMenuItem
const selected = vi.fn()
const clicked = vi.fn()
render(
<Menu modal={false}>
<Trigger>Open</Trigger>
<Content onClick={clicked}>
<Sub>
<SubTrigger>First</SubTrigger>
<SubContent>
<Item>First child</Item>
</SubContent>
</Sub>
<Sub>
<SubTrigger>Second</SubTrigger>
<SubContent>
<Item onSelect={selected}>Second child</Item>
</SubContent>
</Sub>
</Content>
</Menu>
)
if (kind === 'dropdown') {
fireEvent.pointerDown(screen.getByText('Open'), {
button: 0,
pointerType: 'mouse',
ctrlKey: false
})
} else {
fireEvent.contextMenu(screen.getByText('Open'), { clientX: 40, clientY: 20 })
}
const first = screen.getByRole('menuitem', { name: 'First' })
const second = screen.getByRole('menuitem', { name: 'Second' })
fireEvent.click(first)
expect(screen.getByRole('menuitem', { name: 'First child' })).toBeTruthy()
clicked.mockClear()
vi.spyOn(document, 'elementFromPoint').mockReturnValue(second)
// Emulate a move swallowed by Radix's safe triangle at the content boundary.
second.addEventListener('pointermove', (event) => event.preventDefault())
vi.useFakeTimers()
fireEvent.pointerMove(second, { pointerType: 'mouse', clientX: 10, clientY: 40 })
act(() => vi.advanceTimersByTime(1_000))
expect(first.getAttribute('data-state')).toBe('open')
expect(second.getAttribute('data-state')).toBe('closed')
expect(clicked).not.toHaveBeenCalled()
fireEvent.keyDown(first, { key: 'ArrowDown' })
act(() => vi.runOnlyPendingTimers())
expect(second).toBe(document.activeElement)
fireEvent.keyDown(second, { key: 'ArrowRight' })
expect(second.getAttribute('data-state')).toBe('open')
expect(first.getAttribute('data-state')).toBe('closed')
expect(screen.getByRole('menuitem', { name: 'Second child' })).toBeTruthy()
expect(selected).not.toHaveBeenCalled()
expect(clicked).not.toHaveBeenCalled()
}
)
@@ -5,7 +5,10 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { createElement } from 'react'
import { AgentLaunchSetupPanel } from '../components/agent/AgentLaunchSetupPanel'
import { TooltipProvider } from '../components/ui/tooltip'
import { useAgentLaunchPickerInventory } from '../components/agent/use-agent-launch-picker-inventory'
import {
useAgentLaunchPickerInventorySource,
useAgentPickerInventoryEntries
} from '../components/agent/use-agent-launch-picker-inventory'
import type { NewTabAgentMenuModel } from '../components/tab-bar/new-tab-agent-menu-model'
import type { AgentPickerDraft, AgentPickerEntry } from '../../../shared/agent-launch-picker'
import {
@@ -391,7 +394,9 @@ it.each(['claude', 'codex'] as const)(
let current: AgentPickerEntry
const launches: AgentPickerDraft[] = []
function Panel({ open }: { open: boolean }) {
const entries = useAgentLaunchPickerInventory('wt-1', menu, open)
const entries = useAgentPickerInventoryEntries(
useAgentLaunchPickerInventorySource('wt-1', menu, open)
)
current = entries[0]
return open
? createElement(
@@ -0,0 +1,183 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
async function center(item: Locator) {
const bounds = await item.boundingBox()
if (!bounds) {
throw new Error('Menu item has no bounds')
}
return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }
}
async function expectFocusedAtPointer(page: Page, point: { x: number; y: number }) {
await expect
.poll(() =>
page.evaluate(({ x, y }) => {
const row = document.elementFromPoint(x, y)?.closest('[role="menuitem"]')
return Boolean(
row && row === document.activeElement && row.hasAttribute('data-highlighted')
)
}, point)
)
.toBe(true)
}
for (const kind of ['dropdown', 'context'] as const) {
test.describe(kind, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/fixtures.html?id=ui/menu-pointer-highlight:${kind}`)
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready')
await page.getByText('Open menu', { exact: true }).click({
button: kind === 'context' ? 'right' : 'left'
})
await expect(page.getByRole('menuitem', { name: 'Row 1', exact: true })).toBeVisible()
})
test('wheel scrolling keeps focus under a stationary cursor', async ({ page }) => {
const row = page.getByRole('menuitem', { name: 'Row 3', exact: true })
const point = await center(row)
await page.mouse.move(point.x, point.y)
await expect(row).toBeFocused()
const menu = page.locator(`[data-slot="${kind}-menu-content"]`)
// Sample the scroll frame before deferred hover events can hide a stale highlight.
await menu.evaluate((element, pointer) => {
element.addEventListener('scroll', () => {
requestAnimationFrame(() => {
const hit = document
.elementFromPoint(pointer.x, pointer.y)
?.closest('[role="menuitem"]')
element.setAttribute(
'data-scroll-frame-focused',
String(hit === document.activeElement)
)
element.setAttribute('data-scroll-frame-top', String(element.scrollTop))
})
})
}, point)
let previousScroll = 0
for (const delta of [72, 96, 120]) {
await page.mouse.wheel(0, delta)
await expect
.poll(() => menu.evaluate((element) => element.scrollTop))
.toBeGreaterThan(previousScroll)
await expect
.poll(() => menu.getAttribute('data-scroll-frame-top').then(Number))
.toBeGreaterThan(previousScroll)
await expect(menu).toHaveAttribute('data-scroll-frame-focused', 'true')
await expectFocusedAtPointer(page, point)
await expect(row).not.toBeFocused()
previousScroll = await menu.evaluate((element) => element.scrollTop)
}
})
test('wheel scrolling away from an open submenu follows the row under the cursor', async ({
page
}) => {
const first = page.getByRole('menuitem', { name: 'First', exact: true })
const point = await center(first)
await page.mouse.move(point.x, point.y)
await expect(page.getByRole('menuitem', { name: 'First child', exact: true })).toBeVisible()
const menu = page.locator(`[data-slot="${kind}-menu-content"]`)
await page.mouse.wheel(0, 96)
await expect.poll(() => menu.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expectFocusedAtPointer(page, point)
await expect(first).toHaveAttribute('data-state', 'closed')
})
test('keeps submenu pointer grace after wheel scrolling', async ({ page }) => {
const first = page.getByRole('menuitem', { name: 'First', exact: true })
const second = page.getByRole('menuitem', { name: 'Second', exact: true })
const start = await first.boundingBox()
if (!start) {
throw new Error('First submenu trigger has no bounds')
}
await page.mouse.move(start.x + start.width - 60, start.y + start.height / 2)
await expect(first).toHaveAttribute('data-state', 'open')
await page.mouse.wheel(0, 1)
const menu = page.locator(`[data-slot="${kind}-menu-content"]`)
await expect.poll(() => menu.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
const from = await first.boundingBox()
const to = await second.boundingBox()
if (!from || !to) {
throw new Error('Submenu triggers have no bounds')
}
// Cross the next row while moving diagonally toward the open submenu.
await page.mouse.move(from.x + from.width - 60, from.y + from.height / 2)
await page.mouse.move(from.x + from.width - 40, from.y + from.height / 2 + 1)
await page.mouse.move(to.x + to.width - 20, to.y + 1)
await expect(first).toHaveAttribute('data-state', 'open')
await expect(second).toHaveAttribute('data-state', 'closed')
const previousScroll = await menu.evaluate((element) => element.scrollTop)
await page.mouse.wheel(0, 1)
await expect
.poll(() => menu.evaluate((element) => element.scrollTop))
.toBeGreaterThan(previousScroll)
await expect(first).toHaveAttribute('data-state', 'open')
await expect(second).toHaveAttribute('data-state', 'closed')
await expect(second).not.toBeFocused()
const child = page.getByRole('menuitem', { name: 'First child', exact: true })
await child.hover()
await expect(child).toBeFocused()
})
if (kind === 'dropdown') {
test('keyboard reopening after wheel dismissal preserves the first item', async ({
page
}) => {
const trigger = page.getByText('Open menu', { exact: true })
const point = await center(page.getByRole('menuitem', { name: 'Row 3', exact: true }))
const menu = page.locator('[data-slot="dropdown-menu-content"]')
await page.mouse.move(point.x, point.y)
await page.mouse.wheel(0, 96)
await expect.poll(() => menu.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expectFocusedAtPointer(page, point)
await trigger.click()
await expect(menu).toBeHidden()
await page.mouse.move(point.x, point.y)
await trigger.focus()
await page.keyboard.press('ArrowDown')
await expect(menu).toBeVisible()
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
await expect(page.getByRole('menuitem', { name: 'First', exact: true })).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByRole('menuitem', { name: 'First child', exact: true })).toBeVisible()
})
}
test('submenu hover and keyboard navigation do not synthesize clicks', async ({ page }) => {
const first = page.getByRole('menuitem', { name: 'First', exact: true })
const second = page.getByRole('menuitem', { name: 'Second', exact: true })
const start = await center(first)
const destination = await center(second)
await page.mouse.move(start.x, start.y)
await expect(page.getByRole('menuitem', { name: 'First child', exact: true })).toBeVisible()
await page.evaluate(() => {
document.documentElement.setAttribute('data-menu-clicks', '0')
document.addEventListener('click', () => {
const count = Number(document.documentElement.getAttribute('data-menu-clicks'))
document.documentElement.setAttribute('data-menu-clicks', String(count + 1))
})
})
// Moving away from the submenu uses Radix's normal sibling hover behavior.
await page.mouse.move(destination.x - 35, destination.y, { steps: 5 })
await expect(second).toBeFocused()
await expect(second).toHaveAttribute('data-state', 'open')
await expect(first).toHaveAttribute('data-state', 'closed')
await expect(page.getByRole('menuitem', { name: 'Second child', exact: true })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'First child', exact: true })).toBeHidden()
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('menuitem', { name: 'Second child', exact: true })).toBeFocused()
await page.keyboard.press('ArrowLeft')
await expect(second).toBeFocused()
await page.keyboard.press('ArrowUp')
await expect(first).toBeFocused()
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('menuitem', { name: 'First child', exact: true })).toBeFocused()
await expect(page.locator('html')).toHaveAttribute('data-menu-clicks', '0')
})
})
}
@@ -0,0 +1,25 @@
import { defineConfig } from '@playwright/test'
// ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test --config tests/component-fixtures/playwright.config.ts
export default defineConfig({
testDir: '.',
testMatch: '*.spec.ts',
fullyParallel: true,
use: {
baseURL: 'http://127.0.0.1:5197',
headless: true,
viewport: { width: 1000, height: 720 },
reducedMotion: 'reduce',
launchOptions: process.env.ORCA_FIXTURES_BROWSER
? { executablePath: process.env.ORCA_FIXTURES_BROWSER }
: undefined,
trace: 'retain-on-failure'
},
webServer: {
command:
'pnpm exec vite --config config/vite.fixtures.config.ts --host 127.0.0.1 --port 5197 --strictPort',
cwd: '../..',
url: 'http://127.0.0.1:5197/fixtures.html',
timeout: 120_000
}
})
@@ -3,11 +3,17 @@ import {
rpc,
status
} from '../../src/renderer/src/components/tab-bar/new-tab-agent-menu-host-preferences-test-fixture'
import { act, renderHook } from '@testing-library/react'
import { act, render } from '@testing-library/react'
import { createElement } from 'react'
import { expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { useNewTabAgentMenuRows } from '../../src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows'
import { useAgentLaunchPickerInventory } from '../../src/renderer/src/components/agent/use-agent-launch-picker-inventory'
import {
useAgentLaunchPickerInventorySource,
useAgentPickerInventoryEntries,
type AgentPickerInventorySource
} from '../../src/renderer/src/components/agent/use-agent-launch-picker-inventory'
import type { AgentPickerEntry } from '../../src/shared/agent-launch-picker'
import * as acquisitions from '../../src/renderer/src/components/agent/agent-launch-picker-acquisition'
import * as routes from '@/lib/agent-launch-route-input'
import * as profile from '../../src/shared/agent-launch-profile'
@@ -46,16 +52,29 @@ const settle = () =>
})
function mount(count: number) {
let renders = 0
const holders = Array.from({ length: count }, () =>
renderHook(
({ open }) => {
const menu = useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' })
renders++
return useAgentLaunchPickerInventory('wt', menu, open)
const holders = Array.from({ length: count }, () => {
let entries: AgentPickerEntry[] = []
function Subscriber({ source }: { source: AgentPickerInventorySource }) {
entries = useAgentPickerInventoryEntries(source)
return null
}
function Owner({ open }: { open: boolean }) {
const menu = useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' })
const source = useAgentLaunchPickerInventorySource('wt', menu, open)
renders++
return createElement(Subscriber, { source })
}
const view = render(createElement(Owner, { open: true }))
return {
result: {
get current() {
return entries
}
},
{ initialProps: { open: true } }
)
)
rerender: ({ open }: { open: boolean }) => view.rerender(createElement(Owner, { open })),
unmount: view.unmount
}
})
return { holders, renders: () => renders }
}