From ae9ca3fbc3002ffea2c85bab1de16a5f080ac1f6 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Wed, 4 Nov 2026 15:12:01 -0800 Subject: [PATCH 1/3] fix(channels): move focus to channel settings opened from the header menu View channel details opens the Channel Settings tab, but the menu returned focus to the Channel actions button once its exit animation ended. In the macOS app the tab can lose focus first, so the menu's return focus wins and Escape runs mark-read instead of closing the tab. Return focus to the settings tab for that item, falling back to the trigger. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- .../channels/ChannelHeaderMenu.test.tsx | 65 ++++++++++++++++++- src/bundled/channels/ChannelHeaderMenu.tsx | 13 +++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/bundled/channels/ChannelHeaderMenu.test.tsx b/src/bundled/channels/ChannelHeaderMenu.test.tsx index 12c09931f9..ee4f249157 100644 --- a/src/bundled/channels/ChannelHeaderMenu.test.tsx +++ b/src/bundled/channels/ChannelHeaderMenu.test.tsx @@ -3,8 +3,9 @@ import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import { createRef, useLayoutEffect } from "react"; +import { createRef, useLayoutEffect, useState } from "react"; import { ChannelHeaderMenu } from "./ChannelHeaderMenu"; +import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import type { RelaySession } from "../../features/relay/session"; import type { ChannelSummary } from "../../features/relay/contracts"; import { @@ -73,7 +74,7 @@ function harness(value: ChannelSummary | undefined = channel) { , ); - return { props, mount, load, save, run, lifecycleLoad, details }; + return { props, relay, mount, load, save, run, lifecycleLoad, details }; } it("loads only on demand, uses the non-compact shared menu, and opens details without mutation", async () => { const h = harness(); @@ -108,6 +109,66 @@ it("loads only on demand, uses the non-compact shared menu, and opens details wi ); expect(h.props.openDetails).toHaveBeenCalledTimes(2); }); +it.each([true, false])( + "hands focus to the opened settings tab when the menu finishes closing (focus dropped to body=%s)", + async (dropped) => { + const h = harness(); + const close = vi.fn(); + function Page() { + const [settings, setSettings] = useState(false); + return ( + + setSettings(true)} + /> + {settings && ( + {}} + items={[ + { + id: "settings", + label: "Channel settings", + close, + content:

Settings

, + }, + ]} + /> + )} +
+ ); + } + render(, { reactStrictMode: true }); + const user = userEvent.setup(); + const trigger = screen.getByRole("button", { name: "Channel actions" }); + await user.click(trigger); + const menu = await screen.findByRole("menu"); + let release!: () => void; + const finished = new Promise((resolve) => { + release = resolve; + }); + const animations = vi.fn(() => [{ finished }]); + // Hold the menu's exit animation. WebKit can drop focus to the body while + // it runs; jsdom keeps it on the tab the workspace focused. + Object.defineProperty(menu, "getAnimations", { value: animations }); + try { + await user.click( + screen.getByRole("menuitem", { name: "View channel details" }), + ); + await waitFor(() => expect(animations).toHaveBeenCalled()); + if (dropped) (document.activeElement as HTMLElement | null)?.blur(); + await act(async () => release()); + await waitFor(() => expect(menu).not.toBeInTheDocument()); + const tab = screen.getByRole("tab", { name: "Channel settings" }); + await waitFor(() => expect(tab).toHaveFocus()); + await user.keyboard("{Escape}"); + expect(close).toHaveBeenCalledOnce(); + } finally { + await act(async () => release()); + } + }, +); it("supports keyboard dismissal, outside clicks, and Canvas with the stable header focus target", async () => { const h = harness(); h.mount(); diff --git a/src/bundled/channels/ChannelHeaderMenu.tsx b/src/bundled/channels/ChannelHeaderMenu.tsx index 4e670d3a4c..d30fe83eda 100644 --- a/src/bundled/channels/ChannelHeaderMenu.tsx +++ b/src/bundled/channels/ChannelHeaderMenu.tsx @@ -85,7 +85,18 @@ export function ChannelHeaderMenu({ Finish conversion )} - + { + // WebKit can drop the tab's focus before the menu finishes closing. + finalFocus.current = () => + document.querySelector( + '[data-panel-workspace] [data-tab-value="settings"]', + ) ?? + trigger.current ?? + false; + openDetails(); + }} + > From 75fef69f884cb1e028c26e0a9d622a8da977278e Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Wed, 4 Nov 2026 15:58:30 -0800 Subject: [PATCH 2/3] test(channels): keep only the failing case in the settings focus test The case where focus stays on the tab passed before the fix too, and the browser journeys already cover that path in Chromium and WebKit. Keep the case that drops focus to the body before the menu unmounts. Co-authored-by: Cursor Signed-off-by: Cheng Peng --- .../channels/ChannelHeaderMenu.test.tsx | 112 +++++++++--------- 1 file changed, 53 insertions(+), 59 deletions(-) diff --git a/src/bundled/channels/ChannelHeaderMenu.test.tsx b/src/bundled/channels/ChannelHeaderMenu.test.tsx index ee4f249157..c9862dbcce 100644 --- a/src/bundled/channels/ChannelHeaderMenu.test.tsx +++ b/src/bundled/channels/ChannelHeaderMenu.test.tsx @@ -109,66 +109,60 @@ it("loads only on demand, uses the non-compact shared menu, and opens details wi ); expect(h.props.openDetails).toHaveBeenCalledTimes(2); }); -it.each([true, false])( - "hands focus to the opened settings tab when the menu finishes closing (focus dropped to body=%s)", - async (dropped) => { - const h = harness(); - const close = vi.fn(); - function Page() { - const [settings, setSettings] = useState(false); - return ( - - setSettings(true)} +it("hands focus to the opened settings tab when the menu finishes closing after focus drops to the body", async () => { + const h = harness(); + const close = vi.fn(); + function Page() { + const [settings, setSettings] = useState(false); + return ( + + setSettings(true)} /> + {settings && ( + {}} + items={[ + { + id: "settings", + label: "Channel settings", + close, + content:

Settings

, + }, + ]} /> - {settings && ( - {}} - items={[ - { - id: "settings", - label: "Channel settings", - close, - content:

Settings

, - }, - ]} - /> - )} -
- ); - } - render(, { reactStrictMode: true }); - const user = userEvent.setup(); - const trigger = screen.getByRole("button", { name: "Channel actions" }); - await user.click(trigger); - const menu = await screen.findByRole("menu"); - let release!: () => void; - const finished = new Promise((resolve) => { - release = resolve; - }); - const animations = vi.fn(() => [{ finished }]); - // Hold the menu's exit animation. WebKit can drop focus to the body while - // it runs; jsdom keeps it on the tab the workspace focused. - Object.defineProperty(menu, "getAnimations", { value: animations }); - try { - await user.click( - screen.getByRole("menuitem", { name: "View channel details" }), - ); - await waitFor(() => expect(animations).toHaveBeenCalled()); - if (dropped) (document.activeElement as HTMLElement | null)?.blur(); - await act(async () => release()); - await waitFor(() => expect(menu).not.toBeInTheDocument()); - const tab = screen.getByRole("tab", { name: "Channel settings" }); - await waitFor(() => expect(tab).toHaveFocus()); - await user.keyboard("{Escape}"); - expect(close).toHaveBeenCalledOnce(); - } finally { - await act(async () => release()); - } - }, -); + )} +
+ ); + } + render(, { reactStrictMode: true }); + const user = userEvent.setup(); + const trigger = screen.getByRole("button", { name: "Channel actions" }); + await user.click(trigger); + const menu = await screen.findByRole("menu"); + let release!: () => void; + const finished = new Promise((resolve) => { + release = resolve; + }); + const animations = vi.fn(() => [{ finished }]); + // Hold the menu's exit animation. WebKit can drop focus to the body while + // it runs; jsdom keeps it on the tab, so the test drops it. + Object.defineProperty(menu, "getAnimations", { value: animations }); + try { + await user.click( + screen.getByRole("menuitem", { name: "View channel details" }), + ); + await waitFor(() => expect(animations).toHaveBeenCalled()); + (document.activeElement as HTMLElement | null)?.blur(); + await act(async () => release()); + await waitFor(() => expect(menu).not.toBeInTheDocument()); + const tab = screen.getByRole("tab", { name: "Channel settings" }); + await waitFor(() => expect(tab).toHaveFocus()); + await user.keyboard("{Escape}"); + expect(close).toHaveBeenCalledOnce(); + } finally { + await act(async () => release()); + } +}); it("supports keyboard dismissal, outside clicks, and Canvas with the stable header focus target", async () => { const h = harness(); h.mount(); From 2cda1d4ee654974471dfbbd6a6be775068d3da71 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Wed, 4 Nov 2026 16:39:35 -0800 Subject: [PATCH 3/3] ci: rerun against current main Signed-off-by: Cheng Peng Co-authored-by: Cursor