fix(desktop): scope composer autocomplete to focus (#6860)

## Issue
I would send a message in the thread side pane and the main chat's
composer would then open its at-mention completion UI:


https://github.com/user-attachments/assets/25e79e05-8672-4f6e-b5cc-81a46638f229

## Summary

- Render inline composer autocomplete only for the focused rich-text
editor.
- Preserve editor focus when opening composer-owned mention controls.
- Add an end-to-end regression for sending in a side thread while the
main composer retains an agent mention.

## Root cause

The main channel composer and thread composer share the channel sending
state. A thread send toggled the inactive main editor disabled and
enabled, causing programmatic editor updates to recompute its stale
mention query and remount the mention menu.

## Verification

- Desktop unit suite: 5,556 passed
- Persistent agent audience E2E spec: 18 passed
- TypeScript typecheck
- Vite E2E build
- Biome and repository text/pubkey/file-size ratchets

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-09-01 14:28:07 +10:00
committed by GitHub
parent 4a9de1a3a1
commit 571c1902d0
16 changed files with 797 additions and 86 deletions

View File

@@ -5,6 +5,7 @@ import { ChevronDown } from "lucide-react";
import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext";
import { useMentions } from "@/features/messages/lib/useMentions";
@@ -101,6 +102,8 @@ export function ForumComposer({
mentions.isMentionOpen || channelLinks.isChannelOpen;
const submitMessageRef = React.useRef<() => void>(() => {});
const formRef = React.useRef<HTMLFormElement>(null);
const composerOwnsFocus = useComposerFocusOwnership(formRef);
// Set after `useLinkEditor` exists; the editor's link-click handler
// delegates through this ref to break the hook ordering cycle.
@@ -500,6 +503,7 @@ export function ForumComposer({
}}
onFocusCapture={expandCompactComposer}
onSubmit={handleSubmit}
ref={formRef}
>
{media.isDragOver && <DropZoneOverlay />}
{isCompactLayout ? (
@@ -526,6 +530,7 @@ export function ForumComposer({
? channelLinks.channelSuggestions
: []
}
composerOwnsFocus={composerOwnsFocus}
mentionSelectedIndex={mentions.mentionSelectedIndex}
mentionSuggestions={
mentions.isMentionOpen ? mentions.suggestions : []

View File

@@ -8,6 +8,7 @@ import {
type ForumComposerAutocompletesProps = {
channelSelectedIndex: number;
channelSuggestions: ChannelSuggestion[];
composerOwnsFocus: boolean;
mentionSelectedIndex: number;
mentionSuggestions: MentionSuggestion[];
onChannelSelect: (suggestion: ChannelSuggestion) => void;
@@ -20,6 +21,7 @@ type ForumComposerAutocompletesProps = {
export function ForumComposerAutocompletes({
channelSelectedIndex,
channelSuggestions,
composerOwnsFocus,
mentionSelectedIndex,
mentionSuggestions,
onChannelSelect,
@@ -31,12 +33,14 @@ export function ForumComposerAutocompletes({
return (
<>
<ChannelAutocomplete
composerOwnsFocus={composerOwnsFocus}
onSelect={onChannelSelect}
position={position}
selectedIndex={channelSelectedIndex}
suggestions={channelSuggestions}
/>
<MentionAutocomplete
composerOwnsFocus={composerOwnsFocus}
onDismiss={onMentionDismiss}
onFetchMore={onMentionFetchMore}
onSelect={onMentionSelect}

View File

@@ -177,8 +177,11 @@ export function useChannelLinks() {
return { handled: true };
}
// Forward Tab selects; Shift+Tab deliberately does not. The reverse
// move stays the browser's, so this overlay can't swallow a keyboard
// user's way back out (see useMentions for the same split).
if (
event.key === "Tab" ||
(event.key === "Tab" && !event.shiftKey) ||
(event.key === "Enter" &&
!event.ctrlKey &&
!event.metaKey &&

View File

@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
Element: dom.window.Element,
Event: dom.window.Event,
FocusEvent: dom.window.FocusEvent,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
Node: dom.window.Node,
window: dom.window,
});
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
});
after(() => dom.window.close());
async function renderHarness() {
const React = await import("react");
const { render } = await import("@testing-library/react");
const { useComposerFocusOwnership } = await import(
"./useComposerFocusOwnership.ts"
);
function Harness() {
const formRef = React.useRef(null);
const ownsFocus = useComposerFocusOwnership(formRef);
return React.createElement(
React.Fragment,
null,
React.createElement(
"form",
{ "data-testid": "composer", ref: formRef },
React.createElement("input", { "aria-label": "Editor" }),
React.createElement("button", { type: "button" }, "Overlay control"),
React.createElement("output", {
"data-testid": "owned",
"data-owned": String(ownsFocus),
}),
),
React.createElement("input", { "aria-label": "Elsewhere" }),
);
}
const view = render(React.createElement(Harness));
return {
view,
ownership: () => view.getByTestId("owned").getAttribute("data-owned"),
};
}
test("tracks focus entering, moving within, and leaving the composer", async () => {
const { act } = await import("react");
const { view, ownership } = await renderHarness();
assert.equal(ownership(), "false");
const editor = view.getByRole("textbox", { name: "Editor" });
await act(async () => editor.focus());
assert.equal(ownership(), "true");
// Focus handed from the editor to an overlay control stays owned — this is
// the transition an editor-focus gate got wrong, unmounting the overlay
// before the control it was handing focus to could receive it.
const control = view.getByRole("button", { name: "Overlay control" });
await act(async () => control.focus());
assert.equal(ownership(), "true");
const elsewhere = view.getByRole("textbox", { name: "Elsewhere" });
await act(async () => elsewhere.focus());
assert.equal(ownership(), "false");
});
test("an internal focus move never reports an unowned intermediate state", async () => {
const React = await import("react");
const { act } = React;
const { render } = await import("@testing-library/react");
const { useComposerFocusOwnership } = await import(
"./useComposerFocusOwnership.ts"
);
const observed = [];
function Harness() {
const formRef = React.useRef(null);
const ownsFocus = useComposerFocusOwnership(formRef);
observed.push(ownsFocus);
return React.createElement(
"form",
{ ref: formRef },
React.createElement("input", { "aria-label": "Editor" }),
React.createElement("button", { type: "button" }, "Overlay control"),
);
}
const view = render(React.createElement(Harness));
const editor = view.getByRole("textbox", { name: "Editor" });
const control = view.getByRole("button", { name: "Overlay control" });
await act(async () => editor.focus());
observed.length = 0;
// relatedTarget mirrors a browser handing focus editor → overlay control.
// The focusout handler must read it instead of assuming focus left.
const { fireEvent } = await import("@testing-library/react");
fireEvent.focusOut(editor, { relatedTarget: control });
fireEvent.focusIn(control);
assert.equal(observed.includes(false), false);
});

View File

@@ -0,0 +1,44 @@
import * as React from "react";
/**
* Tracks whether a composer owns document focus: true while the focused
* element lives anywhere inside `containerRef` (the composer form) — the
* editor or the focusable controls of its suggestion overlays.
*
* This is the value the autocomplete overlays gate their rendering on. It is
* deliberately not the editor's own focus state: an overlay gated on editor
* focus alone unmounts the moment keyboard focus moves from the editor into
* the overlay's controls, which makes those controls unreachable. Ownership
* is tracked with `focusout` + `relatedTarget` containment rather than
* blur/focus pairs so an internal focus move never passes through a false
* state — a false flicker would unmount the overlay before the control it
* is handing focus to receives it. Each composer form has its own instance,
* so focus in one composer never keeps a sibling composer's overlays alive.
*/
export function useComposerFocusOwnership(
containerRef: React.RefObject<HTMLElement | null>,
): boolean {
const [ownsFocus, setOwnsFocus] = React.useState(false);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
setOwnsFocus(container.contains(document.activeElement));
const handleFocusIn = () => setOwnsFocus(true);
const handleFocusOut = (event: FocusEvent) => {
setOwnsFocus(
event.relatedTarget instanceof Node &&
container.contains(event.relatedTarget),
);
};
container.addEventListener("focusin", handleFocusIn);
container.addEventListener("focusout", handleFocusOut);
return () => {
container.removeEventListener("focusin", handleFocusIn);
container.removeEventListener("focusout", handleFocusOut);
};
}, [containerRef]);
return ownsFocus;
}

View File

@@ -238,8 +238,11 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) {
return { handled: true };
}
// Forward Tab selects; Shift+Tab deliberately does not. The reverse
// move stays the browser's, so this overlay can't swallow a keyboard
// user's way back out (see useMentions for the same split).
if (
event.key === "Tab" ||
(event.key === "Tab" && !event.shiftKey) ||
(event.key === "Enter" &&
!event.ctrlKey &&
!event.metaKey &&

View File

@@ -749,9 +749,13 @@ export function useMentions(
);
return { handled: true };
}
// Shift+Tab is deliberately not a select: it is the keyboard route out
// of the editor — into this overlay's Options controls where the
// composer offers them, otherwise the browser's own backward focus
// move — so those controls stay reachable.
if (
exactMentionSpace ||
event.key === "Tab" ||
(event.key === "Tab" && !event.shiftKey) ||
(event.key === "Enter" &&
!event.ctrlKey &&
!event.metaKey &&

View File

@@ -167,16 +167,12 @@ export function useRichTextEditor({
const addressedAgentMentionNamesRef = React.useRef<readonly string[]>([]);
const onUpdateRef = React.useRef(onUpdate);
onUpdateRef.current = onUpdate;
const onSubmitRef = React.useRef(onSubmit);
onSubmitRef.current = onSubmit;
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
const onEditLinkRef = React.useRef(onEditLink);
onEditLinkRef.current = onEditLink;
const onLinkSelectionChangeRef = React.useRef(onLinkSelectionChange);
onLinkSelectionChangeRef.current = onLinkSelectionChange;
@@ -618,13 +614,19 @@ export function useRichTextEditor({
const hadFocusBeforeDisableRef = React.useRef(false);
React.useEffect(() => {
if (!editor || editor.isEditable === editable) return;
// `emitUpdate: false` on both toggles — the doc hasn't changed, so the
// default synthetic `update` event would replay `onUpdate` with stale
// text/cursor and resurrect consumer state derived from it (e.g. reopen
// a mention menu the user dismissed with Escape, or re-fire a typing
// notification for an untouched draft). Real content changes (typing,
// clearContent) dispatch real transactions that emit their own updates.
if (!editable) {
// About to disable: remember whether we currently hold focus so we know
// whether to restore it when re-enabled.
hadFocusBeforeDisableRef.current = editor.isFocused;
editor.setEditable(false);
editor.setEditable(false, false);
} else {
editor.setEditable(true);
editor.setEditable(true, false);
// Re-enabled: if we owned focus before the disable blurred us, take it
// back (preserving the current selection — `focus()` with no arg keeps
// the existing selection rather than jumping to the end).

View File

@@ -12,6 +12,11 @@ import {
type ChannelAutocompleteProps = {
suggestions: ChannelSuggestion[];
selectedIndex: number;
/**
* Whether the owning composer owns document focus. Composers that don't
* must not render suggestions — see MentionAutocomplete for the rationale.
*/
composerOwnsFocus: boolean;
onSelect: (suggestion: ChannelSuggestion) => void;
position?: "above" | "below";
};
@@ -19,6 +24,7 @@ type ChannelAutocompleteProps = {
export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
suggestions,
selectedIndex,
composerOwnsFocus,
onSelect,
position = "above",
}: ChannelAutocompleteProps) {
@@ -31,7 +37,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
activeItem?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (suggestions.length === 0) {
if (!composerOwnsFocus || suggestions.length === 0) {
return null;
}
@@ -42,6 +48,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
position === "below" ? "top-full mt-1" : "bottom-full mb-1",
)}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, no behavior of its own — an unprevented mousedown here (scrollbar, padding ring) blurs the editor, and the focus gate above would unmount the overlay mid-press. */}
<div
className={cn(
"max-h-48 overflow-y-auto rounded-xl p-1",
@@ -51,6 +58,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
: "origin-bottom slide-in-from-bottom-1",
POPOVER_SURFACE_CLASS,
)}
onMouseDown={(event) => event.preventDefault()}
ref={listRef}
style={POPOVER_SHADOW_STYLE}
>

View File

@@ -195,7 +195,10 @@ export function ComposerMentionButton({
data-testid="message-insert-mention"
disabled={disabled}
onClick={onOpen}
onMouseDown={onCaptureSelection}
onMouseDown={(event) => {
onCaptureSelection();
event.preventDefault();
}}
type="button"
>
<AtSign aria-hidden="true" className="h-4 w-4 shrink-0" />
@@ -303,6 +306,7 @@ export function ComposerMentionButton({
<button
className="shrink-0 rounded-md px-1.5 py-1 font-medium text-primary outline-hidden hover:bg-primary/10 focus-visible:ring-1 focus-visible:ring-ring"
onClick={onConfirmationTurnOff}
onMouseDown={(event) => event.preventDefault()}
type="button"
>
Turn off

View File

@@ -15,6 +15,11 @@ import {
type EmojiAutocompleteProps = {
suggestions: EmojiSuggestion[];
selectedIndex: number;
/**
* Whether the owning composer owns document focus. Composers that don't
* must not render suggestions — see MentionAutocomplete for the rationale.
*/
composerOwnsFocus: boolean;
onSelect: (suggestion: EmojiSuggestion) => void;
position?: "above" | "below";
};
@@ -22,6 +27,7 @@ type EmojiAutocompleteProps = {
export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({
suggestions,
selectedIndex,
composerOwnsFocus,
onSelect,
position = "above",
}: EmojiAutocompleteProps) {
@@ -40,7 +46,7 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({
[],
);
if (suggestions.length === 0) {
if (!composerOwnsFocus || suggestions.length === 0) {
return null;
}
@@ -51,6 +57,7 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({
position === "below" ? "top-full mt-1" : "bottom-full mb-1",
)}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, no behavior of its own — an unprevented mousedown here blurs the editor, and the focus gate above would unmount the overlay mid-press. The scroll element lives inside VirtualizedList, but its mousedown bubbles to this wrapper and preventDefault acts on the same event, so scrollbar presses are covered too. */}
<div
className={cn(
"rounded-xl p-1",
@@ -61,6 +68,7 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({
POPOVER_SURFACE_CLASS,
)}
data-testid="emoji-autocomplete"
onMouseDown={(event) => event.preventDefault()}
style={POPOVER_SHADOW_STYLE}
>
<VirtualizedList

View File

@@ -50,6 +50,7 @@ test("agent rows offer automatic mention controls", async () => {
const props = {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: (value) => selected.push(value),
onToggleAlwaysAddressAgent: (value) => toggled.push(value),
lockedAgentPubkeys: new Set(),
@@ -119,6 +120,7 @@ test("options expand in place without replacing the people list", async () => {
React.createElement(MentionAutocomplete, {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
keepMentionedAgentsPinned: true,
onKeepMentionedAgentsPinnedChange: (value) => changes.push(value),
@@ -150,6 +152,7 @@ test("options expand in place without replacing the people list", async () => {
React.createElement(MentionAutocomplete, {
suggestions: [],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
keepMentionedAgentsPinned: false,
onKeepMentionedAgentsPinnedChange: (value) => changes.push(value),
@@ -161,6 +164,7 @@ test("options expand in place without replacing the people list", async () => {
React.createElement(MentionAutocomplete, {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
keepMentionedAgentsPinned: false,
onKeepMentionedAgentsPinnedChange: (value) => changes.push(value),
@@ -189,6 +193,7 @@ test("automatic selection loads the setting once, then updates it in place", asy
const props = {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
keepMentionedAgentsPinned: false,
onKeepMentionedAgentsPinnedChange: () => {},
@@ -252,6 +257,7 @@ test("clicking outside dismisses the tray without intercepting its trigger", asy
React.createElement(MentionAutocomplete, {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onDismiss: () => {
dismissCount += 1;
},
@@ -311,6 +317,7 @@ test("collision npubs sit inline with agent metadata", async () => {
React.createElement(MentionAutocomplete, {
suggestions,
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
}),
);
@@ -328,52 +335,201 @@ test("collision npubs sit inline with agent metadata", async () => {
}
});
test("does not intercept Tab from the editor", async () => {
test("focusMentionOptionsTrigger hands focus to the Options trigger", async () => {
const React = await import("react");
const { fireEvent, render } = await import("@testing-library/react");
const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx");
const { TooltipProvider } = await import("@/shared/ui/tooltip");
const suggestions = [
{
pubkey: "agent-a",
displayName: "Agent Ada",
isAgent: true,
},
{
pubkey: "agent-b",
displayName: "Agent Bea",
isAgent: true,
},
];
const { render } = await import("@testing-library/react");
const { MentionAutocomplete, focusMentionOptionsTrigger } = await import(
"./MentionAutocomplete.tsx"
);
const suggestion = {
pubkey: "agent-pubkey",
displayName: "Agent Ada",
isAgent: true,
};
const view = render(
React.createElement(
TooltipProvider,
null,
React.createElement(
"form",
null,
React.createElement(
"div",
{ "data-testid": "message-input-scroll" },
React.createElement("input", { "aria-label": "Message" }),
),
React.createElement(MentionAutocomplete, {
suggestions,
selectedIndex: 1,
onSelect: () => {},
onToggleAlwaysAddressAgent: () => {},
}),
),
"form",
{ "data-testid": "composer-form" },
React.createElement("input", { "aria-label": "Message" }),
React.createElement(MentionAutocomplete, {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
keepMentionedAgentsPinned: true,
onKeepMentionedAgentsPinnedChange: () => {},
}),
),
);
const form = view.getByTestId("composer-form");
const input = view.getByRole("textbox", { name: "Message" });
input.focus();
const wasNotCancelled = fireEvent.keyDown(input, { key: "Tab" });
assert.equal(wasNotCancelled, true);
assert.equal(document.activeElement, input);
assert.equal(focusMentionOptionsTrigger(form), true);
assert.equal(
document.activeElement,
view.getByTestId("mention-options-trigger"),
);
});
test("focusMentionOptionsTrigger declines when no Options surface renders", async () => {
const React = await import("react");
const { render } = await import("@testing-library/react");
const { MentionAutocomplete, focusMentionOptionsTrigger } = await import(
"./MentionAutocomplete.tsx"
);
const suggestion = {
pubkey: "agent-pubkey",
displayName: "Agent Ada",
isAgent: true,
};
const view = render(
React.createElement(
"form",
{ "data-testid": "composer-form" },
React.createElement("input", { "aria-label": "Message" }),
// No onKeepMentionedAgentsPinnedChange: composers without audience
// controls render no Options surface, so the key event must fall
// through to its default backward focus move instead of stranding
// focus.
React.createElement(MentionAutocomplete, {
suggestions: [suggestion],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: () => {},
}),
),
);
const form = view.getByTestId("composer-form");
const input = view.getByRole("textbox", { name: "Message" });
input.focus();
assert.equal(focusMentionOptionsTrigger(form), false);
assert.equal(document.activeElement, input);
assert.equal(focusMentionOptionsTrigger(null), false);
});
test("Escape inside the overlay returns focus to the editor and dismisses", async () => {
const React = await import("react");
const { fireEvent, render } = await import("@testing-library/react");
const { MentionAutocomplete, focusMentionOptionsTrigger } = await import(
"./MentionAutocomplete.tsx"
);
const dismissals = [];
const view = render(
React.createElement(
"form",
{ "data-testid": "composer-form" },
React.createElement("input", {
"aria-label": "Message",
"data-testid": "message-input",
}),
React.createElement(MentionAutocomplete, {
suggestions: [
{
pubkey: "agent-pubkey",
displayName: "Agent Ada",
isAgent: true,
},
],
selectedIndex: 0,
composerOwnsFocus: true,
onDismiss: () => dismissals.push(true),
onSelect: () => {},
keepMentionedAgentsPinned: true,
onKeepMentionedAgentsPinnedChange: () => {},
}),
),
);
const form = view.getByTestId("composer-form");
assert.equal(focusMentionOptionsTrigger(form), true);
const trigger = view.getByTestId("mention-options-trigger");
const wasNotCancelled = fireEvent.keyDown(trigger, { key: "Escape" });
assert.equal(wasNotCancelled, false);
assert.equal(document.activeElement, view.getByTestId("message-input"));
assert.deepEqual(dismissals, [true]);
});
test("renders nothing while the composer does not own focus", async () => {
const React = await import("react");
const { render } = await import("@testing-library/react");
const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx");
const props = {
suggestions: [
{
pubkey: "agent-pubkey",
displayName: "Agent Ada",
isAgent: true,
},
],
selectedIndex: 0,
composerOwnsFocus: false,
onSelect: () => {},
};
const view = render(React.createElement(MentionAutocomplete, props));
assert.equal(view.queryByTestId("mention-autocomplete-layer"), null);
view.rerender(
React.createElement(MentionAutocomplete, {
...props,
composerOwnsFocus: true,
}),
);
assert.ok(view.getByTestId("mention-autocomplete-layer"));
});
test("container presses do not blur the editor out from under the overlay", async () => {
const React = await import("react");
const { fireEvent, render } = await import("@testing-library/react");
const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx");
const mention = {
pubkey: "agent-pubkey",
displayName: "Agent Ada",
isAgent: true,
};
const selected = [];
const pinnedChanges = [];
const view = render(
React.createElement(MentionAutocomplete, {
suggestions: [mention],
selectedIndex: 0,
composerOwnsFocus: true,
onSelect: (value) => selected.push(value),
keepMentionedAgentsPinned: true,
onKeepMentionedAgentsPinnedChange: (value) => pinnedChanges.push(value),
}),
);
// fireEvent returns false when the dispatched event was canceled, which is
// what keeps a contenteditable from losing focus on mousedown.
assert.equal(
fireEvent.mouseDown(view.getByTestId("mention-autocomplete")),
false,
);
const options = view.getByRole("button", { name: "Options" });
assert.equal(fireEvent.mouseDown(options.parentElement), false);
// A label hands focus to its control from the click default action, which
// no mousedown guard can cancel, so the label drives the switch itself.
fireEvent.click(options);
assert.equal(
fireEvent.click(view.getByText("Automatically mention agents")),
false,
);
assert.deepEqual(pinnedChanges, [false]);
// The row buttons must keep selecting: preventing mousedown on the
// containers, not pointerdown, leaves their compatibility events intact.
fireEvent.mouseDown(view.getByRole("button", { name: "Mention Agent Ada" }));
assert.deepEqual(selected, [mention]);
});
function suggestion(agentProvenance) {
return {
pubkey: "1".repeat(64),

View File

@@ -37,6 +37,15 @@ export type MentionSuggestion = {
type MentionAutocompleteProps = {
suggestions: MentionSuggestion[];
selectedIndex: number;
/**
* Whether the owning composer owns document focus — its editor or this
* overlay's own controls (see useComposerFocusOwnership). The focus gate
* lives here rather than in each composer so a background composer
* replaying a stale update (e.g. a shared mid-send disabled toggle) can't
* resurrect its suggestion overlay, while keyboard focus moving into the
* Options controls keeps the overlay mounted.
*/
composerOwnsFocus: boolean;
onFetchMore?: () => void;
onSelect: (suggestion: MentionSuggestion) => void;
lockedAgentPubkeys?: ReadonlySet<string>;
@@ -56,9 +65,27 @@ export function showMentionAgentProvenanceMarker(
return hasNameCollision && suggestion.agentProvenance === "managed-elsewhere";
}
/**
* Move keyboard focus to the mention overlay's Options trigger inside
* `container` (the composer form). Returns false when the trigger isn't
* rendered — overlay closed, or a composer without audience controls — so
* the caller can leave the key event to its default behavior.
*/
export function focusMentionOptionsTrigger(
container: HTMLElement | null,
): boolean {
const trigger = container?.querySelector<HTMLElement>(
"[data-mention-options-trigger]",
);
if (!trigger) return false;
trigger.focus();
return true;
}
export const MentionAutocomplete = React.memo(function MentionAutocomplete({
suggestions,
selectedIndex,
composerOwnsFocus,
onFetchMore,
onSelect,
lockedAgentPubkeys,
@@ -145,7 +172,27 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
}
}, [onFetchMore]);
if (suggestions.length === 0) {
// Escape from inside the overlay is the keyboard counterpart of pressing
// outside it: hand focus back to the editor the overlay belongs to, then
// dismiss. Focusing first keeps the composer's focus ownership unbroken, so
// the overlay never unmounts with focus stranded on the document. Escape in
// the editor itself never reaches here — that path is the composer's own
// key handler.
const handleOverlayKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
rootRef.current
?.closest("form")
?.querySelector<HTMLElement>('[data-testid="message-input"]')
?.focus();
onDismiss?.();
},
[onDismiss],
);
if (!composerOwnsFocus || suggestions.length === 0) {
return null;
}
@@ -159,23 +206,27 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: the overlay's own controls are the interactive elements; this listener only gives them the standard Escape-to-dismiss exit.
<div
className={cn(
"absolute left-0 right-0 z-50 px-3 sm:px-4",
position === "below" ? "top-full mt-1" : "bottom-full mb-1",
)}
data-testid="mention-autocomplete-layer"
onKeyDown={handleOverlayKeyDown}
ref={rootRef}
>
<div className="w-full max-w-2xl">
{onKeepMentionedAgentsPinnedChange ? (
<div className="mb-2 flex justify-end">
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, no behavior of its own — an unprevented mousedown on this surface (its padding, the switch's label) blurs the editor, and the focus gate above would unmount the overlay before the click lands. */}
<div
className={cn(
"max-w-full overflow-hidden rounded-xl text-popover-foreground ring-1 ring-border/50 transition-[width] duration-200 ease-out",
POPOVER_SURFACE_CLASS,
optionsOpen ? "w-72" : "w-24",
)}
onMouseDown={(event) => event.preventDefault()}
ref={optionsSurfaceRef}
style={POPOVER_SHADOW_STYLE}
>
@@ -194,9 +245,23 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
transition={{ duration: 0.16, ease: "easeOut" }}
>
<div className="flex min-h-12 items-center justify-between gap-3 px-3 py-2">
{/* A label moves focus to its control from the click
default action, not from mousedown, so the surface's
mousedown guard alone can't keep the editor focused
here. Cancel the forwarding and drive the switch
directly; the association still names the control for
assistive tech, which reaches the switch by keyboard
without going through this label. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: pointer-only affordance; the switch it labels is the keyboard-accessible path. */}
<label
className="flex min-w-0 flex-col"
htmlFor={keepPinnedSwitchId}
onClick={(event) => {
event.preventDefault();
onKeepMentionedAgentsPinnedChange?.(
!keepMentionedAgentsPinned,
);
}}
>
<span className="text-sm font-medium">
Automatically mention agents
@@ -222,6 +287,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
aria-controls={optionsId}
aria-expanded={optionsOpen}
className="flex h-8 w-full items-center justify-between gap-1 px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-popover-foreground"
data-mention-options-trigger
data-testid="mention-options-trigger"
onClick={() => setOptionsOpen((open) => !open)}
onMouseDown={(event) => event.preventDefault()}
@@ -239,6 +305,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
</div>
</div>
) : null}
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, same as the options surface — here it covers presses on the scrollbar and the list's padding ring. */}
<div
className={cn(
"max-h-48 w-full overflow-y-auto rounded-xl p-1",
@@ -249,6 +316,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
POPOVER_SURFACE_CLASS,
)}
data-testid="mention-autocomplete"
onMouseDown={(event) => event.preventDefault()}
onScroll={handleScroll}
ref={listRef}
style={POPOVER_SHADOW_STYLE}

View File

@@ -25,6 +25,7 @@ import {
takeQueuedAttachmentsForDraft,
useBackgroundMediaUpload,
} from "@/features/messages/lib/backgroundMediaUploadStore";
import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership";
import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
@@ -46,11 +47,10 @@ import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
import { EmojiAutocomplete } from "./EmojiAutocomplete";
import { MentionAutocomplete } from "./MentionAutocomplete";
import { focusMentionOptionsTrigger } from "./MentionAutocomplete";
import { MessageComposerAutocompletes } from "./MessageComposerAutocompletes";
import { ComposerDockToolbar } from "./ComposerDockToolbar";
import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
@@ -248,6 +248,8 @@ function MessageComposerImpl({
emojiAutocomplete.isEmojiAutocompleteOpen;
const submitMessageRef = React.useRef<() => void>(() => {});
const composerScrollRef = React.useRef<HTMLDivElement>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const composerOwnsFocus = useComposerFocusOwnership(formRef);
const onEditLinkRef = React.useRef<
((info: LinkSelectionInfo) => void) | null
>(null);
@@ -722,6 +724,19 @@ function MessageComposerImpl({
}
return;
}
// Shift+Tab is the keyboard route from the editor into the mention
// overlay's Options controls — forward Tab is consumed below to select
// the highlighted suggestion. Falls through (e.g. a composer with no
// audience controls) to the browser's native backward focus move.
if (
event.key === "Tab" &&
event.shiftKey &&
mentions.isMentionOpen &&
focusMentionOptionsTrigger(formRef.current)
) {
event.preventDefault();
return;
}
const { handled, suggestion } = mentions.handleMentionKeyDown(event, {
isCodeContext: () => isMentionCodeContext(richText.editor),
});
@@ -756,6 +771,7 @@ function MessageComposerImpl({
applyEmojiInsert,
channelLinks.handleChannelKeyDown,
applyChannelInsert,
mentions.isMentionOpen,
mentions.handleMentionKeyDown,
richText.editor,
selectMentionSuggestion,
@@ -838,46 +854,25 @@ function MessageComposerImpl({
onSubmit={(event) => {
handleSubmit(event);
}}
ref={formRef}
>
{ownsDropZone && media.isDragOver && <DropZoneOverlay />}
<EmojiAutocomplete
onSelect={applyEmojiInsert}
selectedIndex={emojiAutocomplete.emojiSelectedIndex}
suggestions={
emojiAutocomplete.isEmojiAutocompleteOpen
? emojiAutocomplete.emojiSuggestions
: []
}
/>
<ChannelAutocomplete
onSelect={applyChannelInsert}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen
? channelLinks.channelSuggestions
: []
}
/>
<MentionAutocomplete
<MessageComposerAutocompletes
audienceControlsEnabled={Boolean(
audienceScope && editTarget == null,
)}
channelLinks={channelLinks}
composerOwnsFocus={composerOwnsFocus}
emojiAutocomplete={emojiAutocomplete}
keepMentionedAgentsPinned={keepMentionedAgentsPinned}
lockedAgentPubkeys={lockedAgentPubkeys}
mentions={mentions}
openOptionsRequest={openMentionOptionsRequest}
onKeepMentionedAgentsPinnedChange={
audienceScope && editTarget == null
? setKeepMentionedAgentsPinned
: undefined
}
onChannelSelect={applyChannelInsert}
onEmojiSelect={applyEmojiInsert}
onMentionSelect={selectMentionSuggestion}
onOptionsRevealComplete={completeMentionOptionsReveal}
onToggleAlwaysAddressAgent={
audienceScope && editTarget == null
? toggleAlwaysAddressAgent
: undefined
}
onFetchMore={mentions.fetchMoreSuggestions}
onDismiss={mentions.cancelMentionAutocomplete}
onSelect={selectMentionSuggestion}
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent}
/>
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">

View File

@@ -0,0 +1,104 @@
import { setKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference";
import type {
ChannelSuggestion,
UseChannelLinksResult,
} from "@/features/messages/lib/useChannelLinks";
import type {
EmojiSuggestion,
UseEmojiAutocompleteResult,
} from "@/features/messages/lib/useEmojiAutocomplete";
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { EmojiAutocomplete } from "./EmojiAutocomplete";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "./MentionAutocomplete";
type MessageComposerAutocompletesProps = {
/**
* Whether the mention menu offers its agent-audience controls. Edit
* composers and channels without a persistent audience omit them.
*/
audienceControlsEnabled: boolean;
channelLinks: UseChannelLinksResult;
composerOwnsFocus: boolean;
emojiAutocomplete: UseEmojiAutocompleteResult;
keepMentionedAgentsPinned: boolean;
lockedAgentPubkeys: ReadonlySet<string>;
mentions: UseMentionsResult;
/**
* Bumped to ask the mention menu to reveal its agent-audience options; the
* menu reports back through `onOptionsRevealComplete` once it has.
*/
openOptionsRequest?: number;
onChannelSelect: (suggestion: ChannelSuggestion) => void;
onEmojiSelect: (suggestion: EmojiSuggestion) => void;
onMentionSelect: (suggestion: MentionSuggestion) => void;
onOptionsRevealComplete?: (request: number) => void;
onToggleAlwaysAddressAgent: (suggestion: MentionSuggestion) => void;
};
/**
* The message composer's three suggestion overlays. Each one gates its own
* rendering on `composerOwnsFocus`, so a background composer replaying a
* stale update cannot resurrect a suggestion menu over the focused composer,
* while keyboard focus moving into an overlay's own controls keeps that
* overlay mounted.
*/
export function MessageComposerAutocompletes({
audienceControlsEnabled,
channelLinks,
composerOwnsFocus,
emojiAutocomplete,
keepMentionedAgentsPinned,
lockedAgentPubkeys,
mentions,
openOptionsRequest,
onChannelSelect,
onEmojiSelect,
onMentionSelect,
onOptionsRevealComplete,
onToggleAlwaysAddressAgent,
}: MessageComposerAutocompletesProps) {
return (
<>
<EmojiAutocomplete
composerOwnsFocus={composerOwnsFocus}
onSelect={onEmojiSelect}
selectedIndex={emojiAutocomplete.emojiSelectedIndex}
suggestions={
emojiAutocomplete.isEmojiAutocompleteOpen
? emojiAutocomplete.emojiSuggestions
: []
}
/>
<ChannelAutocomplete
composerOwnsFocus={composerOwnsFocus}
onSelect={onChannelSelect}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
/>
<MentionAutocomplete
composerOwnsFocus={composerOwnsFocus}
keepMentionedAgentsPinned={keepMentionedAgentsPinned}
lockedAgentPubkeys={lockedAgentPubkeys}
openOptionsRequest={openOptionsRequest}
onKeepMentionedAgentsPinnedChange={
audienceControlsEnabled ? setKeepMentionedAgentsPinned : undefined
}
onOptionsRevealComplete={onOptionsRevealComplete}
onToggleAlwaysAddressAgent={
audienceControlsEnabled ? onToggleAlwaysAddressAgent : undefined
}
onFetchMore={mentions.fetchMoreSuggestions}
onDismiss={mentions.cancelMentionAutocomplete}
onSelect={onMentionSelect}
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
</>
);
}

View File

@@ -690,6 +690,188 @@ test("always-mentioned agents remain selected without replaying their animation
).toHaveCount(1);
});
test("the unfocused main composer keeps its dismissed mention menu closed through a thread send", async ({
page,
}) => {
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openGeneral(page);
const mainComposer = channelComposer(page);
const mainInput = mainComposer.getByTestId("message-input");
await automaticallyMention(mainComposer, "Morgarita");
await mainInput.fill("@Morgarita earlier message");
await mainInput.press("Enter");
await expect(mainInput).toHaveText("@Morgarita ");
await mainInput.fill("@Morgarita");
await expect(mainInput).toHaveText("@Morgarita");
await expect(mainComposer.getByTestId("mention-autocomplete")).toBeVisible();
await mainInput.press("Escape");
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
const rootMessage = page.locator(
`[data-testid="message-row"][data-message-id="${THREAD_ROOT_ID}"]`,
);
await rootMessage
.getByRole("button", { name: "Reply" })
.evaluate((button: HTMLButtonElement) => button.click());
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await threadInput.click();
await expect(threadInput).toBeFocused();
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
await mainComposer.evaluate((element) => {
element.dataset.mentionMenuReopened = "false";
new MutationObserver(() => {
if (element.querySelector('[data-testid="mention-autocomplete"]')) {
element.dataset.mentionMenuReopened = "true";
}
}).observe(element, { childList: true, subtree: true });
});
const reply = `Thread reply ${Date.now()}`;
await threadInput.fill(reply);
await threadInput.press("Enter");
await expect(mainInput).toHaveAttribute("contenteditable", "false");
await expect(threadPanel).toContainText(reply);
await expect(mainInput).toHaveAttribute("contenteditable", "true");
expect(await mainComposer.getAttribute("data-mention-menu-reopened")).toBe(
"false",
);
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
// Refocusing the drafted composer must not resurrect the menu the user
// dismissed with Escape: the setEditable toggles around the send used to
// emit a phantom update that replayed the stale "@Morgarita" query and
// flipped the mention state back open, so a bare click brought the menu
// back without any typing.
await mainInput.click();
await expect(mainInput).toBeFocused();
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
expect(await mainComposer.getAttribute("data-mention-menu-reopened")).toBe(
"false",
);
});
test("pressing a mention overlay's own container keeps it open", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
await composer.getByTestId("message-insert-mention").click();
const list = composer.getByTestId("mention-autocomplete");
await expect(list).toBeVisible();
await expect(input).toBeFocused();
// A mousedown landing on the list container itself — its padding ring here,
// a native scrollbar on platforms that render one — steals focus from the
// editor unless the default is prevented, and the focus gate would then
// unmount the menu mid-press.
const listBox = await list.boundingBox();
if (!listBox) throw new Error("mention list is not laid out");
await list.click({ position: { x: 2, y: listBox.height / 2 } });
await expect(input).toBeFocused();
await expect(list).toBeVisible();
// Same hazard on the options surface, where it needs no exotic scrollbar
// setting to reproduce: the switch's label text is a container press, so the
// overlay used to vanish before the forwarded click reached the switch.
await composer.getByTestId("mention-options-trigger").click();
const preference = composer.getByTestId("mention-keep-agents-pinned-toggle");
await expect(preference).toHaveAttribute("data-state", "checked");
await composer
.getByText("Automatically mention agents", { exact: true })
.click();
await expect(preference).toHaveAttribute("data-state", "unchecked");
await expect(input).toBeFocused();
await expect(list).toBeVisible();
});
test("the mention Options controls are reachable and operable by keyboard", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const mainComposer = channelComposer(page);
const mainInput = mainComposer.getByTestId("message-input");
await mainInput.click();
await mainInput.fill("@Mor");
const list = mainComposer.getByTestId("mention-autocomplete");
await expect(list).toBeVisible();
await expect(mainInput).toBeFocused();
// Trip a flag if the overlay ever unmounts from here on — "operable while
// the surface stays mounted" has to hold through every focus handoff below,
// not just at the polled assertion boundaries.
await mainComposer.evaluate((element) => {
element.dataset.mentionMenuUnmounted = "false";
new MutationObserver(() => {
if (!element.querySelector('[data-testid="mention-autocomplete"]')) {
element.dataset.mentionMenuUnmounted = "true";
}
}).observe(element, { childList: true, subtree: true });
});
// Forward Tab still selects the highlighted suggestion, so Shift+Tab is the
// route into the overlay. It only reaches the Options controls if the focus
// gate treats them as composer-owned focus rather than unmounting on the
// editor's blur.
await mainInput.press("Shift+Tab");
const optionsTrigger = mainComposer.getByTestId("mention-options-trigger");
await expect(optionsTrigger).toBeFocused();
await page.keyboard.press("Enter");
const preference = mainComposer.getByTestId(
"mention-keep-agents-pinned-toggle",
);
await expect(preference).toBeVisible();
await expect(preference).toHaveAttribute("data-state", "checked");
// The switch sits before its trigger in the expanded surface's tab order.
await page.keyboard.press("Shift+Tab");
await expect(preference).toBeFocused();
await page.keyboard.press("Space");
await expect(preference).toHaveAttribute("data-state", "unchecked");
await expect(list).toBeVisible();
expect(await mainComposer.getAttribute("data-mention-menu-unmounted")).toBe(
"false",
);
// Escape is the way back out: focus returns to the editor and the menu
// closes, rather than stranding focus on a control that just unmounted.
await page.keyboard.press("Escape");
await expect(mainInput).toBeFocused();
await expect(list).toHaveCount(0);
// Forward Tab is unchanged by the Shift+Tab route.
await mainInput.fill("@Morg");
await expect(list).toBeVisible();
await mainInput.press("Tab");
await expect(mainInput).toHaveText("@Morgarita ");
await expect(list).toHaveCount(0);
// Ownership is still per-composer: focus moving to a sibling composer hides
// this composer's menu, which is what stops a background composer from
// resurrecting a stale one. Programmatic focus, not a click — a pointerdown
// would dismiss the menu through its outside-press handler and mask the gate
// under test.
await mainInput.fill("@Mor");
await expect(list).toBeVisible();
const threadInput = threadComposer(page).getByTestId("message-input");
await threadInput.focus();
await expect(threadInput).toBeFocused();
await expect(list).toHaveCount(0);
});
test("a failed always-mentioned send shakes the composer avatar without replaying its selection animation", async ({
page,
}) => {