From 3a7afe7b66e9eb29953fcfcfc7e41dee8c88d143 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 14:59:20 -0600 Subject: [PATCH] fix: @-mention autocomplete selection and dismissal - Use a ref for mentionState so selectMention always reads the latest value (prevents stale-closure "blink" on click/Enter/Tab) - Add explicit space key handling to dismiss the popup immediately - Move Escape handler outside filteredMentions check so it always works - Sync mentionStateRef on all state transitions Co-Authored-By: Claude Opus 4.6 --- ui/src/components/MarkdownEditor.tsx | 62 ++++++++++++++++++---------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 5bbad003..4a6202b3 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -138,8 +138,9 @@ export const MarkdownEditor = forwardRef const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); - // Mention state + // Mention state (ref kept in sync so callbacks always see the latest value) const [mentionState, setMentionState] = useState(null); + const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); const mentionActive = mentionState !== null && mentions && mentions.length > 0; @@ -194,10 +195,12 @@ export const MarkdownEditor = forwardRef // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { if (!mentions || mentions.length === 0 || !containerRef.current) { + mentionStateRef.current = null; setMentionState(null); return; } const result = detectMention(containerRef.current); + mentionStateRef.current = result; if (result) { setMentionState(result); setMentionIndex(0); @@ -224,20 +227,24 @@ export const MarkdownEditor = forwardRef const selectMention = useCallback( (option: MentionOption) => { - if (!mentionState) return; + // Read from ref to avoid stale-closure issues (selectionchange can + // update state between the last render and this callback firing). + const state = mentionStateRef.current; + if (!state) return; const current = latestValueRef.current; - const next = applyMention(current, mentionState.query, option); + const next = applyMention(current, state.query, option); if (next !== current) { latestValueRef.current = next; ref.current?.setMarkdown(next); onChange(next); } + mentionStateRef.current = null; setMentionState(null); requestAnimationFrame(() => { ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }); }, - [mentionState, onChange], + [onChange], ); function hasFilePayload(evt: DragEvent) { @@ -264,32 +271,43 @@ export const MarkdownEditor = forwardRef return; } - // Mention keyboard navigation - if (mentionActive && filteredMentions.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - setMentionIndex((prev) => Math.max(prev - 1, 0)); - return; - } - if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - e.stopPropagation(); - selectMention(filteredMentions[mentionIndex]); + // Mention keyboard handling + if (mentionActive) { + // Space dismisses the popup (let the character be typed normally) + if (e.key === " ") { + mentionStateRef.current = null; + setMentionState(null); return; } + // Escape always dismisses if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); + mentionStateRef.current = null; setMentionState(null); return; } + // Arrow / Enter / Tab only when there are filtered results + if (filteredMentions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + setMentionIndex((prev) => Math.max(prev - 1, 0)); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + selectMention(filteredMentions[mentionIndex]); + return; + } + } } }} onDragEnter={(evt) => {