diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs new file mode 100644 index 0000000000..ab5a491a2d --- /dev/null +++ b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { flushMentionDebounce } from "./flushMentionDebounce.ts"; + +function ref(current) { + return { current }; +} + +function candidate(overrides = {}) { + return { + kind: "identity", + displayName: "Beta", + isAgent: false, + isMember: true, + pubkey: "b".repeat(64), + ...overrides, + }; +} + +test("flushMentionDebounce returns the fresh suggestion with its fresh start index", () => { + const debounceTimerRef = ref(setTimeout(() => {}, 1000)); + + const flushed = flushMentionDebounce({ + debounceTimerRef, + latestValueRef: ref("@Alpha @be"), + latestCursorRef: ref("@Alpha @be".length), + searchableNamesLowerRef: ref(["alpha", "beta"]), + candidates: [ + candidate({ displayName: "Alpha", pubkey: "a".repeat(64) }), + candidate({ displayName: "Beta", pubkey: "b".repeat(64) }), + ], + activePersonaIds: new Set(), + channelType: "group", + }); + + assert.equal(debounceTimerRef.current, null); + assert.equal(flushed?.type, "match"); + assert.equal(flushed?.suggestion.displayName, "Beta"); + assert.equal(flushed?.startIndex, 7); +}); + +test("flushMentionDebounce returns no-match for a fresh query with no matches", () => { + const flushed = flushMentionDebounce({ + debounceTimerRef: ref(setTimeout(() => {}, 1000)), + latestValueRef: ref("@Alpha @zzzq"), + latestCursorRef: ref("@Alpha @zzzq".length), + searchableNamesLowerRef: ref(["alpha", "beta"]), + candidates: [candidate()], + activePersonaIds: new Set(), + channelType: "group", + }); + + assert.deepEqual(flushed, { type: "no-match" }); +}); + +test("flushMentionDebounce returns null for an empty fresh query", () => { + const flushed = flushMentionDebounce({ + debounceTimerRef: ref(setTimeout(() => {}, 1000)), + latestValueRef: ref("@"), + latestCursorRef: ref("@".length), + searchableNamesLowerRef: ref(["alpha", "beta"]), + candidates: [candidate()], + activePersonaIds: new Set(), + channelType: "group", + }); + + assert.equal(flushed, null); +}); diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.ts b/desktop/src/features/messages/lib/flushMentionDebounce.ts new file mode 100644 index 0000000000..fc343e4d8f --- /dev/null +++ b/desktop/src/features/messages/lib/flushMentionDebounce.ts @@ -0,0 +1,84 @@ +/** + * Synchronously flush a pending mention debounce and resolve the correct + * top-ranked suggestion. Used by handleMentionKeyDown to close the race + * window where Tab/Enter fires before the debounce catches up to typed text. + */ +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelType } from "@/shared/api/types"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; +import { + type MentionCandidateForRanking, + rankMentionCandidates, +} from "./mentionRanking"; +import { + mapMentionCandidateToSuggestion, + type MentionSuggestionCandidate, +} from "./mentionSuggestionMapping"; + +type MentionCandidateWithUI = MentionCandidateForRanking & + MentionSuggestionCandidate; + +export type FlushMentionDebounceResult = + | { type: "match"; suggestion: MentionSuggestion; startIndex: number } + | { type: "no-match" }; + +/** + * Cancel the pending debounce timer, re-detect the prefix query from the + * latest editor state, rank candidates, and return the top suggestion — or + * null if no valid match is found. + */ +export function flushMentionDebounce(opts: { + debounceTimerRef: React.MutableRefObject | null>; + latestValueRef: React.RefObject; + latestCursorRef: React.RefObject; + searchableNamesLowerRef: React.RefObject; + candidates: readonly T[]; + activePersonaIds: ReadonlySet; + channelType?: ChannelType | null; + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; +}): FlushMentionDebounceResult | null { + if (opts.debounceTimerRef.current !== null) { + clearTimeout(opts.debounceTimerRef.current); + } + opts.debounceTimerRef.current = null; + + const mention = detectPrefixQuery( + "@", + opts.latestValueRef.current, + opts.latestCursorRef.current, + opts.searchableNamesLowerRef.current, + ); + + if (!mention || mention.query.length === 0) { + return null; + } + + const ranked = rankMentionCandidates( + opts.candidates, + mention.query, + opts.activePersonaIds, + ); + + if (ranked.length === 0) { + return { type: "no-match" }; + } + + const { candidate, label } = ranked[0]; + return { + type: "match", + suggestion: mapMentionCandidateToSuggestion({ + candidate, + label, + channelType: opts.channelType, + currentPubkey: opts.currentPubkey, + ownerProfiles: opts.ownerProfiles, + profiles: opts.profiles, + }), + startIndex: mention.startIndex, + }; +} diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts new file mode 100644 index 0000000000..436391a7a4 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -0,0 +1,54 @@ +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { formatOwnerLabel } from "@/features/profile/lib/identity"; +import type { ChannelRole, ChannelType } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type MentionSuggestionCandidate = { + kind: "identity" | "persona"; + pubkey?: string; + personaId?: string | null; + avatarUrl?: string | null; + isAgent: boolean; + isMember: boolean; + role?: ChannelRole | null; + ownerPubkey?: string | null; +}; + +export function mapMentionCandidateToSuggestion(opts: { + candidate: MentionSuggestionCandidate; + label: string; + channelType?: ChannelType | null; + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; +}): MentionSuggestion { + const { + candidate, + channelType, + currentPubkey, + label, + ownerProfiles, + profiles, + } = opts; + const ownerLabel = candidate.isAgent + ? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, ownerProfiles) + : null; + + return { + pubkey: candidate.pubkey, + personaId: candidate.personaId ?? undefined, + kind: candidate.kind, + displayName: label, + avatarUrl: + candidate.avatarUrl ?? + (candidate.pubkey + ? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl + : null) ?? + null, + isAgent: candidate.isAgent, + notInChannel: channelType !== "dm" && candidate.isMember === false, + ownerLabel, + role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + }; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index ad883df822..5f431a95af 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -32,12 +32,13 @@ import type { UserSearchResult, } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { formatOwnerLabel } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; +import { flushMentionDebounce } from "./flushMentionDebounce"; import { hasMention } from "./hasMention"; import { rankMentionCandidates } from "./mentionRanking"; +import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; const MENTION_DEBOUNCE_MS = 120; const MENTION_SUGGESTION_LIMIT = 50; @@ -531,6 +532,7 @@ export function useMentions( ); const latestValueRef = React.useRef(""); const latestCursorRef = React.useRef(0); + const flushedMentionStartIndexRef = React.useRef(null); const searchableNamesLowerRef = React.useRef(searchableNamesLower); // Keep the known-names ref in sync so the debounced callback never reads stale data. @@ -558,35 +560,16 @@ export function useMentions( activePersonaIds, ) .slice(0, Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidates.length)) - .map(({ candidate, label }) => { - const ownerLabel = candidate.isAgent - ? formatOwnerLabel( - candidate.ownerPubkey, - currentPubkey, - ownerProfilesQuery.data?.profiles, - ) - : null; - const notInChannel = - options?.channelType !== "dm" && candidate.isMember === false; - - return { - pubkey: candidate.pubkey, - personaId: candidate.personaId, - kind: candidate.kind, - displayName: label, - avatarUrl: - candidate.avatarUrl ?? - (candidate.pubkey - ? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl - : null) ?? - null, - isAgent: candidate.isAgent, - notInChannel, - ownerLabel, - role: - !candidate.isAgent && candidate.role === "admin" ? "admin" : null, - }; - }); + .map(({ candidate, label }) => + mapMentionCandidateToSuggestion({ + candidate, + label, + channelType: options?.channelType, + currentPubkey, + ownerProfiles: ownerProfilesQuery.data?.profiles, + profiles, + }), + ); }, [ activePersonaIds, currentPubkey, @@ -695,8 +678,11 @@ export function useMentions( setMentionQuery(null); setMentionSelectedIndex(0); + const startIndex = + flushedMentionStartIndexRef.current ?? mentionStartIndex; + flushedMentionStartIndexRef.current = null; return { - replaceFromOffset: mentionStartIndex, + replaceFromOffset: startIndex, replaceToOffset: selectionEnd, insertText, }; @@ -917,6 +903,34 @@ export function useMentions( !event.shiftKey) ) { event.preventDefault(); + + // If a debounce is pending, the suggestions array reflects a stale query. + // Flush: re-detect synchronously and re-derive the correct suggestion. + if (debounceTimerRef.current !== null) { + const flushed = flushMentionDebounce({ + debounceTimerRef, + latestValueRef, + latestCursorRef, + searchableNamesLowerRef, + candidates: mentionCandidates, + activePersonaIds, + channelType: options?.channelType, + currentPubkey, + ownerProfiles: ownerProfilesQuery.data?.profiles, + profiles, + }); + if (flushed?.type === "match") { + flushedMentionStartIndexRef.current = flushed.startIndex; + setMentionQuery(null); // reset so dropdown closes + return { handled: true, suggestion: flushed.suggestion }; + } + if (flushed?.type === "no-match") { + setMentionQuery(null); + return { handled: true }; + } + // Plain `@` after flush intentionally falls through to existing suggestions. + } + return { handled: true, suggestion: suggestions[mentionSelectedIndex] }; } @@ -928,7 +942,17 @@ export function useMentions( return { handled: false }; }, - [isMentionOpen, mentionSelectedIndex, suggestions], + [ + activePersonaIds, + currentPubkey, + isMentionOpen, + mentionCandidates, + mentionSelectedIndex, + options?.channelType, + ownerProfilesQuery.data?.profiles, + profiles, + suggestions, + ], ); return {