mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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}
|
||||
|
||||
+24
-10
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user