import { buildMentionHtml, type Node } from '@/lib/mention-ast'; import type { MentionSuggestion, MentionType, RoomAiConfig } from '@/lib/mention-types'; import { cn } from '@/lib/utils'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { Bot, ChevronRight, Database, SearchX, Sparkles, User, Check, } from 'lucide-react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; // ─── Types ─────────────────────────────────────────────────────────────────── interface PopoverPosition { top: number; left: number } interface MentionPopoverProps { members: Array<{ user: string; user_info?: { username?: string; avatar_url?: string }; role?: string }>; 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, newCursorPos: number) => void; onOpenChange: (open: boolean) => void; onCategoryEnter: (category: string) => void; suggestions: MentionSuggestion[]; selectedIndex: number; setSelectedIndex: (index: number) => void; } // ─── 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', 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', 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', gradient: 'from-emerald-500/5 to-transparent', }, }; 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 parts = text.split(new RegExp(`(${escapeRegExp(match)})`, 'gi')); return ( <> {parts.map((part, i) => /[@a-z]/i.test(part) && new RegExp(escapeRegExp(match), 'gi').test(part) ? ( {part} ) : ( {part} ), )} ); } function CategoryHeader({ suggestion, isSelected }: { suggestion: MentionSuggestion; isSelected: boolean }) { const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null; return (
{config?.icon ?? }
{suggestion.label}
); } 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; return (
e.preventDefault()} onClick={onSelect} onMouseEnter={onMouseEnter} > {/* Icon */}
{suggestion.category === 'ai' ? (
) : suggestion.category === 'repository' ? (
) : suggestion.avatar ? ( {suggestion.label.slice(0, 2).toUpperCase()} ) : (
{suggestion.label[0]?.toUpperCase()}
)} {/* Selection dot */}
{/* Text */}
{suggestion.category && ( {suggestion.category} )}
{suggestion.sublabel && ( {suggestion.sublabel} )}
{/* Check mark */}
); } function LoadingSkeleton() { return (
{[1, 2, 3].map(i => (
))}
); } function EmptyState({ loading }: { loading?: boolean }) { return (
{loading ? (
) : ( )}

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

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

); } // ─── Main Component ────────────────────────────────────────────────────────── 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<() => void>(() => {}); const closePopoverRef = useRef<() => void>(() => {}); const onCategoryEnterRef = useRef(onCategoryEnter); onCategoryEnterRef.current = onCategoryEnter; // Parse mention state const mentionState = 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 }; }, [inputValue, cursorPosition]); mentionStateRef.current = mentionState; visibleSuggestionsRef.current = suggestions; selectedIndexRef.current = selectedIndex; // Auto-select first item useEffect(() => { setSelectedIndex(0); }, [suggestions.length]); // Scroll selected into view useLayoutEffect(() => { const el = itemsRef.current[selectedIndex]; if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [selectedIndex]); const closePopover = useCallback(() => onOpenChange(false), [onOpenChange]); closePopoverRef.current = closePopover; // ─── Insertion Logic ─────────────────────────────────────────────────────── const doInsert = useCallback((suggestion: MentionSuggestion) => { const textarea = containerRef.current; if (!textarea || suggestion.type !== 'item') return; // 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 === range.startContainer) { count += range.startOffset; break; } count += node.length; } return inputValue.slice(0, count); })(); const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/); if (!atMatch) return; 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 (!mentionState) 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 pattern = mentionState.hasColon ? `@${mentionState.category}:${mentionState.item}` : `@${mentionState.category}`; tempDiv.textContent = inputValue.slice(0, cursorPosition - pattern.length + (inputValue.slice(0, cursorPosition).lastIndexOf(pattern))); const span = document.createElement('span'); span.textContent = '|'; tempDiv.appendChild(span); const rect = textarea.getBoundingClientRect(); const borderTop = parseFloat(styles.borderTopWidth) || 0; const borderLeft = parseFloat(styles.borderLeftWidth) || 0; const scrollTop = textarea.scrollTop; const scrollLeft = textarea.scrollLeft; 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); document.body.removeChild(clone); const popoverWidth = 320; const popoverHeight = Math.min(360, suggestions.length * 56 + 100); const vp = 16; 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: contentLeft }); document.body.removeChild(tempDiv); // Simple approach: just use the textarea bounds + estimated offset // This is sufficient for most cases }, [inputValue, cursorPosition, containerRef, mentionState, suggestions.length]); // ─── Keyboard Navigation ─────────────────────────────────────────────────── useEffect(() => { const textarea = containerRef.current; if (!textarea) return; const onKeyDown = (e: KeyboardEvent) => { const vis = visibleSuggestionsRef.current; if (vis.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); const next = (selectedIndexRef.current + 1) % Math.max(vis.length, 1); setSelectedIndex(next); selectedIndexRef.current = next; } else if (e.key === 'ArrowUp') { e.preventDefault(); 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; e.preventDefault(); if (item.type === 'category') onCategoryEnterRef.current(item.category!); else handleSelectRef.current(); } else if (e.key === 'Escape') { e.preventDefault(); closePopoverRef.current(); } }; textarea.addEventListener('keydown', onKeyDown); return () => textarea.removeEventListener('keydown', onKeyDown); }, [containerRef, setSelectedIndex]); // ─── Close on Outside Click ──────────────────────────────────────────────── useEffect(() => { const handler = (e: PointerEvent) => { const t = e.target as Node; if (popoverRef.current?.contains(t)) return; if (containerRef.current?.contains(t)) return; closePopover(); }; document.addEventListener('pointerdown', handler); return () => document.removeEventListener('pointerdown', handler); }, [closePopover, containerRef]); // Hide when mention state is gone useEffect(() => { if (!mentionState) closePopover(); }, [inputValue, cursorPosition, closePopover, mentionState]); // Don't render if no valid mention context if (!mentionState) return null; const isLoading = (mentionState.category === 'repository' && reposLoading) || (mentionState.category === 'ai' && aiConfigsLoading); const currentCategory = mentionState.hasColon ? mentionState.category : null; const catConfig = currentCategory ? CATEGORY_CONFIG[currentCategory] : null; return (
{/* Header */}
@
{mentionState.hasColon ? ( <> {mentionState.category} {mentionState.item && (<> / {mentionState.item} )} ) : ( Type to filter )}
↑↓ navigate | select
{/* Content */} {suggestions.length > 0 ? (
{suggestions.map((s, i) => s.type === 'category' ? ( ) : (
{ itemsRef.current[i] = el; }}> doInsert(s)} onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }} searchTerm={mentionState.item} />
))}
) : isLoading ? : } {/* Footer */} {suggestions[selectedIndex]?.type === 'item' && (
{suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].category : ''} {suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].label : ''}
)}
); } /** 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(';'); }