From 8aaa37c0970929a5b579b6ab7efd69cc48f536fd Mon Sep 17 00:00:00 2001 From: Smartie Date: Fri, 25 Sep 2026 15:18:12 -0700 Subject: [PATCH 1/3] Fix composer focus when selecting channels and DMs Signed-off-by: Smartie --- src/bundled/channels/ChannelsPage.tsx | 3 ++ .../messages/MessageComposer.test.tsx | 28 ++++++++++++++ src/features/messages/MessageComposer.tsx | 7 ++++ tests/browser/composer-focus.spec.mjs | 37 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 tests/browser/composer-focus.spec.mjs diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index ee9a61992..22a6e8cb0 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1004,6 +1004,9 @@ function ChannelWorkspace({ scope={scope} channelId={current.id} channelName={current.name} + autoFocus={ + !current.readOnly && !requestedMessage && !requestedThread + } onOpenLink={openLink} canOpenLink={canOpenLink} label={ diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 480a5caf3..ee4b31b7f 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -379,6 +379,34 @@ function mount( }; } +it("autofocuses each selected conversation once without stealing focus on updates", () => { + const h = mount({ autoFocus: true }); + expect(h.input()).toHaveFocus(); + const other = document.createElement("button"); + document.body.append(other); + try { + other.focus(); + h.retarget({ channelName: "Renamed", autoFocus: false }); + h.retarget({ autoFocus: true }); + expect(other).toHaveFocus(); + h.retarget({ channelId: "another-channel" }); + expect(h.input()).toHaveFocus(); + } finally { + other.remove(); + } +}); + +it("leaves focus alone unless an enabled composer opts into mount focus", () => { + const h = mount(); + expect(h.input()).not.toHaveFocus(); + h.retarget({ + channelId: "disabled-channel", + disabled: true, + autoFocus: true, + }); + expect(h.input()).not.toHaveFocus(); +}); + it("keeps unpublished completions invisible but lets Escape revoke pending work", () => { const h = mount(); const input = h.input(); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 629d5c510..9dccecaf1 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -92,6 +92,8 @@ export type MessageComposerProps = { replyContext?: ReactNode; mediaTimeSeconds?: number; clearMediaTime?(): void; + /** Focus once when this conversation mounts, not when overlays close. */ + autoFocus?: boolean; focusRequest?: number; hideMediaTimeIndicator?: boolean; disabled?: boolean; @@ -138,6 +140,7 @@ function Composer({ replyContext, mediaTimeSeconds, clearMediaTime, + autoFocus = false, focusRequest, hideMediaTimeIndicator = false, disabled: requestedDisabled = false, @@ -202,6 +205,10 @@ function Composer({ const valueRef = useRef(value); const caret = useRef(undefined); const input = useRef(null); + const focusOnMount = useRef(autoFocus && !disabled); + useEffect(() => { + if (focusOnMount.current) input.current?.focus(); + }, []); const nonmembers = useNonmemberMentions(session, channelId, () => input.current?.focus(), ); diff --git a/tests/browser/composer-focus.spec.mjs b/tests/browser/composer-focus.spec.mjs new file mode 100644 index 000000000..4a3922edd --- /dev/null +++ b/tests/browser/composer-focus.spec.mjs @@ -0,0 +1,37 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +test.use({ productionBroker: true, dmLabels: true }); + +// Browser boundary: real sidebar navigation must hand focus to the mounted +// ProseMirror editor, including after a warm return; typing must need no click. +test("selecting a channel or DM focuses its composer and retains drafts", async ({ + page, + app, +}) => { + await open(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const conversation = page.getByRole("article", { name: "Conversation" }); + const input = conversation.getByRole("textbox"); + for (const [index, name] of [ + "Beta", + "Alice Fixture", + "Beta", + "Alice Fixture", + ].entries()) { + await sidebar.getByRole("button", { name, exact: true }).click(); + await expect(input).toBeFocused(); + if (index < 2) { + await page.keyboard.type(`Draft for ${name}`); + } + await expect(input).toHaveText(`Draft for ${name}`); + } + const settings = page.getByRole("button", { + name: "Channel settings", + exact: true, + }); + await settings.click(); + await expect( + page.getByRole("button", { name: "Close channel settings" }), + ).toBeFocused(); +}); From f6c5e2e5c4db61508541816563a8d1d7920a221d Mon Sep 17 00:00:00 2001 From: Smartie Date: Fri, 25 Sep 2026 15:34:17 -0700 Subject: [PATCH 2/3] Preserve explicit focus restoration during composer mount Signed-off-by: Smartie --- .../messages/MessageComposer.test.tsx | 30 +++++++++++++++++++ src/features/messages/MessageComposer.tsx | 16 ++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index ee4b31b7f..e11043535 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -391,11 +391,41 @@ it("autofocuses each selected conversation once without stealing focus on update expect(other).toHaveFocus(); h.retarget({ channelId: "another-channel" }); expect(h.input()).toHaveFocus(); + h.retarget({ channelId: "keyboard-navigation" }); + expect(h.input()).toHaveFocus(); } finally { other.remove(); } }); +it("lets an explicit focus restoration in the mount commit win", () => { + const h = mount(); + h.unmount(); + const target = document.createElement("button"); + document.body.append(target); + function RestoreFocus() { + useLayoutEffect(() => target.focus(), []); + return null; + } + try { + render( + <> + + + , + ); + expect(target).toHaveFocus(); + } finally { + target.remove(); + } +}); + it("leaves focus alone unless an enabled composer opts into mount focus", () => { const h = mount(); expect(h.input()).not.toHaveFocus(); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 9dccecaf1..5a92224ee 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -205,9 +205,21 @@ function Composer({ const valueRef = useRef(value); const caret = useRef(undefined); const input = useRef(null); - const focusOnMount = useRef(autoFocus && !disabled); + const focusOnMount = useRef( + autoFocus && !disabled && typeof document !== "undefined" + ? document.activeElement + : undefined, + ); useEffect(() => { - if (focusOnMount.current) input.current?.focus(); + // A navigation/dialog owner may restore focus during this commit. Let that + // explicit handoff win over the conversation's default initial focus. + const previous = focusOnMount.current; + if ( + previous && + (previous === document.activeElement || + (!previous.isConnected && document.activeElement === document.body)) + ) + input.current?.focus(); }, []); const nonmembers = useNonmemberMentions(session, channelId, () => input.current?.focus(), From 189a22163fcad0d278bffd9abffeb9cfcc1f41f9 Mon Sep 17 00:00:00 2001 From: Smartie Date: Fri, 25 Sep 2026 15:59:11 -0700 Subject: [PATCH 3/3] Place navigation autofocus caret at restored draft end Signed-off-by: Smartie --- src/features/messages/MessageComposer.tsx | 9 +++++++-- tests/browser/composer-focus.spec.mjs | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 5a92224ee..dddcbbb16 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -218,8 +218,13 @@ function Composer({ previous && (previous === document.activeElement || (!previous.isConnected && document.activeElement === document.body)) - ) - input.current?.focus(); + ) { + const editor = input.current; + if (!editor) return; + const end = editor.value.length; + editor.setSelectionRange(end, end); + editor.focus(); + } }, []); const nonmembers = useNonmemberMentions(session, channelId, () => input.current?.focus(), diff --git a/tests/browser/composer-focus.spec.mjs b/tests/browser/composer-focus.spec.mjs index 4a3922edd..cc9007c63 100644 --- a/tests/browser/composer-focus.spec.mjs +++ b/tests/browser/composer-focus.spec.mjs @@ -23,8 +23,12 @@ test("selecting a channel or DM focuses its composer and retains drafts", async await expect(input).toBeFocused(); if (index < 2) { await page.keyboard.type(`Draft for ${name}`); + await expect(input).toHaveText(`Draft for ${name}`); + } else { + await expect(input).toHaveText(`Draft for ${name}`); + await page.keyboard.type(" continued"); + await expect(input).toHaveText(`Draft for ${name} continued`); } - await expect(input).toHaveText(`Draft for ${name}`); } const settings = page.getByRole("button", { name: "Channel settings",