From e48b5d1a9b6500d22e36b69af58e0a875da33a4b Mon Sep 17 00:00:00 2001 From: macodev00 Date: Mon, 5 Oct 2026 07:07:20 +0000 Subject: [PATCH] fix(web): right-panel launcher letters work while a closed menu stays mounted Closed keepMounted popups matched by slot alone, so the chat header actions menu blocked every launcher letter on an open thread. Popup selectors now match only while open or animating closed. --- .../RightPanelTabs.keyboard.test.tsx | 1 + .../RightPanelTabs.launcherShortcuts.test.ts | 54 +++++++++++++++++++ apps/web/src/components/RightPanelTabs.tsx | 38 +++++++++---- 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..dc1de792e91c 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -194,6 +194,7 @@ describe("right panel new-tab shortcut", () => { expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false); const dialog = document.createElement("div"); dialog.dataset.slot = "dialog-popup"; + dialog.dataset.open = ""; container.append(dialog); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); expect(document.querySelector('[role="menu"]')).toBeNull(); diff --git a/apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts b/apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts new file mode 100644 index 000000000000..c4e77c931c36 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.launcherShortcuts.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vite-plus/test"; + +import { launcherShortcutBlockedByOverlay } from "./RightPanelTabs"; + +const POPUP_SLOTS = [ + "dialog-popup", + "alert-dialog-popup", + "command-dialog-popup", + "menu-popup", + "select-popup", + "popover-popup", + "combobox-popup", + "autocomplete-popup", +] as const; + +/** Mount a popup slot in the document in a closed, open, or closing state. */ +function mountPopup(slot: string, state: "closed" | "open" | "ending"): HTMLElement { + const popup = document.createElement("div"); + popup.dataset.slot = slot; + if (state === "closed") popup.dataset.closed = ""; + if (state === "open") popup.dataset.open = ""; + if (state === "ending") popup.dataset.endingStyle = ""; + document.body.append(popup); + return popup; +} + +describe("launcher shortcut overlays", () => { + it("ignores a closed popup that stays mounted", () => { + for (const slot of POPUP_SLOTS) { + document.body.replaceChildren(); + mountPopup(slot, "closed"); + expect(launcherShortcutBlockedByOverlay(), slot).toBe(false); + } + }); + + it("blocks while a popup is open or animating closed", () => { + for (const slot of POPUP_SLOTS) { + for (const state of ["open", "ending"] as const) { + document.body.replaceChildren(); + mountPopup(slot, state); + expect(launcherShortcutBlockedByOverlay(), `${slot} ${state}`).toBe(true); + } + } + }); + + it("keeps blocking when a closed mounted menu sits beside an open one", () => { + document.body.replaceChildren(); + mountPopup("menu-popup", "closed"); + mountPopup("menu-popup", "open"); + expect(launcherShortcutBlockedByOverlay()).toBe(true); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 2c7e3e3da68c..f5b30a479c97 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -165,18 +165,34 @@ const SURFACE_DISABLED_REASONS = { device: "Devices are only available from a thread.", } as const; -/** Overlays that must win over the launcher's letter shortcuts. */ +/** + * Overlays that must win over the launcher's letter shortcuts. + * Each popup matches only while open or animating closed. A closed popup + * can stay mounted (`keepMounted`); matching it by slot alone made the + * chat header actions menu swallow every launcher letter. + */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ - '[data-slot="dialog-popup"]', - '[data-slot="alert-dialog-popup"]', - '[data-slot="command-dialog-popup"]', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="popover-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', + '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="menu-popup"]:is([data-open],[data-ending-style])', + '[data-slot="select-popup"]:is([data-open],[data-ending-style])', + '[data-slot="popover-popup"]:is([data-open],[data-ending-style])', + '[data-slot="combobox-popup"]:is([data-open],[data-ending-style])', + '[data-slot="autocomplete-popup"]:is([data-open],[data-ending-style])', ].join(","); +/** + * Whether a launcher letter should be ignored because an overlay is open + * or still animating closed. + * + * Closed popups that stay mounted do not count. The chat header actions + * menu used to, and every launcher letter was dead on every thread. + */ +export function launcherShortcutBlockedByOverlay(root: ParentNode = document): boolean { + return root.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS) !== null; +} + /** One-line unavailability hints for the empty-state rows. */ const SURFACE_UNAVAILABLE_HINTS = { browser: "Only available in the desktop app.", @@ -414,7 +430,7 @@ function RightPanelEmptyState(props: { const handler = (event: KeyboardEvent) => { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; - if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (launcherShortcutBlockedByOverlay()) return; // The composed path starts at the real target, which may sit inside a shadow root. const target = event.composedPath()[0] ?? event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; @@ -818,7 +834,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }) !== "rightPanel.new" ) return; - if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (!addSurfaceMenuOpen && launcherShortcutBlockedByOverlay()) return; event.preventDefault(); event.stopPropagation(); if (!event.repeat) {