+ {isLoading || suggestions.length === 0 ? (
+
+ {isLoading ? "Loading mentions…" : "No mentions found"}
+
+ ) : null}
{onKeepMentionedAgentsPinnedChange ? (
{/* 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. */}
@@ -317,7 +331,6 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
)}
data-testid="mention-autocomplete"
onMouseDown={(event) => event.preventDefault()}
- onScroll={handleScroll}
ref={listRef}
style={POPOVER_SHADOW_STYLE}
>
@@ -330,15 +343,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
(suggestion.teamId ? `team-${suggestion.teamId}` : null) ??
suggestion.displayName;
const hasNameCollision =
+ suggestion.hasNameCollision ||
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
const showAgentProvenanceMarker = showMentionAgentProvenanceMarker(
suggestion,
hasNameCollision,
);
- const ownerLabel =
- hasNameCollision && suggestion.agentProvenance
- ? null
- : suggestion.ownerLabel;
+ const reasonId = `${reasonIdPrefix}-${suggestionKey}-reason`;
+ const ownerLabel = suggestion.ownerLabel;
const collisionNpub =
hasNameCollision && suggestion.pubkey
? safeNpub(suggestion.pubkey)
@@ -348,10 +360,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
suggestion.isAgent ||
suggestion.role ||
ownerLabel ||
- suggestion.notInChannel,
+ suggestion.notInChannel ||
+ suggestion.action,
);
const canAlwaysAddress = Boolean(
- onToggleAlwaysAddressAgent &&
+ isMentionActionable(suggestion) &&
+ onToggleAlwaysAddressAgent &&
suggestion.isAgent &&
suggestion.pubkey,
);
@@ -373,14 +387,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
key={suggestionKey}
>
+ {suggestion.action === "unavailable" && suggestion.onRetry ? (
+
+ ) : null}
{canAlwaysAddress ? (
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 9b409cb93b5..a5049bf8154 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -55,6 +55,7 @@ import { ComposerDockToolbar } from "./ComposerDockToolbar";
import { ComposerUploadError } from "./ComposerUploadError";
import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
+import { useComposerScrollToBottom } from "./useComposerScrollToBottom";
import { useComposerVoiceNote } from "./useComposerVoiceNote";
import { useMentionSendFlow } from "./useMentionSendFlow";
import { useAgentAddressLockPicker } from "./useAgentAddressLockPicker";
@@ -152,6 +153,8 @@ function MessageComposerImpl({
const mentions = useMentions(channelId, undefined, profiles, {
channelType,
recentMentionPubkeys,
+ getEditorSnapshot: (): { text: string; cursor: number } =>
+ richText.getPlainTextAndCursor(),
});
const channelLinks = useChannelLinks();
const customEmoji = useCustomEmoji();
@@ -271,13 +274,7 @@ function MessageComposerImpl({
((info: LinkSelectionInfo | null) => void) | null
>(null);
const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null);
- const scrollComposerToBottom = React.useCallback(() => {
- window.requestAnimationFrame(() => {
- const scrollElement = composerScrollRef.current;
- if (!scrollElement) return;
- scrollElement.scrollTop = scrollElement.scrollHeight;
- });
- }, []);
+ const scrollComposerToBottom = useComposerScrollToBottom(composerScrollRef);
const computedPlaceholder = editTarget
? "Edit your message"
: (placeholder ??
@@ -302,6 +299,8 @@ function MessageComposerImpl({
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
+ onSelectionUpdate: ({ text, cursor }) =>
+ mentions.updateMentionQuery(text, cursor),
onUpdate: ({ cursor, linkPreviewContent, text }) => {
trackAuthoredContent(text);
contentRef.current = text;
@@ -439,7 +438,7 @@ function MessageComposerImpl({
if (!replyTarget || composerDisabled) return;
richText.focusPreserve();
}, [composerDisabled, replyTarget, richText.focusPreserve]);
- useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled);
+ useComposerAutofocus(richText.editor, effectiveDraftKey, composerDisabled);
// Hooks return a plain-text edit descriptor; `replacePlainTextRange`
// applies it as a single ProseMirror transaction (no markdown round-trip).
const applyAutocompleteEdit = React.useCallback(
@@ -514,7 +513,6 @@ function MessageComposerImpl({
richText.getPlainTextAndCursor,
],
);
- // ── Emoji insertion ─────────────────────────────────────────────────
const insertEmoji = React.useCallback(
(emoji: string) => {
if (!richText.editor) return;
@@ -564,6 +562,7 @@ function MessageComposerImpl({
onToggle: toggleAlwaysAddressAgent,
});
const submitMessage = React.useCallback(async () => {
+ mentions.cancelMentionAdmission();
const trimmed = syncComposerContentFromEditor().trim();
// Edit mode
if (editTargetRef.current && onEditSaveRef.current) {
@@ -683,6 +682,7 @@ function MessageComposerImpl({
media.setUploadState,
mentionSendFlow.isPreparingMentionSend,
mentionSendFlow.sendMessageWithMentionFlow,
+ mentions.cancelMentionAdmission,
mentions.clearMentions,
richText.clearContent,
richText.setContent,
@@ -907,7 +907,11 @@ function MessageComposerImpl({
{composerLinkPreviews}