feat(chat-input): add category-based mention menu (#13109)

*  feat(chat-input): add category-based mention menu with keyboard navigation

Replace flat mention list with a structured category menu (Agents, Members, Topics).
Supports home/category/search views, Fuse.js fuzzy search, floating-ui positioning,
and full keyboard navigation.

* 🔧 chore: update @lobehub/editor to version 4.3.0 and refactor type definition in useMentionCategories

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(MentionMenu): enhance icon rendering logic in MenuItem component

Updated the MenuItem component to improve how icons are rendered. Now, it checks if the icon is a valid React element or a function, ensuring better flexibility in icon usage. This change enhances the overall user experience in the mention menu.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update @lobehub/editor to version 4.3.1 in package.json

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-03-19 21:48:10 +08:00
committed by GitHub
parent 57ec43cd00
commit 80cb6c9d11
12 changed files with 836 additions and 30 deletions

View File

@@ -188,6 +188,7 @@
"@emoji-mart/react": "^1.1.1",
"@emotion/react": "^11.14.0",
"@fal-ai/client": "^1.8.4",
"@floating-ui/react": "^0.27.19",
"@formkit/auto-animate": "^0.9.0",
"@google/genai": "^1.38.0",
"@henrygd/queue": "^1.2.0",
@@ -253,7 +254,7 @@
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^4.2.0",
"@lobehub/editor": "^4.3.1",
"@lobehub/icons": "^5.0.0",
"@lobehub/market-sdk": "^0.31.3",
"@lobehub/tts": "^5.1.2",

View File

@@ -0,0 +1,51 @@
import type { ISlashMenuOption } from '@lobehub/editor';
import { ArrowLeft } from 'lucide-react';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import MenuItem from './MenuItem';
import { useStyles } from './style';
import type { MentionCategory } from './types';
interface CategoryViewProps {
activeKey: string | null;
category: MentionCategory;
onBack: () => void;
onSelectItem: (item: ISlashMenuOption) => void;
}
const CategoryView = memo<CategoryViewProps>(({ category, activeKey, onSelectItem, onBack }) => {
const { styles } = useStyles();
const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
};
return (
<>
<div
className={styles.backHeader}
role="presentation"
onClick={onBack}
onMouseDown={handleMouseDown}
>
<ArrowLeft size={14} />
{category.label}
</div>
<div className={styles.divider} />
<div className={styles.scrollArea}>
{category.items.map((item) => (
<MenuItem
active={String(item.key) === activeKey}
item={item}
key={item.key}
onClick={onSelectItem}
/>
))}
</div>
</>
);
});
CategoryView.displayName = 'CategoryView';
export default CategoryView;

View File

@@ -0,0 +1,50 @@
import type { ISlashMenuOption } from '@lobehub/editor';
import { ChevronRight } from 'lucide-react';
import { memo } from 'react';
import MenuItem from './MenuItem';
import { useStyles } from './style';
import { isCategoryEntry } from './types';
interface HomeViewProps {
activeKey: string | null;
dividerIndex: number;
onSelectItem: (item: ISlashMenuOption) => void;
visibleItems: ISlashMenuOption[];
}
const HomeView = memo<HomeViewProps>(({ visibleItems, activeKey, onSelectItem, dividerIndex }) => {
const { styles } = useStyles();
return (
<div className={styles.scrollArea}>
{visibleItems.map((item, idx) => {
const isCategory = isCategoryEntry(String(item.key));
const showDivider = idx === dividerIndex && dividerIndex > 0;
return (
<div key={item.key}>
{showDivider && <div className={styles.divider} />}
<MenuItem
active={String(item.key) === activeKey}
item={item}
extra={
isCategory ? (
<span className={styles.categoryExtra}>
{(item as any).metadata?.count}
<ChevronRight size={14} />
</span>
) : undefined
}
onClick={onSelectItem}
/>
</div>
);
})}
</div>
);
});
HomeView.displayName = 'HomeView';
export default HomeView;

View File

@@ -0,0 +1,48 @@
import type { ISlashMenuOption } from '@lobehub/editor';
import { cx } from 'antd-style';
import { createElement, isValidElement, type MouseEvent, type ReactNode } from 'react';
import { memo } from 'react';
import { useStyles } from './style';
interface MenuItemProps {
active?: boolean;
extra?: ReactNode;
item: ISlashMenuOption;
onClick: (item: ISlashMenuOption) => void;
}
const MenuItem = memo<MenuItemProps>(({ item, active, extra, onClick }) => {
const { styles } = useStyles();
const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
};
return (
<div
aria-selected={active}
className={cx(styles.item, active && styles.itemActive)}
data-key={item.key}
id={`mention-item-${item.key}`}
role="option"
onClick={() => onClick(item)}
onMouseDown={handleMouseDown}
>
{item.icon && (
<span className={styles.itemIcon}>
{isValidElement(item.icon)
? item.icon
: typeof item.icon === 'function'
? createElement(item.icon)
: item.icon}
</span>
)}
<span className={styles.itemLabel}>{item.label}</span>
{extra}
</div>
);
});
MenuItem.displayName = 'MenuItem';
export default MenuItem;

View File

@@ -0,0 +1,36 @@
import type { ISlashMenuOption } from '@lobehub/editor';
import { memo } from 'react';
import MenuItem from './MenuItem';
import { useStyles } from './style';
interface SearchViewProps {
activeKey: string | null;
onSelectItem: (item: ISlashMenuOption) => void;
options: ISlashMenuOption[];
}
const SearchView = memo<SearchViewProps>(({ options, activeKey, onSelectItem }) => {
const { styles } = useStyles();
if (options.length === 0) {
return <div className={styles.empty}>No results</div>;
}
return (
<div className={styles.scrollArea}>
{options.map((item) => (
<MenuItem
active={String(item.key) === activeKey}
item={item}
key={item.key}
onClick={onSelectItem}
/>
))}
</div>
);
});
SearchView.displayName = 'SearchView';
export default SearchView;

View File

@@ -0,0 +1,196 @@
import type { ISlashMenuOption, ISlashOption } from '@lobehub/editor';
import { LOBE_THEME_APP_ID } from '@lobehub/ui';
import type { FC, RefObject } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import CategoryView from './CategoryView';
import HomeView from './HomeView';
import SearchView from './SearchView';
import { useStyles } from './style';
import type { MentionCategory, MentionCategoryId, MentionMenuState } from './types';
import { CATEGORY_KEY_PREFIX, getCategoryIdFromKey, isCategoryEntry } from './types';
import { useKeyboardNav } from './useKeyboardNav';
import { useMenuPosition } from './useMenuPosition';
const RECENT_COUNT = 8;
interface MenuRenderProps {
activeKey: string | null;
loading?: boolean;
onSelect?: (option: ISlashMenuOption) => void;
open?: boolean;
options: Array<ISlashOption>;
setActiveKey: (key: string | null) => void;
}
const getRecentItems = (options: ISlashMenuOption[], count: number): ISlashMenuOption[] => {
return [...options]
.sort((a, b) => {
const ta = (a as any).metadata?.timestamp || 0;
const tb = (b as any).metadata?.timestamp || 0;
return tb - ta;
})
.slice(0, count);
};
const buildCategoryEntries = (categories: MentionCategory[]): ISlashMenuOption[] =>
categories
.filter((c) => c.items.length > 0)
.map((cat) => ({
icon: cat.icon,
key: `${CATEGORY_KEY_PREFIX}${cat.id}`,
label: cat.label,
metadata: { categoryId: cat.id, count: cat.items.length, type: '__category__' },
}));
export const createMentionMenu = (
stateRef: RefObject<MentionMenuState>,
categoriesRef: RefObject<MentionCategory[]>,
): FC<MenuRenderProps> => {
const MentionMenu: FC<MenuRenderProps> = memo(
({ activeKey, onSelect, open, options, setActiveKey }) => {
const { styles } = useStyles();
const menuRef = useRef<HTMLDivElement>(null);
const [viewMode, setViewMode] = useState<'home' | 'category'>('home');
const [selectedCategoryId, setSelectedCategoryId] = useState<MentionCategoryId | null>(null);
const isSearch = stateRef.current.isSearch;
const categories = categoriesRef.current;
const position = useMenuPosition(menuRef, !!open);
// Reset on open
useEffect(() => {
if (open) {
setViewMode('home');
setSelectedCategoryId(null);
}
}, [open]);
// Filter options to only ISlashMenuOption (exclude dividers)
const menuOptions = useMemo(
() => options.filter((o): o is ISlashMenuOption => 'key' in o && !!o.key),
[options],
);
// Category entries as pseudo-items for keyboard navigation
const categoryEntries = useMemo(() => buildCategoryEntries(categories), [categories]);
// Derive visible items for current view
const visibleItems = useMemo((): ISlashMenuOption[] => {
if (isSearch) return menuOptions;
if (viewMode === 'category' && selectedCategoryId) {
const cat = categories.find((c) => c.id === selectedCategoryId);
return cat?.items || [];
}
// Home: recent items + category entries (unified list)
const recent = getRecentItems(menuOptions, RECENT_COUNT);
return [...recent, ...categoryEntries];
}, [menuOptions, isSearch, viewMode, selectedCategoryId, categories, categoryEntries]);
// Sync activeKey on view/options change
useEffect(() => {
if (open && visibleItems.length > 0) {
setActiveKey(String(visibleItems[0].key));
}
}, [open, viewMode, selectedCategoryId, isSearch, visibleItems, setActiveKey]);
const handleSelectCategory = useCallback((id: MentionCategoryId) => {
setViewMode('category');
setSelectedCategoryId(id);
}, []);
const handleBack = useCallback(() => {
setViewMode('home');
setSelectedCategoryId(null);
}, []);
// Item selection — intercept category entries
const handleSelectItem = useCallback(
(item: ISlashMenuOption) => {
const key = String(item.key);
if (isCategoryEntry(key)) {
handleSelectCategory(getCategoryIdFromKey(key));
return;
}
onSelect?.(item);
},
[onSelect, handleSelectCategory],
);
const effectiveMode = isSearch ? 'search' : viewMode;
useKeyboardNav({
activeKey,
mode: effectiveMode === 'search' ? 'search' : viewMode,
onBack: handleBack,
onSelect: handleSelectItem,
open: !!open,
setActiveKey,
visibleItems,
});
const lobeApp = useMemo(
() => document.getElementById(LOBE_THEME_APP_ID) ?? document.body,
[],
);
if (!open) return null;
const selectedCategory = selectedCategoryId
? categories.find((c) => c.id === selectedCategoryId)
: null;
// Index where category entries start (for divider placement in HomeView)
const recentCount =
effectiveMode === 'home' ? visibleItems.length - categoryEntries.length : 0;
const menu = (
<div
aria-activedescendant={activeKey ? `mention-item-${activeKey}` : undefined}
className={styles.container}
ref={menuRef}
role="listbox"
style={{
left: position.x,
opacity: position.visible ? 1 : 0,
pointerEvents: position.visible ? 'auto' : 'none',
top: position.y,
visibility: position.visible ? 'visible' : 'hidden',
}}
>
{effectiveMode === 'home' && (
<HomeView
activeKey={activeKey}
dividerIndex={recentCount}
visibleItems={visibleItems}
onSelectItem={handleSelectItem}
/>
)}
{effectiveMode === 'category' && selectedCategory && (
<CategoryView
activeKey={activeKey}
category={selectedCategory}
onBack={handleBack}
onSelectItem={handleSelectItem}
/>
)}
{effectiveMode === 'search' && (
<SearchView
activeKey={activeKey}
options={visibleItems}
onSelectItem={handleSelectItem}
/>
)}
</div>
);
return createPortal(menu, lobeApp);
},
);
MentionMenu.displayName = 'MentionMenu';
return MentionMenu;
};

View File

@@ -0,0 +1,103 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token, isDarkMode }) => ({
backHeader: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
padding-block: 6px;
padding-inline: 12px;
font-size: 13px;
font-weight: 500;
color: ${token.colorTextSecondary};
&:hover {
color: ${token.colorText};
}
`,
categoryExtra: css`
display: flex;
flex-shrink: 0;
gap: 2px;
align-items: center;
font-size: 12px;
color: ${token.colorTextQuaternary};
`,
container: css`
position: fixed;
z-index: 99999;
display: flex;
flex-direction: column;
min-width: 260px;
max-width: 360px;
max-height: 360px;
padding: 4px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: 10px;
background: ${isDarkMode ? token.colorBgElevated : token.colorBgContainer};
box-shadow: ${token.boxShadowSecondary};
`,
divider: css`
height: 1px;
margin-block: 4px;
margin-inline: 8px;
background: ${token.colorBorder};
`,
empty: css`
padding-block: 16px;
padding-inline: 12px;
font-size: 13px;
color: ${token.colorTextQuaternary};
text-align: center;
`,
item: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
padding-block: 6px;
padding-inline: 12px;
border-radius: 6px;
font-size: 13px;
transition: background 0.1s;
&:hover {
background: ${token.colorFillTertiary};
}
`,
itemActive: css`
background: ${token.colorFillSecondary};
`,
itemIcon: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`,
itemLabel: css`
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
`,
scrollArea: css`
overflow-y: auto;
flex: 1;
`,
}));

View File

@@ -0,0 +1,23 @@
import type { ISlashMenuOption } from '@lobehub/editor';
import type { ReactNode } from 'react';
export type MentionCategoryId = 'agent' | 'topic' | 'member';
export interface MentionCategory {
icon: ReactNode;
id: MentionCategoryId;
items: ISlashMenuOption[];
label: string;
}
export interface MentionMenuState {
isSearch: boolean;
matchingString: string;
}
export const CATEGORY_KEY_PREFIX = '__category__';
export const isCategoryEntry = (key: string): boolean => key.startsWith(CATEGORY_KEY_PREFIX);
export const getCategoryIdFromKey = (key: string): MentionCategoryId =>
key.slice(CATEGORY_KEY_PREFIX.length) as MentionCategoryId;

View File

@@ -0,0 +1,66 @@
import type { ISlashMenuOption } from '@lobehub/editor';
import { useEffect } from 'react';
interface KeyboardNavOptions {
activeKey: string | null;
mode: 'home' | 'category' | 'search';
onBack: () => void;
onSelect: (option: ISlashMenuOption) => void;
open: boolean;
setActiveKey: (key: string | null) => void;
visibleItems: ISlashMenuOption[];
}
export const useKeyboardNav = ({
open,
visibleItems,
activeKey,
setActiveKey,
onSelect,
onBack,
mode,
}: KeyboardNavOptions) => {
useEffect(() => {
if (!open || visibleItems.length === 0) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopImmediatePropagation();
const idx = visibleItems.findIndex((i) => String(i.key) === activeKey);
const next =
e.key === 'ArrowDown'
? (idx + 1) % visibleItems.length
: (idx - 1 + visibleItems.length) % visibleItems.length;
const nextKey = String(visibleItems[next].key);
setActiveKey(nextKey);
// Scroll active item into view
requestAnimationFrame(() => {
document.getElementById(`mention-item-${nextKey}`)?.scrollIntoView({ block: 'nearest' });
});
}
if (e.key === 'Enter' || e.key === 'Tab') {
const item = visibleItems.find((i) => String(i.key) === activeKey);
if (item) {
e.preventDefault();
e.stopImmediatePropagation();
onSelect(item);
}
}
if (e.key === 'Escape' && mode === 'category') {
e.preventDefault();
e.stopImmediatePropagation();
onBack();
}
// Home/Search Escape: do NOT intercept → let editor close the menu
};
document.addEventListener('keydown', handler, true); // capture phase
return () => document.removeEventListener('keydown', handler, true);
}, [open, visibleItems, activeKey, setActiveKey, onSelect, onBack, mode]);
};

View File

@@ -0,0 +1,100 @@
import type { VirtualElement } from '@floating-ui/react';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react';
import type { RefObject } from 'react';
import { useLayoutEffect, useState } from 'react';
const MENU_GAP = 4;
const VIEWPORT_MARGIN = 8;
interface MenuPosition {
visible: boolean;
}
const getSelectionRect = () => {
const selection = window.getSelection();
if (!selection?.rangeCount) return new DOMRect();
return selection.getRangeAt(0).getBoundingClientRect();
};
const hasActiveEditorSelection = () => {
const selection = window.getSelection();
if (!selection?.rangeCount) return false;
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return false;
const editor = activeElement.closest('[data-lexical-editor="true"]');
if (!(editor instanceof HTMLElement)) return false;
const anchorNode = selection.anchorNode;
if (!anchorNode) return false;
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode.parentElement;
return !!anchorElement && editor.contains(anchorElement);
};
const createCaretVirtualElement = (): VirtualElement => ({
getBoundingClientRect: () => getSelectionRect(),
});
export const useMenuPosition = (menuRef: RefObject<HTMLDivElement | null>, open: boolean) => {
const { refs, update, x, y } = useFloating({
middleware: [
offset(MENU_GAP),
flip({ fallbackPlacements: ['bottom-start'] }),
shift({ crossAxis: true, mainAxis: false, padding: VIEWPORT_MARGIN }),
],
placement: 'top-start',
strategy: 'fixed',
});
const [position, setPosition] = useState<MenuPosition>({ visible: false });
useLayoutEffect(() => {
if (!open || !menuRef.current) {
setPosition((prev) => ({ ...prev, visible: false }));
return;
}
const menu = menuRef.current;
const reference = createCaretVirtualElement();
refs.setFloating(menu);
refs.setPositionReference(reference);
const updatePosition = async () => {
if (!hasActiveEditorSelection()) {
setPosition({ visible: false });
return;
}
await update();
setPosition({ visible: true });
};
const scheduleUpdate = () => {
requestAnimationFrame(() => {
void updatePosition();
});
};
const cleanupAutoUpdate = autoUpdate(reference, menu, scheduleUpdate, {
animationFrame: true,
});
document.addEventListener('focusin', scheduleUpdate);
document.addEventListener('focusout', scheduleUpdate);
document.addEventListener('selectionchange', scheduleUpdate);
scheduleUpdate();
return () => {
cleanupAutoUpdate();
document.removeEventListener('focusin', scheduleUpdate);
document.removeEventListener('focusout', scheduleUpdate);
document.removeEventListener('selectionchange', scheduleUpdate);
};
}, [menuRef, open, refs, update]);
return { visible: position.visible, x: x ?? 0, y: y ?? 0 };
};

View File

@@ -1,11 +1,12 @@
import { isDesktop } from '@lobechat/const';
import { HotkeyEnum, KeyEnum } from '@lobechat/types';
import { isCommandPressed } from '@lobechat/utils';
import { INSERT_MENTION_COMMAND, ReactMathPlugin, type SlashOptions } from '@lobehub/editor';
import { INSERT_MENTION_COMMAND, ReactMathPlugin } from '@lobehub/editor';
import { Editor, FloatMenu, useEditorState } from '@lobehub/editor/react';
import { combineKeys } from '@lobehub/ui';
import { css, cx } from 'antd-style';
import { memo, useCallback, useEffect, useMemo } from 'react';
import Fuse from 'fuse.js';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { usePasteFile, useUploadFiles } from '@/components/DragUploadZone';
@@ -18,11 +19,12 @@ import { labPreferSelectors, preferenceSelectors, settingsSelectors } from '@/st
import { useAgentId } from '../hooks/useAgentId';
import { useChatInputStore, useStoreApi } from '../store';
import { useSlashActionItems } from './ActionTag';
import { createMentionMenu } from './MentionMenu';
import type { MentionMenuState } from './MentionMenu/types';
import Placeholder from './Placeholder';
import { CHAT_INPUT_EMBED_PLUGINS, createChatInputRichPlugins } from './plugins';
import { INSERT_REFER_TOPIC_COMMAND } from './ReferTopic';
import { useAgentMentionItems } from './useAgentMentionItems';
import { useTopicMentionItems } from './useTopicMentionItems';
import { useMentionCategories } from './useMentionCategories';
const className = cx(css`
p {
@@ -31,14 +33,13 @@ const className = cx(css`
`);
const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
const [editor, slashMenuRef, send, updateMarkdownContent, expand, mentionItems, slashPlacement] =
const [editor, slashMenuRef, send, updateMarkdownContent, expand, slashPlacement] =
useChatInputStore((s) => [
s.editor,
s.slashMenuRef,
s.handleSendButton,
s.updateMarkdownContent,
s.expand,
s.mentionItems,
s.slashPlacement ?? 'top',
]);
@@ -51,32 +52,40 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
const topicMentionItems = useTopicMentionItems();
const agentMentionItems = useAgentMentionItems();
// --- Category-based mention system ---
const categories = useMentionCategories();
const stateRef = useRef<MentionMenuState>({ isSearch: false, matchingString: '' });
const categoriesRef = useRef(categories);
categoriesRef.current = categories;
const mergedMentionItems = useMemo<SlashOptions['items'] | undefined>(() => {
const topicItems = topicMentionItems || [];
// In non-group context, use agent items from hook; in group context, use injected mentionItems
const fallbackAgentItems = !mentionItems ? agentMentionItems : [];
const allMentionItems = useMemo(() => categories.flatMap((c) => c.items), [categories]);
if (!mentionItems && fallbackAgentItems.length === 0 && topicItems.length === 0)
return undefined;
const fuse = useMemo(
() =>
new Fuse(allMentionItems, {
keys: ['key', 'label', 'metadata.topicTitle'],
threshold: 0.3,
}),
[allMentionItems],
);
if (typeof mentionItems === 'function') {
const fn = mentionItems;
return async (search: Parameters<typeof fn>[0]) => {
const groupItems = await fn(search);
return [...groupItems, ...topicItems];
};
}
const mentionItemsFn = useCallback(
async (
search: { leadOffset: number; matchingString: string; replaceableString: string } | null,
) => {
if (search?.matchingString) {
stateRef.current = { isSearch: true, matchingString: search.matchingString };
return fuse.search(search.matchingString).map((r) => r.item);
}
stateRef.current = { isSearch: false, matchingString: '' };
return [...allMentionItems];
},
[allMentionItems, fuse],
);
const externalItems = Array.isArray(mentionItems) ? mentionItems : [];
return [...externalItems, ...fallbackAgentItems, ...topicItems];
}, [mentionItems, topicMentionItems, agentMentionItems]);
const MentionMenuComp = useMemo(() => createMentionMenu(stateRef, categoriesRef), []);
const enableMention =
!!mergedMentionItems &&
(typeof mergedMentionItems === 'function' || mergedMentionItems.length > 0);
const enableMention = allMentionItems.length > 0;
// Get agent's model info for vision support check and handle paste upload
const agentId = useAgentId();
@@ -156,14 +165,14 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
mentionOption={
enableMention
? {
fuseOptions: { keys: ['key', 'label', 'metadata.topicTitle'], threshold: 0.3 },
items: mergedMentionItems,
items: mentionItemsFn,
markdownWriter: (mention) => {
if (mention.metadata?.type === 'topic') {
return `<refer_topic name="${mention.metadata.topicTitle}" id="${mention.metadata.topicId}" />`;
}
return `<mention name="${mention.label}" id="${mention.metadata.id}" />`;
},
maxLength: 50,
onSelect: (editor, option) => {
if (option.metadata?.type === 'topic') {
editor.dispatchCommand(INSERT_REFER_TOPIC_COMMAND, {
@@ -177,6 +186,7 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
});
}
},
renderComp: MentionMenuComp,
}
: undefined
}

View File

@@ -0,0 +1,122 @@
import { Avatar, Icon } from '@lobehub/ui';
import { Bot, MessageSquareText, Users } from 'lucide-react';
import { useMemo } from 'react';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useHomeStore } from '@/store/home';
import { homeAgentListSelectors } from '@/store/home/selectors';
import { useAgentId } from '../hooks/useAgentId';
import { useChatInputStore } from '../store';
import type { MentionCategory } from './MentionMenu/types';
const MAX_AGENT_ITEMS = 20;
const MAX_TOPIC_LABEL = 50;
type MenuOptionWithMetadata = { key: string; metadata?: Record<string, unknown> };
export const useMentionCategories = (): MentionCategory[] => {
const currentAgentId = useAgentId();
const allAgents = useHomeStore(homeAgentListSelectors.allAgents);
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
const topicsSelector = useMemo(
() => topicSelectors.displayTopicsForSidebar(topicPageSize),
[topicPageSize],
);
const topics = useChatStore(topicsSelector);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const externalMentionItems = useChatInputStore((s) => s.mentionItems);
const isGroupChat = !!externalMentionItems;
return useMemo(() => {
const categories: MentionCategory[] = [];
// --- Agents (non-group only) ---
if (!isGroupChat) {
const items = allAgents
.filter((a) => a.type === 'agent' && a.id !== currentAgentId)
.slice(0, MAX_AGENT_ITEMS)
.map((agent) => ({
icon: (
<Avatar
avatar={typeof agent.avatar === 'string' ? agent.avatar : undefined}
background={agent.backgroundColor ?? undefined}
size={24}
/>
),
key: `agent-${agent.id}`,
label: agent.title || 'Untitled Agent',
metadata: {
id: agent.id,
timestamp: agent.updatedAt ? new Date(agent.updatedAt).getTime() : 0,
type: 'agent' as const,
},
}));
if (items.length > 0) {
categories.push({
id: 'agent',
icon: <Icon icon={Bot} size={16} />,
items,
label: 'Agents',
});
}
}
// --- Members (group chat only) ---
if (isGroupChat && Array.isArray(externalMentionItems)) {
const items = externalMentionItems
.filter((item): item is MenuOptionWithMetadata => 'key' in item && !!item.key)
.map((item) => ({
...item,
metadata: Object.assign({ timestamp: 0, type: 'member' as const }, item.metadata),
}));
if (items.length > 0) {
categories.push({
id: 'member',
icon: <Icon icon={Users} size={16} />,
items,
label: 'Members',
});
}
}
// --- Topics ---
if (topics && topics.length > 0) {
const items = topics
.filter((t) => t.id !== activeTopicId)
.map((topic) => {
const title = topic.title || 'Untitled';
const label =
title.length > MAX_TOPIC_LABEL ? `${title.slice(0, MAX_TOPIC_LABEL)}...` : title;
return {
icon: <Icon icon={MessageSquareText} size={16} />,
key: `topic-${topic.id}`,
label,
metadata: {
topicId: topic.id,
topicTitle: topic.title,
timestamp: topic.updatedAt || 0,
type: 'topic' as const,
},
};
});
if (items.length > 0) {
categories.push({
id: 'topic',
icon: <Icon icon={MessageSquareText} size={16} />,
items,
label: 'Topics',
});
}
}
return categories;
}, [allAgents, currentAgentId, topics, activeTopicId, isGroupChat, externalMentionItems]);
};