diff --git a/src/components/room/MentionPopover.tsx b/src/components/room/MentionPopover.tsx index f8b34df..734849c 100644 --- a/src/components/room/MentionPopover.tsx +++ b/src/components/room/MentionPopover.tsx @@ -1,13 +1,31 @@ import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client'; import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast'; import { cn } from '@/lib/utils'; -import { Bot, Check, ChevronRight, SearchX } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ModelIcon } from './icon-match'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Bot, + Check, + ChevronRight, + Database, + GitBranch, + SearchX, + Sparkles, + User, +} from 'lucide-react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; /** Room AI config — the configured model for this room */ export interface RoomAiConfig { - model: string; // model UID / model ID + model: string; modelName?: string; } @@ -15,9 +33,7 @@ interface MentionSuggestion { type: 'category' | 'item'; category?: MentionMentionType; label: string; - /** Display sub-label (e.g. "member · repo_name") */ sublabel?: string; - /** ID used when building HTML mention (absent for category headers) */ mentionId?: string; avatar?: string | null; } @@ -35,13 +51,9 @@ interface PopoverPosition { } interface MentionPopoverProps { - /** Available members for @user: mention suggestions */ members: RoomMemberResponse[]; - /** Available repositories for @repository: mention suggestions */ repos?: ProjectRepositoryItem[]; - /** Room AI configs for @ai: mention suggestions */ aiConfigs?: RoomAiConfig[]; - /** Whether repos/aiConfigs are still loading */ reposLoading?: boolean; aiConfigsLoading?: boolean; inputValue: string; @@ -51,6 +63,304 @@ interface MentionPopoverProps { onOpenChange: (open: boolean) => void; } +// Category configuration with icons and colors +const CATEGORY_CONFIG: Record< + string, + { + icon: React.ReactNode; + color: string; + bgColor: string; + borderColor: string; + gradient: string; + } +> = { + 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', + }, +}; + +// Highlight matched text +function HighlightMatch({ text, match }: { text: string; match: string }) { + if (!match) return <>{text}; + const regex = new RegExp(`(${escapeRegExp(match)})`, 'gi'); + const parts = text.split(regex); + return ( + <> + {parts.map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Animated container component +function AnimatedContainer({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + 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} + +
+ ); +} + +// Item row component +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} + > + {/* Avatar/Icon */} +
+ {suggestion.category === 'ai' ? ( +
+ +
+ ) : suggestion.category === 'repository' ? ( +
+ +
+ ) : suggestion.avatar ? ( + + + + {suggestion.label.slice(0, 2).toUpperCase()} + + + ) : ( +
+ + {suggestion.label[0]?.toUpperCase()} + +
+ )} + + {/* Selection indicator dot */} +
+
+ + {/* Text content */} +
+
+ + + + {suggestion.category && ( + + {suggestion.category} + + )} +
+ {suggestion.sublabel && ( + + {suggestion.sublabel} + + )} +
+ + {/* Check mark for selected */} +
+ +
+
+ ); +} + +// Loading skeleton +function LoadingSkeleton() { + return ( +
+ {[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'} +

+
+ ); +} + export function MentionPopover({ members, repos = [], @@ -66,18 +376,18 @@ export function MentionPopover({ const [position, setPosition] = useState({ top: 0, left: 0 }); const [selectedIndex, setSelectedIndex] = useState(0); const popoverRef = useRef(null); + const itemsRef = useRef([]); - // Stable mutable refs so keyboard handler always reads fresh values + // Stable mutable refs const mentionStateRef = useRef(null); const visibleSuggestionsRef = useRef([]); const selectedIndexRef = useRef(selectedIndex); const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {}); const closePopoverRef = useRef<() => void>(() => {}); - // Parse mention state from input + // Parse mention state const mentionState = useMemo(() => { const textBeforeCursor = inputValue.slice(0, cursorPosition); - // Match @ but NOT @@ or mentions already inside HTML tags const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/); if (!atMatch) return null; @@ -92,13 +402,12 @@ export function MentionPopover({ }; }, [inputValue, cursorPosition]); - // Keep refs in sync with state mentionStateRef.current = mentionState; const categories: MentionSuggestion[] = [ - { type: 'category', category: 'repository', label: 'Repository', value: '@repository:' }, - { type: 'category', category: 'user', label: 'User', value: '@user:' }, - { type: 'category', category: 'ai', label: 'AI', value: '@ai:' }, + { type: 'category', category: 'repository', label: 'Repository' }, + { type: 'category', category: 'user', label: 'User' }, + { type: 'category', category: 'ai', label: 'AI' }, ]; const suggestions = useMemo((): MentionSuggestion[] => { @@ -106,19 +415,17 @@ export function MentionPopover({ const { category, item, hasColon } = mentionState; - // Before colon: show filtered category headers if (!hasColon) { if (!category) return categories; return categories.filter((c) => c.category?.startsWith(category)); } - // Unknown category after colon: fallback to filtered categories if (!['repository', 'user', 'ai'].includes(category)) { return categories.filter((c) => c.category?.startsWith(category)); } if (category === 'repository') { - if (reposLoading) return [{ type: 'category', label: 'Loading...', value: '' }]; + if (reposLoading) return [{ type: 'category', label: 'Loading...' }]; return repos .filter((r) => !item || r.repo_name.toLowerCase().includes(item)) .slice(0, 8) @@ -150,8 +457,7 @@ export function MentionPopover({ }); } - // category === 'ai' - if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...', value: '' }]; + if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...' }]; return aiConfigs .filter((cfg) => { const n = cfg.modelName ?? cfg.model; @@ -172,23 +478,19 @@ export function MentionPopover({ const visibleSuggestions = suggestions; visibleSuggestionsRef.current = visibleSuggestions; - // Auto-select first item when suggestion list changes - const prevSuggestionsLenRef = useRef(0); + // Auto-select first item useEffect(() => { - if (visibleSuggestions.length !== prevSuggestionsLenRef.current) { - setSelectedIndex(0); - prevSuggestionsLenRef.current = visibleSuggestions.length; - } - }, [visibleSuggestions.length]); + setSelectedIndex(0); + selectedIndexRef.current = 0; + }, [visibleSuggestions.length, mentionState?.category]); - // Auto-select best match: first item if category changed - const prevCategoryRef = useRef(null); - useEffect(() => { - if (mentionState?.category !== prevCategoryRef.current) { - setSelectedIndex(0); - prevCategoryRef.current = mentionState?.category ?? null; + // Scroll selected item into view + useLayoutEffect(() => { + const el = itemsRef.current[selectedIndex]; + if (el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } - }, [mentionState?.category]); + }, [selectedIndex]); const closePopover = useCallback(() => { onOpenChange(false); @@ -203,7 +505,11 @@ export function MentionPopover({ const before = inputValue.slice(0, ms.startPos); const after = inputValue.slice(cursorPosition); - const html = buildMentionHtml(suggestion.category!, suggestion.mentionId!, suggestion.label); + const html = buildMentionHtml( + suggestion.category!, + suggestion.mentionId!, + suggestion.label, + ); const spacer = ' '; const newValue = before + html + spacer + after; const newCursorPos = ms.startPos + html.length + spacer.length; @@ -215,42 +521,40 @@ export function MentionPopover({ handleSelectRef.current = handleSelect; - // Position cache: only recalculate when mentionState or cursor changes meaningfully - const posCacheRef = useRef<{ - text: string; - cursor: number; - top: number; - left: number; - } | null>(null); - + // Position calculation useEffect(() => { if (!mentionState || !textareaRef.current) return; - const cache = posCacheRef.current; - const text = inputValue.slice(0, cursorPosition); - if (cache && cache.text === text && cache.cursor === cursorPosition) { - setPosition({ top: cache.top, left: cache.left }); - return; - } - const textarea = textareaRef.current; const styles = window.getComputedStyle(textarea); - // Reuse a cached DOM element if possible; create only when needed - let tempDiv = (textarea as unknown as { _mentionTempDiv?: HTMLDivElement })._mentionTempDiv; + let tempDiv = (textarea as unknown as { _mentionTempDiv?: HTMLDivElement }) + ._mentionTempDiv; if (!tempDiv) { tempDiv = document.createElement('div'); - (textarea as unknown as { _mentionTempDiv: HTMLDivElement })._mentionTempDiv = tempDiv; + (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); } + const text = inputValue.slice(0, cursorPosition); 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', + '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; tempDiv.style.width = styles.width; @@ -270,17 +574,20 @@ export function MentionPopover({ const contentTop = rect.top + borderTop + span.offsetTop - textarea.scrollTop; const contentLeft = rect.left + borderLeft + span.offsetLeft - textarea.scrollLeft; - const popoverWidth = 288; - const popoverHeight = Math.min(320, visibleSuggestions.length * 44 + 80); - const vp = 8; + const popoverWidth = 320; + const popoverHeight = Math.min(360, visibleSuggestions.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.min( + 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); - posCacheRef.current = { text, cursor: cursorPosition, top, left }; setPosition({ top, left }); }, [mentionState, inputValue, textareaRef, cursorPosition, visibleSuggestions.length]); @@ -302,7 +609,8 @@ 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') { @@ -325,7 +633,8 @@ export function MentionPopover({ useEffect(() => { const onPointerDown = (e: PointerEvent) => { const t = e.target as Node; - if (popoverRef.current?.contains(t) || textareaRef.current?.contains(t)) return; + if (popoverRef.current?.contains(t) || textareaRef.current?.contains(t)) + return; closePopover(); }; document.addEventListener('pointerdown', onPointerDown); @@ -339,91 +648,144 @@ export function MentionPopover({ if (!mentionState) return null; const selectedItem = visibleSuggestions[selectedIndex]; + const isLoading = + (mentionState.category === 'repository' && reposLoading) || + (mentionState.category === 'ai' && aiConfigsLoading); + + // Get current category for styling + const currentCategory = mentionState.hasColon + ? mentionState.category + : null; + const categoryConfig = currentCategory + ? CATEGORY_CONFIG[currentCategory] + : null; return ( -
- {/* Header */} -
- @ - - {mentionState.hasColon ? mentionState.category : 'type to filter'} - {mentionState.item ? `: ${mentionState.item}` : ''} - -
- - {/* Suggestion list */} - {visibleSuggestions.length > 0 ? ( -
- {visibleSuggestions.map((s, i) => ( -
e.preventDefault()} - onClick={() => s.type === 'item' && handleSelect(s)} - onMouseEnter={() => { - setSelectedIndex(i); - selectedIndexRef.current = i; - }} - > - {/* Icon */} -
+
+ {/* Header */} +
+
+ @ +
+
+ {mentionState.hasColon ? ( + <> + + {mentionState.category} + + {mentionState.item && ( + <> + / + + {mentionState.item} + + )} - > - {s.type === 'category' ? ( - - ) : s.category === 'ai' ? ( - - ) : s.avatar ? ( - + + ) : ( + + Type to filter mentions + + )} +
+
+ + ↑↓ + + navigate +
+
+ + {/* Content */} + {visibleSuggestions.length > 0 ? ( + +
+ {visibleSuggestions.map((s, i) => + s.type === 'category' ? ( + ) : ( - s.label[0]?.toUpperCase() - )} -
- - {/* Label */} -
- {s.label} - {s.sublabel && ( - {s.sublabel} - )} -
- - {/* Check mark */} - {i === selectedIndex && s.type === 'item' && ( - + handleSelect(s)} + onMouseEnter={() => { + setSelectedIndex(i); + selectedIndexRef.current = i; + }} + searchTerm={mentionState.item} + ref={(el) => { + if (el) itemsRef.current[i] = el; + }} + /> + ), )}
- ))} -
- ) : ( -
- - {reposLoading || aiConfigsLoading ? 'Loading…' : 'No matches'} -
- )} + + ) : isLoading ? ( + + ) : ( + + )} - {/* Footer hint */} - {selectedItem?.type === 'item' && ( -
- - to insert -
- )} -
+ {/* Footer */} + {selectedItem?.type === 'item' && ( +
+
+ + {selectedItem.category} + + {selectedItem.label} +
+
+ + Enter + + to insert +
+
+ )} +
+ ); }