💄 style(chat-input): rework Plus menu with toggle switches and grouped submenus (#15433)

This commit is contained in:
René Wang
2026-06-04 21:24:28 +08:00
committed by GitHub
parent ed47d9ece5
commit 537c39f771
11 changed files with 164 additions and 113 deletions
+2 -1
View File
@@ -208,6 +208,7 @@
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
"heteroAgent.executionTarget.downloadDesktop": "Get Desktop App",
"heteroAgent.executionTarget.infoTooltip": "Pick a remote device to drive that machine from the web. \"This device\" runs the agent locally and is only available inside the desktop app.",
"heteroAgent.executionTarget.loading": "Loading devices…",
"heteroAgent.executionTarget.local": "This device",
@@ -217,7 +218,6 @@
"heteroAgent.executionTarget.online": "Online",
"heteroAgent.executionTarget.sandbox": "Cloud sandbox",
"heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox",
"heteroAgent.executionTarget.downloadDesktop": "Get Desktop App",
"heteroAgent.executionTarget.title": "Execution Device",
"heteroAgent.executionTarget.unknownDevice": "Unknown device",
"heteroAgent.fullAccess.label": "Full access",
@@ -412,6 +412,7 @@
"platformAgent.deviceGuard.platformUnavailable.desc": "{{name}} is not installed on the connected device.",
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} not available",
"platformAgent.deviceGuard.refresh": "Refresh",
"plus.addAttachments": "Attachments",
"plus.addSkills": "Add Skills...",
"plus.search.appSearch": "Smart Search",
"plus.search.appSearchDesc": "LobeHub optimized search service, delivering best retrieval results.",
+1
View File
@@ -13,6 +13,7 @@
"actions.expand.on": "Expand",
"actions.typobar.off": "Hide formatting toolbar",
"actions.typobar.on": "Show formatting toolbar",
"actions.typobar.title": "Formatting Tool",
"autoSave.latest": "Latest version loaded",
"autoSave.saved": "Saved",
"autoSave.saving": "Auto-saving...",
+2 -1
View File
@@ -208,6 +208,7 @@
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
"heteroAgent.cloudRepo.notSet": "未选择仓库",
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
"heteroAgent.executionTarget.downloadDesktop": "下载桌面端",
"heteroAgent.executionTarget.infoTooltip": "选择「远程设备」后可在网页中驱动该机器;「本机」仅在桌面端内运行 agent。",
"heteroAgent.executionTarget.loading": "正在加载设备…",
"heteroAgent.executionTarget.local": "本机",
@@ -217,7 +218,6 @@
"heteroAgent.executionTarget.online": "在线",
"heteroAgent.executionTarget.sandbox": "云端沙箱",
"heteroAgent.executionTarget.sandboxDesc": "在临时云端沙箱中运行",
"heteroAgent.executionTarget.downloadDesktop": "下载桌面端",
"heteroAgent.executionTarget.title": "执行设备",
"heteroAgent.executionTarget.unknownDevice": "未知设备",
"heteroAgent.fullAccess.label": "完全访问权限",
@@ -412,6 +412,7 @@
"platformAgent.deviceGuard.platformUnavailable.desc": "连接的设备上未安装 {{name}}。",
"platformAgent.deviceGuard.platformUnavailable.title": "{{name}} 不可用",
"platformAgent.deviceGuard.refresh": "刷新",
"plus.addAttachments": "附件",
"plus.addSkills": "添加技能...",
"plus.search.appSearch": "智能检索",
"plus.search.appSearchDesc": "LobeHub 专属调优的搜索服务,检索效果最佳。",
+1
View File
@@ -13,6 +13,7 @@
"actions.expand.on": "展开",
"actions.typobar.off": "隐藏格式工具栏",
"actions.typobar.on": "显示格式工具栏",
"actions.typobar.title": "格式工具",
"autoSave.latest": "已加载最新版本",
"autoSave.saved": "已保存",
"autoSave.saving": "自动保存中…",
@@ -14,7 +14,10 @@ import { agentByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import CheckboxItem from '../components/CheckboxWithLoading';
const labelMaxWidth = 'min(400px, 56vw)';
// Cap so the widest library/file row (icon + label + checkbox + paddings) stays within the
// submenu's 320px footer-driven width, keeping it level with the skill submenu instead of
// growing past it.
const labelMaxWidth = 'min(210px, 45vw)';
const styles = createStaticStyles(({ css }) => ({
viewMore: css`
@@ -24,9 +27,17 @@ const styles = createStaticStyles(({ css }) => ({
gap: 8px;
align-items: center;
width: calc(100% + 8px);
/* width 320 + margin-inline -12 anchors the submenu to 320px (matching the skill
submenu) and lets the row span full width; padding-inline 12 lines its icon/text
up with the menu items above. */
width: 320px;
min-height: 32px;
margin-inline-start: -4px;
/* The footer wrapper adds padding-block: 8px top & bottom; the top keeps it separated
from the list, but the bottom leaves a dead gap against the popup edge — cancel it. */
margin-block-end: -8px;
margin-inline: -12px;
padding-inline: 12px;
border: 0;
border-radius: 6px;
@@ -107,28 +118,11 @@ export const useControls = ({
),
}));
// Flat list (no "Libraries" / "Files" group headers): libraries first, then files.
const relatedGroups: ItemType[] = [
...(libraryItems.length > 0
? [
{
children: libraryItems,
key: 'relativeLibraries',
label: t('knowledgeBase.libraries'),
type: 'group' as const,
},
]
: []),
...libraryItems,
...(libraryItems.length > 0 && fileItems.length > 0 ? [{ type: 'divider' as const }] : []),
...(fileItems.length > 0
? [
{
children: fileItems,
key: 'relativeFiles',
label: t('knowledgeBase.files'),
type: 'group' as const,
},
]
: []),
...fileItems,
];
const footer = (
+59 -55
View File
@@ -3,7 +3,7 @@
import { validateVideoFileSize } from '@lobechat/utils/client';
import type { IconProps } from '@lobehub/ui';
import { Icon, Popover } from '@lobehub/ui';
import { BrainOffIcon, GlobeOffIcon, SkillsIcon } from '@lobehub/ui/icons';
import { GlobeOffIcon, SkillsIcon } from '@lobehub/ui/icons';
import { Upload } from 'antd';
import { css, cssVar, cx } from 'antd-style';
import {
@@ -16,7 +16,6 @@ import {
PlusIcon,
SearchCheck,
Settings2Icon,
Store,
TypeIcon,
} from 'lucide-react';
import type { ReactNode } from 'react';
@@ -25,7 +24,6 @@ import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import { openAttachKnowledgeModal } from '@/features/LibraryModal';
import { createSkillStoreModal } from '@/features/SkillStore';
import { useModelSupportToolUse } from '@/hooks/useModelSupportToolUse';
import { useVisualMediaUploadAbility } from '@/hooks/useVisualMediaUploadAbility';
import { useAgentStore } from '@/store/agent';
@@ -235,7 +233,10 @@ const stripPopoverContent = (items?: ActionDropdownMenuItems): ActionDropdownMen
delete nextItem.popoverContent;
if ('children' in nextItem && nextItem.children) {
return { ...nextItem, children: stripPopoverContent(nextItem.children) };
return {
...nextItem,
children: stripPopoverContent(nextItem.children),
} as ActionDropdownMenuItems[number];
}
if ('label' in nextItem) {
@@ -315,9 +316,12 @@ const PlusAction = memo(() => {
const activeSearchOption: 'off' | 'app' | 'provider' =
searchMode === 'off' ? 'off' : useModelBuiltinSearch ? 'provider' : 'app';
const handleToggleMemory = useCallback(async () => {
await updateAgentChatConfig({ memory: { enabled: !isMemoryEnabled } });
}, [isMemoryEnabled, updateAgentChatConfig]);
const handleToggleMemory = useCallback(
async (enabled: boolean) => {
await updateAgentChatConfig({ memory: { enabled } });
},
[updateAgentChatConfig],
);
const handleSelectSearch = useCallback(
async (option: 'off' | 'app' | 'provider') => {
@@ -332,11 +336,6 @@ const PlusAction = memo(() => {
[updateAgentChatConfig],
);
const handleOpenTools = useCallback(() => {
setDropdownOpen(false);
createSkillStoreModal();
}, []);
const handleToggleParams = useCallback(() => {
setDropdownOpen(false);
if (isParamsPanelActive) {
@@ -399,7 +398,8 @@ const PlusAction = memo(() => {
const uploadItems: ActionDropdownMenuItems = [
{
closeOnClick: false,
icon: FileUp,
// Match the 20px file/library icons below so the label lines up with those rows.
icon: <Icon icon={FileUp} size={20} />,
key: 'upload-file-or-image',
label: (
<Upload
@@ -435,12 +435,11 @@ const PlusAction = memo(() => {
const toolsItems: ActionDropdownMenuItems =
isAgentModeEnabled && enableFC
? [
{ type: 'divider' },
{
children: skillMenuItems,
footer: skillMarketFooter,
header: skillMarketHeader,
icon: activeIcon(SkillsIcon, activeSkillCount > 0),
icon: SkillsIcon,
key: 'tools',
label: renderLabelWithCount(
tSetting('tools.title'),
@@ -452,38 +451,19 @@ const PlusAction = memo(() => {
),
),
} as ActionDropdownMenuItems[number],
{
icon: Store,
key: 'add-skills',
label: t('plus.addSkills'),
onClick: handleOpenTools,
},
{ type: 'divider' },
]
: [];
const capabilityItems: ActionDropdownMenuItems = [
{ type: 'divider' },
// Rich text toolbar toggle
// Memory toggle — trailing switch; toggle by clicking the switch or the whole row
{
icon: TypeIcon,
key: 'typo',
label: renderActive(tEditor('actions.typobar.on'), Boolean(showTypoBar)),
onClick: () => setShowTypoBar(!showTypoBar),
},
// Advanced parameter settings — mirrors ParamsPanelToggle in the agent header.
{
icon: Settings2Icon,
key: 'params',
label: renderActive(tSetting('settingModel.params.title'), isParamsPanelActive),
onClick: handleToggleParams,
},
{ type: 'divider' },
// Memory toggle
{
icon: activeIcon(isMemoryEnabled ? Brain : BrainOffIcon, Boolean(isMemoryEnabled)),
checked: Boolean(isMemoryEnabled),
icon: Brain,
key: 'memory',
label: renderActive(t('memory.title'), Boolean(isMemoryEnabled)),
onClick: handleToggleMemory,
label: t('memory.title'),
onCheckedChange: handleToggleMemory,
type: 'switch',
},
// Web search: simple toggle when 2 options, submenu when 3
...(showProviderSearch
@@ -538,32 +518,57 @@ const PlusAction = memo(() => {
} as ActionDropdownMenuItems[number],
]
: [
// Web search toggle — trailing switch; toggle by clicking the switch or the whole row
{
icon: activeIcon(
activeSearchOption === 'off' ? GlobeOffIcon : Globe,
activeSearchOption !== 'off',
),
checked: activeSearchOption !== 'off',
icon: Globe,
key: 'search-toggle',
label: renderActive(t('search.title'), activeSearchOption !== 'off'),
onClick: () => handleSelectSearch(activeSearchOption === 'off' ? 'app' : 'off'),
label: t('search.title'),
onCheckedChange: (checked: boolean) => handleSelectSearch(checked ? 'app' : 'off'),
type: 'switch',
} as ActionDropdownMenuItems[number],
]),
{ type: 'divider' },
// Skills (with "Add Skills..." merged in) sits directly under the Web Search divider.
...toolsItems,
// Formatting toolbar toggle — trailing switch; toggle by clicking the switch or the whole row
{
checked: Boolean(showTypoBar),
icon: TypeIcon,
key: 'typo',
label: tEditor('actions.typobar.title'),
onCheckedChange: (checked: boolean) => setShowTypoBar(checked),
type: 'switch',
},
// Advanced parameter settings — mirrors ParamsPanelToggle in the agent header.
{
icon: Settings2Icon,
key: 'params',
label: renderActive(tSetting('settingModel.params.title'), isParamsPanelActive),
onClick: handleToggleParams,
},
];
const knowledgeSectionItems: ActionDropdownMenuItems = enableKnowledgeBase
// "Add Attachments..." merges file upload with the knowledge base (libraries / files).
// When the knowledge base is disabled there is no submenu, so Upload stays a top-level entry.
const attachmentsItems: ActionDropdownMenuItems = enableKnowledgeBase
? [
{
children: knowledgeItems,
children: [
...uploadItems,
...(knowledgeItems.length > 0
? [{ type: 'divider' as const }, ...knowledgeItems]
: []),
],
footer: knowledgeFooter,
icon: activeIcon(LibraryBig, knowledgeEnabledCount > 0),
key: 'knowledge-base',
label: renderLabelWithCount(t('knowledgeBase.title'), knowledgeEnabledCount),
icon: LibraryBig,
key: 'attachments',
label: renderLabelWithCount(t('plus.addAttachments'), knowledgeEnabledCount),
} as ActionDropdownMenuItems[number],
]
: [];
: uploadItems;
return [...uploadItems, ...knowledgeSectionItems, ...capabilityItems];
return [...attachmentsItems, ...capabilityItems];
}, [
activeSearchOption,
canUploadImage,
@@ -571,7 +576,6 @@ const PlusAction = memo(() => {
editor,
enableFC,
enableKnowledgeBase,
handleOpenTools,
handleSelectSearch,
handleToggleMemory,
handleToggleParams,
@@ -20,6 +20,7 @@ import {
Package,
Pin,
Settings,
Store,
Trash2,
Wrench,
Zap,
@@ -30,6 +31,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import DevModal from '@/features/PluginDevModal';
import { createSkillStoreModal } from '@/features/SkillStore';
import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useAgentStore } from '@/store/agent';
@@ -347,7 +349,6 @@ const styles = createStaticStyles(({ css }) => ({
width: 24px;
height: 24px;
margin-inline-start: auto;
padding: 0;
border: 0;
border-radius: 6px;
@@ -1279,20 +1280,43 @@ export const useControls = ({ closeDropdown }: { closeDropdown?: () => void } =
<Icon icon={Zap} size={12} />
{allAutoItems.length}
</span>
<Tooltip placement="top" title={t('tools.plugins.management')}>
<button
aria-label={t('tools.plugins.management')}
className={cx(styles.statsSettingsButton)}
type="button"
onClick={(event) => {
event.stopPropagation();
closeDropdown?.();
navigate('/settings/skill');
}}
>
<Icon icon={Settings} size={14} />
</button>
</Tooltip>
<span
style={{
alignItems: 'center',
display: 'inline-flex',
gap: 2,
marginInlineStart: 'auto',
}}
>
<Tooltip placement="top" title={t('plus.addSkills', { ns: 'chat' })}>
<button
aria-label={t('plus.addSkills', { ns: 'chat' })}
className={cx(styles.statsSettingsButton)}
type="button"
onClick={(event) => {
event.stopPropagation();
closeDropdown?.();
createSkillStoreModal();
}}
>
<Icon icon={Store} size={14} />
</button>
</Tooltip>
<Tooltip placement="top" title={t('tools.plugins.management')}>
<button
aria-label={t('tools.plugins.management')}
className={cx(styles.statsSettingsButton)}
type="button"
onClick={(event) => {
event.stopPropagation();
closeDropdown?.();
navigate('/settings/skill');
}}
>
<Icon icon={Settings} size={14} />
</button>
</Tooltip>
</span>
</div>
) : undefined;
@@ -36,6 +36,11 @@ const hotArea = css`
}
`;
// Keep every row's leading icon the same width. The menu's icon slot sizes to its
// content, so a larger file-type icon next to a smaller line icon would widen that
// slot and push its label out of alignment with the upload / "view more" rows.
const MENU_ICON_SIZE = 20;
const FileUpload = memo(() => {
const { t } = useTranslation('chat');
@@ -76,7 +81,7 @@ const FileUpload = memo(() => {
{
closeOnClick: false,
disabled: !canUploadImage,
icon: ImageUp,
icon: <Icon icon={ImageUp} size={MENU_ICON_SIZE} />,
key: 'upload-image',
label: canUploadImage ? (
<Upload
@@ -101,7 +106,7 @@ const FileUpload = memo(() => {
},
{
closeOnClick: false,
icon: FileUp,
icon: <Icon icon={FileUp} size={MENU_ICON_SIZE} />,
key: 'upload-file',
label: (
<Upload
@@ -138,7 +143,7 @@ const FileUpload = memo(() => {
},
{
closeOnClick: false,
icon: FolderUp,
icon: <Icon icon={FolderUp} size={MENU_ICON_SIZE} />,
key: 'upload-folder',
label: (
<Upload
@@ -184,7 +189,7 @@ const FileUpload = memo(() => {
children: [
// first the files
...files.map((item) => ({
icon: <FileIcon fileName={item.name} fileType={item.type} size={20} />,
icon: <FileIcon fileName={item.name} fileType={item.type} size={MENU_ICON_SIZE} />,
key: item.id,
label: (
<CheckboxItem
@@ -202,7 +207,7 @@ const FileUpload = memo(() => {
// then the knowledge bases
...knowledgeBases.map((item) => ({
icon: <RepoIcon />,
icon: <RepoIcon size={MENU_ICON_SIZE} />,
key: item.id,
label: (
<CheckboxItem
@@ -231,7 +236,7 @@ const FileUpload = memo(() => {
},
{
extra: <Icon icon={ArrowRight} />,
icon: LibraryBig,
icon: <Icon icon={LibraryBig} size={MENU_ICON_SIZE} />,
key: 'knowledge-base-store',
label: t('knowledgeBase.viewMore'),
onClick: () => {
@@ -1,8 +1,10 @@
'use client';
import {
type BaseMenuItemType,
type DropdownMenuPopupProps,
type DropdownMenuProps,
type MenuInfo,
type MenuItemType,
type MenuProps,
type PopoverTrigger,
@@ -95,7 +97,12 @@ const SubmenuScrollStyle = createGlobalStyle`
export type ActionDropdownMenuItem = MenuItemType;
export type ActionDropdownMenuItems = MenuProps<ActionDropdownMenuItem>['items'];
/**
* `renderDropdownMenuItems` accepts Base UI's full item union (`BaseMenuItemType`),
* which is wider than antd's `MenuProps['items']` — it also covers `type: 'switch'`
* and `type: 'checkbox'` items. Use it here so callers can declare those directly.
*/
export type ActionDropdownMenuItems = BaseMenuItemType[];
type ActionDropdownMenu = Omit<
Pick<MenuProps<ActionDropdownMenuItem>, 'className' | 'onClick' | 'style'>,
@@ -199,29 +206,40 @@ const ActionDropdown = memo<ActionDropdownProps>(
children: item.children ? decorateMenuItems(item.children) : item.children,
};
}
// Switch / checkbox items are self-contained: they toggle via `onCheckedChange`,
// and Base UI already wires up "click the row or the control" for them. Pass them
// through untouched instead of wrapping their click handler.
if ('type' in item && (item.type === 'switch' || item.type === 'checkbox')) {
return item;
}
if ('children' in item && item.children) {
const originalOnOpenChange = (item as { onOpenChange?: unknown }).onOpenChange as
| ((open: boolean, details: unknown) => void)
| undefined;
// Any item carrying a `children` key is a submenu (children may be optional);
// route them all here so plain items never inherit a submenu's click signature.
if ('children' in item) {
return {
...item,
children: decorateMenuItems(item.children),
children: item.children ? decorateMenuItems(item.children) : item.children,
type: 'submenu',
};
// `children` is re-widened to the full item union; cast back to satisfy
// the mixed rc-menu / Base UI submenu types in `BaseMenuItemType`.
} as BaseMenuItemType;
}
const itemOnClick = 'onClick' in item ? item.onClick : undefined;
const closeOnClick = 'closeOnClick' in item ? item.closeOnClick : undefined;
// Submenus are handled above; everything else is a plain menu item. Base UI's
// types keep optional-`children` submenu members in scope here, so narrow to the
// plain-item type before wrapping the click handler.
const menuItem = item as ActionDropdownMenuItem;
const itemOnClick = menuItem.onClick;
const closeOnClick = menuItem.closeOnClick;
const keepOpenOnClick = closeOnClick === false;
const itemLabel = 'label' in item ? item.label : undefined;
const itemLabel = menuItem.label;
const shouldKeepOpen = isValidElement(itemLabel);
const resolvedCloseOnClick = closeOnClick ?? (shouldKeepOpen ? false : undefined);
return {
...item,
...menuItem,
...(resolvedCloseOnClick !== undefined ? { closeOnClick: resolvedCloseOnClick } : null),
onClick: (info) => {
onClick: (info: MenuInfo) => {
if (keepOpenOnClick) {
info.domEvent.stopPropagation();
menu.onClick?.(info);
+1
View File
@@ -469,6 +469,7 @@ export default {
'plus.search.modelSearchDesc': 'May cause unexpected behavior when enabled, not recommended.',
'plus.search.off': 'Off',
'plus.search.offDesc': '',
'plus.addAttachments': 'Attachments',
'plus.addSkills': 'Add Skills...',
'plus.title': 'Add',
'plus.tooltip': 'Add files, skills, and more context...',
+1
View File
@@ -3,6 +3,7 @@ export default {
'actions.expand.on': 'Expand',
'actions.typobar.off': 'Hide formatting toolbar',
'actions.typobar.on': 'Show formatting toolbar',
'actions.typobar.title': 'Formatting Tool',
'autoSave.latest': 'Latest version loaded',
'autoSave.saved': 'Saved',
'autoSave.saving': 'Auto-saving...',