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..e11043535 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -379,6 +379,64 @@ 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();
+ 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();
+ 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..dddcbbb16 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,27 @@ function Composer({
const valueRef = useRef(value);
const caret = useRef(undefined);
const input = useRef(null);
+ const focusOnMount = useRef(
+ autoFocus && !disabled && typeof document !== "undefined"
+ ? document.activeElement
+ : undefined,
+ );
+ useEffect(() => {
+ // 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))
+ ) {
+ 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
new file mode 100644
index 000000000..cc9007c63
--- /dev/null
+++ b/tests/browser/composer-focus.spec.mjs
@@ -0,0 +1,41 @@
+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}`);
+ } else {
+ await expect(input).toHaveText(`Draft for ${name}`);
+ await page.keyboard.type(" continued");
+ await expect(input).toHaveText(`Draft for ${name} continued`);
+ }
+ }
+ const settings = page.getByRole("button", {
+ name: "Channel settings",
+ exact: true,
+ });
+ await settings.click();
+ await expect(
+ page.getByRole("button", { name: "Close channel settings" }),
+ ).toBeFocused();
+});