perf(room): optimize MentionPopover with caching, stable refs, and loading states

- Position caching: skip recalculation when text+cursor unchanged
- TempDiv reuse: cached DOM element on textarea, created once
- Stable refs pattern: avoid stale closures in keyboard handler
- Auto-selection: reset to first item on category/list change
- Loading states: reposLoading + aiConfigsLoading wired from context
This commit is contained in:
ZhenYi 2026-04-17 23:48:26 +08:00
parent 26682973e7
commit f9a3b51406
3 changed files with 274 additions and 231 deletions

View File

@ -1,9 +1,8 @@
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, CornerDownLeft, Keyboard, SearchX } from 'lucide-react'; import { Bot, Check, ChevronRight, SearchX } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SetStateAction } from 'react';
import { ModelIcon } from './icon-match'; import { ModelIcon } from './icon-match';
/** Room AI config — the configured model for this room */ /** Room AI config — the configured model for this room */
@ -16,8 +15,8 @@ interface MentionSuggestion {
type: 'category' | 'item'; type: 'category' | 'item';
category?: MentionMentionType; category?: MentionMentionType;
label: string; label: string;
/** Raw value stored in suggestion (for display) */ /** Display sub-label (e.g. "member · repo_name") */
value: string; sublabel?: string;
/** ID used when building HTML mention (absent for category headers) */ /** ID used when building HTML mention (absent for category headers) */
mentionId?: string; mentionId?: string;
avatar?: string | null; avatar?: string | null;
@ -33,7 +32,6 @@ interface MentionState {
interface PopoverPosition { interface PopoverPosition {
top: number; top: number;
left: number; left: number;
placement: 'top' | 'bottom';
} }
interface MentionPopoverProps { interface MentionPopoverProps {
@ -43,6 +41,9 @@ interface MentionPopoverProps {
repos?: ProjectRepositoryItem[]; repos?: ProjectRepositoryItem[];
/** Room AI configs for @ai: mention suggestions */ /** Room AI configs for @ai: mention suggestions */
aiConfigs?: RoomAiConfig[]; aiConfigs?: RoomAiConfig[];
/** Whether repos/aiConfigs are still loading */
reposLoading?: boolean;
aiConfigsLoading?: boolean;
inputValue: string; inputValue: string;
cursorPosition: number; cursorPosition: number;
onSelect: (newValue: string, newCursorPosition: number) => void; onSelect: (newValue: string, newCursorPosition: number) => void;
@ -54,19 +55,30 @@ export function MentionPopover({
members, members,
repos = [], repos = [],
aiConfigs = [], aiConfigs = [],
reposLoading = false,
aiConfigsLoading = false,
inputValue, inputValue,
cursorPosition, cursorPosition,
onSelect, onSelect,
textareaRef, textareaRef,
onOpenChange, onOpenChange,
}: MentionPopoverProps) { }: MentionPopoverProps) {
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0, placement: 'top' }); const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
const [selectedIndex, setSelectedIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
// Stable mutable refs so keyboard handler always reads fresh values
const mentionStateRef = useRef<MentionState | null>(null);
const visibleSuggestionsRef = useRef<MentionSuggestion[]>([]);
const selectedIndexRef = useRef(selectedIndex);
const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {});
const closePopoverRef = useRef<() => void>(() => {});
// Parse mention state from input
const mentionState = useMemo<MentionState | null>(() => { const mentionState = useMemo<MentionState | null>(() => {
const textBeforeCursor = inputValue.slice(0, cursorPosition); const textBeforeCursor = inputValue.slice(0, cursorPosition);
const atMatch = textBeforeCursor.match(/@([^:@\s]*)(:([^\s]*))?$/); // Match @ but NOT @@ or mentions already inside HTML tags
const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!atMatch) return null; if (!atMatch) return null;
const [fullMatch, category, , item] = atMatch; const [fullMatch, category, , item] = atMatch;
@ -80,56 +92,42 @@ export function MentionPopover({
}; };
}, [inputValue, cursorPosition]); }, [inputValue, cursorPosition]);
const mentionKey = useMemo(() => { // Keep refs in sync with state
if (!mentionState) return ''; mentionStateRef.current = mentionState;
return [mentionState.startPos, mentionState.category, mentionState.item, mentionState.hasColon].join(':');
}, [mentionState]);
const [selectionState, setSelectionState] = useState<{ key: string; index: number }>({ const categories = useMemo<MentionSuggestion[]>(() => [
key: '', { type: 'category', category: 'repository', label: 'Repository', value: '@repository:' },
index: 0, { type: 'category', category: 'user', label: 'User', value: '@user:' },
}); { type: 'category', category: 'ai', label: 'AI', value: '@ai:' },
const selectedIndex = selectionState.key === mentionKey ? selectionState.index : 0; // eslint-disable-next-line react-hooks/exhaustive-deps
const setSelectedIndex = useCallback( }, []); // intentionally static
(action: SetStateAction<number>) => {
setSelectionState((previous) => {
const baseIndex = previous.key === mentionKey ? previous.index : 0;
const nextIndex = typeof action === 'function' ? action(baseIndex) : action;
return { key: mentionKey, index: nextIndex };
});
},
[mentionKey],
);
const suggestions = useMemo((): MentionSuggestion[] => { const suggestions = useMemo((): MentionSuggestion[] => {
if (!mentionState) return []; if (!mentionState) return [];
const { category, item, hasColon } = mentionState; const { category, item, hasColon } = 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:' },
];
// Before colon: show filtered category headers
if (!hasColon) { if (!hasColon) {
if (!category) return categories; if (!category) return categories;
return categories.filter((c) => c.category?.toLowerCase().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?.toLowerCase().startsWith(category)); return categories.filter((c) => c.category?.startsWith(category));
} }
if (category === 'repository') { if (category === 'repository') {
if (reposLoading) return [{ type: 'category', label: 'Loading...', value: '' }];
return repos return repos
.filter((repo) => !item || repo.repo_name.toLowerCase().includes(item)) .filter((r) => !item || r.repo_name.toLowerCase().includes(item))
.map((repo) => ({ .slice(0, 8)
.map((r) => ({
type: 'item' as const, type: 'item' as const,
category: 'repository' as const, category: 'repository' as const,
label: repo.repo_name, label: r.repo_name,
value: `@repository:${repo.repo_name}`, mentionId: r.uid,
mentionId: repo.uid,
avatar: null,
})); }));
} }
@ -137,115 +135,182 @@ export function MentionPopover({
return members return members
.filter((m) => m.role !== 'ai') .filter((m) => m.role !== 'ai')
.filter((m) => { .filter((m) => {
const username = m.user_info?.username ?? m.user; const u = m.user_info?.username ?? m.user;
return !item || username.toLowerCase().includes(item); return !item || u.toLowerCase().includes(item);
}) })
.slice(0, 8)
.map((m) => { .map((m) => {
const username = m.user_info?.username ?? m.user; const u = m.user_info?.username ?? m.user;
return { return {
type: 'item' as const, type: 'item' as const,
category: 'user' as const, category: 'user' as const,
label: username, label: u,
value: `@user:${username}`,
mentionId: m.user, mentionId: m.user,
avatar: m.user_info?.avatar_url ?? null, avatar: m.user_info?.avatar_url,
}; };
}); });
} }
if (category === 'ai') { // category === 'ai'
if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...', value: '' }];
return aiConfigs return aiConfigs
.filter((cfg) => { .filter((cfg) => {
const name = cfg.modelName ?? cfg.model; const n = cfg.modelName ?? cfg.model;
return !item || name.toLowerCase().includes(item); return !item || n.toLowerCase().includes(item);
}) })
.slice(0, 8)
.map((cfg) => { .map((cfg) => {
const label = cfg.modelName ?? cfg.model; const label = cfg.modelName ?? cfg.model;
return { return {
type: 'item' as const, type: 'item' as const,
category: 'ai' as const, category: 'ai' as const,
label, label,
value: `@ai:${label}`,
mentionId: cfg.model, mentionId: cfg.model,
avatar: null,
}; };
}); });
}, [mentionState, members, repos, aiConfigs, categories, reposLoading, aiConfigsLoading]);
const visibleSuggestions = suggestions;
visibleSuggestionsRef.current = visibleSuggestions;
// Auto-select first item when suggestion list changes
const prevSuggestionsLenRef = useRef(0);
useEffect(() => {
if (visibleSuggestions.length !== prevSuggestionsLenRef.current) {
setSelectedIndex(0);
prevSuggestionsLenRef.current = visibleSuggestions.length;
} }
}, [visibleSuggestions.length]);
return []; // Auto-select best match: first item if category changed
}, [mentionState, members, repos, aiConfigs]); const prevCategoryRef = useRef<string | null>(null);
useEffect(() => {
const visibleSuggestions = useMemo(() => suggestions.slice(0, 8), [suggestions]); if (mentionState?.category !== prevCategoryRef.current) {
const validSelectedIndex = selectedIndex >= visibleSuggestions.length ? 0 : selectedIndex; setSelectedIndex(0);
prevCategoryRef.current = mentionState?.category ?? null;
}
}, [mentionState?.category]);
const closePopover = useCallback(() => { const closePopover = useCallback(() => {
onOpenChange(false); onOpenChange(false);
}, [onOpenChange]); }, [onOpenChange]);
const handleSelect = useMemo(() => {
return (suggestion: MentionSuggestion) => {
if (!mentionState) return;
const before = inputValue.slice(0, mentionState.startPos);
const after = inputValue.slice(cursorPosition);
let newValue: string;
let newCursorPos: number;
if (suggestion.type === 'category') {
// User selected a category header (e.g., @user:) — keep the partial
newValue = before + suggestion.value + after;
newCursorPos = mentionState.startPos + suggestion.value.length;
onOpenChange(true);
} else {
// Build new HTML mention string
const html = buildMentionHtml(
suggestion.category!,
suggestion.mentionId!,
suggestion.label,
);
const spacer = ' ';
newValue = before + html + spacer + after;
newCursorPos = mentionState.startPos + html.length + spacer.length;
onSelect(newValue, newCursorPos);
closePopover();
}
};
}, [mentionState, inputValue, cursorPosition, onSelect, closePopover, onOpenChange]);
// Refs for keyboard navigation to avoid stale closure
const visibleSuggestionsRef = useRef(visibleSuggestions);
const validSelectedIndexRef = useRef(validSelectedIndex);
const handleSelectRef = useRef(handleSelect);
const closePopoverRef = useRef(closePopover);
visibleSuggestionsRef.current = visibleSuggestions;
validSelectedIndexRef.current = validSelectedIndex;
handleSelectRef.current = handleSelect;
closePopoverRef.current = closePopover; closePopoverRef.current = closePopover;
const handleSelect = useCallback(
(suggestion: MentionSuggestion) => {
const ms = mentionStateRef.current;
if (!ms || suggestion.type !== 'item') return;
const before = inputValue.slice(0, ms.startPos);
const after = inputValue.slice(cursorPosition);
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;
onSelect(newValue, newCursorPos);
closePopover();
},
[inputValue, cursorPosition, onSelect, closePopover],
);
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);
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;
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);
}
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;
tempDiv.style.width = styles.width;
for (const prop of styleProps) {
tempDiv.style.setProperty(prop, styles.getPropertyValue(prop));
}
tempDiv.textContent = text;
const span = document.createElement('span');
span.textContent = '|';
tempDiv.appendChild(span);
const rect = textarea.getBoundingClientRect();
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0;
const borderTop = Number.parseFloat(styles.borderTopWidth) || 0;
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 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]);
// Keyboard navigation
useEffect(() => { useEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
const handleKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (!mentionState) return; const ms = mentionStateRef.current;
const currentVisible = visibleSuggestionsRef.current; if (!ms) return;
const currentSelected = validSelectedIndexRef.current;
const vis = visibleSuggestionsRef.current;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
if (currentVisible.length > 0) { const next = (selectedIndexRef.current + 1) % Math.max(vis.length, 1);
setSelectedIndex((prev) => (prev + 1) % currentVisible.length); setSelectedIndex(next);
} selectedIndexRef.current = next;
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
if (currentVisible.length > 0) { const prev = (selectedIndexRef.current - 1 + vis.length) % Math.max(vis.length, 1);
setSelectedIndex((prev) => (prev - 1 + currentVisible.length) % currentVisible.length); setSelectedIndex(prev);
} selectedIndexRef.current = prev;
} else if (e.key === 'Enter' || e.key === 'Tab') { } else if (e.key === 'Enter' || e.key === 'Tab') {
if (currentVisible[currentSelected]) { const item = vis[selectedIndexRef.current];
if (item && item.type === 'item') {
e.preventDefault(); e.preventDefault();
handleSelectRef.current(currentVisible[currentSelected]); handleSelectRef.current(item);
} }
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
@ -253,159 +318,113 @@ export function MentionPopover({
} }
}; };
textarea.addEventListener('keydown', handleKeyDown); textarea.addEventListener('keydown', onKeyDown);
return () => textarea.removeEventListener('keydown', handleKeyDown); return () => textarea.removeEventListener('keydown', onKeyDown);
}, [mentionState, setSelectedIndex, textareaRef]); }, [textareaRef]);
// Close on outside click
useEffect(() => { useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => { const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node; const t = e.target as Node;
if (popoverRef.current?.contains(target)) return; if (popoverRef.current?.contains(t) || textareaRef.current?.contains(t)) return;
if (textareaRef.current?.contains(target)) return;
closePopover(); closePopover();
}; };
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('mousedown', handleOutsideClick); return () => document.removeEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, [closePopover, textareaRef]); }, [closePopover, textareaRef]);
useEffect(() => { useEffect(() => {
if (!mentionState) closePopover(); if (!mentionState) closePopover();
}, [mentionState, closePopover]); }, [mentionState, closePopover]);
useEffect(() => {
if (!textareaRef.current || !mentionState) return;
const textarea = textareaRef.current;
const caretPos = cursorPosition;
const tempDiv = document.createElement('div');
const styles = window.getComputedStyle(textarea);
const styleProps = [
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
'letter-spacing', 'text-transform', 'word-spacing', 'text-indent', 'white-space',
'word-wrap', 'word-break', 'overflow-wrap', 'padding-left', 'padding-right',
'padding-top', 'padding-bottom', 'border-left-width', 'border-right-width',
'border-top-width', 'border-bottom-width', 'box-sizing',
];
tempDiv.style.position = 'absolute';
tempDiv.style.visibility = 'hidden';
tempDiv.style.top = '-9999px';
tempDiv.style.left = '-9999px';
tempDiv.style.width = styles.width;
styleProps.forEach((prop) => {
tempDiv.style.setProperty(prop, styles.getPropertyValue(prop));
});
document.body.appendChild(tempDiv);
tempDiv.textContent = inputValue.slice(0, caretPos);
const span = document.createElement('span');
span.textContent = '|';
tempDiv.appendChild(span);
const spanLeft = span.offsetLeft;
const spanTop = span.offsetTop;
const rect = textarea.getBoundingClientRect();
const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0;
const borderTop = Number.parseFloat(styles.borderTopWidth) || 0;
const scrollTop = textarea.scrollTop;
const scrollLeft = textarea.scrollLeft;
const lineHeight = Number.parseFloat(styles.lineHeight) || 20;
const contentTop = rect.top + borderTop + spanTop - scrollTop;
const contentLeft = rect.left + borderLeft + spanLeft - scrollLeft;
const popoverWidth = 288;
const popoverHeight = Math.min(360, Math.max(72, visibleSuggestions.length * 40 + 40));
const viewportPadding = 8;
const left = Math.min(
Math.max(contentLeft, viewportPadding),
window.innerWidth - popoverWidth - viewportPadding,
);
const bottomTop = contentTop + lineHeight + 8;
const shouldPlaceBottom = bottomTop + popoverHeight < window.innerHeight - viewportPadding;
const top = shouldPlaceBottom ? bottomTop : Math.max(viewportPadding, contentTop - popoverHeight - 8);
document.body.removeChild(tempDiv);
setPosition({ top, left, placement: shouldPlaceBottom ? 'bottom' : 'top' });
}, [mentionState, inputValue, textareaRef, cursorPosition, visibleSuggestions.length]);
if (!mentionState) return null; if (!mentionState) return null;
const selectedItem = visibleSuggestions[selectedIndex];
return ( return (
<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="fixed z-50 w-72 overflow-hidden rounded-xl border border-border/90 bg-popover shadow-2xl"
style={{ top: position.top, left: position.left }} style={{ top: position.top, left: position.left }}
> >
<div className="flex items-center justify-between border-b border-border/70 px-3 py-2 text-xs text-muted-foreground"> {/* Header */}
<span className="font-medium">Mention Suggestions</span> <div className="flex items-center gap-1.5 border-b border-border/70 px-3 py-2">
<span className="inline-flex items-center gap-1"> <span className="text-xs font-medium text-muted-foreground">@</span>
<Keyboard className="h-3.5 w-3.5" /> <span className="text-xs text-muted-foreground truncate flex-1">
{mentionState.hasColon ? mentionState.category : 'type to filter'}
{mentionState.item ? `: ${mentionState.item}` : ''}
</span> </span>
</div> </div>
{/* Suggestion list */}
{visibleSuggestions.length > 0 ? ( {visibleSuggestions.length > 0 ? (
<div className="max-h-72 overflow-y-auto p-1.5"> <div className="max-h-72 overflow-y-auto p-1">
{visibleSuggestions.map((suggestion, index) => ( {visibleSuggestions.map((s, i) => (
<div <div
key={suggestion.value} key={s.label + i}
className={cn( className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 transition-colors', 'group flex items-center gap-2.5 rounded-lg px-3 py-2 cursor-pointer transition-colors',
index === validSelectedIndex i === selectedIndex
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'cursor-pointer hover:bg-accent/50', : 'hover:bg-accent/60',
)} )}
onMouseDown={(e) => e.preventDefault()} onPointerDown={(e) => e.preventDefault()}
onClick={() => handleSelect(suggestion)} onClick={() => s.type === 'item' && handleSelect(s)}
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => {
setSelectedIndex(i);
selectedIndexRef.current = i;
}}
> >
{/* Icon */}
<div <div
className={cn( className={cn(
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold', 'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
suggestion.category === 'repository' && 'bg-purple-500/10 text-purple-600', s.category === 'repository' && 'bg-purple-500/10 text-purple-600',
suggestion.category === 'user' && 'bg-blue-500/10 text-blue-600', s.category === 'user' && 'bg-blue-500/10 text-blue-600',
suggestion.category === 'ai' && 'bg-green-500/10 text-green-600', s.category === 'ai' && 'bg-green-500/10 text-green-600',
suggestion.type === 'category' && 'bg-muted text-muted-foreground', s.type === 'category' && 'bg-muted text-muted-foreground',
)} )}
> >
{suggestion.type === 'category' ? ( {s.type === 'category' ? (
'>' <ChevronRight className="h-3.5 w-3.5" />
) : suggestion.category === 'ai' ? ( ) : s.category === 'ai' ? (
suggestion.avatar ? (
<ModelIcon modelId={suggestion.mentionId} className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
) ) : s.avatar ? (
<img src={s.avatar} alt="" className="h-full w-full rounded-full object-cover" />
) : ( ) : (
suggestion.label[0]?.toUpperCase() s.label[0]?.toUpperCase()
)} )}
</div> </div>
<span className="flex-1 truncate text-sm">{suggestion.label}</span>
{index === validSelectedIndex && <Check className="h-4 w-4 text-muted-foreground" />} {/* 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> </div>
) : ( ) : (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-muted-foreground"> <div className="flex items-center gap-2 px-3 py-5 text-sm text-muted-foreground">
<SearchX className="h-4 w-4" /> <SearchX className="h-4 w-4 shrink-0" />
No matching mentions {reposLoading || aiConfigsLoading ? 'Loading…' : 'No matches'}
</div> </div>
)} )}
<div className="flex items-center justify-end gap-3 border-t border-border/70 px-3 py-2 text-[11px] text-muted-foreground"> {/* Footer hint */}
<span className="inline-flex items-center gap-1"> {selectedItem?.type === 'item' && (
<CornerDownLeft className="h-3 w-3" /> <div className="border-t border-border/70 px-3 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5">
Enter <kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]"></kbd>
</span> <span>to insert</span>
<span className="inline-flex items-center gap-1">Tab</span>
<span>Esc</span>
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -40,7 +40,9 @@ interface ChatInputAreaProps {
isSending: boolean; isSending: boolean;
members: RoomMemberResponse[]; members: RoomMemberResponse[];
repos?: ProjectRepositoryItem[]; repos?: ProjectRepositoryItem[];
reposLoading?: boolean;
aiConfigs?: RoomAiConfig[]; aiConfigs?: RoomAiConfig[];
aiConfigsLoading?: boolean;
replyingTo?: { id: string; display_name?: string; content: string } | null; replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void; onCancelReply?: () => void;
draft: string; draft: string;
@ -55,7 +57,9 @@ const ChatInputArea = memo(function ChatInputArea({
isSending, isSending,
members, members,
repos, repos,
reposLoading,
aiConfigs, aiConfigs,
aiConfigsLoading,
replyingTo, replyingTo,
onCancelReply, onCancelReply,
draft, draft,
@ -178,7 +182,9 @@ const ChatInputArea = memo(function ChatInputArea({
<MentionPopover <MentionPopover
members={members} members={members}
repos={repos} repos={repos}
reposLoading={reposLoading}
aiConfigs={aiConfigs} aiConfigs={aiConfigs}
aiConfigsLoading={aiConfigsLoading}
inputValue={draft} inputValue={draft}
cursorPosition={cursorPosition} cursorPosition={cursorPosition}
onSelect={handleMentionSelect} onSelect={handleMentionSelect}
@ -255,7 +261,9 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
threads, threads,
refreshThreads, refreshThreads,
projectRepos, projectRepos,
reposLoading,
roomAiConfigs, roomAiConfigs,
aiConfigsLoading,
} = useRoom(); } = useRoom();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@ -543,7 +551,9 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
isSending={false} isSending={false}
members={members} members={members}
repos={projectRepos} repos={projectRepos}
reposLoading={reposLoading}
aiConfigs={roomAiConfigs} aiConfigs={roomAiConfigs}
aiConfigsLoading={aiConfigsLoading}
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null} replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
onCancelReply={() => setReplyingTo(null)} onCancelReply={() => setReplyingTo(null)}
draft={draft} draft={draft}

View File

@ -155,8 +155,10 @@ interface RoomContextValue {
/** Project repositories for @repository: mention suggestions */ /** Project repositories for @repository: mention suggestions */
projectRepos: ProjectRepositoryItem[]; projectRepos: ProjectRepositoryItem[];
reposLoading?: boolean;
/** Room AI configs for @ai: mention suggestions */ /** Room AI configs for @ai: mention suggestions */
roomAiConfigs: RoomAiConfig[]; roomAiConfigs: RoomAiConfig[];
aiConfigsLoading?: boolean;
} }
const RoomContext = createContext<RoomContextValue | null>(null); const RoomContext = createContext<RoomContextValue | null>(null);
@ -428,8 +430,10 @@ export function RoomProvider({
// Project repos for @repository: mention suggestions // Project repos for @repository: mention suggestions
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]); const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
const [reposLoading, setReposLoading] = useState(false);
// Room AI configs for @ai: mention suggestions // Room AI configs for @ai: mention suggestions
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]); const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
// Available models (for looking up AI model names) // Available models (for looking up AI model names)
const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]); const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]);
@ -1119,6 +1123,7 @@ export function RoomProvider({
setProjectRepos([]); setProjectRepos([]);
return; return;
} }
setReposLoading(true);
try { try {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectName)}/repos`); const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectName)}/repos`);
@ -1127,6 +1132,8 @@ export function RoomProvider({
setProjectRepos(json.data?.items ?? []); setProjectRepos(json.data?.items ?? []);
} catch { } catch {
setProjectRepos([]); setProjectRepos([]);
} finally {
setReposLoading(false);
} }
}, [projectName]); }, [projectName]);
@ -1137,6 +1144,7 @@ export function RoomProvider({
setRoomAiConfigs([]); setRoomAiConfigs([]);
return; return;
} }
setAiConfigsLoading(true);
try { try {
const configs = await client.aiList(activeRoomId); const configs = await client.aiList(activeRoomId);
// Look up model names from the available models list // Look up model names from the available models list
@ -1148,6 +1156,8 @@ export function RoomProvider({
); );
} catch { } catch {
setRoomAiConfigs([]); setRoomAiConfigs([]);
} finally {
setAiConfigsLoading(false);
} }
}, [activeRoomId, availableModels]); }, [activeRoomId, availableModels]);
@ -1302,7 +1312,9 @@ export function RoomProvider({
deleteRoom, deleteRoom,
streamingMessages: streamingContent, streamingMessages: streamingContent,
projectRepos, projectRepos,
reposLoading,
roomAiConfigs, roomAiConfigs,
aiConfigsLoading,
}), }),
[ [
wsStatus, wsStatus,
@ -1352,7 +1364,9 @@ export function RoomProvider({
deleteRoom, deleteRoom,
streamingContent, streamingContent,
projectRepos, projectRepos,
reposLoading,
roomAiConfigs, roomAiConfigs,
aiConfigsLoading,
], ],
); );