diff --git a/src/components/room/MentionPopover.tsx b/src/components/room/MentionPopover.tsx index d0ccf9c..e958987 100644 --- a/src/components/room/MentionPopover.tsx +++ b/src/components/room/MentionPopover.tsx @@ -1,9 +1,8 @@ import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client'; import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast'; 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 type { SetStateAction } from 'react'; import { ModelIcon } from './icon-match'; /** Room AI config — the configured model for this room */ @@ -16,8 +15,8 @@ interface MentionSuggestion { type: 'category' | 'item'; category?: MentionMentionType; label: string; - /** Raw value stored in suggestion (for display) */ - value: string; + /** Display sub-label (e.g. "member · repo_name") */ + sublabel?: string; /** ID used when building HTML mention (absent for category headers) */ mentionId?: string; avatar?: string | null; @@ -33,7 +32,6 @@ interface MentionState { interface PopoverPosition { top: number; left: number; - placement: 'top' | 'bottom'; } interface MentionPopoverProps { @@ -43,6 +41,9 @@ interface MentionPopoverProps { repos?: ProjectRepositoryItem[]; /** Room AI configs for @ai: mention suggestions */ aiConfigs?: RoomAiConfig[]; + /** Whether repos/aiConfigs are still loading */ + reposLoading?: boolean; + aiConfigsLoading?: boolean; inputValue: string; cursorPosition: number; onSelect: (newValue: string, newCursorPosition: number) => void; @@ -54,19 +55,30 @@ export function MentionPopover({ members, repos = [], aiConfigs = [], + reposLoading = false, + aiConfigsLoading = false, inputValue, cursorPosition, onSelect, textareaRef, onOpenChange, }: MentionPopoverProps) { - const [position, setPosition] = useState({ top: 0, left: 0, placement: 'top' }); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [selectedIndex, setSelectedIndex] = useState(0); const popoverRef = useRef(null); + // Stable mutable refs so keyboard handler always reads fresh values + const mentionStateRef = useRef(null); + const visibleSuggestionsRef = useRef([]); + const selectedIndexRef = useRef(selectedIndex); + const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {}); + const closePopoverRef = useRef<() => void>(() => {}); + + // Parse mention state from input const mentionState = useMemo(() => { 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; const [fullMatch, category, , item] = atMatch; @@ -80,56 +92,42 @@ export function MentionPopover({ }; }, [inputValue, cursorPosition]); - const mentionKey = useMemo(() => { - if (!mentionState) return ''; - return [mentionState.startPos, mentionState.category, mentionState.item, mentionState.hasColon].join(':'); - }, [mentionState]); + // Keep refs in sync with state + mentionStateRef.current = mentionState; - const [selectionState, setSelectionState] = useState<{ key: string; index: number }>({ - key: '', - index: 0, - }); - const selectedIndex = selectionState.key === mentionKey ? selectionState.index : 0; - const setSelectedIndex = useCallback( - (action: SetStateAction) => { - 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 categories = useMemo(() => [ + { type: 'category', category: 'repository', label: 'Repository', value: '@repository:' }, + { type: 'category', category: 'user', label: 'User', value: '@user:' }, + { type: 'category', category: 'ai', label: 'AI', value: '@ai:' }, + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // intentionally static const suggestions = useMemo((): MentionSuggestion[] => { if (!mentionState) return []; 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 (!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)) { - return categories.filter((c) => c.category?.toLowerCase().startsWith(category)); + return categories.filter((c) => c.category?.startsWith(category)); } if (category === 'repository') { + if (reposLoading) return [{ type: 'category', label: 'Loading...', value: '' }]; return repos - .filter((repo) => !item || repo.repo_name.toLowerCase().includes(item)) - .map((repo) => ({ + .filter((r) => !item || r.repo_name.toLowerCase().includes(item)) + .slice(0, 8) + .map((r) => ({ type: 'item' as const, category: 'repository' as const, - label: repo.repo_name, - value: `@repository:${repo.repo_name}`, - mentionId: repo.uid, - avatar: null, + label: r.repo_name, + mentionId: r.uid, })); } @@ -137,115 +135,182 @@ export function MentionPopover({ return members .filter((m) => m.role !== 'ai') .filter((m) => { - const username = m.user_info?.username ?? m.user; - return !item || username.toLowerCase().includes(item); + const u = m.user_info?.username ?? m.user; + return !item || u.toLowerCase().includes(item); }) + .slice(0, 8) .map((m) => { - const username = m.user_info?.username ?? m.user; + const u = m.user_info?.username ?? m.user; return { type: 'item' as const, category: 'user' as const, - label: username, - value: `@user:${username}`, + label: u, mentionId: m.user, - avatar: m.user_info?.avatar_url ?? null, + avatar: m.user_info?.avatar_url, }; }); } - if (category === 'ai') { - return aiConfigs - .filter((cfg) => { - const name = cfg.modelName ?? cfg.model; - return !item || name.toLowerCase().includes(item); - }) - .map((cfg) => { - const label = cfg.modelName ?? cfg.model; - return { - type: 'item' as const, - category: 'ai' as const, - label, - value: `@ai:${label}`, - mentionId: cfg.model, - avatar: null, - }; - }); + // category === 'ai' + if (aiConfigsLoading) return [{ type: 'category', label: 'Loading...', value: '' }]; + return aiConfigs + .filter((cfg) => { + const n = cfg.modelName ?? cfg.model; + return !item || n.toLowerCase().includes(item); + }) + .slice(0, 8) + .map((cfg) => { + const label = cfg.modelName ?? cfg.model; + return { + type: 'item' as const, + category: 'ai' as const, + label, + mentionId: cfg.model, + }; + }); + }, [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 []; - }, [mentionState, members, repos, aiConfigs]); - - const visibleSuggestions = useMemo(() => suggestions.slice(0, 8), [suggestions]); - const validSelectedIndex = selectedIndex >= visibleSuggestions.length ? 0 : selectedIndex; + // Auto-select best match: first item if category changed + const prevCategoryRef = useRef(null); + useEffect(() => { + if (mentionState?.category !== prevCategoryRef.current) { + setSelectedIndex(0); + prevCategoryRef.current = mentionState?.category ?? null; + } + }, [mentionState?.category]); const closePopover = useCallback(() => { onOpenChange(false); }, [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; + 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(() => { const textarea = textareaRef.current; if (!textarea) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (!mentionState) return; - const currentVisible = visibleSuggestionsRef.current; - const currentSelected = validSelectedIndexRef.current; + const onKeyDown = (e: KeyboardEvent) => { + const ms = mentionStateRef.current; + if (!ms) return; + + const vis = visibleSuggestionsRef.current; if (e.key === 'ArrowDown') { e.preventDefault(); - if (currentVisible.length > 0) { - setSelectedIndex((prev) => (prev + 1) % currentVisible.length); - } + const next = (selectedIndexRef.current + 1) % Math.max(vis.length, 1); + setSelectedIndex(next); + selectedIndexRef.current = next; } else if (e.key === 'ArrowUp') { e.preventDefault(); - if (currentVisible.length > 0) { - setSelectedIndex((prev) => (prev - 1 + currentVisible.length) % currentVisible.length); - } + const prev = (selectedIndexRef.current - 1 + vis.length) % Math.max(vis.length, 1); + setSelectedIndex(prev); + selectedIndexRef.current = prev; } else if (e.key === 'Enter' || e.key === 'Tab') { - if (currentVisible[currentSelected]) { + const item = vis[selectedIndexRef.current]; + if (item && item.type === 'item') { e.preventDefault(); - handleSelectRef.current(currentVisible[currentSelected]); + handleSelectRef.current(item); } } else if (e.key === 'Escape') { e.preventDefault(); @@ -253,159 +318,113 @@ export function MentionPopover({ } }; - textarea.addEventListener('keydown', handleKeyDown); - return () => textarea.removeEventListener('keydown', handleKeyDown); - }, [mentionState, setSelectedIndex, textareaRef]); + textarea.addEventListener('keydown', onKeyDown); + return () => textarea.removeEventListener('keydown', onKeyDown); + }, [textareaRef]); + // Close on outside click useEffect(() => { - const handleOutsideClick = (e: MouseEvent) => { - const target = e.target as Node; - if (popoverRef.current?.contains(target)) return; - if (textareaRef.current?.contains(target)) return; + const onPointerDown = (e: PointerEvent) => { + const t = e.target as Node; + if (popoverRef.current?.contains(t) || textareaRef.current?.contains(t)) return; closePopover(); }; - - document.addEventListener('mousedown', handleOutsideClick); - return () => document.removeEventListener('mousedown', handleOutsideClick); + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); }, [closePopover, textareaRef]); useEffect(() => { if (!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; + const selectedItem = visibleSuggestions[selectedIndex]; + return (
-
- Mention Suggestions - - ↑↓ + {/* Header */} +
+ @ + + {mentionState.hasColon ? mentionState.category : 'type to filter'} + {mentionState.item ? `: ${mentionState.item}` : ''}
+ {/* Suggestion list */} {visibleSuggestions.length > 0 ? ( -
- {visibleSuggestions.map((suggestion, index) => ( +
+ {visibleSuggestions.map((s, i) => (
e.preventDefault()} - onClick={() => handleSelect(suggestion)} - onMouseEnter={() => setSelectedIndex(index)} + onPointerDown={(e) => e.preventDefault()} + onClick={() => s.type === 'item' && handleSelect(s)} + onMouseEnter={() => { + setSelectedIndex(i); + selectedIndexRef.current = i; + }} > + {/* Icon */}
- {suggestion.type === 'category' ? ( - '>' - ) : suggestion.category === 'ai' ? ( - suggestion.avatar ? ( - - ) : ( - - ) + {s.type === 'category' ? ( + + ) : s.category === 'ai' ? ( + + ) : s.avatar ? ( + ) : ( - suggestion.label[0]?.toUpperCase() + s.label[0]?.toUpperCase() )}
- {suggestion.label} - {index === validSelectedIndex && } + + {/* Label */} +
+ {s.label} + {s.sublabel && ( + {s.sublabel} + )} +
+ + {/* Check mark */} + {i === selectedIndex && s.type === 'item' && ( + + )}
))}
) : ( -
- - No matching mentions +
+ + {reposLoading || aiConfigsLoading ? 'Loading…' : 'No matches'}
)} -
- - - Enter - - Tab - Esc -
+ {/* Footer hint */} + {selectedItem?.type === 'item' && ( +
+ + to insert +
+ )}
); } diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 09094a5..a0d7dba 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -40,7 +40,9 @@ interface ChatInputAreaProps { isSending: boolean; members: RoomMemberResponse[]; repos?: ProjectRepositoryItem[]; + reposLoading?: boolean; aiConfigs?: RoomAiConfig[]; + aiConfigsLoading?: boolean; replyingTo?: { id: string; display_name?: string; content: string } | null; onCancelReply?: () => void; draft: string; @@ -55,7 +57,9 @@ const ChatInputArea = memo(function ChatInputArea({ isSending, members, repos, + reposLoading, aiConfigs, + aiConfigsLoading, replyingTo, onCancelReply, draft, @@ -178,7 +182,9 @@ const ChatInputArea = memo(function ChatInputArea({ (null); @@ -543,7 +551,9 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane isSending={false} members={members} repos={projectRepos} + reposLoading={reposLoading} aiConfigs={roomAiConfigs} + aiConfigsLoading={aiConfigsLoading} replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null} onCancelReply={() => setReplyingTo(null)} draft={draft} diff --git a/src/contexts/room-context.tsx b/src/contexts/room-context.tsx index 9412b84..31d2c19 100644 --- a/src/contexts/room-context.tsx +++ b/src/contexts/room-context.tsx @@ -155,8 +155,10 @@ interface RoomContextValue { /** Project repositories for @repository: mention suggestions */ projectRepos: ProjectRepositoryItem[]; + reposLoading?: boolean; /** Room AI configs for @ai: mention suggestions */ roomAiConfigs: RoomAiConfig[]; + aiConfigsLoading?: boolean; } const RoomContext = createContext(null); @@ -428,8 +430,10 @@ export function RoomProvider({ // Project repos for @repository: mention suggestions const [projectRepos, setProjectRepos] = useState([]); + const [reposLoading, setReposLoading] = useState(false); // Room AI configs for @ai: mention suggestions const [roomAiConfigs, setRoomAiConfigs] = useState([]); + const [aiConfigsLoading, setAiConfigsLoading] = useState(false); // Available models (for looking up AI model names) const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]); @@ -1119,6 +1123,7 @@ export function RoomProvider({ setProjectRepos([]); return; } + setReposLoading(true); try { const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin; const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectName)}/repos`); @@ -1127,6 +1132,8 @@ export function RoomProvider({ setProjectRepos(json.data?.items ?? []); } catch { setProjectRepos([]); + } finally { + setReposLoading(false); } }, [projectName]); @@ -1137,6 +1144,7 @@ export function RoomProvider({ setRoomAiConfigs([]); return; } + setAiConfigsLoading(true); try { const configs = await client.aiList(activeRoomId); // Look up model names from the available models list @@ -1148,6 +1156,8 @@ export function RoomProvider({ ); } catch { setRoomAiConfigs([]); + } finally { + setAiConfigsLoading(false); } }, [activeRoomId, availableModels]); @@ -1302,7 +1312,9 @@ export function RoomProvider({ deleteRoom, streamingMessages: streamingContent, projectRepos, + reposLoading, roomAiConfigs, + aiConfigsLoading, }), [ wsStatus, @@ -1352,7 +1364,9 @@ export function RoomProvider({ deleteRoom, streamingContent, projectRepos, + reposLoading, roomAiConfigs, + aiConfigsLoading, ], );