import { IconFile, IconFolder, IconStack2, IconFileText, IconCheckbox, IconMail, IconUser, IconPresentation, IconMessageChatbot, IconTrash, IconPlus, IconHelp, IconHistory, IconTerminal2, IconClipboardList, IconPencil, } from "@tabler/icons-react"; import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef, } from "react"; import { createPortal } from "react-dom"; import type { MentionItem, SkillResult, SlashCommand } from "./types.js"; export interface MentionPopoverRef { moveUp: () => void; moveDown: () => void; getSelectedIndex: () => number; getSelectedMention: () => MentionItem | null; getSelectedCommand: () => SlashCommand | null; } interface MentionPopoverProps { type: "@" | "/"; position: { top: number; left: number } | null; mentionItems: MentionItem[]; skills: SkillResult[]; commands?: SlashCommand[]; hint?: string; isLoading: boolean; query: string; onSelectMention: (item: MentionItem) => void; onSelectSkill: (skill: SkillResult) => void; onSelectCommand?: (command: SlashCommand) => void; onClose: () => void; } const iconProps = { size: 14, className: "shrink-0 text-muted-foreground" }; function MentionItemIcon({ icon }: { icon?: string }) { switch (icon) { case "folder": return ; case "document": return ; case "form": return ; case "email": return ; case "user": return ; case "deck": return ; case "agent": return ; case "file": return ; default: return ; } } function CommandIcon({ icon }: { icon?: string }) { switch (icon) { case "clear": return ; case "new": return ; case "help": return ; case "history": return ; case "plan": return ; case "act": return ; default: return ; } } function HintWithLink({ hint }: { hint: string }) { // If hint contains a URL, split it and render the URL as a link const urlMatch = hint.match(/(https?:\/\/\S+)/); if (!urlMatch) return <>{hint}; const before = hint.slice(0, urlMatch.index); const url = urlMatch[1]; return ( <> {before} Learn more ); } function LoadingSkeleton() { return (
{[1, 2, 3].map((i) => (
))}
); } function LoadingSkeletonRow() { return (
); } export const MentionPopover = forwardRef< MentionPopoverRef, MentionPopoverProps >(function MentionPopover(props, ref) { const { type, position, mentionItems, skills, commands = [], hint, isLoading, query, onSelectMention, onSelectSkill, onSelectCommand, onClose, } = props; const [selectedIndex, setSelectedIndex] = useState(0); const listRef = useRef(null); const itemCount = type === "@" ? mentionItems.length : commands.length + skills.length; // Group mention items by section for @ popover const groupedMentions = React.useMemo(() => { if (type !== "@") return []; const groups = new Map(); for (const item of mentionItems) { const section = item.section || "Other"; if (!groups.has(section)) groups.set(section, []); groups.get(section)!.push(item); } // Sort: Agents first, then Connected Agents, then template-specific, // then Files, then Other const sorted: { section: string; items: MentionItem[] }[] = []; const knownSections = new Set([ "Agents", "Connected Agents", "Files", "Other", ]); // Agents first if (groups.has("Agents")) { sorted.push({ section: "Agents", items: groups.get("Agents")! }); groups.delete("Agents"); } if (groups.has("Connected Agents")) { sorted.push({ section: "Connected Agents", items: groups.get("Connected Agents")!, }); groups.delete("Connected Agents"); } // Template-specific sections (anything not in knownSections) for (const [section, items] of groups) { if (!knownSections.has(section)) { sorted.push({ section, items }); } } // Files if (groups.has("Files")) { sorted.push({ section: "Files", items: groups.get("Files")! }); } // Other if (groups.has("Other")) { sorted.push({ section: "Other", items: groups.get("Other")! }); } return sorted; }, [type, mentionItems]); // Flat list of mention items in section order for keyboard index tracking const flatMentionItems = React.useMemo(() => { return groupedMentions.flatMap((g) => g.items); }, [groupedMentions]); // Reset selection when items change useEffect(() => { setSelectedIndex(0); }, [commands, mentionItems, skills, query]); // Scroll selected item into view useEffect(() => { const container = listRef.current; if (!container) return; // Find the actual item element by data attribute const selected = container.querySelector( `[data-mention-index="${selectedIndex}"]`, ) as HTMLElement | undefined; if (selected) { selected.scrollIntoView({ block: "nearest" }); } }, [selectedIndex]); useImperativeHandle(ref, () => ({ moveUp: () => { setSelectedIndex((prev) => prev <= 0 ? Math.max(0, itemCount - 1) : prev - 1, ); }, moveDown: () => { setSelectedIndex((prev) => (prev >= itemCount - 1 ? 0 : prev + 1)); }, getSelectedIndex: () => selectedIndex, getSelectedMention: () => flatMentionItems[selectedIndex] ?? null, getSelectedCommand: () => { if (type !== "/" || selectedIndex >= commands.length) return null; return commands[selectedIndex] ?? null; }, })); if (!position) return null; const content = ( <> {/* Backdrop to capture outside clicks */}
{isLoading && itemCount === 0 ? ( ) : itemCount === 0 ? (
{type === "@" ? ( query ? ( "No results found" ) : ( "Type to search..." ) ) : hint ? ( ) : ( "No skills available" )}
) : (
{isLoading && } {type === "@" ? (() => { let flatIndex = 0; return groupedMentions.map((group) => (
{group.section}
{group.items.map((item) => { const idx = flatIndex++; return ( ); })}
)); })() : (() => { let idx = 0; return ( <> {commands.length > 0 && (
Commands
{commands.map((cmd) => { const i = idx++; return ( ); })}
)} {skills.length > 0 && (
{commands.length > 0 && (
Skills
)} {(skills as SkillResult[]).map((skill) => { const i = idx++; return ( ); })}
)} ); })()}
)}
); return createPortal(content, document.body); });