From 7995b36e44874bb2f13f01884386965dfaeb2306 Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Wed, 8 Jul 2026 14:50:10 -0700 Subject: [PATCH 1/3] fix(mentions): flush debounce on Tab/Enter to prevent stale autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user types '@timp' quickly and presses Tab before the 120ms debounce fires, the mention query still reflects the stale empty-string state from the initial '@' keystroke. This causes the autocomplete to commit suggestions[0] from the full unfiltered member list — often the current user themselves. Fix: when Tab or Enter is pressed while a debounce timer is pending, cancel the timer, re-run detectPrefixQuery synchronously against the already-stashed latest editor value and cursor (latestValueRef / latestCursorRef), then re-derive the ranked suggestions inline via rankMentionCandidates. The top-ranked candidate for the fresh query is returned as the suggestion, closing the race window completely. This is Option 1 (flush + re-derive inline) — the user gets the correct completion on the first Tab press with no extra interaction required. Extracted flushMentionDebounce into its own module to keep useMentions.ts under the 1000-line file-size limit. Co-authored-by: Cameron Hotchkies Signed-off-by: Cameron Hotchkies Co-authored-by: Goose Ai-assisted: true --- .../messages/lib/flushMentionDebounce.ts | 77 +++++++++++++++++++ .../src/features/messages/lib/useMentions.ts | 30 +++++++- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/lib/flushMentionDebounce.ts diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.ts b/desktop/src/features/messages/lib/flushMentionDebounce.ts new file mode 100644 index 0000000000..f16f5366f1 --- /dev/null +++ b/desktop/src/features/messages/lib/flushMentionDebounce.ts @@ -0,0 +1,77 @@ +/** + * 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 { ChannelType } from "@/shared/api/types"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; +import { + type MentionCandidateForRanking, + rankMentionCandidates, +} from "./mentionRanking"; + +type MentionCandidateWithUI = MentionCandidateForRanking & { + avatarUrl?: string | null; + kind: "identity" | "persona"; + personaId?: string; + pubkey?: string; + isMember: boolean; + role?: string | null; +}; + +/** + * 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; +}): MentionSuggestion | 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 null; + } + + const { candidate, label } = ranked[0]; + return { + pubkey: candidate.pubkey, + personaId: candidate.personaId, + kind: candidate.kind, + displayName: label, + avatarUrl: candidate.avatarUrl ?? null, + isAgent: candidate.isAgent, + notInChannel: opts.channelType !== "dm" && candidate.isMember === false, + ownerLabel: null, + 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..ff1b72e3ea 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -36,6 +36,7 @@ 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"; @@ -917,6 +918,26 @@ 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, + }); + if (flushed) { + setMentionQuery(null); // reset so dropdown closes + return { handled: true, suggestion: flushed }; + } + // No match after flush — fall through to existing suggestions. + } + return { handled: true, suggestion: suggestions[mentionSelectedIndex] }; } @@ -928,7 +949,14 @@ export function useMentions( return { handled: false }; }, - [isMentionOpen, mentionSelectedIndex, suggestions], + [ + activePersonaIds, + isMentionOpen, + mentionCandidates, + mentionSelectedIndex, + options?.channelType, + suggestions, + ], ); return { From d9e04fc38385c942edab598c798d9e7f56318ee9 Mon Sep 17 00:00:00 2001 From: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 Date: Wed, 8 Jul 2026 15:51:15 -0700 Subject: [PATCH 2/3] fix(mentions): preserve flushed mention range Co-authored-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 Signed-off-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 --- .../lib/flushMentionDebounce.test.mjs | 54 +++++++++++++++++++ .../messages/lib/flushMentionDebounce.ts | 23 ++++---- .../src/features/messages/lib/useMentions.ts | 9 +++- 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/messages/lib/flushMentionDebounce.test.mjs 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..81e2a7e94d --- /dev/null +++ b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs @@ -0,0 +1,54 @@ +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?.suggestion.displayName, "Beta"); + assert.equal(flushed?.startIndex, 7); +}); + +test("flushMentionDebounce returns null 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.equal(flushed, null); +}); diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.ts b/desktop/src/features/messages/lib/flushMentionDebounce.ts index f16f5366f1..687a8e7fb1 100644 --- a/desktop/src/features/messages/lib/flushMentionDebounce.ts +++ b/desktop/src/features/messages/lib/flushMentionDebounce.ts @@ -35,7 +35,7 @@ export function flushMentionDebounce(opts: { candidates: readonly T[]; activePersonaIds: ReadonlySet; channelType?: ChannelType | null; -}): MentionSuggestion | null { +}): { suggestion: MentionSuggestion; startIndex: number } | null { if (opts.debounceTimerRef.current !== null) { clearTimeout(opts.debounceTimerRef.current); } @@ -64,14 +64,17 @@ export function flushMentionDebounce(opts: { const { candidate, label } = ranked[0]; return { - pubkey: candidate.pubkey, - personaId: candidate.personaId, - kind: candidate.kind, - displayName: label, - avatarUrl: candidate.avatarUrl ?? null, - isAgent: candidate.isAgent, - notInChannel: opts.channelType !== "dm" && candidate.isMember === false, - ownerLabel: null, - role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + suggestion: { + pubkey: candidate.pubkey, + personaId: candidate.personaId, + kind: candidate.kind, + displayName: label, + avatarUrl: candidate.avatarUrl ?? null, + isAgent: candidate.isAgent, + notInChannel: opts.channelType !== "dm" && candidate.isMember === false, + ownerLabel: null, + role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + }, + startIndex: mention.startIndex, }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index ff1b72e3ea..941bf8622c 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -532,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. @@ -696,8 +697,11 @@ export function useMentions( setMentionQuery(null); setMentionSelectedIndex(0); + const startIndex = + flushedMentionStartIndexRef.current ?? mentionStartIndex; + flushedMentionStartIndexRef.current = null; return { - replaceFromOffset: mentionStartIndex, + replaceFromOffset: startIndex, replaceToOffset: selectionEnd, insertText, }; @@ -932,8 +936,9 @@ export function useMentions( channelType: options?.channelType, }); if (flushed) { + flushedMentionStartIndexRef.current = flushed.startIndex; setMentionQuery(null); // reset so dropdown closes - return { handled: true, suggestion: flushed }; + return { handled: true, suggestion: flushed.suggestion }; } // No match after flush — fall through to existing suggestions. } From 5885487aa4fb5cd304529a71187510f0515992b6 Mon Sep 17 00:00:00 2001 From: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 17:58:44 -0700 Subject: [PATCH 3/3] fix(mentions): tighten flushed suggestion handling Share candidate-to-suggestion mapping between the rendered autocomplete list and the synchronous flush path, so avatar and owner metadata cannot drift. Also make the no-match flush case explicit: close the dropdown without committing a stale suggestion while preserving the plain bare-@ fallthrough. Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co> --- .../lib/flushMentionDebounce.test.mjs | 17 +++++- .../messages/lib/flushMentionDebounce.ts | 46 +++++++++------- .../messages/lib/mentionSuggestionMapping.ts | 54 ++++++++++++++++++ .../src/features/messages/lib/useMentions.ts | 55 ++++++++----------- 4 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 desktop/src/features/messages/lib/mentionSuggestionMapping.ts diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs index 81e2a7e94d..ab5a491a2d 100644 --- a/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs +++ b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs @@ -35,11 +35,12 @@ test("flushMentionDebounce returns the fresh suggestion with its fresh start ind }); assert.equal(debounceTimerRef.current, null); + assert.equal(flushed?.type, "match"); assert.equal(flushed?.suggestion.displayName, "Beta"); assert.equal(flushed?.startIndex, 7); }); -test("flushMentionDebounce returns null for a fresh query with no matches", () => { +test("flushMentionDebounce returns no-match for a fresh query with no matches", () => { const flushed = flushMentionDebounce({ debounceTimerRef: ref(setTimeout(() => {}, 1000)), latestValueRef: ref("@Alpha @zzzq"), @@ -50,5 +51,19 @@ test("flushMentionDebounce returns null for a fresh query with no matches", () = 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 index 687a8e7fb1..fc343e4d8f 100644 --- a/desktop/src/features/messages/lib/flushMentionDebounce.ts +++ b/desktop/src/features/messages/lib/flushMentionDebounce.ts @@ -4,21 +4,24 @@ * 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; -type MentionCandidateWithUI = MentionCandidateForRanking & { - avatarUrl?: string | null; - kind: "identity" | "persona"; - personaId?: string; - pubkey?: string; - isMember: boolean; - role?: string | null; -}; +export type FlushMentionDebounceResult = + | { type: "match"; suggestion: MentionSuggestion; startIndex: number } + | { type: "no-match" }; /** * Cancel the pending debounce timer, re-detect the prefix query from the @@ -35,7 +38,10 @@ export function flushMentionDebounce(opts: { candidates: readonly T[]; activePersonaIds: ReadonlySet; channelType?: ChannelType | null; -}): { suggestion: MentionSuggestion; startIndex: number } | null { + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; +}): FlushMentionDebounceResult | null { if (opts.debounceTimerRef.current !== null) { clearTimeout(opts.debounceTimerRef.current); } @@ -59,22 +65,20 @@ export function flushMentionDebounce(opts: { ); if (ranked.length === 0) { - return null; + return { type: "no-match" }; } const { candidate, label } = ranked[0]; return { - suggestion: { - pubkey: candidate.pubkey, - personaId: candidate.personaId, - kind: candidate.kind, - displayName: label, - avatarUrl: candidate.avatarUrl ?? null, - isAgent: candidate.isAgent, - notInChannel: opts.channelType !== "dm" && candidate.isMember === false, - ownerLabel: null, - role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, - }, + 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 941bf8622c..5f431a95af 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -32,13 +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; @@ -560,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, @@ -934,13 +915,20 @@ export function useMentions( candidates: mentionCandidates, activePersonaIds, channelType: options?.channelType, + currentPubkey, + ownerProfiles: ownerProfilesQuery.data?.profiles, + profiles, }); - if (flushed) { + if (flushed?.type === "match") { flushedMentionStartIndexRef.current = flushed.startIndex; setMentionQuery(null); // reset so dropdown closes return { handled: true, suggestion: flushed.suggestion }; } - // No match after flush — fall through to existing suggestions. + 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] }; @@ -956,10 +944,13 @@ export function useMentions( }, [ activePersonaIds, + currentPubkey, isMentionOpen, mentionCandidates, mentionSelectedIndex, options?.channelType, + ownerProfilesQuery.data?.profiles, + profiles, suggestions, ], );