From 65161adce5af8ce467fc353120ed9775ce3b0c76 Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 19:20:06 -0700 Subject: [PATCH] fix(desktop): close focused threads on Escape Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../channels/ui/FocusThreadDrawer.tsx | 14 +++++++++++ desktop/tests/e2e/thread-focus-mode.spec.ts | 25 ++++++------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 065d607a13..b857183024 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -143,6 +143,20 @@ export function FocusThreadDrawer({ const drawerRef = React.useRef(null); const previousFocusRef = React.useRef(null); + React.useEffect(() => { + function handleEscape(event: KeyboardEvent) { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopImmediatePropagation(); + onClose(); + } + + window.addEventListener("keydown", handleEscape, { capture: true }); + return () => { + window.removeEventListener("keydown", handleEscape, { capture: true }); + }; + }, [onClose]); + React.useLayoutEffect(() => { previousFocusRef.current = document.activeElement instanceof HTMLElement diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 75b817a8a0..c9ae42061f 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -200,29 +200,18 @@ test("focus and split preserve reading context and interaction ownership", async body.locator(`[data-message-id="${anchorId}"]`), ).toBeInViewport(); - // Escape layering: a nested control inside the drawer claims Escape first. - // With mention autocomplete open, Escape closes only the autocomplete — the - // drawer must stay. + // Focus mode owns Escape even while the rich-text composer and one of its + // nested controls has focus: one press exits the focused thread. const threadInput = page .getByTestId("message-thread-panel") .getByTestId("message-input"); await threadInput.click(); await threadInput.pressSequentially("@al"); - const mentionDropdown = page - .getByTestId("message-thread-panel") - .getByTestId("mention-autocomplete"); - await expect(mentionDropdown).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(mentionDropdown).toHaveCount(0); - await expect(drawer).toBeVisible(); - - // Known gap (pre-existing, tracked as a follow-up): while the composer's - // rich-text editor holds focus it claims Escape internally even with no - // autocomplete open, so Escape-from-composer never reaches the drawer's - // close listener. Move focus to the drawer itself before asserting - // Escape-to-close. When the editor is taught to release an idle Escape, - // this focus hop can be removed. - await drawer.focus(); + await expect( + page + .getByTestId("message-thread-panel") + .getByTestId("mention-autocomplete"), + ).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0); await expect(channel).not.toHaveAttribute("inert", "");