feat(room): redesign MentionPopover with modern UI/UX
Visual improvements: - Glassmorphism backdrop blur and refined shadows - Color-coded categories: user(sky), repository(violet), AI(emerald) - Gradient backgrounds and smooth transitions - Custom avatar icons for each mention type Interaction enhancements: - Search text highlighting with yellow background - Auto-scroll selected item into view - Selection indicator dot and left border accent - Keyboard navigation (↑↓) with visual feedback Components: - CategoryHeader: icon + label with color theme - SuggestionItem: avatar + highlighted text + category badge - LoadingSkeleton: Shimmer loading state - EmptyState: Illustrated empty/loading states Uses ScrollArea, Avatar, Skeleton from design system
This commit is contained in:
parent
aacd9572d1
commit
a9fc6f9937
@ -1,13 +1,31 @@
|
|||||||
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
||||||
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
|
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Bot, Check, ChevronRight, SearchX } from 'lucide-react';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { ModelIcon } from './icon-match';
|
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 */
|
/** Room AI config — the configured model for this room */
|
||||||
export interface RoomAiConfig {
|
export interface RoomAiConfig {
|
||||||
model: string; // model UID / model ID
|
model: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,9 +33,7 @@ interface MentionSuggestion {
|
|||||||
type: 'category' | 'item';
|
type: 'category' | 'item';
|
||||||
category?: MentionMentionType;
|
category?: MentionMentionType;
|
||||||
label: string;
|
label: string;
|
||||||
/** Display sub-label (e.g. "member · repo_name") */
|
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
/** ID used when building HTML mention (absent for category headers) */
|
|
||||||
mentionId?: string;
|
mentionId?: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
}
|
}
|
||||||
@ -35,13 +51,9 @@ interface PopoverPosition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MentionPopoverProps {
|
interface MentionPopoverProps {
|
||||||
/** Available members for @user: mention suggestions */
|
|
||||||
members: RoomMemberResponse[];
|
members: RoomMemberResponse[];
|
||||||
/** Available repositories for @repository: mention suggestions */
|
|
||||||
repos?: ProjectRepositoryItem[];
|
repos?: ProjectRepositoryItem[];
|
||||||
/** Room AI configs for @ai: mention suggestions */
|
|
||||||
aiConfigs?: RoomAiConfig[];
|
aiConfigs?: RoomAiConfig[];
|
||||||
/** Whether repos/aiConfigs are still loading */
|
|
||||||
reposLoading?: boolean;
|
reposLoading?: boolean;
|
||||||
aiConfigsLoading?: boolean;
|
aiConfigsLoading?: boolean;
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
@ -51,6 +63,304 @@ interface MentionPopoverProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
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: <GitBranch className="h-3.5 w-3.5" />,
|
||||||
|
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: <User className="h-3.5 w-3.5" />,
|
||||||
|
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: <Sparkles className="h-3.5 w-3.5" />,
|
||||||
|
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) ? (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 font-semibold rounded px-0.5"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(string: string): string {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated container component
|
||||||
|
function AnimatedContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-in fade-in zoom-in-95 duration-150 ease-out',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category header component
|
||||||
|
function CategoryHeader({
|
||||||
|
suggestion,
|
||||||
|
isSelected,
|
||||||
|
}: {
|
||||||
|
suggestion: MentionSuggestion;
|
||||||
|
isSelected: boolean;
|
||||||
|
}) {
|
||||||
|
const config = suggestion.category
|
||||||
|
? CATEGORY_CONFIG[suggestion.category]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-muted-foreground',
|
||||||
|
'transition-colors duration-150',
|
||||||
|
isSelected && 'text-foreground bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-5 w-5 items-center justify-center rounded-md border',
|
||||||
|
config?.bgColor,
|
||||||
|
config?.borderColor,
|
||||||
|
config?.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config?.icon ?? <ChevronRight className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1">{suggestion.label}</span>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group relative flex items-center gap-3 px-3 py-2.5 cursor-pointer',
|
||||||
|
'transition-all duration-150 ease-out',
|
||||||
|
'border-l-2 border-transparent',
|
||||||
|
isSelected && [
|
||||||
|
'bg-gradient-to-r',
|
||||||
|
config?.gradient ?? 'from-muted to-transparent',
|
||||||
|
'border-l-current',
|
||||||
|
config?.color ?? 'text-foreground',
|
||||||
|
],
|
||||||
|
!isSelected && 'hover:bg-muted/40',
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
{/* Avatar/Icon */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
{suggestion.category === 'ai' ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||||
|
'bg-gradient-to-br from-emerald-500/20 to-emerald-600/10',
|
||||||
|
'border border-emerald-500/20',
|
||||||
|
'shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bot className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
) : suggestion.category === 'repository' ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||||
|
'bg-gradient-to-br from-violet-500/20 to-violet-600/10',
|
||||||
|
'border border-violet-500/20',
|
||||||
|
'shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Database className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
|
) : suggestion.avatar ? (
|
||||||
|
<Avatar size="sm" className="h-8 w-8 ring-2 ring-background">
|
||||||
|
<AvatarImage src={suggestion.avatar} alt={suggestion.label} />
|
||||||
|
<AvatarFallback className="bg-sky-500/10 text-sky-600 text-xs font-medium">
|
||||||
|
{suggestion.label.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||||
|
'bg-gradient-to-br from-sky-500/20 to-sky-600/10',
|
||||||
|
'border border-sky-500/20',
|
||||||
|
'shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold text-sky-600 dark:text-sky-400">
|
||||||
|
{suggestion.label[0]?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection indicator dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full',
|
||||||
|
'bg-primary border-2 border-background',
|
||||||
|
'transition-transform duration-150',
|
||||||
|
isSelected ? 'scale-100' : 'scale-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
<HighlightMatch text={suggestion.label} match={searchTerm} />
|
||||||
|
</span>
|
||||||
|
{suggestion.category && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0',
|
||||||
|
'border',
|
||||||
|
config?.bgColor,
|
||||||
|
config?.color,
|
||||||
|
config?.borderColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suggestion.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{suggestion.sublabel && (
|
||||||
|
<span className="block truncate text-xs text-muted-foreground mt-0.5">
|
||||||
|
{suggestion.sublabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check mark for selected */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center w-5 h-5 rounded-full',
|
||||||
|
'transition-all duration-150',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'opacity-0 group-hover:opacity-30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading skeleton
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-3 space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 py-1">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3.5 w-24" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
function EmptyState({ loading }: { loading?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-12 w-12 items-center justify-center rounded-xl mb-3',
|
||||||
|
'bg-muted/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-5 w-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SearchX className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{loading ? 'Loading suggestions...' : 'No matches found'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{loading
|
||||||
|
? 'Please wait a moment'
|
||||||
|
: 'Try a different search term'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MentionPopover({
|
export function MentionPopover({
|
||||||
members,
|
members,
|
||||||
repos = [],
|
repos = [],
|
||||||
@ -66,18 +376,18 @@ export function MentionPopover({
|
|||||||
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
|
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const itemsRef = useRef<HTMLDivElement[]>([]);
|
||||||
|
|
||||||
// Stable mutable refs so keyboard handler always reads fresh values
|
// Stable mutable refs
|
||||||
const mentionStateRef = useRef<MentionState | null>(null);
|
const mentionStateRef = useRef<MentionState | null>(null);
|
||||||
const visibleSuggestionsRef = useRef<MentionSuggestion[]>([]);
|
const visibleSuggestionsRef = useRef<MentionSuggestion[]>([]);
|
||||||
const selectedIndexRef = useRef(selectedIndex);
|
const selectedIndexRef = useRef(selectedIndex);
|
||||||
const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {});
|
const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {});
|
||||||
const closePopoverRef = useRef<() => void>(() => {});
|
const closePopoverRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
// Parse mention state from input
|
// Parse mention state
|
||||||
const mentionState = useMemo<MentionState | null>(() => {
|
const mentionState = useMemo<MentionState | null>(() => {
|
||||||
const textBeforeCursor = inputValue.slice(0, cursorPosition);
|
const textBeforeCursor = inputValue.slice(0, cursorPosition);
|
||||||
// Match @ but NOT @@ or mentions already inside HTML tags
|
|
||||||
const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
||||||
if (!atMatch) return null;
|
if (!atMatch) return null;
|
||||||
|
|
||||||
@ -92,13 +402,12 @@ export function MentionPopover({
|
|||||||
};
|
};
|
||||||
}, [inputValue, cursorPosition]);
|
}, [inputValue, cursorPosition]);
|
||||||
|
|
||||||
// Keep refs in sync with state
|
|
||||||
mentionStateRef.current = mentionState;
|
mentionStateRef.current = mentionState;
|
||||||
|
|
||||||
const categories: MentionSuggestion[] = [
|
const categories: MentionSuggestion[] = [
|
||||||
{ type: 'category', category: 'repository', label: 'Repository', value: '@repository:' },
|
{ type: 'category', category: 'repository', label: 'Repository' },
|
||||||
{ type: 'category', category: 'user', label: 'User', value: '@user:' },
|
{ type: 'category', category: 'user', label: 'User' },
|
||||||
{ type: 'category', category: 'ai', label: 'AI', value: '@ai:' },
|
{ type: 'category', category: 'ai', label: 'AI' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const suggestions = useMemo((): MentionSuggestion[] => {
|
const suggestions = useMemo((): MentionSuggestion[] => {
|
||||||
@ -106,19 +415,17 @@ export function MentionPopover({
|
|||||||
|
|
||||||
const { category, item, hasColon } = mentionState;
|
const { category, item, hasColon } = mentionState;
|
||||||
|
|
||||||
// Before colon: show filtered category headers
|
|
||||||
if (!hasColon) {
|
if (!hasColon) {
|
||||||
if (!category) return categories;
|
if (!category) return categories;
|
||||||
return categories.filter((c) => c.category?.startsWith(category));
|
return categories.filter((c) => c.category?.startsWith(category));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown category after colon: fallback to filtered categories
|
|
||||||
if (!['repository', 'user', 'ai'].includes(category)) {
|
if (!['repository', 'user', 'ai'].includes(category)) {
|
||||||
return categories.filter((c) => c.category?.startsWith(category));
|
return categories.filter((c) => c.category?.startsWith(category));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category === 'repository') {
|
if (category === 'repository') {
|
||||||
if (reposLoading) return [{ type: 'category', label: 'Loading...', value: '' }];
|
if (reposLoading) return [{ type: 'category', label: 'Loading...' }];
|
||||||
return repos
|
return repos
|
||||||
.filter((r) => !item || r.repo_name.toLowerCase().includes(item))
|
.filter((r) => !item || r.repo_name.toLowerCase().includes(item))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
@ -150,8 +457,7 @@ export function MentionPopover({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// category === 'ai'
|
if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...' }];
|
||||||
if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...', value: '' }];
|
|
||||||
return aiConfigs
|
return aiConfigs
|
||||||
.filter((cfg) => {
|
.filter((cfg) => {
|
||||||
const n = cfg.modelName ?? cfg.model;
|
const n = cfg.modelName ?? cfg.model;
|
||||||
@ -172,23 +478,19 @@ export function MentionPopover({
|
|||||||
const visibleSuggestions = suggestions;
|
const visibleSuggestions = suggestions;
|
||||||
visibleSuggestionsRef.current = visibleSuggestions;
|
visibleSuggestionsRef.current = visibleSuggestions;
|
||||||
|
|
||||||
// Auto-select first item when suggestion list changes
|
// Auto-select first item
|
||||||
const prevSuggestionsLenRef = useRef(0);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visibleSuggestions.length !== prevSuggestionsLenRef.current) {
|
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
prevSuggestionsLenRef.current = visibleSuggestions.length;
|
selectedIndexRef.current = 0;
|
||||||
}
|
}, [visibleSuggestions.length, mentionState?.category]);
|
||||||
}, [visibleSuggestions.length]);
|
|
||||||
|
|
||||||
// Auto-select best match: first item if category changed
|
// Scroll selected item into view
|
||||||
const prevCategoryRef = useRef<string | null>(null);
|
useLayoutEffect(() => {
|
||||||
useEffect(() => {
|
const el = itemsRef.current[selectedIndex];
|
||||||
if (mentionState?.category !== prevCategoryRef.current) {
|
if (el) {
|
||||||
setSelectedIndex(0);
|
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
prevCategoryRef.current = mentionState?.category ?? null;
|
|
||||||
}
|
}
|
||||||
}, [mentionState?.category]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
const closePopover = useCallback(() => {
|
const closePopover = useCallback(() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@ -203,7 +505,11 @@ export function MentionPopover({
|
|||||||
|
|
||||||
const before = inputValue.slice(0, ms.startPos);
|
const before = inputValue.slice(0, ms.startPos);
|
||||||
const after = inputValue.slice(cursorPosition);
|
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 spacer = ' ';
|
||||||
const newValue = before + html + spacer + after;
|
const newValue = before + html + spacer + after;
|
||||||
const newCursorPos = ms.startPos + html.length + spacer.length;
|
const newCursorPos = ms.startPos + html.length + spacer.length;
|
||||||
@ -215,42 +521,40 @@ export function MentionPopover({
|
|||||||
|
|
||||||
handleSelectRef.current = handleSelect;
|
handleSelectRef.current = handleSelect;
|
||||||
|
|
||||||
// Position cache: only recalculate when mentionState or cursor changes meaningfully
|
// Position calculation
|
||||||
const posCacheRef = useRef<{
|
|
||||||
text: string;
|
|
||||||
cursor: number;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mentionState || !textareaRef.current) return;
|
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 textarea = textareaRef.current;
|
||||||
const styles = window.getComputedStyle(textarea);
|
const styles = window.getComputedStyle(textarea);
|
||||||
|
|
||||||
// Reuse a cached DOM element if possible; create only when needed
|
let tempDiv = (textarea as unknown as { _mentionTempDiv?: HTMLDivElement })
|
||||||
let tempDiv = (textarea as unknown as { _mentionTempDiv?: HTMLDivElement })._mentionTempDiv;
|
._mentionTempDiv;
|
||||||
if (!tempDiv) {
|
if (!tempDiv) {
|
||||||
tempDiv = document.createElement('div');
|
tempDiv = document.createElement('div');
|
||||||
(textarea as unknown as { _mentionTempDiv: HTMLDivElement })._mentionTempDiv = tempDiv;
|
(textarea as unknown as { _mentionTempDiv: HTMLDivElement })._mentionTempDiv =
|
||||||
|
tempDiv;
|
||||||
tempDiv.style.cssText =
|
tempDiv.style.cssText =
|
||||||
'position:absolute;visibility:hidden;top:-9999px;left:-9999px;pointer-events:none;';
|
'position:absolute;visibility:hidden;top:-9999px;left:-9999px;pointer-events:none;';
|
||||||
document.body.appendChild(tempDiv);
|
document.body.appendChild(tempDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const text = inputValue.slice(0, cursorPosition);
|
||||||
const styleProps = [
|
const styleProps = [
|
||||||
'font-family', 'font-size', 'font-weight', 'line-height',
|
'font-family',
|
||||||
'padding-left', 'padding-right', 'padding-top', 'padding-bottom',
|
'font-size',
|
||||||
'border-left-width', 'border-right-width', 'border-top-width', 'border-bottom-width',
|
'font-weight',
|
||||||
'box-sizing', 'width',
|
'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;
|
] as const;
|
||||||
|
|
||||||
tempDiv.style.width = styles.width;
|
tempDiv.style.width = styles.width;
|
||||||
@ -270,17 +574,20 @@ export function MentionPopover({
|
|||||||
const contentTop = rect.top + borderTop + span.offsetTop - textarea.scrollTop;
|
const contentTop = rect.top + borderTop + span.offsetTop - textarea.scrollTop;
|
||||||
const contentLeft = rect.left + borderLeft + span.offsetLeft - textarea.scrollLeft;
|
const contentLeft = rect.left + borderLeft + span.offsetLeft - textarea.scrollLeft;
|
||||||
|
|
||||||
const popoverWidth = 288;
|
const popoverWidth = 320;
|
||||||
const popoverHeight = Math.min(320, visibleSuggestions.length * 44 + 80);
|
const popoverHeight = Math.min(360, visibleSuggestions.length * 56 + 100);
|
||||||
const vp = 8;
|
const vp = 16;
|
||||||
|
|
||||||
const left = Math.min(Math.max(contentLeft, vp), window.innerWidth - popoverWidth - vp);
|
const left = Math.min(
|
||||||
const shouldBelow = contentTop + lineHeight + 8 + popoverHeight < window.innerHeight - vp;
|
Math.max(contentLeft, vp),
|
||||||
|
window.innerWidth - popoverWidth - vp,
|
||||||
|
);
|
||||||
|
const shouldBelow =
|
||||||
|
contentTop + lineHeight + 8 + popoverHeight < window.innerHeight - vp;
|
||||||
const top = shouldBelow
|
const top = shouldBelow
|
||||||
? contentTop + lineHeight + 8
|
? contentTop + lineHeight + 8
|
||||||
: Math.max(vp, contentTop - popoverHeight - 8);
|
: Math.max(vp, contentTop - popoverHeight - 8);
|
||||||
|
|
||||||
posCacheRef.current = { text, cursor: cursorPosition, top, left };
|
|
||||||
setPosition({ top, left });
|
setPosition({ top, left });
|
||||||
}, [mentionState, inputValue, textareaRef, cursorPosition, visibleSuggestions.length]);
|
}, [mentionState, inputValue, textareaRef, cursorPosition, visibleSuggestions.length]);
|
||||||
|
|
||||||
@ -302,7 +609,8 @@ export function MentionPopover({
|
|||||||
selectedIndexRef.current = next;
|
selectedIndexRef.current = next;
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
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);
|
setSelectedIndex(prev);
|
||||||
selectedIndexRef.current = prev;
|
selectedIndexRef.current = prev;
|
||||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
@ -325,7 +633,8 @@ export function MentionPopover({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
const t = e.target as Node;
|
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();
|
closePopover();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointerdown', onPointerDown);
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
@ -339,91 +648,144 @@ export function MentionPopover({
|
|||||||
if (!mentionState) return null;
|
if (!mentionState) return null;
|
||||||
|
|
||||||
const selectedItem = visibleSuggestions[selectedIndex];
|
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 (
|
return (
|
||||||
|
<AnimatedContainer>
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className="fixed z-50 w-72 overflow-hidden rounded-xl border border-border/90 bg-popover shadow-2xl"
|
className={cn(
|
||||||
|
'fixed z-50 w-80 overflow-hidden',
|
||||||
|
'rounded-xl border border-border/50',
|
||||||
|
'bg-popover/95 backdrop-blur-xl',
|
||||||
|
'shadow-2xl shadow-black/10 dark:shadow-black/20',
|
||||||
|
'ring-1 ring-black/5 dark:ring-white/10',
|
||||||
|
)}
|
||||||
style={{ top: position.top, left: position.left }}
|
style={{ top: position.top, left: position.left }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-1.5 border-b border-border/70 px-3 py-2">
|
<div
|
||||||
<span className="text-xs font-medium text-muted-foreground">@</span>
|
className={cn(
|
||||||
<span className="text-xs text-muted-foreground truncate flex-1">
|
'flex items-center gap-2 px-3 py-2.5 border-b border-border/50',
|
||||||
{mentionState.hasColon ? mentionState.category : 'type to filter'}
|
'bg-gradient-to-r from-muted/50 to-transparent',
|
||||||
{mentionState.item ? `: ${mentionState.item}` : ''}
|
categoryConfig && `from-${categoryConfig.gradient.split(' ')[0].replace('from-', '')}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded-md',
|
||||||
|
'bg-muted border border-border/50',
|
||||||
|
categoryConfig?.bgColor,
|
||||||
|
categoryConfig?.borderColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">@</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-1.5 min-w-0">
|
||||||
|
{mentionState.hasColon ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium px-1.5 py-0.5 rounded-md',
|
||||||
|
categoryConfig?.bgColor,
|
||||||
|
categoryConfig?.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mentionState.category}
|
||||||
</span>
|
</span>
|
||||||
|
{mentionState.item && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-xs text-foreground font-medium truncate">
|
||||||
|
{mentionState.item}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Type to filter mentions
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono">
|
||||||
|
↑↓
|
||||||
|
</kbd>
|
||||||
|
<span>navigate</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suggestion list */}
|
{/* Content */}
|
||||||
{visibleSuggestions.length > 0 ? (
|
{visibleSuggestions.length > 0 ? (
|
||||||
<div className="max-h-72 overflow-y-auto p-1">
|
<ScrollArea className="max-h-80">
|
||||||
{visibleSuggestions.map((s, i) => (
|
<div className="py-1">
|
||||||
<div
|
{visibleSuggestions.map((s, i) =>
|
||||||
|
s.type === 'category' ? (
|
||||||
|
<CategoryHeader
|
||||||
key={s.label + i}
|
key={s.label + i}
|
||||||
className={cn(
|
suggestion={s}
|
||||||
'group flex items-center gap-2.5 rounded-lg px-3 py-2 cursor-pointer transition-colors',
|
isSelected={i === selectedIndex}
|
||||||
i === selectedIndex
|
/>
|
||||||
? 'bg-accent text-accent-foreground'
|
) : (
|
||||||
: 'hover:bg-accent/60',
|
<SuggestionItem
|
||||||
)}
|
key={s.mentionId || s.label + i}
|
||||||
onPointerDown={(e) => e.preventDefault()}
|
suggestion={s}
|
||||||
onClick={() => s.type === 'item' && handleSelect(s)}
|
isSelected={i === selectedIndex}
|
||||||
|
onSelect={() => handleSelect(s)}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setSelectedIndex(i);
|
setSelectedIndex(i);
|
||||||
selectedIndexRef.current = i;
|
selectedIndexRef.current = i;
|
||||||
}}
|
}}
|
||||||
>
|
searchTerm={mentionState.item}
|
||||||
{/* Icon */}
|
ref={(el) => {
|
||||||
<div
|
if (el) itemsRef.current[i] = el;
|
||||||
className={cn(
|
}}
|
||||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
|
/>
|
||||||
s.category === 'repository' && 'bg-purple-500/10 text-purple-600',
|
),
|
||||||
s.category === 'user' && 'bg-blue-500/10 text-blue-600',
|
|
||||||
s.category === 'ai' && 'bg-green-500/10 text-green-600',
|
|
||||||
s.type === 'category' && 'bg-muted text-muted-foreground',
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{s.type === 'category' ? (
|
</ScrollArea>
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
) : isLoading ? (
|
||||||
) : s.category === 'ai' ? (
|
<LoadingSkeleton />
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
) : s.avatar ? (
|
|
||||||
<img src={s.avatar} alt="" className="h-full w-full rounded-full object-cover" />
|
|
||||||
) : (
|
) : (
|
||||||
s.label[0]?.toUpperCase()
|
<EmptyState loading={isLoading} />
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="block truncate text-sm font-medium leading-tight">{s.label}</span>
|
|
||||||
{s.sublabel && (
|
|
||||||
<span className="block truncate text-xs text-muted-foreground leading-tight">{s.sublabel}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Check mark */}
|
|
||||||
{i === selectedIndex && s.type === 'item' && (
|
|
||||||
<Check className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-5 text-sm text-muted-foreground">
|
|
||||||
<SearchX className="h-4 w-4 shrink-0" />
|
|
||||||
{reposLoading || aiConfigsLoading ? 'Loading…' : 'No matches'}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer hint */}
|
{/* Footer */}
|
||||||
{selectedItem?.type === 'item' && (
|
{selectedItem?.type === 'item' && (
|
||||||
<div className="border-t border-border/70 px-3 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5">
|
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50 bg-muted/30">
|
||||||
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">↵</kbd>
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
<span>to insert</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-1.5 py-0.5 rounded-md font-medium',
|
||||||
|
categoryConfig?.bgColor,
|
||||||
|
categoryConfig?.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedItem.category}
|
||||||
|
</span>
|
||||||
|
<span className="truncate max-w-[140px]">{selectedItem.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono text-[10px]">
|
||||||
|
Enter
|
||||||
|
</kbd>
|
||||||
|
<span className="text-[10px] text-muted-foreground">to insert</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AnimatedContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user