From fe10c40b9a702d983d2f6fc6cffff41e44c44931 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:03:59 +0200 Subject: [PATCH] fix(app): keep a hover card open when a pressed button disables itself (#6185) Clicking Refresh on a usage card in the context window details closed the card within the close grace, with the pointer still on it. Refresh disables itself while the request is pending, which drops the click's focus to the document, and the hover card treated any focus leaving the card as a reason to close. Only keyboard focus keeps a hover card open, so only losing keyboard focus now closes it. Whether focus is keyboard focus is read when it arrives, because the element losing focus no longer matches :focus-visible during focusout. Closes #6176 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/context-window-meter.spec.ts | 10 ++++++++-- packages/app/e2e/browser/sidebar-workspace.spec.ts | 7 ++++++- packages/app/src/components/ui/hover-card.tsx | 8 +++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 80a43d6aa2..5d024de761 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -268,10 +268,16 @@ for (const theme of ["light", "dark"] as const) { await expect(card).toBeVisible(); }); - await test.step("Refresh replaces the card with the source's new report", async () => { - usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + await test.step("Refresh keeps the card open and replaces it with the new report", async () => { + const refreshed = gate(); + usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] }); const card = contextWindowDetails(page); await refreshUsageCard(card, "Claude"); + await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled(); + // Past the card's close grace, with the pointer still on the card. + await page.waitForTimeout(500); + await expect(card).toBeVisible(); + refreshed.open(); await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); expect(usage.refreshedAgents()).toEqual([agent.agentId]); }); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 55782a2155..8071b3b4f3 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -429,7 +429,7 @@ test.describe("Sidebar workspace list", () => { } }); - test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => { + test("workspace hover card follows keyboard focus on a row", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); try { @@ -445,6 +445,11 @@ test.describe("Sidebar workspace list", () => { await expectWorkspaceHoverCardStaysOpen(page); await page.keyboard.press("Escape"); await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await page.keyboard.press("Tab"); + await expectWorkspaceHoverCardClosed(page); } finally { await workspace.cleanup(); } diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index e186b39cd0..3d438505d3 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -82,6 +82,8 @@ function WebHoverCard({ const contentRef = useRef(null); const [open, setOpen] = useState(false); const graceTimerRef = useRef | null>(null); + // Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible. + const keyboardFocusedRef = useRef(false); const clearGraceTimer = useCallback(() => { if (graceTimerRef.current) { @@ -176,16 +178,20 @@ function WebHoverCard({ setOpen(false); }; const focusEntered = (event: FocusEvent) => { - const target = event.target as Node; + const target = event.target as Element; + keyboardFocusedRef.current = target.matches(":focus-visible"); if (trigger.contains(target)) { openNow(); } else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) { clearGraceTimer(); } }; + // Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button + // that disables itself on press drops pointer focus while the pointer is still on the card. const focusLeft = (event: FocusEvent) => { const next = event.relatedTarget as Node | null; if ( + keyboardFocusedRef.current && !trigger.contains(next) && !(contentRef.current as unknown as HTMLElement | null)?.contains(next) ) {