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);
});