diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 700d1e200f..55782a2155 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -6,6 +6,11 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + expectWorkspaceHoverCardClosed, + expectWorkspaceHoverCardOpen, + expectWorkspaceHoverCardStaysOpen, + focusWorkspaceRowWithKeyboard, + movePointerAwayFromWorkspaceHoverCard, openMobileAgentSidebar, pinWorkspaceFromSidebar, } from "../support/helpers/sidebar"; @@ -404,6 +409,47 @@ test.describe("Sidebar workspace list", () => { } }); + test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + const row = await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await row.click(); + await expect(row).toBeFocused(); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + + test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardStaysOpen(page); + await page.keyboard.press("Escape"); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + test("marks a finished workspace unread until it is opened again", async ({ page }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "sidebar-mark-unread-", diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index c2afc34ef8..1909b4f31b 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); } +export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise { + // Two moves, so the pointer always moves even when it is already parked here. + await page.mouse.move(1200, 200, { steps: 5 }); + await page.mouse.move(1200, 400, { steps: 5 }); +} + +export async function focusWorkspaceRowWithKeyboard( + page: Page, + workspaceId: string, +): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await row.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(row).toBeFocused(); +} + +export async function expectWorkspaceHoverCardOpen(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); +} + +export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise { + // Longer than the hover card's close grace period. + await page.waitForTimeout(300); + await expectWorkspaceHoverCardOpen(page); +} + +export async function expectWorkspaceHoverCardClosed(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); +} + export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise { const workspaceKey = `${getServerId()}:${workspaceId}`; await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click(); diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index f6497ce257..e186b39cd0 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -98,14 +98,21 @@ function WebHoverCard({ ); }, []); + // Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves + // behind on the trigger or a button in the card does not outlast the pointer leaving. + const keyboardFocusInside = useCallback(() => { + return focusInside() && document.activeElement?.matches(":focus-visible") === true; + }, [focusInside]); + const scheduleClose = useCallback(() => { - if (focusInside()) return; + if (keyboardFocusInside()) return; if (graceTimerRef.current) return; graceTimerRef.current = setTimeout(() => { graceTimerRef.current = null; + if (keyboardFocusInside()) return; setOpen(false); }, CLOSE_GRACE_MS); - }, [focusInside]); + }, [keyboardFocusInside]); const openNow = useCallback(() => { clearGraceTimer();