fix(app): let Escape close an open popover before it interrupts the agent

The agent.interrupt shortcut listens on the window in the capture phase, so
it sees Escape before anything else. Registered overlays get first refusal
through dispatchTopWebOverlayKeyDown, but plugins cannot register, and draw
their floating panels with the Popover API instead. With such a panel open
and the composer focused while an agent runs, Escape closed the panel and
interrupted the agent in the same press.

While a popover is open, Escape is left to it and no app shortcut runs. A
second Escape, with nothing open, interrupts as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
This commit is contained in:
luke
2026-10-15 13:45:38 +08:00
co-authored by Claude Opus 5.5 RoundRedDot
parent e5124b039a
commit 388099ebbf
3 changed files with 41 additions and 2 deletions
@@ -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)) {
@@ -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<void> {
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);
});
});
+15
View File
@@ -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]",