From 3cd5b3003c810beb5bfcda86d5eb0572fa7a8da5 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 00:25:03 +0800 Subject: [PATCH] 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 --- src/components/room/MentionPopover.tsx | 6 ++++ src/components/room/RoomChatPanel.tsx | 47 ++++++++++++++++++++++---- src/lib/mention-refs.ts | 9 +++++ 3 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 src/lib/mention-refs.ts diff --git a/src/components/room/MentionPopover.tsx b/src/components/room/MentionPopover.tsx index b9d28aa..5b4c82c 100644 --- a/src/components/room/MentionPopover.tsx +++ b/src/components/room/MentionPopover.tsx @@ -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({ 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(() => { diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index da31b7f..4411e15 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -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) => { - 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} /> )} @@ -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(); }} /> diff --git a/src/lib/mention-refs.ts b/src/lib/mention-refs.ts new file mode 100644 index 0000000..f20dadd --- /dev/null +++ b/src/lib/mention-refs.ts @@ -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[] };