From 30ffe6e297c268af5c0cc5ccb73e4e54bf07dfc2 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sun, 1 Nov 2026 01:39:06 -0700
Subject: [PATCH] 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
---
config/scripts/check-test-file-runners.mjs | 6 +
.../AgentLaunchPickerMenu.language.test.tsx | 62 +++
.../AgentLaunchPickerMenu.search.test.tsx | 85 ++++
.../agent/AgentLaunchPickerMenu.tsx | 47 ++-
.../agent/agent-launch-picker-acquisition.ts | 4 +-
...t-launch-picker-freshness-test-fixture.tsx | 9 +-
...use-agent-launch-picker-inventory.test.tsx | 129 ++++--
.../use-agent-launch-picker-inventory.ts | 72 +++-
.../QuickLaunchButton.launch-status.test.tsx | 2 +-
.../components/tab-bar/QuickLaunchButton.tsx | 15 +-
.../tab-bar/TabBarAgentPickerEntry.tsx | 25 ++
...t-agent-launch-preferences-source.test.tsx | 9 +-
.../tab-bar/tab-bar-create-menu-contract.ts | 5 +-
.../components/tab-bar/tab-bar-surface.tsx | 12 +-
...w-tab-agent-menu-host-preferences.test.tsx | 34 +-
.../use-tab-bar-create-menu-controller.ts | 11 +-
.../src/components/ui/context-menu.tsx | 5 +
.../src/components/ui/dropdown-menu.tsx | 5 +
.../ui/menu-pointer-highlight.fixture.tsx | 49 +++
.../ui/menu-pointer-highlight.test.tsx | 386 ++++++++++++++++++
.../components/ui/menu-pointer-highlight.ts | 168 ++++++++
.../ui/menu-pointer-submenu-handoff.test.tsx | 81 ++++
.../lib/launch-agent-picker-contract.test.ts | 9 +-
.../menu-pointer-highlight.spec.ts | 183 +++++++++
tests/component-fixtures/playwright.config.ts | 25 ++
...agent-launch-preferences-work.unit.test.ts | 41 +-
26 files changed, 1377 insertions(+), 102 deletions(-)
create mode 100644 src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx
create mode 100644 src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx
create mode 100644 src/renderer/src/components/tab-bar/TabBarAgentPickerEntry.tsx
create mode 100644 src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx
create mode 100644 src/renderer/src/components/ui/menu-pointer-highlight.test.tsx
create mode 100644 src/renderer/src/components/ui/menu-pointer-highlight.ts
create mode 100644 src/renderer/src/components/ui/menu-pointer-submenu-handoff.test.tsx
create mode 100644 tests/component-fixtures/menu-pointer-highlight.spec.ts
create mode 100644 tests/component-fixtures/playwright.config.ts
diff --git a/config/scripts/check-test-file-runners.mjs b/config/scripts/check-test-file-runners.mjs
index 68f22769568..8e0a738743e 100644
--- a/config/scripts/check-test-file-runners.mjs
+++ b/config/scripts/check-test-file-runners.mjs
@@ -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'],
diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx
new file mode 100644
index 00000000000..0bc0da0d791
--- /dev/null
+++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.language.test.tsx
@@ -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(
+
+
+ New tab
+
+
+
+
+
+ )
+ )
+ const row = document.querySelector('[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()
+ }
+})
diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx
new file mode 100644
index 00000000000..ba3cd12d540
--- /dev/null
+++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.search.test.tsx
@@ -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) =>
+ props.renderAnchor?.()
+ )
+}))
+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[]) => (
+
+ Open
+
+
+
+
+ )
+ 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
+ )
+})
diff --git a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx
index e375a1dfe46..e9558d180f1 100644
--- a/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx
+++ b/src/renderer/src/components/agent/AgentLaunchPickerMenu.tsx
@@ -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, 'entries'> & {
+ inventory: AgentPickerInventorySource
+}) {
+ const entries = useAgentPickerInventoryEntries(inventory)
+ return
+}
+
+// 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(null)
const configured = useRef(undefined)
@@ -212,4 +239,4 @@ function AgentPickerMenuRow({
)}
/>
)
-}
+})
diff --git a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts
index bd6e08e8e13..fddc7ec72ec 100644
--- a/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts
+++ b/src/renderer/src/components/agent/agent-launch-picker-acquisition.ts
@@ -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
})
}
diff --git a/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx b/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx
index 76dccdd64e4..65186c01aef 100644
--- a/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx
+++ b/src/renderer/src/components/agent/agent-launch-picker-freshness-test-fixture.tsx
@@ -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(
diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx
index b7f1b8edc84..28f3d3a267e 100644
--- a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx
+++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.test.tsx
@@ -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>()
+ mocks.session.mockImplementation((_target, method, params: { agent: string }) => {
+ if (method !== 'agentSession.modelCatalog') {
+ return Promise.resolve({ supported: true })
+ }
+ const read = Promise.withResolvers()
+ 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 }) =>
- 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 }) =>
- 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,
diff --git a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts
index 5bb02aeede7..f2ec14a528b 100644
--- a/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts
+++ b/src/renderer/src/components/agent/use-agent-launch-picker-inventory.ts
@@ -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
}
diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
index f20020b941f..43f7dc451fd 100644
--- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
+++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
@@ -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)
diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
index b9465505ade..ae59d2b0f36 100644
--- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
+++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
@@ -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({
)}
) : null}
- {pickerEntries ? (
- & {
+ 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
+}
diff --git a/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx b/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx
index 591a7639c97..f2507dcc73c 100644
--- a/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx
+++ b/src/renderer/src/components/tab-bar/host-agent-launch-preferences-source.test.tsx
@@ -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))
diff --git a/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts b/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts
index 5dc52db91c4..b166f375cd5 100644
--- a/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts
+++ b/src/renderer/src/components/tab-bar/tab-bar-create-menu-contract.ts
@@ -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
newTabMenuOpen: boolean
diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx
index dfb6d734ced..d73ba8238a3 100644
--- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx
+++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx
@@ -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 ? (
<>
- {
queueNewActiveTerminalFocusAfterNewTabMenuClose()
@@ -283,7 +281,7 @@ export function renderTabBarSurface({
{
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 () => {})
diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts
index d3c3fd9ccd6..4be1917a4a0 100644
--- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts
+++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts
@@ -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()
- 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(null)
const pendingNewTabMenuFocusRetryRef = useRef(null)
@@ -277,8 +275,7 @@ export function useTabBarCreateMenuController({
}, [newTabMenuOpen])
return {
agentMenu,
- agentLaunchOptions,
- agentPickerEntries,
+ agentPickerInventory,
selectPickerAgent,
launchAgentRow,
newTabMenuOpen,
diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx
index 7f217eadaed..0ac22823057 100644
--- a/src/renderer/src/components/ui/context-menu.tsx
+++ b/src/renderer/src/components/ui/context-menu.tsx
@@ -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) {
return
@@ -70,6 +71,7 @@ function ContextMenuSubContent({
...props
}: React.ComponentProps &
VariantProps) {
+ const pointerHighlight = useMenuPointerHighlight(props)
return (
handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
@@ -97,6 +100,7 @@ function ContextMenuContent({
...props
}: React.ComponentProps &
VariantProps) {
+ const pointerHighlight = useMenuPointerHighlight(props)
return (
handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx
index 7ab1989120a..db61f38d1ff 100644
--- a/src/renderer/src/components/ui/dropdown-menu.tsx
+++ b/src/renderer/src/components/ui/dropdown-menu.tsx
@@ -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) {
return
@@ -28,6 +29,7 @@ function DropdownMenuContent({
style,
...props
}: React.ComponentProps) {
+ const pointerHighlight = useMenuPointerHighlight(props)
return (
handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
@@ -215,6 +218,7 @@ function DropdownMenuSubContent({
alignOffset = -5,
...props
}: React.ComponentProps) {
+ const pointerHighlight = useMenuPointerHighlight(props)
return (
handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
diff --git a/src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx b/src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx
new file mode 100644
index 00000000000..e02c9a20be8
--- /dev/null
+++ b/src/renderer/src/components/ui/menu-pointer-highlight.fixture.tsx
@@ -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 (
+
+ )
+}
+
+export const dropdown = defineFixture({
+ width: 800,
+ render: () =>
+})
+
+export const context = defineFixture({
+ width: 800,
+ render: () =>
+})
diff --git a/src/renderer/src/components/ui/menu-pointer-highlight.test.tsx b/src/renderer/src/components/ui/menu-pointer-highlight.test.tsx
new file mode 100644
index 00000000000..624e7b35348
--- /dev/null
+++ b/src/renderer/src/components/ui/menu-pointer-highlight.test.tsx
@@ -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, '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 (
+
+ )
+}
+
+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())
+ 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())
+ await act(async () => root!.render())
+
+ 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)
+ }
+)
diff --git a/src/renderer/src/components/ui/menu-pointer-highlight.ts b/src/renderer/src/components/ui/menu-pointer-highlight.ts
new file mode 100644
index 00000000000..a00ad8f79d1
--- /dev/null
+++ b/src/renderer/src/components/ui/menu-pointer-highlight.ts
@@ -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
+ onPointerMove?: React.PointerEventHandler
+ onPointerOver?: React.PointerEventHandler
+ onPointerLeave?: React.PointerEventHandler
+ onKeyDownCapture?: React.KeyboardEventHandler
+ onScrollCapture?: React.UIEventHandler
+}
+
+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 }
+): Required & { ref: React.RefCallback } {
+ const stateRef = React.useRef({
+ pointer: null,
+ pointerMovePrevented: false,
+ frame: 0
+ })
+ const [content, setContent] = React.useState(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)
+ }
+ })
+ }
+ }
+}
diff --git a/src/renderer/src/components/ui/menu-pointer-submenu-handoff.test.tsx b/src/renderer/src/components/ui/menu-pointer-submenu-handoff.test.tsx
new file mode 100644
index 00000000000..c207aa26322
--- /dev/null
+++ b/src/renderer/src/components/ui/menu-pointer-submenu-handoff.test.tsx
@@ -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(
+
+ )
+ 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()
+ }
+)
diff --git a/src/renderer/src/lib/launch-agent-picker-contract.test.ts b/src/renderer/src/lib/launch-agent-picker-contract.test.ts
index 6cedef88f7e..703bf9efa84 100644
--- a/src/renderer/src/lib/launch-agent-picker-contract.test.ts
+++ b/src/renderer/src/lib/launch-agent-picker-contract.test.ts
@@ -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(
diff --git a/tests/component-fixtures/menu-pointer-highlight.spec.ts b/tests/component-fixtures/menu-pointer-highlight.spec.ts
new file mode 100644
index 00000000000..46a11682b5a
--- /dev/null
+++ b/tests/component-fixtures/menu-pointer-highlight.spec.ts
@@ -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((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')
+ })
+ })
+}
diff --git a/tests/component-fixtures/playwright.config.ts b/tests/component-fixtures/playwright.config.ts
new file mode 100644
index 00000000000..d92e4cfa18f
--- /dev/null
+++ b/tests/component-fixtures/playwright.config.ts
@@ -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
+ }
+})
diff --git a/tests/e2e/host-agent-launch-preferences-work.unit.test.ts b/tests/e2e/host-agent-launch-preferences-work.unit.test.ts
index bef35b71975..735ae6c58c8 100644
--- a/tests/e2e/host-agent-launch-preferences-work.unit.test.ts
+++ b/tests/e2e/host-agent-launch-preferences-work.unit.test.ts
@@ -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 }
}