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
+
+
+ )}
+
+
);
}