fix(app): close hover cards when the pointer leaves a clicked row (#6167)

* fix(app): close hover cards when the pointer leaves a clicked trigger

A hover card stayed open while any focus sat inside its trigger or content,
so clicking a sidebar workspace row (which focuses the row's button) pinned
the card after the pointer moved away, until Escape or a click elsewhere.
Only keyboard-visible focus now keeps the card open, which preserves the
keyboard entry path added with the shared hover card.

* fix(app): keep a hover card open when keyboard focus arrives during its close delay

Recheck keyboard focus when the close timer fires, and move the hover card e2e steps into sidebar helpers.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-05 23:06:34 +02:00
committed by GitHub
parent 2f0cb2f54b
commit f02cc16bb4
3 changed files with 86 additions and 2 deletions
@@ -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-",
@@ -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<void> {
// 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<void> {
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<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
// Longer than the hover card's close grace period.
await page.waitForTimeout(300);
await expectWorkspaceHoverCardOpen(page);
}
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
@@ -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();