feat(frontend): render mentions as styled buttons in input and messages
Some checks are pending
CI / Frontend Build (push) Blocked by required conditions
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run

- MentionInput: contenteditable div that renders all mention types as
  emoji+label pill spans (🤖 AI, 👤 User, 📦 Repo, 🔔 Notify)
- Single-backspace deletes entire mention at cursor (detects caret
  at mention start boundary)
- Ctrl+Enter sends, plain Enter swallowed, Shift+Enter inserts newline
- Placeholder CSS via data-placeholder attribute
- MessageMentions: emoji button rendering extended to all mention types
  (user, repository, notify) with click-to-insert support
- Rich input synced via cursorRef (no stale-state re-renders)
This commit is contained in:
ZhenYi 2026-04-18 01:06:39 +08:00
parent b96ef0342c
commit b7328e22f3
4 changed files with 418 additions and 181 deletions

View File

@ -0,0 +1,248 @@
import { parse, type Node } from '@/lib/mention-ast';
import { forwardRef, useCallback, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
interface MentionInputProps {
value: string;
onChange: (value: string) => void;
onSend?: () => void;
placeholder?: string;
disabled?: boolean;
/** Mutable ref for cursor position — updated on every selection change without causing re-renders */
cursorRef?: React.MutableRefObject<number>;
}
const mentionStyles: Record<string, string> = {
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5',
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5',
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5',
notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium text-sm leading-5',
};
const iconMap: Record<string, string> = {
ai: '🤖',
user: '👤',
repository: '📦',
notify: '🔔',
};
const labelMap: Record<string, string> = {
ai: 'AI',
user: 'User',
repository: 'Repo',
notify: 'Notify',
};
function textToHtml(text: string): string {
const nodes = parse(text);
if (nodes.length === 0) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
}
let html = '';
for (const node of nodes) {
if (node.type === 'text') {
html += (node as Node & { type: 'text' }).text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
} else if (node.type === 'mention') {
const m = node as Node & { type: 'mention'; mentionType: string; id: string; label: string };
const style = mentionStyles[m.mentionType] ?? mentionStyles.user;
const icon = iconMap[m.mentionType] ?? '🏷';
const label = labelMap[m.mentionType] ?? 'Mention';
html += `<span class="mention-span" data-mention-id="${m.id}" data-mention-type="${m.mentionType}">`;
html += `<span class="mention-icon">${icon}</span>`;
html += `<span class="mention-label">${label}:</span>`;
html += `<span class="mention-name">${m.label}</span>`;
html += '</span>';
} else if (node.type === 'ai_action') {
const a = node as Node & { type: 'ai_action'; action: string; args?: string };
html += `<span class="ai-action-span">/${a.action}${a.args ? ' ' + a.args : ''}</span>`;
}
}
return html;
}
/** Find the character offset of the cursor inside a contenteditable div */
function getCaretOffset(container: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return container.textContent?.length ?? 0;
const range = selection.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset);
return preRange.toString().length;
}
/** Place caret at a given character offset inside a contenteditable div */
function setCaretOffset(container: HTMLElement, offset: number) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
let charCount = 0;
let found = false;
function traverse(node: Node) {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? '';
if (charCount + text.length >= offset) {
range.setStart(node, Math.min(offset - charCount, text.length));
range.collapse(true);
found = true;
return;
}
charCount += text.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
for (const child of Array.from(node.childNodes)) {
traverse(child as Node);
if (found) return;
}
}
}
traverse(container as unknown as Node);
if (!found) {
range.selectNodeContents(container);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
/** Check if cursor is at the START of a mention span */
function isCursorAtMentionStart(container: HTMLElement): HTMLElement | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType === Node.TEXT_NODE) {
const parent = node.parentElement;
if ((parent?.classList.contains('mention-span') || parent?.classList.contains('ai-action-span')) && range.startOffset === 0) {
return parent;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const prevSibling = node.childNodes[range.startOffset - 1] as HTMLElement;
if (prevSibling?.classList?.contains('mention-span') || prevSibling?.classList?.contains('ai-action-span')) {
return prevSibling;
}
}
return null;
}
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({
value,
onChange,
onSend,
placeholder,
disabled,
cursorRef,
}: MentionInputProps) {
const containerRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false);
const lastValueRef = useRef(value);
const isInternalChangeRef = useRef(false);
// Keep cursorRef in sync with actual caret position
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () => {
if (cursorRef) cursorRef.current = getCaretOffset(container);
};
container.addEventListener('selectionchange', update);
return () => container.removeEventListener('selectionchange', update);
}, [cursorRef]);
useEffect(() => {
if (isInternalChangeRef.current) {
isInternalChangeRef.current = false;
return;
}
if (!containerRef.current) return;
const currentText = containerRef.current.textContent ?? '';
if (currentText === value) return;
const prevLen = lastValueRef.current.length;
const cursor = getCaretOffset(containerRef.current);
const ratio = prevLen > 0 ? cursor / prevLen : 0;
containerRef.current.innerHTML = textToHtml(value);
const newLen = value.length;
const newCursor = Math.round(ratio * newLen);
setCaretOffset(containerRef.current, Math.min(newCursor, newLen));
lastValueRef.current = value;
}, [value]);
const handleInput = useCallback(() => {
if (isComposingRef.current) return;
const container = containerRef.current;
if (!container) return;
const newValue = container.textContent ?? '';
lastValueRef.current = newValue;
isInternalChangeRef.current = true;
onChange(newValue);
}, [onChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container) return;
// Backspace: delete entire mention span at cursor
if (e.key === 'Backspace') {
const span = isCursorAtMentionStart(container);
if (span) {
e.preventDefault();
span.remove();
const newValue = container.textContent ?? '';
lastValueRef.current = newValue;
isInternalChangeRef.current = true;
onChange(newValue);
return;
}
}
// Ctrl+Enter: send
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (onSend) onSend();
return;
}
// Plain Enter: swallow (Shift+Enter naturally inserts newline via default behavior)
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
}
},
[onChange, onSend],
);
return (
<div
ref={containerRef}
contentEditable={!disabled}
suppressContentEditableWarning
className={cn(
'min-h-[80px] w-full resize-none overflow-y-auto rounded-md border border-input bg-background px-3 py-2',
'text-sm text-foreground outline-none',
'whitespace-pre-wrap break-words leading-5',
disabled && 'opacity-50 cursor-not-allowed select-none',
)}
onInput={handleInput}
onKeyDown={handleKeyDown}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={handleInput}
data-placeholder={placeholder}
dangerouslySetInnerHTML={{ __html: textToHtml(value) }}
/>
);
});

View File

@ -127,15 +127,29 @@ function renderNode(
}
if (node.type === 'mention') {
const displayName = resolveName(node.mentionType, node.id, node.label);
const baseClass = mentionStyles[node.mentionType] ?? mentionStyles.user;
if (node.mentionType === 'ai') {
const iconMap: Record<string, string> = {
ai: '🤖',
user: '👤',
repository: '📦',
notify: '🔔',
};
const labelMap: Record<string, string> = {
ai: 'AI',
user: 'User',
repository: 'Repo',
notify: 'Notify',
};
const icon = iconMap[node.mentionType] ?? '🏷';
const label = labelMap[node.mentionType] ?? 'Mention';
return (
<button
key={index}
type="button"
className={cn(
baseClass,
mentionStyles[node.mentionType] ?? mentionStyles.user,
'inline-flex items-center gap-1 cursor-pointer border-0 bg-transparent p-0',
'hover:opacity-80 transition-opacity',
)}
@ -143,7 +157,7 @@ function renderNode(
document.dispatchEvent(
new CustomEvent('mention-click', {
detail: {
type: 'ai',
type: node.mentionType,
id: node.id,
label: displayName,
},
@ -152,19 +166,12 @@ function renderNode(
);
}}
>
<span className="text-sm">🤖</span>
<span>AI:</span>
<span className="text-sm">{icon}</span>
<span>{label}:</span>
<span className="font-medium">{displayName}</span>
</button>
);
}
return (
<span key={index} className={baseClass}>
@{displayName}
</span>
);
}
if (node.type === 'ai_action') {
return (
<span

View File

@ -3,10 +3,10 @@ import { useRoom, type MessageWithMeta } from '@/contexts';
import { type RoomAiConfig } from '@/contexts/room-context';
import { useRoomDraft } from '@/hooks/useRoomDraft';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { buildMentionHtml } from '@/lib/mention-ast';
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
import { MentionInput } from './MentionInput';
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
import {
memo,
@ -30,7 +30,6 @@ import { RoomMentionPanel } from './RoomMentionPanel';
import { RoomThreadPanel } from './RoomThreadPanel';
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
export interface ChatInputAreaHandle {
@ -72,23 +71,23 @@ const ChatInputArea = memo(function ChatInputArea({
onClearDraft,
ref,
}: ChatInputAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [cursorPosition, setCursorPosition] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef(0);
const [showMentionPopover, setShowMentionPopover] = useState(false);
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
if (!containerRef.current) return;
const container = containerRef.current;
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const after = textarea.value.substring(cursorPos);
const before = draft.substring(0, startPos);
const after = draft.substring(cursorPos);
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
if (!suggestion || suggestion.type !== 'item') return;
@ -105,158 +104,124 @@ const ChatInputArea = memo(function ChatInputArea({
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.value = newValue;
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current.focus();
const container = containerRef.current;
if (!container) return;
container.innerHTML = newValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
// Place cursor at newCursorPos
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
let charCount = 0;
let found = false;
function walk(node: Node) {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? '';
if (charCount + t.length >= newCursorPos) {
range.setStart(node, Math.min(newCursorPos - charCount, t.length));
range.collapse(true);
found = true;
return;
}
charCount += t.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
for (const c of Array.from(node.childNodes)) {
walk(c);
if (found) return;
}
}
}
walk(container);
if (!found) { range.selectNodeContents(container); range.collapse(false); }
sel.removeAllRanges();
sel.addRange(range);
container.focus();
}, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentionally use refs, not state
}, []); // Uses draft from closure
useImperativeHandle(ref, () => ({
insertMention: (id: string, label: string) => {
if (!textareaRef.current) return;
const value = textareaRef.current.value;
const cursorPos = textareaRef.current.selectionStart;
const cursorPos = cursorRef.current;
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedId = id.replace(/"/g, '&quot;');
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
const before = value.substring(0, cursorPos);
const after = value.substring(cursorPos);
const before = draft.substring(0, cursorPos);
const after = draft.substring(cursorPos);
const newValue = before + mentionText + after;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
}
containerRef.current?.focus();
}, 0);
},
insertCategory: (category: string) => {
// Enter a category: e.g. @ai → @ai:
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const textBefore = textarea.value.substring(0, textarea.selectionStart);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = textarea.selectionStart - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1; // after '@ai:'
// Directly update React state to trigger mention detection
onDraftChange(newValue);
setCursorPosition(newCursorPos);
const textBefore2 = newValue.substring(0, newCursorPos);
const match = textBefore2.match(MENTION_PATTERN);
setShowMentionPopover(!!match);
},
}));
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart;
onDraftChange(value);
setCursorPosition(cursorPos);
// Detect @ mention
const textBefore = value.substring(0, cursorPos);
const match = textBefore.match(MENTION_PATTERN);
if (match) {
setShowMentionPopover(true);
} else {
setShowMentionPopover(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Detect mention directly from DOM — avoids stale React state between handleChange and handleKeyDown
const hasMention = textareaRef.current
? textareaRef.current.value.substring(0, textareaRef.current.selectionStart).match(MENTION_PATTERN) !== null
: false;
if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
// Enter/Tab: do mention selection directly here using module-level refs
if ((e.key === 'Enter' || e.key === 'Tab')) {
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
if (suggestion && suggestion.type === 'item') {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const after = textarea.value.substring(cursorPos);
const html = buildMentionHtml(suggestion.category!, suggestion.mentionId!, suggestion.label);
const spacer = ' ';
const newValue = before + html + spacer + after;
const newCursorPos = startPos + html.length + spacer.length;
const before = draft.substring(0, startPos);
const afterPartial = draft.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.value = newValue;
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current.focus();
}
}, 0);
}
}
return;
}
// Shift+Enter → let textarea naturally insert newline (pass through)
// Ctrl+Enter → send message
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
const content = e.currentTarget.value.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
}
}
// Plain Enter (no modifiers) → only trigger mention select; otherwise do nothing
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
// Do nothing — Shift+Enter falls through to let textarea insert newline
}
};
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
},
}));
// Listen for mention-click events from message content (e.g. 🤖 AI button)
useEffect(() => {
const onMentionClick = (e: Event) => {
const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail;
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
if (type === 'ai') {
// Insert @ai:mention at cursor position
const html = buildMentionHtml('ai', id, label);
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
const spacer = ' ';
const newValue = textBefore + html + spacer + textarea.value.substring(cursorPos);
const newValue = textBefore + html + spacer + draft.substring(cursorPos);
const newCursorPos = cursorPos + html.length + spacer.length;
onDraftChange(newValue);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.value = newValue;
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current.focus();
const container = containerRef.current;
if (!container) return;
container.focus();
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
let charCount = 0;
let found = false;
function walk(node: Node) {
if (found) return;
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? '';
if (charCount + t.length >= newCursorPos) {
range.setStart(node, Math.min(newCursorPos - charCount, t.length));
range.collapse(true);
found = true;
return;
}
charCount += t.length;
} else if (node.nodeType === Node.ELEMENT_NODE) {
for (const c of Array.from(node.childNodes)) {
walk(c);
if (found) return;
}
}
}
walk(container);
if (!found) { range.selectNodeContents(container); range.collapse(false); }
sel.removeAllRanges();
sel.addRange(range);
}, 0);
}
};
document.addEventListener('mention-click', onMentionClick);
return () => document.removeEventListener('mention-click', onMentionClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@ -272,20 +237,34 @@ const ChatInputArea = memo(function ChatInputArea({
)}
<div className="relative">
<Textarea
ref={textareaRef}
<MentionInput
ref={containerRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
cursorRef={cursorRef}
onChange={(v) => {
onDraftChange(v);
const textBefore = v.substring(0, cursorRef.current);
if (textBefore.match(MENTION_PATTERN)) {
setShowMentionPopover(true);
} else {
setShowMentionPopover(false);
}
}}
onSend={() => {
const content = draft.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
}
}}
placeholder={`Message #${roomName}...`}
className="min-h-[80px] resize-none pr-20"
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1">
<Button
size="sm"
onClick={() => {
const content = textareaRef.current?.value.trim();
const content = draft.trim();
if (content && !isSending) {
onSend(content);
onClearDraft();
@ -308,27 +287,23 @@ const ChatInputArea = memo(function ChatInputArea({
aiConfigs={aiConfigs}
aiConfigsLoading={aiConfigsLoading}
inputValue={draft}
cursorPosition={cursorPosition}
cursorPosition={cursorRef.current}
onSelect={handleMentionSelect}
textareaRef={textareaRef}
textareaRef={containerRef}
onOpenChange={setShowMentionPopover}
onCategoryEnter={(category: string) => {
// Enter a category: @ai → @ai: to show next-level items
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const textBefore = textarea.value.substring(0, textarea.selectionStart);
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = textarea.selectionStart - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
const startPos = cursorPos - fullMatch.length;
const before = draft.substring(0, startPos);
const afterPartial = draft.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChange(newValue);
setCursorPosition(newCursorPos);
const match2 = newValue.substring(0, newCursorPos).match(MENTION_PATTERN);
setShowMentionPopover(!!match2);
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
}}
/>
)}

View File

@ -127,3 +127,10 @@
@apply font-sans;
}
}
/* Placeholder support for contenteditable MentionInput */
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}