From 735bea5dee1aa6fa04b84bc3144fcf93930f2e33 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 14:53:46 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20@-mention=20autocomplete=20=E2=80=94=20E?= =?UTF-8?q?nter/Tab/click=20now=20insert=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old approach used document.execCommand("insertText") to directly manipulate the contentEditable DOM, but MDXEditor (Lexical) reverted these changes during reconciliation causing the "blink" bug. Fix: work at the markdown string level instead — find the @query in the markdown, replace it with @Name, and update via setMarkdown(). Also add an input event listener alongside selectionchange for more reliable mention detection (fixes space-to-dismiss). Co-Authored-By: Claude Opus 4.6 --- ui/src/components/MarkdownEditor.tsx | 42 ++++++++++++++++++---------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index d3c7b289..5bbad003 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -108,17 +108,13 @@ function detectMention(container: HTMLElement): MentionState | null { }; } -function insertMention(state: MentionState, option: MentionOption) { - const range = document.createRange(); - range.setStart(state.textNode, state.atPos); - range.setEnd(state.textNode, state.endPos); - - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - - // insertText preserves undo stack and triggers editor onChange - document.execCommand("insertText", false, `@${option.name} `); +/** Replace `@` in the markdown string with `@ `. */ +function applyMention(markdown: string, query: string, option: MentionOption): string { + const search = `@${query}`; + const replacement = `@${option.name} `; + const idx = markdown.lastIndexOf(search); + if (idx === -1) return markdown; + return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); } /* ---- Component ---- */ @@ -213,17 +209,35 @@ export const MarkdownEditor = forwardRef useEffect(() => { if (!mentions || mentions.length === 0) return; + const el = containerRef.current; + // Listen for input events on the container so mention detection + // also fires after typing (e.g. space to dismiss). + const onInput = () => requestAnimationFrame(checkMention); + document.addEventListener("selectionchange", checkMention); - return () => document.removeEventListener("selectionchange", checkMention); + el?.addEventListener("input", onInput, true); + return () => { + document.removeEventListener("selectionchange", checkMention); + el?.removeEventListener("input", onInput, true); + }; }, [checkMention, mentions]); const selectMention = useCallback( (option: MentionOption) => { if (!mentionState) return; - insertMention(mentionState, option); + const current = latestValueRef.current; + const next = applyMention(current, mentionState.query, option); + if (next !== current) { + latestValueRef.current = next; + ref.current?.setMarkdown(next); + onChange(next); + } setMentionState(null); + requestAnimationFrame(() => { + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); + }); }, - [mentionState], + [mentionState, onChange], ); function hasFilePayload(evt: DragEvent) {