fix(room): fix mention Enter key by reading textarea DOM directly

Problem: showMentionPopover state is stale when handleKeyDown fires
immediately after handleChange (both in same event loop), causing Enter
to be silently swallowed.

Solution:
- Read textarea.value directly in handleKeyDown to detect @ mentions
- Module-level refs (mentionSelectedIdxRef, mentionVisibleRef) share
  selection state between MentionPopover and ChatInputArea
- handleMentionSelect reads DOM instead of relying on props state
This commit is contained in:
ZhenYi 2026-04-18 00:25:03 +08:00
parent 13f5ff328c
commit 3cd5b3003c
3 changed files with 55 additions and 7 deletions

View File

@ -1,5 +1,6 @@
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
@ -63,6 +64,8 @@ interface MentionPopoverProps {
onOpenChange: (open: boolean) => void;
/** Mutable ref: write the confirm fn here so ChatInputArea can call it on Enter */
onMentionConfirmRef?: React.MutableRefObject<() => void>;
/** Simpler alternative to onMentionConfirmRef — parent provides a callback directly */
onMentionConfirm?: () => void;
}
// Category configuration with icons and colors
@ -375,6 +378,7 @@ export function MentionPopover({
textareaRef,
onOpenChange,
onMentionConfirmRef,
onMentionConfirm,
}: MentionPopoverProps) {
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
const [selectedIndex, setSelectedIndex] = useState(0);
@ -480,6 +484,8 @@ export function MentionPopover({
const visibleSuggestions = suggestions;
visibleSuggestionsRef.current = visibleSuggestions;
mentionSelectedIdxRef.current = selectedIndex;
mentionVisibleRef.current = visibleSuggestions;
// Auto-select first item
useEffect(() => {

View File

@ -5,6 +5,8 @@ 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 { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
import {
memo,
@ -29,8 +31,8 @@ import { RoomThreadPanel } from './RoomThreadPanel';
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
/** Module-level ref shared between ChatInputArea (writes) and RoomChatPanel (reads) */
const mentionConfirmRef = { current: (() => {}) as () => void };
export const mentionConfirmRef = { current: (() => {}) as () => void };
export interface ChatInputAreaHandle {
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
@ -76,7 +78,32 @@ const ChatInputArea = memo(function ChatInputArea({
const [cursorPosition, setCursorPosition] = useState(0);
const [showMentionPopover, setShowMentionPopover] = useState(false);
const handleMentionSelect = useCallback((newValue: string, newCursorPos: number) => {
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);
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 suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
if (!suggestion || suggestion.type !== 'item') return;
const html = buildMentionHtml(
suggestion.category!,
suggestion.mentionId!,
suggestion.label,
);
const spacer = ' ';
const newValue = before + html + spacer + after;
const newCursorPos = startPos + html.length + spacer.length;
onDraftChange(newValue);
setShowMentionPopover(false);
setTimeout(() => {
@ -86,7 +113,8 @@ const ChatInputArea = memo(function ChatInputArea({
textareaRef.current.focus();
}
}, 0);
}, [onDraftChange]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentionally use refs, not state
useImperativeHandle(ref, () => ({
insertMention: (id: string, label: string) => {
@ -126,7 +154,12 @@ const ChatInputArea = memo(function ChatInputArea({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionPopover && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
// 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();
if ((e.key === 'Enter' || e.key === 'Tab') && onMentionConfirm) {
onMentionConfirm();
@ -206,7 +239,7 @@ const ChatInputArea = memo(function ChatInputArea({
onSelect={handleMentionSelect}
textareaRef={textareaRef}
onOpenChange={setShowMentionPopover}
onMentionConfirmRef={mentionConfirmRef}
onMentionConfirm={handleMentionSelect}
/>
)}
</div>
@ -576,7 +609,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
draft={draft}
onDraftChange={setDraft}
onClearDraft={clearDraft}
onMentionConfirm={() => mentionConfirmRef.current()}
onMentionConfirm={() => { console.log('[RoomChatPanel] onMentionConfirm called'); mentionConfirmRef.current(); }}
/>
</div>

9
src/lib/mention-refs.ts Normal file
View File

@ -0,0 +1,9 @@
import type { MentionSuggestion } from '@/components/room/MentionPopover';
/**
* Module-level refs shared between ChatInputArea and MentionPopover.
* Avoids stale closure / TDZ issues when components are defined in different
* parts of the same file.
*/
export const mentionSelectedIdxRef = { current: 0 };
export const mentionVisibleRef = { current: [] as MentionSuggestion[] };