Fix @mention tagging in prompts with CJK characters (#1376)

Two bugs were preventing @image / @video / @character references from
being tagged in prompts written with Chinese, Japanese, or Korean text:

1. The mention-highlight regex was built with just the "g" flag, so a
user-typed "@image6" never matched the canonical label "@Image6" and the
reference was rendered as plain text. Make the regex case-insensitive
and do the colorMap lookup via a lowercase-keyed table so "@image6"
still finds the color for "@Image6".

2. detectMention required the character immediately before "@" to match
/\s/, which never matches CJK characters. Typing "@" right after a
Chinese character like "从" therefore never opened the autocomplete
dropdown, leaving users to type the mention by hand (which then hit bug
1). Replace the whitespace check with a "not an ASCII identifier char"
check so CJK, quotes, and punctuation all count as valid word boundaries
before an @mention.
This commit is contained in:
BFlat authored and GitHub committed 2026-04-16 07:05:21 +07:00
1 parent f397ad825c
commit f94fc5bf07
1 file changed
+24 -6
@@ -348,15 +348,26 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}, [mentionItems, mentionState.isOpen, mentionState.query]);
// Build a regex that matches any known mention label (supports spaces in names)
// Sort longest-first so "@Pumpkin Head" matches before "@Pumpkin"
// Sort longest-first so "@Pumpkin Head" matches before "@Pumpkin".
// Case-insensitive so users can type "@image1" and still get it tagged
// when the canonical label is "@Image1".
const mentionRegex = useMemo(() => {
const labels = mentionItems.map((item) => item.label);
if (labels.length === 0) return null;
const sorted = [...labels].sort((a, b) => b.length - a.length);
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
return new RegExp(`(${pattern})`, "g");
return new RegExp(`(${pattern})`, "gi");
}, [mentionItems]);
// Case-insensitive lookup table for colorMap so "@image1" finds "@Image1".
const lowerColorMap = useMemo(() => {
const m: Record<string, string> = {};
for (const [k, v] of Object.entries(colorMap)) {
m[k.toLowerCase()] = v;
}
return m;
}, [colorMap]);
// Build innerHTML with colored @mentions inline
const buildHTML = useCallback(
(text: string): string => {
@@ -376,7 +387,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
// biome-ignore lint/suspicious/noAssignInExpressions: --
while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0];
const color = colorMap[fullMatch];
const color = lowerColorMap[fullMatch.toLowerCase()];
if (match.index > lastIndex) {
html += escapeHTML(text.slice(lastIndex, match.index));
@@ -402,7 +413,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
return html;
},
[colorMap, mentionRegex],
[lowerColorMap, mentionRegex],
);
// Sync DOM when value changes from parent (not from user input)
@@ -464,13 +475,20 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}, []);
// Detect @mention trigger from cursor position
// Supports multi-word names by scanning back to the nearest @
// Supports multi-word names by scanning back to the nearest @.
// A valid trigger requires the char before @ to not be an ASCII
// identifier char (letter/digit/underscore) — this lets CJK characters,
// quotes, and punctuation all count as word boundaries, so users can
// type "@Image1" directly after Chinese text like 从@Image1.
const detectMention = useCallback(
(text: string, cursorPos: number) => {
// Find the last @ before cursor
const textBefore = text.slice(0, cursorPos);
const lastAt = textBefore.lastIndexOf("@");
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
if (
lastAt !== -1 &&
(lastAt === 0 || !/[A-Za-z0-9_]/.test(text[lastAt - 1]))
) {
const query = text.slice(lastAt, cursorPos); // includes @
// Only open if there's no newline in the query
if (!query.includes("\n")) {