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:
parent
26682973e7
commit
f9a3b51406
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user