From 537c39f771e67ad4ca4c8e9203aced7a020c548d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Wang?= <52880665+rivertwilight@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:24:28 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style(chat-input):=20rework=20Pl?= =?UTF-8?q?us=20menu=20with=20toggle=20switches=20and=20grouped=20submenus?= =?UTF-8?q?=20(#15433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en-US/chat.json | 3 +- locales/en-US/editor.json | 1 + locales/zh-CN/chat.json | 3 +- locales/zh-CN/editor.json | 1 + .../ActionBar/Knowledge/useControls.tsx | 40 +++--- .../ChatInput/ActionBar/Plus/index.tsx | 114 +++++++++--------- .../ChatInput/ActionBar/Tools/useControls.tsx | 54 ++++++--- .../ChatInput/ActionBar/Upload/index.tsx | 17 ++- .../ActionBar/components/ActionDropdown.tsx | 42 +++++-- src/locales/default/chat.ts | 1 + src/locales/default/editor.ts | 1 + 11 files changed, 164 insertions(+), 113 deletions(-) diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index d668e2e5d5..e404778ec0 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -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.", diff --git a/locales/en-US/editor.json b/locales/en-US/editor.json index 789f9c032b..680ac327e1 100644 --- a/locales/en-US/editor.json +++ b/locales/en-US/editor.json @@ -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...", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index f7b6e4ea9e..36ad695ebc 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -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 专属调优的搜索服务,检索效果最佳。", diff --git a/locales/zh-CN/editor.json b/locales/zh-CN/editor.json index 44c6ce4bd2..b9c4a226fa 100644 --- a/locales/zh-CN/editor.json +++ b/locales/zh-CN/editor.json @@ -13,6 +13,7 @@ "actions.expand.on": "展开", "actions.typobar.off": "隐藏格式工具栏", "actions.typobar.on": "显示格式工具栏", + "actions.typobar.title": "格式工具", "autoSave.latest": "已加载最新版本", "autoSave.saved": "已保存", "autoSave.saving": "自动保存中…", diff --git a/src/features/ChatInput/ActionBar/Knowledge/useControls.tsx b/src/features/ChatInput/ActionBar/Knowledge/useControls.tsx index 432400348f..395e5bef7d 100644 --- a/src/features/ChatInput/ActionBar/Knowledge/useControls.tsx +++ b/src/features/ChatInput/ActionBar/Knowledge/useControls.tsx @@ -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 = ( diff --git a/src/features/ChatInput/ActionBar/Plus/index.tsx b/src/features/ChatInput/ActionBar/Plus/index.tsx index a369dcfd97..cb00c365a1 100644 --- a/src/features/ChatInput/ActionBar/Plus/index.tsx +++ b/src/features/ChatInput/ActionBar/Plus/index.tsx @@ -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: , key: 'upload-file-or-image', label: ( { 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, diff --git a/src/features/ChatInput/ActionBar/Tools/useControls.tsx b/src/features/ChatInput/ActionBar/Tools/useControls.tsx index 7c4431ab4e..2e1b933fa8 100644 --- a/src/features/ChatInput/ActionBar/Tools/useControls.tsx +++ b/src/features/ChatInput/ActionBar/Tools/useControls.tsx @@ -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 } = {allAutoItems.length} - - - + + + + + + + + ) : undefined; diff --git a/src/features/ChatInput/ActionBar/Upload/index.tsx b/src/features/ChatInput/ActionBar/Upload/index.tsx index 24d3b91c53..4c543c0688 100644 --- a/src/features/ChatInput/ActionBar/Upload/index.tsx +++ b/src/features/ChatInput/ActionBar/Upload/index.tsx @@ -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: , key: 'upload-image', label: canUploadImage ? ( { }, { closeOnClick: false, - icon: FileUp, + icon: , key: 'upload-file', label: ( { }, { closeOnClick: false, - icon: FolderUp, + icon: , key: 'upload-folder', label: ( { children: [ // first the files ...files.map((item) => ({ - icon: , + icon: , key: item.id, label: ( { // then the knowledge bases ...knowledgeBases.map((item) => ({ - icon: , + icon: , key: item.id, label: ( { }, { extra: , - icon: LibraryBig, + icon: , key: 'knowledge-base-store', label: t('knowledgeBase.viewMore'), onClick: () => { diff --git a/src/features/ChatInput/ActionBar/components/ActionDropdown.tsx b/src/features/ChatInput/ActionBar/components/ActionDropdown.tsx index d3226e919f..5882444c32 100644 --- a/src/features/ChatInput/ActionBar/components/ActionDropdown.tsx +++ b/src/features/ChatInput/ActionBar/components/ActionDropdown.tsx @@ -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['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, 'className' | 'onClick' | 'style'>, @@ -199,29 +206,40 @@ const ActionDropdown = memo( 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); diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index bdf47e441a..b7759afcbf 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -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...', diff --git a/src/locales/default/editor.ts b/src/locales/default/editor.ts index 143074e2cf..4cefef0eb8 100644 --- a/src/locales/default/editor.ts +++ b/src/locales/default/editor.ts @@ -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...',