diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index d723b5be04..4354d364ff 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -40,7 +40,7 @@ import { navigateToLastWorkspace, useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { dispatchTopWebOverlayKeyDown } from "@/lib/overlay-root"; +import { dispatchTopWebOverlayKeyDown, hasOpenDomPopover } from "@/lib/overlay-root"; export function useKeyboardShortcuts({ enabled, @@ -321,6 +321,13 @@ export function useKeyboardShortcuts({ return; } + // An open popover (a plugin's panel) is the top layer and closes on its + // own Escape listener, which runs after this one. Escape is not an app + // shortcut then: it must not also interrupt the running agent. + if (event.key === "Escape" && hasOpenDomPopover()) { + return; + } + // During IME composition, Enter confirms the candidate selection and must // not route through global shortcuts like message send. if (isImeComposingKeyboardEvent(event)) { diff --git a/packages/app/src/lib/overlay-root.browser.test.tsx b/packages/app/src/lib/overlay-root.browser.test.tsx index a21403dd8d..8cf09fcf72 100644 --- a/packages/app/src/lib/overlay-root.browser.test.tsx +++ b/packages/app/src/lib/overlay-root.browser.test.tsx @@ -2,7 +2,7 @@ import React, { type RefCallback } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { useWebOverlayRegistration } from "./overlay-root"; +import { hasOpenDomPopover, useWebOverlayRegistration } from "./overlay-root"; function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -68,3 +68,20 @@ describe("useWebOverlayRegistration in the browser", () => { expect(openerFocusCalls).toBe(1); }); }); + +describe("hasOpenDomPopover", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("is true only while a popover is shown", () => { + const panel = document.createElement("div"); + panel.setAttribute("popover", "manual"); + document.body.append(panel); + expect(hasOpenDomPopover()).toBe(false); + panel.showPopover(); + expect(hasOpenDomPopover()).toBe(true); + panel.hidePopover(); + expect(hasOpenDomPopover()).toBe(false); + }); +}); diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 9ffd81725f..bb0ec6d168 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -112,6 +112,21 @@ export function hasActiveWebOverlay(): boolean { return getTopWebOverlay() !== undefined; } +/** + * True while an element shown with the Popover API is open. Such a layer + * floats above the app like a registered overlay but lives outside the + * registry: plugins draw their panels this way, since they cannot register. + */ +export function hasOpenDomPopover(): boolean { + if (typeof document === "undefined") return false; + try { + return document.querySelector(":popover-open") !== null; + } catch { + // An engine without the Popover API rejects the selector, and has none open. + return false; + } +} + function getFocusableElements(scope: HTMLElement): HTMLElement[] { const selector = [ "a[href]",