mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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 : []
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user