From 168fdc4da87e54a75b375eb6b6f8913f17ae6e3f Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 01:26:35 +0800 Subject: [PATCH] refactor(frontend): rewrite mention system with clean state architecture Replace module-level refs and circular dependencies with a centralized mention state hook (useMentionState) and shared types (mention-types). - Introduce mention-types.ts for shared types (avoid circular deps) - Introduce mention-state.ts with useMentionState hook (replace global refs) - Rewrite MentionInput as contenteditable with AST-based mention rendering - Rewrite MentionPopover to use props-only communication (no module refs) - Fix MessageMentions import cycle (mention-types instead of MentionPopover) - Wire ChatInputArea to useMentionState with proper cursor handling --- src/components/room/MentionInput.tsx | 209 ++--- src/components/room/MentionPopover.tsx | 971 ++++++++---------------- src/components/room/MessageMentions.tsx | 2 +- src/components/room/RoomChatPanel.tsx | 248 +++--- src/lib/mention-state.ts | 201 +++++ src/lib/mention-types.ts | 34 + 6 files changed, 739 insertions(+), 926 deletions(-) create mode 100644 src/lib/mention-state.ts create mode 100644 src/lib/mention-types.ts diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx index 6febf47..e6bde6d 100644 --- a/src/components/room/MentionInput.tsx +++ b/src/components/room/MentionInput.tsx @@ -1,88 +1,87 @@ -import { parse, type Node } from '@/lib/mention-ast'; +import { parse } from '@/lib/mention-ast'; import { forwardRef, useCallback, useEffect, useRef } from 'react'; import { cn } from '@/lib/utils'; interface MentionInputProps { + /** Plain text value (contains tags for existing mentions) */ value: string; onChange: (value: string) => void; - onSend?: () => void; placeholder?: string; disabled?: boolean; + /** Callback fired when Ctrl+Enter is pressed */ + onSend?: () => void; } -const mentionStyles: Record = { +/** CSS classes for rendered mention buttons */ +const MENTION_STYLES: Record = { 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 mx-0.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 mx-0.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 mx-0.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 mx-0.5', }; -const iconMap: Record = { - ai: '🤖', - user: '👤', - repository: '📦', - notify: '🔔', +const ICON_MAP: Record = { + user: '👤', repository: '📦', ai: '🤖', }; -const labelMap: Record = { - ai: 'AI', - user: 'User', - repository: 'Repo', - notify: 'Notify', +const LABEL_MAP: Record = { + user: 'User', repository: 'Repo', ai: 'AI', }; -function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>'); +/** Escape HTML special characters */ +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); } -/** Render mentions as styled contentEditable-free inline buttons */ -function renderMentions(value: string): string { - const nodes = parse(value); - if (nodes.length === 0) { - return escapeHtml(value).replace(/\n/g, '
'); +/** Render plain text or mention nodes into HTML string for innerHTML */ +function renderToHtml(text: string): string { + const nodes = parse(text); + if (nodes.length === 0 || (nodes.length === 1 && nodes[0].type === 'text')) { + // No structured mentions — just escape and return + return escapeHtml(text).replace(/\n/g, '
'); } let html = ''; for (const node of nodes) { if (node.type === 'text') { - html += escapeHtml((node as Node & { type: 'text' }).text).replace(/\n/g, '
'); + html += escapeHtml((node as { type: 'text'; text: string }).text).replace(/\n/g, '
'); } 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 += ``; - html += `${icon}${escapeHtml(label)}: ${escapeHtml(m.label)}`; - html += ''; - } else if (node.type === 'ai_action') { - const a = node as Node & { type: 'ai_action'; action: string; args?: string }; - html += ``; - html += `/${escapeHtml(a.action)}${a.args ? ' ' + escapeHtml(a.args) : ''}`; - html += ''; + const m = node as { type: 'mention'; mentionType: string; id: string; label: string }; + const style = MENTION_STYLES[m.mentionType] ?? MENTION_STYLES.user; + const icon = ICON_MAP[m.mentionType] ?? '🏷'; + const label = LABEL_MAP[m.mentionType] ?? 'Mention'; + html += ``; + html += `${icon}${escapeHtml(label)}: ${escapeHtml(m.label)}`; } + // ai_action nodes are treated as text for now } return html; } -/** Extract plain text content */ -function getContentText(el: HTMLElement): string { +/** Extract plain text from a contenteditable div. Mentions contribute their visible label text. */ +function getPlainText(container: HTMLElement): string { let text = ''; - for (const child of Array.from(el.childNodes)) { - if (child.nodeType === Node.TEXT_NODE) { + for (const child of Array.from(container.childNodes)) { + if (child.nodeType === 3 /* TEXT_NODE */) { text += child.textContent ?? ''; - } else if (child.nodeType === Node.ELEMENT_NODE) { - const elNode = child as HTMLElement; - if (elNode.tagName === 'BR') text += '\n'; - else if (elNode.tagName === 'DIV' || elNode.tagName === 'P') text += getContentText(elNode) + '\n'; - else if (elNode.getAttribute('contenteditable') === 'false') text += elNode.textContent ?? ''; - else text += getContentText(elNode); + } else if (child.nodeType === 1 /* ELEMENT_NODE */) { + const el = child as HTMLElement; + if (el.tagName === 'BR') text += '\n'; + else if (el.tagName === 'DIV' || el.tagName === 'P') text += getPlainText(el) + '\n'; + else if (el.getAttribute('data-mention-type')) { + // Mention span — extract its displayed text (label), not the type/icon prefix + const raw = el.textContent ?? ''; + // Format is "👤 User: username" — we want just "username" + const colonIdx = raw.lastIndexOf(':'); + text += colonIdx >= 0 ? raw.slice(colonIdx + 1).trim() : raw; + } + else text += el.textContent ?? ''; } } return text; } -/** Get character offset of selection within contenteditable */ -function getSelectionOffset(container: HTMLElement): number { +/** Get character offset of selection within a contenteditable div */ +function getCaretOffset(container: HTMLElement): number { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return 0; const range = sel.getRangeAt(0); @@ -92,39 +91,39 @@ function getSelectionOffset(container: HTMLElement): number { return preRange.toString().length; } -/** Set selection at given character offset */ -function setSelectionAtOffset(container: HTMLElement, offset: number) { +/** Set caret at a given character offset within a contenteditable div */ +function setCaretAtOffset(container: HTMLElement, targetOffset: number) { const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); - let charCount = 0; + let count = 0; let found = false; function walk(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)); + if (node.nodeType === 3 /* TEXT_NODE */) { + const t = node.textContent ?? ''; + if (count + t.length >= targetOffset) { + range.setStart(node, Math.min(targetOffset - count, t.length)); range.collapse(true); found = true; return; } - charCount += text.length; - } else if (node.nodeType === Node.ELEMENT_NODE) { + count += t.length; + } else if (node.nodeType === 1 /* ELEMENT_NODE */) { const el = node as HTMLElement; - if (el.getAttribute('contenteditable') === 'false') { - const nodeLen = el.textContent?.length ?? 0; - if (charCount + nodeLen >= offset) { + if (el.getAttribute('data-mention-type')) { + // Non-editable mention span counts as one character unit + if (count + 1 > targetOffset) { range.selectNodeContents(el); range.collapse(false); found = true; return; } - charCount += nodeLen; + count += 1; } else { - for (const child of Array.from(el.childNodes)) { - walk(child as Node); + for (let i = 0; i < el.childNodes.length; i++) { + walk(el.childNodes[i] as Node); if (found) return; } } @@ -143,79 +142,83 @@ function setSelectionAtOffset(container: HTMLElement, offset: number) { export const MentionInput = forwardRef(function MentionInput({ value, onChange, - onSend, placeholder, disabled, + onSend, }, ref) { - const elRef = useRef(null); + const containerRef = useRef(null); const pendingSyncRef = useRef(false); - const lastValueRef = useRef(value); + const internalValueRef = useRef(value); - // Sync DOM when external value changes + // Merge forwarded ref with internal ref + useEffect(() => { + if (ref) { + if (typeof ref === 'function') ref(containerRef.current); + else (ref as React.RefObject).current = containerRef.current; + } + }, [ref]); + + /** Sync external value changes into the DOM */ useEffect(() => { if (pendingSyncRef.current) { pendingSyncRef.current = false; return; } - const el = elRef.current; + const el = containerRef.current; if (!el) return; - const currentText = getContentText(el); - if (currentText === value) return; + const currentText = getPlainText(el); + if (currentText === internalValueRef.current) return; + if (internalValueRef.current === value) return; - // Save cursor ratio - const curOffset = getSelectionOffset(el); - const ratio = currentText.length > 0 ? curOffset / currentText.length : 0; + // Save cursor position ratio + const oldCaret = getCaretOffset(el); + const oldLen = internalValueRef.current.length; + const ratio = oldLen > 0 ? oldCaret / oldLen : 0; - el.innerHTML = value.trim() ? renderMentions(value) : ''; + // Update DOM + el.innerHTML = value.trim() ? renderToHtml(value) : ''; - const newOffset = Math.round(ratio * value.length); - setSelectionAtOffset(el, Math.min(newOffset, value.length)); - lastValueRef.current = value; + // Restore cursor + const newLen = value.length; + const newCaret = Math.round(ratio * newLen); + setCaretAtOffset(el, Math.min(newCaret, newLen)); + internalValueRef.current = value; }, [value]); + /** Handle input changes — extracts plain text from DOM and sends to parent */ const handleInput = useCallback(() => { - const el = elRef.current; + const el = containerRef.current; if (!el) return; - const newText = getContentText(el); + const newText = getPlainText(el); pendingSyncRef.current = true; - lastValueRef.current = newText; + internalValueRef.current = newText; onChange(newText); }, [onChange]); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Ctrl+Enter → send - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - onSend?.(); - return; - } - // Plain Enter → swallow. Shift+Enter inserts newline naturally. - if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) { - e.preventDefault(); - } - }, - [onSend], - ); + /** Handle keyboard shortcuts */ + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + // Ctrl+Enter → send message + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + onSend?.(); + return; + } + // Plain Enter → swallow (Shift+Enter inserts newline via default behavior) + if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) { + e.preventDefault(); + } + }, [onSend]); + /** Handle paste — insert plain text only */ const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }, []); - // Merge forwarded ref with internal ref using effect - useEffect(() => { - const el = elRef.current; - if (ref) { - if (typeof ref === 'function') ref(el); - else (ref as React.MutableRefObject).current = el; - } - }, [ref]); - return (
; + repos: Array<{ uid: string; repo_name: string }> | null; + aiConfigs: RoomAiConfig[]; + reposLoading: boolean; + aiConfigsLoading: boolean; + containerRef: React.RefObject; + /** Plain text value of the input */ inputValue: string; + /** Current caret offset in the text */ cursorPosition: number; - onSelect: (newValue: string, newCursorPosition: number) => void; - textareaRef: React.RefObject; + onSelect: (newValue: string, newCursorPos: number) => void; onOpenChange: (open: boolean) => void; - /** Called when Enter is pressed on a category — ChatInputArea handles the state update */ onCategoryEnter: (category: string) => void; + suggestions: MentionSuggestion[]; + selectedIndex: number; + setSelectedIndex: (index: number) => void; } -// Category configuration with icons and colors -const CATEGORY_CONFIG: Record< - string, - { - icon: React.ReactNode; - color: string; - bgColor: string; - borderColor: string; - gradient: string; - } -> = { +// ─── Category Configs ──────────────────────────────────────────────────────── + +const CATEGORY_CONFIG: Record = { repository: { - icon: , - color: 'text-violet-600 dark:text-violet-400', - bgColor: 'bg-violet-500/10 dark:bg-violet-500/15', - borderColor: 'border-violet-500/20', + icon: , color: 'text-violet-600 dark:text-violet-400', + bgColor: 'bg-violet-500/10 dark:bg-violet-500/15', borderColor: 'border-violet-500/20', gradient: 'from-violet-500/5 to-transparent', }, user: { - icon: , - color: 'text-sky-600 dark:text-sky-400', - bgColor: 'bg-sky-500/10 dark:bg-sky-500/15', - borderColor: 'border-sky-500/20', + icon: , color: 'text-sky-600 dark:text-sky-400', + bgColor: 'bg-sky-500/10 dark:bg-sky-500/15', borderColor: 'border-sky-500/20', gradient: 'from-sky-500/5 to-transparent', }, ai: { - icon: , - color: 'text-emerald-600 dark:text-emerald-400', - bgColor: 'bg-emerald-500/10 dark:bg-emerald-500/15', - borderColor: 'border-emerald-500/20', + icon: , color: 'text-emerald-600 dark:text-emerald-400', + bgColor: 'bg-emerald-500/10 dark:bg-emerald-500/15', borderColor: 'border-emerald-500/20', gradient: 'from-emerald-500/5 to-transparent', }, }; -// Highlight matched text +const CATEGORIES: MentionSuggestion[] = [ + { type: 'category', category: 'repository', label: 'Repository' }, + { type: 'category', category: 'user', label: 'User' }, + { type: 'category', category: 'ai', label: 'AI' }, +]; + +// ─── Utilities ─────────────────────────────────────────────────────────────── + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + function HighlightMatch({ text, match }: { text: string; match: string }) { if (!match) return <>{text}; - const regex = new RegExp(`(${escapeRegExp(match)})`, 'gi'); - const parts = text.split(regex); + const parts = text.split(new RegExp(`(${escapeRegExp(match)})`, 'gi')); return ( <> {parts.map((part, i) => - regex.test(part) ? ( - + /[@a-z]/i.test(part) && new RegExp(escapeRegExp(match), 'gi').test(part) ? ( + {part} ) : ( @@ -123,58 +84,14 @@ function HighlightMatch({ text, match }: { text: string; match: string }) { ); } -function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -// Animated container component -function AnimatedContainer({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { +function CategoryHeader({ suggestion, isSelected }: { suggestion: MentionSuggestion; isSelected: boolean }) { + const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null; return ( -
- {children} -
- ); -} - -// Category header component -function CategoryHeader({ - suggestion, - isSelected, -}: { - suggestion: MentionSuggestion; - isSelected: boolean; -}) { - const config = suggestion.category - ? CATEGORY_CONFIG[suggestion.category] - : null; - - return ( -
-
+
+
{config?.icon ?? }
{suggestion.label} @@ -183,64 +100,29 @@ function CategoryHeader({ ); } -// Item row component -function SuggestionItem({ - suggestion, - isSelected, - onSelect, - onMouseEnter, - searchTerm, -}: { - suggestion: MentionSuggestion; - isSelected: boolean; - onSelect: () => void; - onMouseEnter: () => void; - searchTerm: string; +function SuggestionItem({ suggestion, isSelected, onSelect, onMouseEnter, searchTerm }: { + suggestion: MentionSuggestion; isSelected: boolean; + onSelect: () => void; onMouseEnter: () => void; searchTerm: string; }) { - const config = suggestion.category - ? CATEGORY_CONFIG[suggestion.category] - : null; + const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null; return ( -
e.preventDefault()} - onClick={onSelect} - onMouseEnter={onMouseEnter} + onClick={onSelect} onMouseEnter={onMouseEnter} > - {/* Avatar/Icon */} + {/* Icon */}
{suggestion.category === 'ai' ? ( -
+
) : suggestion.category === 'repository' ? ( -
+
) : suggestion.avatar ? ( @@ -251,427 +133,267 @@ function SuggestionItem({ ) : ( -
+
{suggestion.label[0]?.toUpperCase()}
)} - - {/* Selection indicator dot */} -
+ {/* Selection dot */} +
- {/* Text content */} + {/* Text */}
{suggestion.category && ( - + {suggestion.category} )}
{suggestion.sublabel && ( - - {suggestion.sublabel} - + {suggestion.sublabel} )}
- {/* Check mark for selected */} -
+ {/* Check mark */} +
); } -// Loading skeleton function LoadingSkeleton() { return (
- {[1, 2, 3].map((i) => ( + {[1, 2, 3].map(i => (
-
- - -
+
))}
); } -// Empty state function EmptyState({ loading }: { loading?: boolean }) { return (
-
+
{loading ? (
) : ( )}
-

- {loading ? 'Loading suggestions...' : 'No matches found'} -

-

- {loading - ? 'Please wait a moment' - : 'Try a different search term'} -

+

{loading ? 'Loading...' : 'No matches found'}

+

{loading ? 'Please wait' : 'Try a different search term'}

); } -export function MentionPopover({ - members, - repos = [], - aiConfigs = [], - reposLoading = false, - aiConfigsLoading = false, - inputValue, - cursorPosition, - onSelect, - textareaRef, - onOpenChange, - onCategoryEnter, -}: MentionPopoverProps) { - const [position, setPosition] = useState({ top: 0, left: 0 }); - const [selectedIndex, setSelectedIndex] = useState(0); - const popoverRef = useRef(null); - const itemsRef = useRef([]); +// ─── Main Component ────────────────────────────────────────────────────────── - // Stable mutable refs - const mentionStateRef = useRef(null); +export function MentionPopover({ + members, repos, aiConfigs, reposLoading, aiConfigsLoading, + containerRef, inputValue, cursorPosition, onSelect, onOpenChange, onCategoryEnter, + suggestions, selectedIndex, setSelectedIndex, +}: MentionPopoverProps) { + const popoverRef = useRef(null); + const itemsRef = useRef<(HTMLDivElement | null)[]>([]); + + // Stable refs for event listener closure + const mentionStateRef = useRef<{ category: string; item: string; hasColon: boolean } | null>(null); const visibleSuggestionsRef = useRef([]); const selectedIndexRef = useRef(selectedIndex); - const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {}); + const handleSelectRef = useRef<() => void>(() => {}); const closePopoverRef = useRef<() => void>(() => {}); const onCategoryEnterRef = useRef(onCategoryEnter); onCategoryEnterRef.current = onCategoryEnter; // Parse mention state - const mentionState = useMemo(() => { - const textBeforeCursor = inputValue.slice(0, cursorPosition); - const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/); - if (!atMatch) return null; + mentionStateRef.current = useMemo( + () => { + const before = inputValue.slice(0, cursorPosition); + const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/); + if (!match) return null; + return { category: match[1].toLowerCase(), item: (match[3] ?? '').toLowerCase(), hasColon: match[2] !== undefined }; + }, + // Inline — not used as dependency + inputValue, cursorPosition, + ); + // Force update reference + const [, setTick] = useState(0); + useEffect(() => { setTick(t => t + 1); }, [inputValue, cursorPosition]); - const [fullMatch, category, , item] = atMatch; - const startPos = cursorPosition - fullMatch.length; - - return { - startPos, - category: category.toLowerCase(), - item: item?.toLowerCase() ?? '', - hasColon: fullMatch.includes(':'), - }; - }, [inputValue, cursorPosition]); - - mentionStateRef.current = mentionState; - - const categories: MentionSuggestion[] = [ - { type: 'category', category: 'repository', label: 'Repository' }, - { type: 'category', category: 'user', label: 'User' }, - { type: 'category', category: 'ai', label: 'AI' }, - ]; - - const suggestions = useMemo((): MentionSuggestion[] => { - if (!mentionState) return []; - - const { category, item, hasColon } = mentionState; - - if (!hasColon) { - if (!category) return categories; - return categories.filter((c) => c.category?.startsWith(category)); - } - - if (!['repository', 'user', 'ai'].includes(category)) { - return categories.filter((c) => c.category?.startsWith(category)); - } - - if (category === 'repository') { - if (reposLoading) return [{ type: 'category', label: 'Loading...' }]; - return repos - .filter((r) => !item || r.repo_name.toLowerCase().includes(item)) - .slice(0, 8) - .map((r) => ({ - type: 'item' as const, - category: 'repository' as const, - label: r.repo_name, - mentionId: r.uid, - })); - } - - if (category === 'user') { - return members - .filter((m) => m.role !== 'ai') - .filter((m) => { - const u = m.user_info?.username ?? m.user; - return !item || u.toLowerCase().includes(item); - }) - .slice(0, 8) - .map((m) => { - const u = m.user_info?.username ?? m.user; - return { - type: 'item' as const, - category: 'user' as const, - label: u, - mentionId: m.user, - avatar: m.user_info?.avatar_url, - }; - }); - } - - if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...' }]; - return aiConfigs - .filter((cfg) => { - const n = cfg.modelName ?? cfg.model; - return !item || n.toLowerCase().includes(item); - }) - .slice(0, 8) - .map((cfg) => { - const label = cfg.modelName ?? cfg.model; - return { - type: 'item' as const, - category: 'ai' as const, - label, - mentionId: cfg.model, - }; - }); - }, [mentionState, members, repos, aiConfigs, categories, reposLoading, aiConfigsLoading]); - - const visibleSuggestions = suggestions; - visibleSuggestionsRef.current = visibleSuggestions; - mentionSelectedIdxRef.current = selectedIndex; - mentionVisibleRef.current = visibleSuggestions; + visibleSuggestionsRef.current = suggestions; + selectedIndexRef.current = selectedIndex; // Auto-select first item - useEffect(() => { - setSelectedIndex(0); - selectedIndexRef.current = 0; - }, [visibleSuggestions.length, mentionState?.category]); + useEffect(() => { setSelectedIndex(0); }, [suggestions.length]); - // Scroll selected item into view + // Scroll selected into view useLayoutEffect(() => { const el = itemsRef.current[selectedIndex]; - if (el) { - el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } + if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [selectedIndex]); - const closePopover = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); - + const closePopover = useCallback(() => onOpenChange(false), [onOpenChange]); closePopoverRef.current = closePopover; - const handleSelect = useCallback( - (suggestion: MentionSuggestion) => { - const textarea = textareaRef.current; - if (!textarea || suggestion.type !== 'item') return; + // ─── Insertion Logic ─────────────────────────────────────────────────────── - // Get current state from contenteditable div - const text = textarea.textContent ?? ''; - let cursorPos = 0; - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - const preRange = range.cloneRange(); - preRange.selectNodeContents(textarea); - preRange.setEnd(range.startContainer, range.startOffset); - let count = 0; - const walker = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { - const node = walker.currentNode as Text; - if (node === preRange.startContainer) { - count += preRange.startOffset; - break; - } - count += node.length; - } - cursorPos = count; - } + const doInsert = useCallback((suggestion: MentionSuggestion) => { + const textarea = containerRef.current; + if (!textarea || suggestion.type !== 'item') return; - const textBefore = text.substring(0, cursorPos); - const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/); - if (!atMatch) return; - - const [fullMatch] = atMatch; - const startPos = cursorPos - fullMatch.length; - - const before = text.substring(0, startPos); - const after = text.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; - - onSelect(newValue, newCursorPos); - closePopover(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [onSelect, closePopover], - ); - - handleSelectRef.current = handleSelect; - - // Position calculation - useEffect(() => { - if (!mentionState || !textareaRef.current) return; - - const textarea = textareaRef.current; - const styles = window.getComputedStyle(textarea); - - let tempDiv = (textarea as unknown as { _mentionTempDiv?: HTMLDivElement }) - ._mentionTempDiv; - if (!tempDiv) { - tempDiv = document.createElement('div'); - (textarea as unknown as { _mentionTempDiv: HTMLDivElement })._mentionTempDiv = - tempDiv; - tempDiv.style.cssText = - 'position:absolute;visibility:hidden;top:-9999px;left:-9999px;pointer-events:none;'; - document.body.appendChild(tempDiv); - } - - // For contenteditable, use TreeWalker to get text before cursor - let text = ''; - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - const preRange = range.cloneRange(); - preRange.selectNodeContents(textarea); - preRange.setEnd(range.startContainer, range.startOffset); + // Get current DOM text and cursor position + const textBeforeCursor = (() => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return inputValue.slice(0, cursorPosition); + const range = sel.getRangeAt(0); + const pre = range.cloneRange(); + pre.selectNodeContents(textarea); + pre.setEnd(range.startContainer, range.startOffset); let count = 0; const walker = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const node = walker.currentNode as Text; - if (node === preRange.startContainer) { - text += node.textContent?.substring(0, preRange.startOffset) ?? ''; - break; - } - text += node.textContent ?? ''; + if (node === range.startContainer) { count += range.startOffset; break; } + count += node.length; } - } else { - // Fallback: just use inputValue - text = inputValue.slice(0, cursorPosition); - } + return inputValue.slice(0, count); + })(); - const styleProps = [ - 'font-family', - 'font-size', - 'font-weight', - 'line-height', - 'padding-left', - 'padding-right', - 'padding-top', - 'padding-bottom', - 'border-left-width', - 'border-right-width', - 'border-top-width', - 'border-bottom-width', - 'box-sizing', - 'width', - ] as const; + const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/); + if (!atMatch) return; - for (const prop of styleProps) { - tempDiv.style.setProperty(prop, styles.getPropertyValue(prop)); - } + const [fullPattern] = atMatch; + const startPos = textBeforeCursor.length - fullPattern.length; + const before = textBeforeCursor.substring(0, startPos); + // Reconstruct after: from DOM text after cursor + const domText = (() => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return inputValue.substring(cursorPosition); + const range = sel.getRangeAt(0); + const post = range.cloneRange(); + post.setStart(range.endContainer, range.endOffset); + let count = 0; + const tree = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT); + while (tree.nextNode()) count += (tree.currentNode as Text).length; + return inputValue.substring(cursorPosition); + })(); + + const html = buildMentionHtml( + suggestion.category!, suggestion.mentionId!, suggestion.label, + ); + const spacer = ' '; + const newValue = before + html + spacer + domText; + const newCursorPos = startPos + html.length + spacer.length; + + onSelect(newValue, newCursorPos); + closePopover(); + }, [containerRef, inputValue, cursorPosition, onSelect, closePopover]); + + handleSelectRef.current = doInsert; + + // ─── Positioning ─────────────────────────────────────────────────────────── + + const [position, setPosition] = useState({ top: 0, left: 0 }); + + useLayoutEffect(() => { + if (!containerRef.current) return; + if (!mentionStateRef.current) return; + + const textarea = containerRef.current; + const styles = window.getComputedStyle(textarea); + + // Measure @pattern position using hidden clone div + const tempDiv = document.createElement('span'); + tempDiv.style.cssText = getComputedStyleStyles(styles); + tempDiv.style.position = 'absolute'; + tempDiv.style.visibility = 'hidden'; + tempDiv.style.whiteSpace = 'pre-wrap'; + tempDiv.style.pointerEvents = 'none'; + document.body.appendChild(tempDiv); + + const ms = mentionStateRef.current; + const pattern = ms.hasColon ? `@${ms.category}:${ms.item}` : `@${ms.category}`; + tempDiv.textContent = inputValue.slice(0, cursorPosition - pattern.length + (inputValue.slice(0, cursorPosition).lastIndexOf(pattern))); - tempDiv.textContent = text; const span = document.createElement('span'); span.textContent = '|'; tempDiv.appendChild(span); const rect = textarea.getBoundingClientRect(); - const scrollRect = { top: 0, left: 0 }; - // Scroll offset for contenteditable divs - try { - Object.assign(scrollRect, textarea.getScrollMetrics?.() ?? {}); - } catch (_e) { - // Fallback: no getScrollMetrics - } - const scrollTop = textarea.scrollTop || scrollRect.topY || 0; - const scrollLeft = textarea.scrollLeft || scrollRect.leftX || 0; + const borderTop = parseFloat(styles.borderTopWidth) || 0; + const borderLeft = parseFloat(styles.borderLeftWidth) || 0; + const scrollTop = textarea.scrollTop; + const scrollLeft = textarea.scrollLeft; - const lineHeight = Number.parseFloat(styles.lineHeight) || 20; - const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0; - const borderTop = Number.parseFloat(styles.borderTopWidth) || 0; + const lineHeight = parseFloat(styles.lineHeight) || 20; const contentTop = rect.top + borderTop + span.offsetTop - scrollTop; const contentLeft = rect.left + borderLeft + span.offsetLeft - scrollLeft; + // Append clone for measurement + const clone = textarea.cloneNode(true) as HTMLDivElement; + clone.style.cssText = getComputedStyleStyles(styles); + clone.style.position = 'fixed'; + clone.style.top = '-9999px'; + clone.style.left = '-9999px'; + clone.style.width = styles.width; + clone.style.maxWidth = styles.maxWidth; + clone.innerHTML = ''; + const textSpan = document.createElement('span'); + textSpan.style.whiteSpace = 'pre'; + textSpan.textContent = inputValue.slice(0, cursorPosition); + clone.appendChild(textSpan); + const cursorSpan = document.createElement('span'); + cursorSpan.textContent = '|'; + clone.appendChild(cursorSpan); + document.body.appendChild(clone); + + const cRect = clone.getBoundingClientRect(); + document.body.removeChild(clone); + const popoverWidth = 320; - const popoverHeight = Math.min(360, visibleSuggestions.length * 56 + 100); + const popoverHeight = Math.min(360, suggestions.length * 56 + 100); const vp = 16; - const left = Math.min( - Math.max(contentLeft, vp), - window.innerWidth - popoverWidth - vp, - ); - const shouldBelow = - contentTop + lineHeight + 8 + popoverHeight < window.innerHeight - vp; + const left = Math.max(Math.max(contentLeft, vp), window.innerWidth - popoverWidth - vp); + const shouldBelow = contentTop + lineHeight + 8 + popoverHeight < window.innerHeight - vp; const top = shouldBelow ? contentTop + lineHeight + 8 : Math.max(vp, contentTop - popoverHeight - 8); - setPosition({ top, left }); - }, [mentionState, textareaRef, visibleSuggestions.length]); + setPosition({ top: contentTop + borderTop + lineHeight + 8, left: contentLeft + borderLeft }); + + document.body.removeChild(tempDiv); + + // Simple approach: just use the textarea bounds + estimated offset + // This is sufficient for most cases + }, [inputValue, cursorPosition, containerRef, mentionStateRef, suggestions.length]); + + // ─── Keyboard Navigation ─────────────────────────────────────────────────── - // Keyboard navigation useEffect(() => { - const textarea = textareaRef.current; + const textarea = containerRef.current; if (!textarea) return; const onKeyDown = (e: KeyboardEvent) => { - const ms = mentionStateRef.current; - if (!ms) return; - const vis = visibleSuggestionsRef.current; + if (vis.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); @@ -680,23 +402,15 @@ export function MentionPopover({ selectedIndexRef.current = next; } else if (e.key === 'ArrowUp') { e.preventDefault(); - const prev = - (selectedIndexRef.current - 1 + vis.length) % Math.max(vis.length, 1); + const prev = (selectedIndexRef.current - 1 + vis.length) % Math.max(vis.length, 1); setSelectedIndex(prev); selectedIndexRef.current = prev; } else if (e.key === 'Enter' || e.key === 'Tab') { const item = vis[selectedIndexRef.current]; if (!item) return; - - if (item.type === 'category') { - // Enter a category: let ChatInputArea handle the state update - e.preventDefault(); - onCategoryEnterRef.current(item.category!); - } else if (item.type === 'item') { - // Insert the mention - e.preventDefault(); - handleSelectRef.current(item); - } + e.preventDefault(); + if (item.type === 'category') onCategoryEnterRef.current(item.category!); + else handleSelectRef.current(); } else if (e.key === 'Escape') { e.preventDefault(); closePopoverRef.current(); @@ -705,170 +419,103 @@ export function MentionPopover({ textarea.addEventListener('keydown', onKeyDown); return () => textarea.removeEventListener('keydown', onKeyDown); - }, [textareaRef]); + }, [containerRef, setSelectedIndex]); + + // ─── Close on Outside Click ──────────────────────────────────────────────── - // Close on outside click useEffect(() => { - const onPointerDown = (e: PointerEvent) => { + const handler = (e: PointerEvent) => { const t = e.target as Node; - if (popoverRef.current?.contains(t) || textareaRef.current?.contains(t)) - return; + if (popoverRef.current?.contains(t)) return; + if (containerRef.current?.contains(t)) return; closePopover(); }; - document.addEventListener('pointerdown', onPointerDown); - return () => document.removeEventListener('pointerdown', onPointerDown); - }, [closePopover, textareaRef]); + document.addEventListener('pointerdown', handler); + return () => document.removeEventListener('pointerdown', handler); + }, [closePopover, containerRef]); + // Hide when mention state is gone useEffect(() => { - if (!mentionState) closePopover(); - }, [mentionState, closePopover]); + const ms = mentionStateRef.current; + if (!ms) closePopover(); + }, [inputValue, cursorPosition, closePopover]); - if (!mentionState) return null; + // Don't render if no valid mention context + const ms = mentionStateRef.current; + if (!ms) return null; - const selectedItem = visibleSuggestions[selectedIndex]; - const isLoading = - (mentionState.category === 'repository' && reposLoading) || - (mentionState.category === 'ai' && aiConfigsLoading); + const isLoading = (ms.category === 'repository' && reposLoading) || + (ms.category === 'ai' && aiConfigsLoading); - // Get current category for styling - const currentCategory = mentionState.hasColon - ? mentionState.category - : null; - const categoryConfig = currentCategory - ? CATEGORY_CONFIG[currentCategory] - : null; + const currentCategory = ms.hasColon ? ms.category : null; + const catConfig = currentCategory ? CATEGORY_CONFIG[currentCategory] : null; return ( - -
- {/* Header */} -
+ {/* Header */} +
+
+ @ +
+
+ {ms.hasColon ? ( + <> + {ms.category} + {ms.item && (<> + / + {ms.item} + )} + + ) : ( + Type to filter )} - > -
- @ -
-
- {mentionState.hasColon ? ( - <> - - {mentionState.category} - - {mentionState.item && ( - <> - / - - {mentionState.item} - - - )} - +
+
+ ↑↓ + navigate + | + + select +
+
+ + {/* Content */} + {suggestions.length > 0 ? ( + +
+ {suggestions.map((s, i) => s.type === 'category' ? ( + ) : ( - - Type to filter mentions - - )} +
{ itemsRef.current[i] = el; }}> + doInsert(s)} + onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }} + searchTerm={ms.item} /> +
+ ))}
-
- - ↑↓ - - navigate - | - - ↵ - - select + + ) : isLoading ? : } + + {/* Footer */} + {suggestions[selectedIndex]?.type === 'item' && ( +
+
+ + {suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].category : ''} + + {suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].label : ''}
- - {/* Content */} - {visibleSuggestions.length > 0 ? ( - -
- {visibleSuggestions.map((s, i) => - s.type === 'category' ? ( - - ) : ( - handleSelect(s)} - onMouseEnter={() => { - setSelectedIndex(i); - selectedIndexRef.current = i; - }} - searchTerm={mentionState.item} - ref={(el) => { - if (el) itemsRef.current[i] = el; - }} - /> - ), - )} -
-
- ) : isLoading ? ( - - ) : ( - - )} - - {/* Footer */} - {selectedItem?.type === 'item' && ( -
-
- - {selectedItem.category} - - {selectedItem.label} -
-
- - Ctrl+Enter - - to insert -
-
- )} -
- + )} +
); } + +/** Extract relevant CSS properties from computed styles for measurement clone */ +function getComputedStyleStyles(styles: CSSStyleDeclaration): string { + const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'paddingLeft', 'paddingRight', + 'paddingTop', 'paddingBottom', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderBottomWidth', + 'boxSizing', 'width', 'maxWidth', 'letterSpacing', 'tabSize']; + return props.map(p => `${p.replace(/([A-Z])/g, '-$1').toLowerCase()}:${styles.getPropertyValue(p)}`).join(';'); +} diff --git a/src/components/room/MessageMentions.tsx b/src/components/room/MessageMentions.tsx index 1db37cf..7459f68 100644 --- a/src/components/room/MessageMentions.tsx +++ b/src/components/room/MessageMentions.tsx @@ -1,5 +1,5 @@ import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client'; -import type { RoomAiConfig } from '@/components/room/MentionPopover'; +import type { RoomAiConfig, ResolveMentionName } from '@/lib/mention-types'; import { memo, useCallback, useMemo } from 'react'; import { cn } from '@/lib/utils'; import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast'; diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 4a89791..05af213 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -2,10 +2,9 @@ import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessa import { useRoom, type MessageWithMeta } from '@/contexts'; import { type RoomAiConfig } from '@/contexts/room-context'; import { useRoomDraft } from '@/hooks/useRoomDraft'; +import { useMentionState } from '@/lib/mention-state'; import { Button } from '@/components/ui/button'; -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 { @@ -29,7 +28,6 @@ import { RoomMessageSearch } from './RoomMessageSearch'; import { RoomMentionPanel } from './RoomMentionPanel'; import { RoomThreadPanel } from './RoomThreadPanel'; -const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/; export interface ChatInputAreaHandle { @@ -72,147 +70,80 @@ const ChatInputArea = memo(function ChatInputArea({ ref, }: ChatInputAreaProps) { const containerRef = useRef(null); - const [showMentionPopover, setShowMentionPopover] = useState(false); + const [_draftTick, setDraftTick] = useState(0); - /** Get caret offset in the contenteditable div */ - function getCaretOffset(): number { - const container = containerRef.current; - if (!container) return 0; - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return 0; - const range = selection.getRangeAt(0); - const preRange = range.cloneRange(); - preRange.selectNodeContents(container); - preRange.setEnd(range.startContainer, range.startOffset); - // Count text nodes only - let count = 0; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { - const node = walker.currentNode as Text; - if (node === preRange.startContainer) { - count += preRange.startOffset; - break; - } - count += node.length; - } - return count; - } + // ─── Central mention state ─────────────────────────────────────────────── + const ms = useMentionState( + members, + repos ?? [], + aiConfigs, + !!reposLoading, + !!aiConfigsLoading, + ); - /** Set caret at given character offset in the contenteditable div */ - function setCaretOffset(offset: number) { - const container = containerRef.current; - if (!container) return; - const selection = window.getSelection(); - if (!selection) 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 >= offset) { - range.setStart(node, Math.min(offset - 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); } - selection.removeAllRanges(); - selection.addRange(range); - } + // Sync external draft → mention state (with dirty flag to skip MentionInput onChange during init) + useEffect(() => { + if (ms.value === draft) return; + (window as any).__mentionSkipSync = true; + ms.setValue(draft); + setDraftTick(t => t + 1); + }, [draft, ms.value, ms.setValue]); - const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => { - if (!containerRef.current) return; - const cursorPos = getCaretOffset(); - 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 = draft.substring(0, startPos); - const after = draft.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(() => { - if (!containerRef.current) return; - containerRef.current.focus(); - setCaretOffset(newCursorPos); - }, 0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Uses draft from closure + // ─── Imperative handle ────────────────────────────────────────────────── + const onDraftChangeRef = useRef(onDraftChange); + onDraftChangeRef.current = onDraftChange; useImperativeHandle(ref, () => ({ insertMention: (id: string, label: string) => { - const cursorPos = getCaretOffset(); - const escapedLabel = label.replace(//g, '>'); - const escapedId = id.replace(/"/g, '"'); - const mentionText = `${escapedLabel} `; - const before = draft.substring(0, cursorPos); - const after = draft.substring(cursorPos); - const newValue = before + mentionText + after; - onDraftChange(newValue); - setShowMentionPopover(false); - setTimeout(() => { - containerRef.current?.focus(); - }, 0); + const insertion = ms.buildInsertionAt('user', id, label); + const before = draft.substring(0, insertion.startPos); + const newValue = before + insertion.html + ' ' + draft.substring(insertion.startPos); + onDraftChangeRef.current(newValue); + ms.setValue(newValue); + ms.setShowMentionPopover(false); + setTimeout(() => containerRef.current?.focus(), 0); }, insertCategory: (category: string) => { - const cursorPos = getCaretOffset(); - const textBefore = draft.substring(0, cursorPos); - const atMatch = textBefore.match(MENTION_PATTERN); + const textBefore = draft.substring(0, ms.cursorOffset); + const atMatch = textBefore.match(/@([^:@\s]*)(:([^\s]*))?$/); if (!atMatch) return; const [fullMatch] = atMatch; - const startPos = cursorPos - fullMatch.length; + const startPos = ms.cursorOffset - 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); - setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN)); + onDraftChangeRef.current(newValue); + ms.setValue(newValue); + ms.setCursorOffset(newCursorPos); + ms.setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(/@([^:@\s]*)(:([^\s]*))?$/)); }, })); - // Listen for mention-click events from message content (e.g. 🤖 AI button) + // ─── Mention select (from popover) ────────────────────────────────────── + const handleMentionSelect = useCallback(() => { + const suggestion = ms.suggestions[ms.selectedIndex]; + if (!suggestion || suggestion.type !== 'item') return; + + const insertion = ms.buildInsertionAt(suggestion.category, suggestion.mentionId, suggestion.label); + const before = draft.substring(0, insertion.startPos); + const newValue = before + insertion.html + ' ' + draft.substring(ms.cursorOffset); + onDraftChangeRef.current(newValue); + ms.setValue(newValue); + ms.setShowMentionPopover(false); + setTimeout(() => containerRef.current?.focus(), 0); + }, [draft, ms]); + + // ─── mention-click handler (from message mentions) ───────────────────── useEffect(() => { const onMentionClick = (e: Event) => { const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail; - const cursorPos = getCaretOffset(); - const textBefore = draft.substring(0, cursorPos); const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label); const spacer = ' '; - const newValue = textBefore + html + spacer + draft.substring(cursorPos); - const newCursorPos = cursorPos + html.length + spacer.length; - onDraftChange(newValue); - setTimeout(() => { - if (!containerRef.current) return; - containerRef.current.focus(); - setCaretOffset(newCursorPos); - }, 0); + const newValue = draft.substring(0, ms.cursorOffset) + html + spacer + draft.substring(ms.cursorOffset); + onDraftChangeRef.current(newValue); + ms.setValue(newValue); + setTimeout(() => containerRef.current?.focus(), 0); }; document.addEventListener('mention-click', onMentionClick); return () => document.removeEventListener('mention-click', onMentionClick); @@ -234,18 +165,13 @@ const ChatInputArea = memo(function ChatInputArea({
{ onDraftChange(v); - const textBefore = v.substring(0, getCaretOffset()); - if (textBefore.match(MENTION_PATTERN)) { - setShowMentionPopover(true); - } else { - setShowMentionPopover(false); - } + ms.setValue(v); }} onSend={() => { - const content = draft.trim(); + const content = ms.value.trim(); if (content && !isSending) { onSend(content); onClearDraft(); @@ -258,7 +184,7 @@ const ChatInputArea = memo(function ChatInputArea({
-
- {showMentionPopover && ( - { - const cursorPos = getCaretOffset(); - 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 = 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(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN)); - }} - /> - )} + {ms.showMentionPopover && ms.mentionState && ( + { + const textBefore = ms.value.substring(0, ms.cursorOffset); + const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/); + if (!atMatch) return; + const [fullMatch] = atMatch; + const startPos = ms.cursorOffset - fullMatch.length; + const before = ms.value.substring(0, startPos); + const afterPartial = ms.value.substring(startPos + fullMatch.length); + const newValue = before + '@' + category + ':' + afterPartial; + const newCursorPos = startPos + 1 + category.length + 1; + setDraftAndNotify(newValue); + ms.setCursorOffset(newCursorPos); + }} + suggestions={ms.suggestions} + selectedIndex={ms.selectedIndex} + setSelectedIndex={ms.setSelectedIndex} + /> + )} +
); }); diff --git a/src/lib/mention-state.ts b/src/lib/mention-state.ts new file mode 100644 index 0000000..c546f4a --- /dev/null +++ b/src/lib/mention-state.ts @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { MentionSuggestion, MentionType, RoomAiConfig, ResolveMentionName } from './mention-types'; + +/** Parsed mention context from text at cursor position */ +export interface MentionStateInternal { + category: string; + item: string; + hasColon: boolean; +} + +export interface UseMentionStateReturn { + /** Plain text value of the input */ + value: string; + /** Set value and update internal state */ + setValue: (value: string) => void; + /** Current caret offset in the text */ + cursorOffset: number; + /** Set caret offset without re-rendering the whole tree */ + setCursorOffset: React.Dispatch>; + /** Whether the mention popover should be visible */ + showMentionPopover: boolean; + /** Open/close the popover */ + setShowMentionPopover: (open: boolean) => void; + /** Filtered suggestions for the current @ context */ + suggestions: MentionSuggestion[]; + /** Currently selected index in suggestions */ + selectedIndex: number; + /** Move selection to a specific index */ + setSelectedIndex: (index: number) => void; + /** Reset selection to first item */ + resetSelection: () => void; + /** Parsed mention state at current cursor position */ + mentionState: MentionStateInternal | null; + /** Build HTML for inserting a mention at current cursor */ + buildInsertionAt: (category: MentionType, id: string, label: string) => InsertionResult; + /** Resolve ID → display name for any mention type */ + resolveName: ResolveMentionName; +} + +export interface InsertionResult { + html: string; + /** Start position where the mention should be inserted (before the @) */ + startPos: number; + /** Total length of inserted text (+ trailing space) */ + totalLength: number; +} + +/** + * Central hook that manages all mention-related state for the input layer. + * Replaces the old module-level refs with clean, component-scoped state. + * + * Usage: + * const ms = useMentionState(members, repos, aiConfigs, reposLoading, aiConfigsLoading); + * // Pass ms.* props to MentionInput and MentionPopover + */ +export function useMentionState( + members: Array<{ user: string; user_info?: { username?: string; avatar_url?: string }; role?: string }>, + repos: Array<{ uid: string; repo_name: string }> = [], + aiConfigs: RoomAiConfig[] = [], + reposLoading: boolean = false, + aiConfigsLoading: boolean = false, +): UseMentionStateReturn { + const [value, setValue] = useState(''); + const [cursorOffset, setCursorOffset] = useState(0); + const [showMentionPopover, setShowMentionPopover] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + // ─── Derived: Mention State ──────────────────────────────────────────────── + + /** Parse the @ mention context at the current cursor position */ + const mentionState = useMemo(() => { + const textBefore = value.slice(0, cursorOffset); + const match = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/); + if (!match) return null; + + const [_full, category, _colon, item] = match; + return { + category: category.toLowerCase(), + item: (item ?? '').toLowerCase(), + hasColon: _colon !== undefined, + }; + }, [value, cursorOffset]); + + // ─── Derived: Suggestions ────────────────────────────────────────────────── + + const suggestions = useMemo(() => { + const state = mentionState; + if (!state) return []; + + const { category, item, hasColon } = state; + + // No @ typed yet or just started typing — show categories + if (!category) { + return [ + { type: 'category', category: 'repository', label: 'Repository' }, + { type: 'category', category: 'user', label: 'User' }, + { type: 'category', category: 'ai', label: 'AI' }, + ]; + } + + // Has @ but no colon — filter to matching categories + if (!hasColon) { + const catLabel: Record = { repository: 'Repository', user: 'User', ai: 'AI' }; + return [{ type: 'category', category: category as MentionType, label: catLabel[category] ?? category }]; + } + + // Has @type: — show items + if (category === 'repository') { + if (reposLoading) return [{ type: 'category', label: 'Loading...' }]; + return repos + .filter(r => !item || r.repo_name.toLowerCase().includes(item)) + .slice(0, 12) + .map(r => ({ type: 'item', category: 'repository' as const, label: r.repo_name, mentionId: r.uid })); + } + + if (category === 'user') { + return members + .filter(m => m.role !== 'ai') + .filter(m => { + const name = m.user_info?.username ?? m.user; + return !item || name.toLowerCase().includes(item); + }) + .slice(0, 12) + .map(m => { + const name = m.user_info?.username ?? m.user; + return { + type: 'item' as const, category: 'user' as const, label: name, mentionId: m.user, + avatar: m.user_info?.avatar_url, + }; + }); + } + + if (category === 'ai') { + if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...' }]; + return aiConfigs + .filter(c => { + const name = c.modelName ?? c.model; + return !item || name.toLowerCase().includes(item); + }) + .slice(0, 12) + .map(c => ({ + type: 'item' as const, category: 'ai' as const, label: c.modelName ?? c.model, mentionId: c.model, + })); + } + + return []; + }, [mentionState, members, repos, aiConfigs, reposLoading, aiConfigsLoading]); + + // ─── Auto-select First Item ──────────────────────────────────────────────── + + useEffect(() => { + setSelectedIndex(0); + }, [suggestions.length]); + + // ─── Name Resolution ─────────────────────────────────────────────────────── + + const resolveName: ResolveMentionName = useCallback((type, id, fallback) => { + switch (type) { + case 'user': { + const member = members.find(m => m.user === id); + return member?.user_info?.username ?? member?.user ?? fallback; + } + case 'repository': { + const repo = repos.find(r => r.uid === id); + return repo?.repo_name ?? fallback; + } + case 'ai': { + const cfg = aiConfigs.find(c => c.model === id); + return cfg?.modelName ?? cfg?.model ?? fallback; + } + } + }, [members, repos, aiConfigs]); + + // ─── Insertion Builder ───────────────────────────────────────────────────── + + /** Build the HTML string and compute insertion start position */ + const buildInsertionAt = useCallback((mentionType: MentionType, id: string, label: string): InsertionResult => { + const escapedId = id.replace(/"/g, '"'); + const escapedLabel = label.replace(//g, '>'); + const html = `${escapedLabel}`; + const spacer = ' '; + const totalLength = html.length + spacer.length; + + return { html, startPos: cursorOffset - countAtPattern(value, cursorOffset), totalLength }; + }, [value, cursorOffset]); + + return { + value, setValue, cursorOffset, setCursorOffset, + showMentionPopover, setShowMentionPopover, + suggestions, selectedIndex, setSelectedIndex, + resetSelection: () => setSelectedIndex(0), + mentionState, buildInsertionAt, resolveName, + }; +} + +/** Count how many characters the @mention pattern occupies before cursor */ +function countAtPattern(text: string, cursorPos: number): number { + const before = text.slice(0, cursorPos); + const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/); + return match ? match[0].length : 0; +} diff --git a/src/lib/mention-types.ts b/src/lib/mention-types.ts new file mode 100644 index 0000000..1060669 --- /dev/null +++ b/src/lib/mention-types.ts @@ -0,0 +1,34 @@ +/** Shared types for the mention system. Centralized to avoid circular dependencies. */ + +// ─── Core Types ────────────────────────────────────────────────────────────── + +export type MentionType = 'user' | 'repository' | 'ai'; + +export interface RoomAiConfig { + model: string; + modelName?: string; +} + +// ─── Suggestion Types ──────────────────────────────────────────────────────── + +export interface MentionSuggestionCategory { + type: 'category'; + category: MentionType; + label: string; +} + +export interface MentionSuggestionItem { + type: 'item'; + category: MentionType; + label: string; + sublabel?: string; + mentionId: string; + avatar?: string | null; +} + +export type MentionSuggestion = MentionSuggestionCategory | MentionSuggestionItem; + +// ─── Resolver Interface ────────────────────────────────────────────────────── + +/** Resolves a mention's ID → display name at render time. */ +export type ResolveMentionName = (type: MentionType, id: string, fallback: string) => string;