diff --git a/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx index 4f5ed3c679..adba3a3bc1 100644 --- a/packages/ui/src/__tests__/session-history-multi-select.test.tsx +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -76,13 +76,13 @@ function installDomStubs(window: ReturnType['window']): void { */ function pointerEvent( window: ReturnType['window'], - type: 'click' | 'contextmenu', + type: 'click' | 'contextmenu' | 'pointerdown' | 'pointerup', modifiers: Record = {}, ): Event { const event = new window.Event(type, { bubbles: true, cancelable: true }); Object.assign(event, { detail: 1, - button: type === 'contextmenu' ? 2 : 0, + button: type === 'contextmenu' || type.startsWith('pointer') ? 2 : 0, metaKey: false, ctrlKey: false, shiftKey: false, @@ -129,6 +129,8 @@ interface Harness { archives: number; document: Document; clickRow(sessionId: string, modifiers?: Record): Promise; + beginRightClickRow(sessionId: string): Promise; + endRightClickRow(sessionId: string): Promise; rightClickRow(sessionId: string): Promise; openRowMenu(sessionId: string): Promise; clickMenuItem(index: number): Promise; @@ -264,15 +266,32 @@ async function mount( rowButton(sessionId).dispatchEvent(pointerEvent(window, 'click', modifiers)); }); }, - rightClickRow: async (sessionId) => { + beginRightClickRow: async (sessionId) => { const event = pointerEvent(window, 'contextmenu'); await act(() => { + rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerdown')); rowButton(sessionId).dispatchEvent(event); }); // Whether the rail claimed the press. Unclaimed, it goes on to the native // menu, which is the whole answer for a row the rail cannot act on. return event.defaultPrevented; }, + endRightClickRow: async (sessionId) => { + await act(async () => { + rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerup')); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }, + rightClickRow: async (sessionId) => { + const event = pointerEvent(window, 'contextmenu'); + await act(async () => { + rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerdown')); + rowButton(sessionId).dispatchEvent(event); + rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerup')); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + return event.defaultPrevented; + }, openRowMenu: async (sessionId) => { const trigger = document.querySelector( `[data-session-id="${sessionId}"] .maka-session-row-action button`, @@ -520,8 +539,16 @@ test('right-clicking a pickable row opens its menu', async () => { // right-click cannot drift into two lists of items that disagree. const harness = await mount({ selectedIds: ['b'] }); try { - const prevented = await harness.rightClickRow('b'); + const prevented = await harness.beginRightClickRow('b'); assert.equal(prevented, true); + assert.equal( + harness.document + .querySelector('[data-session-id="b"] .maka-session-row-action') + ?.getAttribute('data-menu-open'), + null, + 'the unfinished secondary-button gesture must not light-dismiss the menu it opens', + ); + await harness.endRightClickRow('b'); assert.equal( harness.document .querySelector('[data-session-id="b"] .maka-session-row-action') diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 02e53643a3..b85529633f 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -188,8 +188,43 @@ export function SessionHistoryList() { const selection = useSessionRailSelection(); const locale = useUiLocale(); const listRef = useRef(null); + const secondaryPointerDownRef = useRef(false); + const pendingContextMenuTriggerRef = useRef(null); const commands = selection?.commands; + useEffect(() => { + function handlePointerDown(event: PointerEvent) { + if (event.button === 2) secondaryPointerDownRef.current = true; + } + + function handlePointerUp(event: PointerEvent) { + if (event.button !== 2) return; + secondaryPointerDownRef.current = false; + const trigger = pendingContextMenuTriggerRef.current; + pendingContextMenuTriggerRef.current = null; + // Run after the pointerup dispatch and its native light-dismiss default + // action have both completed. Opening inside this listener is still too + // early: the default action would dismiss the newly opened popover. + window.setTimeout(() => trigger?.click(), 0); + } + + function cancelPendingContextMenu() { + secondaryPointerDownRef.current = false; + pendingContextMenuTriggerRef.current = null; + } + + document.addEventListener('pointerdown', handlePointerDown, { capture: true }); + document.addEventListener('pointerup', handlePointerUp, { capture: true }); + document.addEventListener('pointercancel', cancelPendingContextMenu, { capture: true }); + window.addEventListener('blur', cancelPendingContextMenu); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, { capture: true }); + document.removeEventListener('pointerup', handlePointerUp, { capture: true }); + document.removeEventListener('pointercancel', cancelPendingContextMenu, { capture: true }); + window.removeEventListener('blur', cancelPendingContextMenu); + }; + }, []); + /** * The rail's rendered order, read from the DOM at the moment of a click. * @@ -280,6 +315,15 @@ export function SessionHistoryList() { const trigger = row.querySelector('.maka-session-row-action button'); event.preventDefault(); adoptForMenu(sessionId); + // Chromium fires `contextmenu` before the secondary-button pointerup on + // macOS. Opening a light-dismiss popover here lets that unfinished press + // dismiss the new menu as soon as the button is released. Other platforms + // may deliver `contextmenu` after pointerup, and keyboard invocations have + // no secondary press, so only defer while that pointer is observably down. + if (event.button === 2 && secondaryPointerDownRef.current) { + pendingContextMenuTriggerRef.current = trigger; + return; + } // Opening the menu re-enters `handleListClickCapture` with a synthetic // click, so the adoption is dispatched twice — harmless only because both // of its branches are idempotent: `replace` sets the same one row, and