From 8ef0e99a7cbe69241b6ca56cc963dadbf6f393fc Mon Sep 17 00:00:00 2001 From: Zuz666 <109312+Zuz666@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:19:45 +0500 Subject: [PATCH] fix(ui): use focusable tooltip menu triggers (#9595) * fix(ui): use focusable tooltip menu triggers * test(tooltips): give each menu surface its own remount check Keep all hover, focus, description, keyboard, pointer and two-remount assertions while avoiding a shared 120-second budget across three surfaces. The failed CI trace passed five full exercises before global teardown. --------- Co-authored-by: Yuris Auzins Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../ActivityThreadOptionsMenu.test.tsx | 51 +++++--- .../activity/activity-thread-options-menu.tsx | 27 ++--- .../commit/commit-action-menu.tsx | 40 +++---- .../panel/header-overflow-menu.tsx | 30 +++-- ...tooltip-dropdown-trigger-stability.spec.ts | 112 ++++++++++++++++++ 5 files changed, 191 insertions(+), 69 deletions(-) create mode 100644 tests/e2e/tooltip-dropdown-trigger-stability.spec.ts diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx index 544101f7ff2..2f905623efa 100644 --- a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, type ReactElement } from 'react' +import { act, StrictMode, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -150,23 +150,40 @@ describe('ActivityThreadOptionsMenu', () => { } }) - it('opens without recursively updating composed Radix trigger refs', async () => { - await act(async () => { - root.render() - }) + it('keeps the focused menu button described through repeated opens and rerenders', async () => { + for (let cycle = 0; cycle < 3; cycle += 1) { + await act(async () => { + root.render( + + + + ) + }) + const trigger = container.querySelector( + 'button[aria-label="Thread list options"]' + ) + if (!trigger) { + throw new Error('Thread list options button missing') + } - const trigger = container.querySelector( - 'button[aria-label="Thread list options"]' - ) - - expect(trigger).not.toBeNull() - expect(trigger?.parentElement?.tagName).toBe('SPAN') - - await act(async () => { - trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) - }) - - expect(document.body.textContent).toContain('Compact mode') + await act(async () => trigger.focus()) + await vi.waitFor(() => { + const descriptionId = trigger.getAttribute('aria-describedby') + expect(descriptionId).toBeTruthy() + expect(document.getElementById(descriptionId ?? '')?.textContent).toBe('Activity options') + }) + await act(async () => { + trigger.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + expect(document.body.textContent).toContain('Compact mode') + await act(async () => { + document + .querySelector('[role="menu"]') + ?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })) + trigger.blur() + }) + await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull()) + } }) it.each(['removed host', 'single project', 'stale project'] as const)( diff --git a/src/renderer/src/components/activity/activity-thread-options-menu.tsx b/src/renderer/src/components/activity/activity-thread-options-menu.tsx index 0ee6dfa5689..f3a39aef0bb 100644 --- a/src/renderer/src/components/activity/activity-thread-options-menu.tsx +++ b/src/renderer/src/components/activity/activity-thread-options-menu.tsx @@ -99,21 +99,18 @@ export function ActivityThreadOptionsMenu({ - {/* Why: keep Tooltip and Dropdown from composing refs onto the same button (Radix setRef crash loop). */} - - - - - + + + {activeFilterCount > 0 diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/commit-action-menu.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/commit-action-menu.tsx index 84e4f221c97..ec9dd19be1b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/commit-action-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/commit-action-menu.tsx @@ -55,27 +55,25 @@ export function CommitActionMenu({ - - - - - + + + {moreCommitAndRemoteActionsLabel} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/header-overflow-menu.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/header-overflow-menu.tsx index 3d5e9b6ac80..0febb01cc49 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/header-overflow-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/header-overflow-menu.tsx @@ -40,22 +40,20 @@ export function SourceControlHeaderOverflowMenu({ - - - - - + + + {translate( diff --git a/tests/e2e/tooltip-dropdown-trigger-stability.spec.ts b/tests/e2e/tooltip-dropdown-trigger-stability.spec.ts new file mode 100644 index 00000000000..2999b622905 --- /dev/null +++ b/tests/e2e/tooltip-dropdown-trigger-stability.spec.ts @@ -0,0 +1,112 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +async function expectTooltipClosed(page: Page, text: string): Promise { + const content = page.locator('[data-slot="tooltip-content"]').filter({ hasText: text }) + await expect + .poll(() => + content.evaluateAll((elements) => + elements.every((element) => element.getAttribute('data-state') === 'closed') + ) + ) + .toBe(true) + await expect(content).toHaveCount(0) +} + +async function exerciseMenuTrigger(page: Page, button: Locator, text: string): Promise { + await page.mouse.move(400, 400) + await button.blur() + await expectTooltipClosed(page, text) + await button.hover() + await expect(page.getByRole('tooltip').filter({ hasText: text })).toBeVisible() + await page.mouse.move(400, 400) + await expectTooltipClosed(page, text) + + await button.focus() + await expect(button).toBeFocused() + await expect(button).toHaveAccessibleDescription(text) + await expect(page.getByRole('tooltip').filter({ hasText: text })).toBeVisible() + + await button.blur() + await expectTooltipClosed(page, text) + + for (const input of ['keyboard', 'pointer'] as const) { + if (input === 'keyboard') { + await button.focus() + await page.keyboard.press('Enter') + } else { + await button.click() + } + const menu = page.getByRole('menu') + await expect(menu).toBeVisible() + await expect + .poll(() => menu.evaluate((element) => element.contains(document.activeElement))) + .toBe(true) + // A menu item's tooltip can consume the first Escape. + await page.keyboard.press('Escape') + if ( + await menu.evaluateAll((elements) => + elements.some((element) => element.getAttribute('data-state') === 'open') + ) + ) { + await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(0) + await page.keyboard.press('Escape') + } + await expect(page.getByRole('menu')).toBeHidden() + await expect(button).toBeFocused() + } +} + +for (const surface of [ + { + name: 'source control header', + openButton: /^Source Control/, + closeButton: /^Explorer/, + triggerLabel: 'More source control actions', + tooltip: 'More source control actions' + }, + { + name: 'commit actions', + openButton: /^Source Control/, + closeButton: /^Explorer/, + triggerLabel: 'More commit and remote actions', + tooltip: 'More commit and remote actions' + }, + { + name: 'activity options', + openButton: 'View activity', + closeButton: 'Turn off activity view', + triggerLabel: 'Thread list options', + tooltip: 'Activity options' + } +] as const) { + test(`${surface.name} menu button retains tooltip descriptions through focus, hover and remounts`, async ({ + orcaPage: page + }) => { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + const errors: string[] = [] + const captureError = (error: Error): void => { + errors.push(error.message) + } + page.on('pageerror', captureError) + try { + for (let cycle = 0; cycle < 2; cycle += 1) { + await page.getByRole('button', { name: surface.openButton, exact: true }).click() + await exerciseMenuTrigger( + page, + page.getByRole('button', { name: surface.triggerLabel, exact: true }), + surface.tooltip + ) + await page.getByRole('button', { name: surface.closeButton, exact: true }).click() + await expect( + page.getByRole('button', { name: surface.triggerLabel, exact: true }) + ).toHaveCount(0) + expect(errors).toEqual([]) + } + } finally { + page.off('pageerror', captureError) + } + }) +}