♻️ refactor: migrate to new DropdownMenuV2 and showContextMenu API (#11079)

* ♻️ refactor: migrate to new DropdownMenuV2 and showContextMenu API

- Replace Dropdown with DropdownMenuV2 for action menus
- Use showContextMenu for context menu handling instead of Dropdown wrapper
- Update @lobehub/ui to preview version with new context menu API
- Add styles for popup-open state in NavItem component

* ♻️ refactor: migrate to new DropdownMenuV2 and showContextMenu API

* chore: Update @lobehub/ui dependency to version ^4.6.3.

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

* ♻️ refactor: migrate to new DropdownMenuV2 and showContextMenu API

- Remove deprecated ContextMenu component
- Migrate all context menu usages to DropdownMenuV2 and showContextMenu API
- Update multiple Action components across Conversation features
- Update ResourceManager toolbar components
- Clean up related styles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* feat: Update `@lobehub/ui` dependency, simplify `ActionIconGroup` menu prop, and ensure action group visibility when popups are open.

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

* fix: Add null check for context menu items, include debug log, and update `@lobehub/ui` dependency.

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

* ♻️ refactor: migrate TopicSelector to new DropdownMenuV2 API

Migrate from antd/Dropdown to @lobehub/ui DropdownMenu component
with checkbox items pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-01-02 01:14:30 +08:00 committed by GitHub
parent e3f0f46436
commit 04cfc0e9e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1170 additions and 1437 deletions

View file

@ -207,7 +207,7 @@
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "^0.25.1",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.6.2",
"@lobehub/ui": "^4.8.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.1",

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,4 +1,4 @@
import { ActionIcon, Dropdown, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { MessageSquareDashed, Star } from 'lucide-react';
import { Suspense, memo, useCallback } from 'react';
@ -92,34 +92,28 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
return (
<Flexbox style={{ position: 'relative' }}>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !threadId && !isInAgentSubRoute}
disabled={editing}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}
fill={fav ? cssVar.colorWarning : 'transparent'}
icon={Star}
onClick={(e) => {
e.stopPropagation();
favoriteTopic(id, !fav);
}}
size={'small'}
/>
}
loading={isLoading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={title}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !threadId && !isInAgentSubRoute}
contextMenuItems={dropdownMenu}
disabled={editing}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}
fill={fav ? cssVar.colorWarning : 'transparent'}
icon={Star}
onClick={(e) => {
e.stopPropagation();
favoriteTopic(id, !fav);
}}
size={'small'}
/>
}
loading={isLoading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={title}
/>
<Editing id={id} title={title} toggleEditing={toggleEditing} />
{active && (
<Suspense

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { ExternalLink, LucideCopy, PencilLine, Trash, Wand2 } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
@ -17,7 +17,7 @@ interface TopicItemDropdownMenuProps {
export const useTopicItemDropdownMenu = ({
id,
toggleEditing,
}: TopicItemDropdownMenuProps): MenuProps['items'] => {
}: TopicItemDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['topic', 'common']);
const { modal } = App.useApp();
@ -30,7 +30,7 @@ export const useTopicItemDropdownMenu = ({
s.removeTopic,
]);
return useMemo(() => {
return useCallback(() => {
if (!id) return [];
return [

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,4 +1,4 @@
import { Dropdown, Icon } from '@lobehub/ui';
import { Icon } from '@lobehub/ui';
import { TreeDownRightIcon } from '@lobehub/ui/icons';
import { cssVar } from 'antd-style';
import { memo, useCallback } from 'react';
@ -46,23 +46,15 @@ const ThreadItem = memo<ThreadItemProps>(({ title, id }) => {
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !isInAgentSubRoute}
disabled={editing}
icon={
<Icon color={cssVar.colorTextDescription} icon={TreeDownRightIcon} size={'small'} />
}
onClick={handleClick}
title={title}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !isInAgentSubRoute}
contextMenuItems={dropdownMenu}
disabled={editing}
icon={<Icon color={cssVar.colorTextDescription} icon={TreeDownRightIcon} size={'small'} />}
onClick={handleClick}
title={title}
/>
<Editing id={id} title={title} toggleEditing={toggleEditing} />
</>
);

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { PencilLine, Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
@ -14,13 +14,13 @@ interface ThreadItemDropdownMenuProps {
export const useThreadItemDropdownMenu = ({
id,
toggleEditing,
}: ThreadItemDropdownMenuProps): MenuProps['items'] => {
}: ThreadItemDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['thread', 'common']);
const { modal } = App.useApp();
const [removeThread] = useChatStore((s) => [s.removeThread]);
return useMemo(() => {
return useCallback(() => {
return [
{
icon: <Icon icon={PencilLine} />,

View file

@ -1,7 +1,7 @@
'use client';
import { Icon } from '@lobehub/ui';
import { App, Dropdown } from 'antd';
import { Button, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { createStaticStyles } from 'antd-style';
import { ChevronDownIcon } from 'lucide-react';
import { memo, useState } from 'react';
@ -17,10 +17,17 @@ import { useHomeStore } from '@/store/home';
import { useDetailContext } from '../../DetailProvider';
const styles = createStaticStyles(({ css }) => ({
button: css`
button {
width: 100%;
}
buttonGroup: css`
width: 100%;
`,
menuButton: css`
padding-inline: 8px;
border-start-start-radius: 0 !important;
border-end-start-radius: 0 !important;
`,
primaryButton: css`
border-start-end-radius: 0 !important;
border-end-end-radius: 0 !important;
`,
}));
@ -127,28 +134,41 @@ const AddAgent = memo<{ mobile?: boolean }>(({ mobile }) => {
}
};
const menuItems = [
{
key: 'addAgent',
label: t('assistants.addAgent'),
onClick: handleAddAgent,
},
];
return (
<Dropdown.Button
className={styles.button}
icon={<Icon icon={ChevronDownIcon} />}
loading={isLoading}
menu={{
items: [
{
key: 'addAgent',
label: t('assistants.addAgent'),
onClick: handleAddAgent,
},
],
}}
onClick={handleAddAgentAndConverse}
overlayStyle={{ minWidth: 267 }}
size={'large'}
style={{ flex: 1, width: 'unset' }}
type={'primary'}
>
{t('assistants.addAgentAndConverse')}
</Dropdown.Button>
<Flexbox className={styles.buttonGroup} gap={0} horizontal>
<Button
block
className={styles.primaryButton}
loading={isLoading}
onClick={handleAddAgentAndConverse}
size={'large'}
style={{ flex: 1, width: 'unset' }}
type={'primary'}
>
{t('assistants.addAgentAndConverse')}
</Button>
<DropdownMenu
items={menuItems}
popupProps={{ style: { minWidth: 267 } }}
triggerProps={{ disabled: isLoading }}
>
<Button
className={styles.menuButton}
disabled={isLoading}
icon={<Icon icon={ChevronDownIcon} />}
size={'large'}
type={'primary'}
/>
</DropdownMenu>
</Flexbox>
);
});

View file

@ -4,6 +4,7 @@ import {
Tag as AntTag,
Avatar,
Block,
DropdownMenu,
Flexbox,
Icon,
Tag,
@ -11,7 +12,7 @@ import {
Tooltip,
TooltipGroup,
} from '@lobehub/ui';
import { App, Dropdown } from 'antd';
import { App } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import {
AlertTriangle,
@ -258,14 +259,14 @@ const UserAgentCard = memo<UserAgentCardProps>(
width={'100%'}
>
{isOwner && (
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<DropdownMenu items={menuItems}>
<div
className={cx('more-button', styles.moreButton)}
onClick={(e) => e.stopPropagation()}
>
<Icon icon={MoreVerticalIcon} size={16} style={{ cursor: 'pointer' }} />
</div>
</Dropdown>
</DropdownMenu>
)}
<Flexbox
align={'flex-start'}

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,4 +1,4 @@
import { ActionIcon, Dropdown, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { MessageSquareDashed, Star } from 'lucide-react';
import { Suspense, memo, useCallback } from 'react';
@ -92,34 +92,28 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
return (
<Flexbox style={{ position: 'relative' }}>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !threadId && !isInAgentSubRoute}
disabled={editing}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}
fill={fav ? cssVar.colorWarning : 'transparent'}
icon={Star}
onClick={(e) => {
e.stopPropagation();
favoriteTopic(id, !fav);
}}
size={'small'}
/>
}
loading={isLoading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={title}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !threadId && !isInAgentSubRoute}
contextMenuItems={dropdownMenu}
disabled={editing}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}
fill={fav ? cssVar.colorWarning : 'transparent'}
icon={Star}
onClick={(e) => {
e.stopPropagation();
favoriteTopic(id, !fav);
}}
size={'small'}
/>
}
loading={isLoading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={title}
/>
<Editing id={id} title={title} toggleEditing={toggleEditing} />
{active && (
<Suspense

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { ExternalLink, LucideCopy, PencilLine, Trash, Wand2 } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
@ -17,7 +17,7 @@ interface TopicItemDropdownMenuProps {
export const useTopicItemDropdownMenu = ({
id,
toggleEditing,
}: TopicItemDropdownMenuProps): MenuProps['items'] => {
}: TopicItemDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['topic', 'common']);
const { modal } = App.useApp();
@ -30,7 +30,7 @@ export const useTopicItemDropdownMenu = ({
s.removeTopic,
]);
return useMemo(() => {
return useCallback(() => {
if (!id) return [];
return [

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,4 +1,4 @@
import { Dropdown, Icon } from '@lobehub/ui';
import { Icon } from '@lobehub/ui';
import { TreeDownRightIcon } from '@lobehub/ui/icons';
import { cssVar } from 'antd-style';
import { memo, useCallback } from 'react';
@ -46,23 +46,15 @@ const ThreadItem = memo<ThreadItemProps>(({ title, id }) => {
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !isInAgentSubRoute}
disabled={editing}
icon={
<Icon color={cssVar.colorTextDescription} icon={TreeDownRightIcon} size={'small'} />
}
onClick={handleClick}
title={title}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !isInAgentSubRoute}
contextMenuItems={dropdownMenu}
disabled={editing}
icon={<Icon color={cssVar.colorTextDescription} icon={TreeDownRightIcon} size={'small'} />}
onClick={handleClick}
title={title}
/>
<Editing id={id} title={title} toggleEditing={toggleEditing} />
</>
);

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { PencilLine, Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
@ -14,13 +14,13 @@ interface ThreadItemDropdownMenuProps {
export const useThreadItemDropdownMenu = ({
id,
toggleEditing,
}: ThreadItemDropdownMenuProps): MenuProps['items'] => {
}: ThreadItemDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['thread', 'common']);
const { modal } = App.useApp();
const [removeThread] = useChatStore((s) => [s.removeThread]);
return useMemo(() => {
return useCallback(() => {
return [
{
icon: <Icon icon={PencilLine} />,

View file

@ -1,6 +1,4 @@
import { ActionIcon, Tag } from '@lobehub/ui';
import { Dropdown } from 'antd';
import type { ItemType } from 'antd/es/menu/interface';
import { ActionIcon, DropdownMenu, type DropdownMenuCheckboxItem, Tag } from '@lobehub/ui';
import { Clock3Icon, PlusIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -32,15 +30,23 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
[topics, activeTopicId],
);
const items = useMemo<ItemType[]>(
const items = useMemo<DropdownMenuCheckboxItem[]>(
() =>
(topics || []).map((topic) => ({
checked: topic.id === activeTopicId,
closeOnClick: true,
key: topic.id,
label: topic.title,
onClick: () => switchTopic(topic.id),
onCheckedChange: (checked) => {
if (checked) {
switchTopic(topic.id);
}
},
type: 'checkbox',
})),
[topics, t, switchTopic],
[topics, switchTopic, activeTopicId],
);
const isEmpty = !topics || topics.length === 0;
return (
<NavHeader
@ -53,22 +59,14 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
size={DESKTOP_HEADER_ICON_SIZE}
title={t('actions.addNewTopic')}
/>
<Dropdown
disabled={!topics || topics.length === 0}
menu={{
items,
selectedKeys: activeTopicId ? [activeTopicId] : [],
}}
overlayStyle={{
maxHeight: 600,
minWidth: 200,
overflowY: 'auto',
}}
<DropdownMenu
items={items}
placement="bottomRight"
trigger={['click']}
popupProps={{ style: { maxHeight: 600, minWidth: 200, overflowY: 'auto' } }}
triggerProps={{ disabled: isEmpty }}
>
<ActionIcon disabled={!topics || topics.length === 0} icon={Clock3Icon} />
</Dropdown>
<ActionIcon disabled={isEmpty} icon={Clock3Icon} />
</DropdownMenu>
</>
}
showTogglePanelButton={false}

View file

@ -1,6 +1,6 @@
import { GROUP_CHAT_URL } from '@lobechat/const';
import type { SidebarAgentItem } from '@lobechat/types';
import { ActionIcon, Dropdown, Icon, type MenuProps } from '@lobehub/ui';
import { ActionIcon, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2, PinIcon } from 'lucide-react';
import { type CSSProperties, type DragEvent, memo, useCallback, useMemo } from 'react';
@ -85,7 +85,7 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
return <GroupAvatar avatars={(avatar as any) || []} size={22} />;
}, [isUpdating, avatar]);
const dropdownMenu: MenuProps['items'] = useDropdownMenu({
const dropdownMenu = useDropdownMenu({
group: undefined,
id,
openCreateGroupModal: () => {}, // Groups don't need this
@ -97,29 +97,23 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<Link aria-label={id} to={groupUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
disabled={editing || isUpdating}
draggable={!editing && !isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
style={style}
title={displayTitle}
/>
</Link>
</Dropdown>
<Link aria-label={id} to={groupUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
contextMenuItems={dropdownMenu}
disabled={editing || isUpdating}
draggable={!editing && !isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
style={style}
title={displayTitle}
/>
</Link>
<Editing id={id} title={displayTitle} toggleEditing={toggleEditing} />
</>
);

View file

@ -1,6 +1,6 @@
import { SESSION_CHAT_URL } from '@lobechat/const';
import type { SidebarAgentItem } from '@lobechat/types';
import { ActionIcon, Dropdown, Icon, type MenuProps } from '@lobehub/ui';
import { ActionIcon, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2, PinIcon } from 'lucide-react';
import { type CSSProperties, type DragEvent, memo, useCallback, useMemo } from 'react';
@ -96,7 +96,7 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
return <Avatar avatar={typeof avatar === 'string' ? avatar : undefined} />;
}, [isUpdating, avatar]);
const dropdownMenu: MenuProps['items'] = useDropdownMenu({
const dropdownMenu = useDropdownMenu({
group: undefined, // TODO: pass group from parent if needed
id,
openCreateGroupModal: handleOpenCreateGroupModal,
@ -108,30 +108,25 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<Link aria-label={id} to={agentUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
disabled={editing || isUpdating}
draggable={!editing && !isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
loading={isLoading}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
style={style}
title={displayTitle}
/>
</Link>
</Dropdown>
<Link aria-label={id} to={agentUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
contextMenuItems={dropdownMenu}
disabled={editing || isUpdating}
draggable={!editing && !isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
loading={isLoading}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
style={style}
title={displayTitle}
/>
</Link>
<Editing
avatar={typeof avatar === 'string' ? avatar : undefined}
id={id}

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,5 +1,5 @@
import { type MenuProps } from '@lobehub/ui';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useSessionItemMenuItems } from '../../../../hooks';
@ -21,7 +21,7 @@ export const useDropdownMenu = ({
pinned,
sessionType,
toggleEditing,
}: ActionProps): MenuProps['items'] => {
}: ActionProps): (() => MenuProps['items']) => {
const {
pinMenuItem,
renameMenuItem,
@ -31,7 +31,7 @@ export const useDropdownMenu = ({
deleteMenuItem,
} = useSessionItemMenuItems();
return useMemo(
return useCallback(
() =>
[
pinMenuItem(id, pinned, parentType),

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionsProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionsProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,4 +1,3 @@
import { Dropdown } from '@lobehub/ui';
import { BoxIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
@ -39,20 +38,14 @@ const ProjectItem = memo<ProjectItemProps>(({ id, name }) => {
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
disabled={editing || isUpdating}
icon={BoxIcon}
loading={isLoading || isUpdating}
title={name}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
contextMenuItems={dropdownMenu}
disabled={editing || isUpdating}
icon={BoxIcon}
loading={isLoading || isUpdating}
title={name}
/>
<Editing id={id} name={name} toggleEditing={toggleEditing} />
</>
);

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { PencilLine, Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
@ -14,12 +14,12 @@ interface ProjectItemDropdownMenuProps {
export const useProjectItemDropdownMenu = ({
id,
toggleEditing,
}: ProjectItemDropdownMenuProps): MenuProps['items'] => {
}: ProjectItemDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['home', 'common']);
const [removeKnowledgeBase] = useKnowledgeBaseStore((s) => [s.removeKnowledgeBase]);
const { modal } = App.useApp();
return useMemo<MenuProps['items']>(
return useCallback(
() => [
{
icon: <Icon icon={PencilLine} />,

View file

@ -1,6 +1,5 @@
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox } from '@lobehub/ui';
import { CreateBotIcon } from '@lobehub/ui/icons';
import { Dropdown } from 'antd';
import { cssVar } from 'antd-style';
import { ChevronDownIcon } from 'lucide-react';
import React, { memo, useCallback, useMemo } from 'react';
@ -45,7 +44,7 @@ const AddButton = memo(() => {
size={DESKTOP_HEADER_ICON_SIZE}
title={tChat('newAgent')}
/>
<Dropdown menu={{ items: dropdownItems || [] }}>
<DropdownMenu items={dropdownItems}>
<ActionIcon
color={cssVar.colorTextQuaternary}
icon={ChevronDownIcon}
@ -54,7 +53,7 @@ const AddButton = memo(() => {
width: 16,
}}
/>
</Dropdown>
</DropdownMenu>
</Flexbox>
);
});

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,5 +1,4 @@
import { Avatar, type MenuProps } from '@lobehub/ui';
import { Dropdown } from '@lobehub/ui';
import { Avatar } from '@lobehub/ui';
import { FileTextIcon } from 'lucide-react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -56,7 +55,7 @@ const PageListItem = memo<DocumentItemProps>(({ pageId, className }) => {
return FileTextIcon;
}, [emoji]);
const dropdownMenu: MenuProps['items'] = useDropdownMenu({
const dropdownMenu = useDropdownMenu({
documentContent: document?.content || undefined,
pageId,
toggleEditing,
@ -64,23 +63,17 @@ const PageListItem = memo<DocumentItemProps>(({ pageId, className }) => {
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active}
className={className}
disabled={editing}
icon={icon}
key={pageId}
onClick={handleClick}
title={title}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active}
className={className}
contextMenuItems={dropdownMenu}
disabled={editing}
icon={icon}
key={pageId}
onClick={handleClick}
title={title}
/>
<Editing
currentEmoji={emoji}
documentId={pageId}

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { Copy, CopyPlus, Pencil, Trash2 } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useFileStore } from '@/store/file';
@ -16,7 +16,7 @@ export const useDropdownMenu = ({
documentContent,
pageId,
toggleEditing,
}: ActionProps): MenuProps['items'] => {
}: ActionProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['common', 'file']);
const { message, modal } = App.useApp();
const removeDocument = useFileStore((s) => s.removeDocument);
@ -59,7 +59,7 @@ export const useDropdownMenu = ({
}
};
return useMemo(
return useCallback(
() =>
[
{

View file

@ -1,25 +1,16 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionProps>(({ dropdownMenu }) => {
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,5 +1,4 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { Dropdown } from '@lobehub/ui';
import { Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import React, { type CSSProperties, memo, useCallback, useMemo } from 'react';
@ -66,33 +65,27 @@ const KnowledgeBaseItem = memo<KnowledgeBaseItemProps>(({ id, name, active, styl
return <RepoIcon size={18} />;
}, [isLoading]);
const dropdownMenu: MenuProps['items'] = useDropdownMenu({
const dropdownMenu = useDropdownMenu({
id,
toggleEditing,
});
return (
<>
<Dropdown
menu={{
items: dropdownMenu,
}}
trigger={['contextMenu']}
>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active}
className={className}
disabled={editing}
icon={icon}
key={id}
loading={isLoading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
style={style}
title={name}
/>
</Dropdown>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active}
className={className}
contextMenuItems={dropdownMenu}
disabled={editing}
icon={icon}
key={id}
loading={isLoading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
style={style}
title={name}
/>
<Editing id={id} name={name} toggleEditing={toggleEditing} />
</>
);

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { PencilLine, Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
@ -11,7 +11,7 @@ interface ActionProps {
toggleEditing: (visible?: boolean) => void;
}
export const useDropdownMenu = ({ id, toggleEditing }: ActionProps): MenuProps['items'] => {
export const useDropdownMenu = ({ id, toggleEditing }: ActionProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['file', 'common']);
const { modal } = App.useApp();
const removeKnowledgeBase = useKnowledgeBaseStore((s) => s.removeKnowledgeBase);
@ -29,7 +29,7 @@ export const useDropdownMenu = ({ id, toggleEditing }: ActionProps): MenuProps['
});
};
return useMemo(
return useCallback(
() =>
[
{

View file

@ -1,9 +1,7 @@
'use client';
import { useDroppable } from '@dnd-kit/core';
import { Center, Flexbox, Skeleton, Text } from '@lobehub/ui';
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { Center, type DropdownItem, DropdownMenu, Flexbox, Skeleton, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { ChevronsUpDown } from 'lucide-react';
import { memo, useCallback, useMemo } from 'react';
@ -81,7 +79,7 @@ const Head = memo<{ id: string }>(({ id }) => {
[navigate, setMode],
);
const menuItems: MenuProps['items'] = useMemo(() => {
const menuItems = useMemo<DropdownItem[]>(() => {
if (!libraries) return [];
return libraries.map((library) => ({
@ -120,14 +118,14 @@ const Head = memo<{ id: string }>(({ id }) => {
</Flexbox>
)}
{name && (
<Dropdown menu={{ items: menuItems }} placement="bottomRight" trigger={['click']}>
<DropdownMenu items={menuItems} placement="bottomRight">
<ChevronsUpDown
className={styles.icon}
onClick={(e) => e.stopPropagation()}
size={16}
style={{ cursor: 'pointer', flex: 'none' }}
/>
</Dropdown>
</DropdownMenu>
)}
</Flexbox>
);

View file

@ -1,7 +1,5 @@
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Dropdown } from 'antd';
import { ActionIcon, DropdownMenu, type DropdownMenuCheckboxItem, Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import type { ItemType } from 'antd/es/menu/interface';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { Clock3Icon, PlusIcon } from 'lucide-react';
@ -53,7 +51,7 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
[topics, activeTopicId],
);
const items = useMemo<ItemType[]>(
const items = useMemo<DropdownMenuCheckboxItem[]>(
() =>
(topics || []).map((topic) => {
const displayTime =
@ -62,6 +60,8 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
: dayjs(topic.updatedAt).format('YYYY-MM-DD');
return {
checked: topic.id === activeTopicId,
closeOnClick: true,
key: topic.id,
label: (
<Flexbox align="center" gap={4} horizontal justify="space-between" width="100%">
@ -69,11 +69,17 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
<span className={styles.time}>{displayTime}</span>
</Flexbox>
),
onClick: () => switchTopic(topic.id),
onCheckedChange: (checked) => {
if (checked) {
switchTopic(topic.id);
}
},
type: 'checkbox',
};
}),
[topics, switchTopic, styles],
[topics, switchTopic, styles, activeTopicId],
);
const isEmpty = !topics || topics.length === 0;
return (
<NavHeader
@ -88,19 +94,14 @@ const TopicSelector = memo<TopicSelectorProps>(({ agentId }) => {
size={DESKTOP_HEADER_ICON_SIZE}
title={t('actions.addNewTopic')}
/>
<Dropdown
disabled={!topics || topics.length === 0}
menu={{
items,
selectedKeys: activeTopicId ? [activeTopicId] : [],
style: { maxHeight: 400, overflowY: 'auto' },
}}
overlayStyle={{ minWidth: 280 }}
<DropdownMenu
items={items}
placement="bottomRight"
trigger={['click']}
popupProps={{ style: { maxHeight: 400, minWidth: 280, overflowY: 'auto' } }}
triggerProps={{ disabled: isEmpty }}
>
<ActionIcon disabled={!topics || topics.length === 0} icon={Clock3Icon} />
</Dropdown>
<ActionIcon disabled={isEmpty} icon={Clock3Icon} />
</DropdownMenu>
</>
}
showTogglePanelButton={false}

View file

@ -22,6 +22,13 @@ export const styles = createStaticStyles(({ css, cssVar }) => {
display: flex;
}
&:has([data-popup-open]) {
div[role='menubar'] {
pointer-events: unset;
opacity: 1;
}
}
&:hover {
time,
div[role='menubar'] {

View file

@ -15,9 +15,7 @@ export const ErrorActionsBar = memo<ErrorActionsBarProps>(({ actions, onActionCl
return (
<ActionIconGroup
items={[regenerate, del]}
menu={{
items: [edit, copy, divider, del],
}}
menu={[edit, copy, divider, del]}
onActionClick={onActionClick}
/>
);

View file

@ -1,10 +1,16 @@
import { type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup } from '@lobehub/ui';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
import ShareMessageModal from '../../../components/ShareMessageModal';
import { messageStateSelectors, useConversationStore } from '../../../store';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
Provider,
createStore,
messageStateSelectors,
useConversationStore,
useConversationStoreApi,
} from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
@ -57,7 +63,30 @@ interface AssistantActionsBarProps {
export const AssistantActionsBar = memo<AssistantActionsBarProps>(
({ actionsConfig, id, data, index }) => {
const { error, tools } = data;
const [showShareModal, setShareModal] = useState(false);
const store = useConversationStoreApi();
const handleOpenShareModal = useCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [data, store]);
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
@ -65,7 +94,7 @@ export const AssistantActionsBar = memo<AssistantActionsBarProps>(
data,
id,
index,
onOpenShareModal: () => setShareModal(true),
onOpenShareModal: handleOpenShareModal,
});
const hasTools = !!tools;
@ -174,17 +203,9 @@ export const AssistantActionsBar = memo<AssistantActionsBarProps>(
[allActions],
);
const shareOnCancel = useCallback(() => {
setShareModal(false);
}, []);
if (error) return <ErrorActionsBar actions={defaultActions} onActionClick={handleAction} />;
return (
<>
<ActionIconGroup items={items} menu={{ items: menu }} onActionClick={handleAction} />
<ShareMessageModal message={data} onCancel={shareOnCancel} open={showShareModal} />
</>
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
},
);

View file

@ -1,10 +1,16 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup } from '@lobehub/ui';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
import ShareMessageModal from '../../../components/ShareMessageModal';
import { messageStateSelectors, useConversationStore } from '../../../store';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
Provider,
createStore,
messageStateSelectors,
useConversationStore,
useConversationStoreApi,
} from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
@ -59,7 +65,29 @@ interface GroupActionsProps {
*/
const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, contentBlock }) => {
const { tools } = data;
const [showShareModal, setShareModal] = useState(false);
const store = useConversationStoreApi();
const handleOpenShareModal = useCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [data, store]);
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
@ -67,7 +95,7 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
contentBlock,
data,
id,
onOpenShareModal: () => setShareModal(true),
onOpenShareModal: handleOpenShareModal,
});
const hasTools = !!tools;
@ -130,16 +158,7 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
[allActions],
);
return (
<>
<ActionIconGroup items={items} menu={{ items: menu }} onActionClick={handleAction} />
<ShareMessageModal
message={data}
onCancel={() => setShareModal(false)}
open={showShareModal}
/>
</>
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
});
WithContentId.displayName = 'GroupActionsWithContentId';

View file

@ -1,10 +1,16 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup } from '@lobehub/ui';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
import ShareMessageModal from '../../../components/ShareMessageModal';
import { messageStateSelectors, useConversationStore } from '../../../store';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
Provider,
createStore,
messageStateSelectors,
useConversationStore,
useConversationStoreApi,
} from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
@ -58,7 +64,29 @@ interface GroupActionsProps {
* Actions bar for group messages with content (has assistant message content)
*/
const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, contentBlock }) => {
const [showShareModal, setShareModal] = useState(false);
const store = useConversationStoreApi();
const handleOpenShareModal = useCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [data, store]);
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
@ -66,7 +94,7 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
contentBlock,
data,
id,
onOpenShareModal: () => setShareModal(true),
onOpenShareModal: handleOpenShareModal,
});
// Get collapse/expand action based on current state
@ -122,16 +150,7 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
[allActions],
);
return (
<>
<ActionIconGroup items={items} menu={{ items: menu }} onActionClick={handleAction} />
<ShareMessageModal
message={data}
onCancel={() => setShareModal(false)}
open={showShareModal}
/>
</>
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
});
WithContentId.displayName = 'GroupActionsWithContentId';

View file

@ -15,9 +15,7 @@ export const ErrorActionsBar = memo<ErrorActionsBarProps>(({ actions, onActionCl
return (
<ActionIconGroup
items={[regenerate, del]}
menu={{
items: [edit, copy, divider, del],
}}
menu={[edit, copy, divider, del]}
onActionClick={onActionClick}
/>
);

View file

@ -1,9 +1,12 @@
import { type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup } from '@lobehub/ui';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
import ShareMessageModal from '../../../components/ShareMessageModal';
import { useEventCallback } from '@/hooks/useEventCallback';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import { Provider, createStore, useConversationStoreApi } from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
@ -56,13 +59,35 @@ interface AssistantActionsBarProps {
export const AssistantActionsBar = memo<AssistantActionsBarProps>(
({ actionsConfig, id, data, index }) => {
const { error, tools } = data;
const [showShareModal, setShareModal] = useState(false);
const store = useConversationStoreApi();
const handleOpenShareModal = useEventCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
});
const defaultActions = useAssistantActions({
data,
id,
index,
onOpenShareModal: () => setShareModal(true),
onOpenShareModal: handleOpenShareModal,
});
const hasTools = !!tools;
@ -159,16 +184,7 @@ export const AssistantActionsBar = memo<AssistantActionsBarProps>(
if (error) return <ErrorActionsBar actions={defaultActions} onActionClick={handleAction} />;
return (
<>
<ActionIconGroup items={items} menu={{ items: menu }} onActionClick={handleAction} />
<ShareMessageModal
message={data}
onCancel={() => setShareModal(false)}
open={showShareModal}
/>
</>
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
},
);

View file

@ -135,7 +135,7 @@ export const UserActionsBar = memo<UserActionsProps>(({ actionsConfig, id, data
[allActions],
);
return <ActionIconGroup items={items} menu={{ items: menu }} onActionClick={handleAction} />;
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
});
UserActionsBar.displayName = 'UserActionsBar';

View file

@ -4,29 +4,13 @@ import { isDesktop } from '@lobechat/const';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import isEqual from 'fast-deep-equal';
import {
type MouseEvent,
type ReactNode,
type RefObject,
Suspense,
memo,
useCallback,
useMemo,
useRef,
} from 'react';
import type { VListHandle } from 'virtua';
import { type MouseEvent, type ReactNode, Suspense, memo, useCallback } from 'react';
import BubblesLoading from '@/components/BubblesLoading';
import ContextMenu from '../components/ContextMenu';
import History from '../components/History';
import { useChatItemContextMenu } from '../hooks/useChatItemContextMenu';
import {
dataSelectors,
messageStateSelectors,
useConversationStore,
virtuaListSelectors,
} from '../store';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../store';
import AgentCouncilMessage from './AgentCouncil';
import AssistantMessage from './Assistant';
import AssistantGroupMessage from './AssistantGroup';
@ -73,13 +57,10 @@ const MessageItem = memo<MessageItemProps>(
index,
isLatestItem,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const topic = useConversationStore((s) => s.context.topicId);
// Get message and actionsBar from ConversationStore
// Get message from ConversationStore
const message = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual);
const actionsBar = useConversationStore((s) => s.actionsBar);
const role = message?.role;
const [editing, isMessageCreating] = useConversationStore((s) => [
@ -87,24 +68,13 @@ const MessageItem = memo<MessageItemProps>(
messageStateSelectors.isMessageCreating(id)(s),
]);
const {
containerRef: contextMenuContainerRef,
contextMenuState,
handleContextMenu,
hideContextMenu,
} = useChatItemContextMenu({
const { handleContextMenu } = useChatItemContextMenu({
editing,
onActionClick: () => {},
id,
inPortalThread,
topic,
});
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
contextMenuContainerRef.current = node;
},
[contextMenuContainerRef],
);
const onContextMenu = useCallback(
async (event: MouseEvent<HTMLDivElement>) => {
if (!role || (role !== 'user' && role !== 'assistant' && role !== 'assistantGroup')) return;
@ -129,15 +99,6 @@ const MessageItem = memo<MessageItemProps>(
},
[handleContextMenu, id, role, message],
);
// Get virtuaScrollMethods from ConversationStore
const virtuaScrollMethods = useConversationStore(virtuaListSelectors.virtuaScrollMethods);
// Create a ref-like object for ContextMenu compatibility
// VirtuaScrollMethods is a subset of VListHandle with the methods ContextMenu needs
const virtuaRef = useMemo<RefObject<VListHandle | null>>(
() => ({ current: virtuaScrollMethods as VListHandle | null }),
[virtuaScrollMethods],
);
const renderContent = useCallback(() => {
switch (role) {
@ -202,7 +163,7 @@ const MessageItem = memo<MessageItemProps>(
}
return null;
}, [role, disableEditing, id, index, isLatestItem, actionsBar]);
}, [role, disableEditing, id, index, isLatestItem]);
if (!role) return;
@ -213,22 +174,10 @@ const MessageItem = memo<MessageItemProps>(
className={cx(styles.message, className, isMessageCreating && styles.loading)}
data-index={index}
onContextMenu={onContextMenu}
ref={setContainerRef}
>
<Suspense fallback={<BubblesLoading />}>{renderContent()}</Suspense>
{endRender}
</Flexbox>
<ContextMenu
id={id}
inPortalThread={inPortalThread}
index={index}
onClose={hideContextMenu}
position={contextMenuState.position}
selectedText={contextMenuState.selectedText}
topic={topic}
virtuaRef={virtuaRef}
visible={contextMenuState.visible}
/>
</>
);
},

View file

@ -1,418 +0,0 @@
'use client';
import { type UIChatMessage } from '@lobechat/types';
import { ActionIcon } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { Dropdown, type MenuProps } from 'antd';
import { App } from 'antd';
import { createStaticStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import {
type ComponentType,
type ReactNode,
type RefObject,
isValidElement,
memo,
useCallback,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { VListHandle } from 'virtua';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { useChatListActionsBar } from '../hooks/useChatListActionsBar';
import {
dataSelectors,
messageStateSelectors,
useConversationStore,
useConversationStoreApi,
} from '../store';
import ShareMessageModal from './ShareMessageModal';
interface ActionMenuItem extends ActionIconGroupItemType {
children?: { key: string; label: ReactNode }[];
disable?: boolean;
popupClassName?: string;
}
type MenuItem = ActionMenuItem | { type: 'divider' };
type ContextMenuEvent = ActionIconGroupEvent & { selectedText?: string };
const styles = createStaticStyles(({ css }) => ({
contextMenu: css`
position: fixed;
z-index: 1000;
min-width: 160px;
.ant-dropdown-menu {
border: none;
border-radius: 6px;
box-shadow: 0 4px 12px color-mix(in srgb, black 15%, transparent);
}
`,
trigger: css`
pointer-events: none;
position: fixed;
width: 1px;
height: 1px;
opacity: 0;
`,
}));
interface ContextMenuProps {
id: string;
inPortalThread: boolean;
index: number;
onClose: () => void;
position: { x: number; y: number };
selectedText?: string;
topic?: string | null;
virtuaRef?: RefObject<VListHandle | null> | null;
visible: boolean;
}
const ContextMenu = memo<ContextMenuProps>(
({ visible, position, selectedText, id, index, inPortalThread, topic, virtuaRef, onClose }) => {
const { message } = App.useApp();
const { t } = useTranslation('common');
const [shareMessage, setShareMessage] = useState<UIChatMessage | null>(null);
const [isShareModalOpen, setShareModalOpen] = useState(false);
const storeApi = useConversationStoreApi();
const [role, error, isCollapsed, hasThread, isRegenerating] = useConversationStore((s) => {
const item = dataSelectors.getDisplayMessageById(id)(s);
return [
item?.role,
item?.error,
messageStateSelectors.isMessageCollapsed(id)(s),
messageStateSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
];
}, isEqual);
const isThreadMode = useConversationStore(messageStateSelectors.isThreadMode);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const actionsBar = useChatListActionsBar({ hasThread, isRegenerating });
const inThread = isThreadMode || inPortalThread;
const [
toggleMessageEditing,
deleteMessage,
regenerateUserMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
copyMessage,
openThreadCreator,
resendThreadMessage,
delAndResendThreadMessage,
toggleMessageCollapsed,
] = useConversationStore((s) => [
s.toggleMessageEditing,
s.deleteMessage,
s.regenerateUserMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
s.copyMessage,
s.openThreadCreator,
s.resendThreadMessage,
s.delAndResendThreadMessage,
s.toggleMessageCollapsed,
]);
const getMessage = useCallback(
() => dataSelectors.getDisplayMessageById(id)(storeApi.getState()),
[id, storeApi],
);
const menuItems = useMemo<MenuItem[]>(() => {
if (!role) return [];
const {
branching,
collapse,
copy,
del,
delAndRegenerate,
divider,
edit,
expand,
regenerate,
share,
translate,
tts,
} = actionsBar;
if (role === 'assistant') {
if (error) {
return [edit, copy, divider, del, divider, regenerate].filter(Boolean) as MenuItem[];
}
const collapseAction = isCollapsed ? expand : collapse;
const list: MenuItem[] = [edit, copy, collapseAction];
if (!inThread && !isGroupSession) list.push(branching);
list.push(
divider,
tts,
translate,
divider,
share,
divider,
regenerate,
delAndRegenerate,
del,
);
return list.filter(Boolean) as MenuItem[];
}
if (role === 'assistantGroup') {
if (error) {
return [edit, copy, divider, del, divider, regenerate].filter(Boolean) as MenuItem[];
}
const collapseAction = isCollapsed ? expand : collapse;
const list: MenuItem[] = [
edit,
copy,
collapseAction,
divider,
share,
divider,
regenerate,
del,
];
return list.filter(Boolean) as MenuItem[];
}
if (role === 'user') {
const list: MenuItem[] = [edit, copy];
if (!inThread) list.push(branching);
list.push(divider, tts, translate, divider, regenerate, del);
return list.filter(Boolean) as MenuItem[];
}
return [];
}, [actionsBar, error, inThread, isCollapsed, isGroupSession, role]);
const handleShare = useCallback(() => {
const item = getMessage();
if (!item || item.role !== 'assistant') return;
setShareMessage(item);
setShareModalOpen(true);
}, [getMessage]);
const handleShareClose = useCallback(() => {
setShareModalOpen(false);
setShareMessage(null);
}, []);
const handleAction = useCallback(
async (action: ContextMenuEvent) => {
const item = getMessage();
if (!item) return;
switch (action.key) {
case 'edit': {
toggleMessageEditing(id, true);
break;
}
case 'copy': {
await copyMessage(id, item.content);
message.success(t('copySuccess'));
break;
}
case 'expand':
case 'collapse': {
toggleMessageCollapsed(id);
break;
}
case 'branching': {
if (!topic) {
message.warning(t('branchingRequiresSavedTopic'));
break;
}
openThreadCreator(id);
break;
}
case 'del': {
deleteMessage(id);
break;
}
case 'regenerate': {
if (inPortalThread) {
resendThreadMessage(id);
} else if (role === 'assistant') {
regenerateAssistantMessage(id);
} else {
regenerateUserMessage(id);
}
if (item.error) deleteMessage(id);
break;
}
case 'delAndRegenerate': {
if (inPortalThread) {
delAndResendThreadMessage(id);
} else {
delAndRegenerateMessage(id);
}
break;
}
case 'tts': {
ttsMessage(id);
break;
}
case 'share': {
handleShare();
break;
}
}
if (action.keyPath?.at(-1) === 'translate') {
const lang = action.keyPath[0];
translateMessage(id, lang);
}
},
[
copyMessage,
deleteMessage,
delAndRegenerateMessage,
delAndResendThreadMessage,
getMessage,
handleShare,
id,
index,
inPortalThread,
message,
openThreadCreator,
regenerateAssistantMessage,
regenerateUserMessage,
resendThreadMessage,
role,
t,
toggleMessageCollapsed,
toggleMessageEditing,
topic,
translateMessage,
ttsMessage,
virtuaRef,
],
);
const renderIcon = useCallback((iconComponent: ActionIconGroupItemType['icon']) => {
if (!iconComponent) return null;
if (isValidElement(iconComponent)) {
return <ActionIcon icon={iconComponent} size={'small'} />;
}
const IconComponent = iconComponent as ComponentType<{ size?: number }>;
return <ActionIcon icon={<IconComponent size={16} />} size={'small'} />;
}, []);
const dropdownMenuItems = useMemo(() => {
return (menuItems ?? []).filter(Boolean).map((item) => {
if ('type' in item && item.type === 'divider') return { type: 'divider' as const };
const actionItem = item as ActionMenuItem;
const children = actionItem.children?.map((child) => ({
key: child.key,
label: child.label,
}));
const disabled =
actionItem.disabled ??
(typeof actionItem.disable === 'boolean' ? actionItem.disable : undefined);
return {
children,
danger: actionItem.danger,
disabled,
icon: renderIcon(actionItem.icon),
key: actionItem.key,
label: actionItem.label,
popupClassName: actionItem.popupClassName,
};
});
}, [menuItems, renderIcon]);
const handleMenuClick = useCallback(
(info: Parameters<NonNullable<MenuProps['onClick']>>[0]) => {
const event = {
...info,
selectedText,
} as ContextMenuEvent;
handleAction(event);
onClose();
},
[handleAction, onClose, selectedText],
);
if (!visible || menuItems.length === 0) return null;
return (
<>
{createPortal(
<>
<div
className={styles.trigger}
style={{
left: position.x,
top: position.y,
}}
/>
<Dropdown
menu={{
items: dropdownMenuItems,
onClick: handleMenuClick,
}}
open={visible}
placement="bottomLeft"
trigger={[]}
>
<div
className={styles.contextMenu}
style={{
left: position.x,
top: position.y,
}}
/>
</Dropdown>
</>,
document.body,
)}
{shareMessage && (
<ShareMessageModal
message={shareMessage}
onCancel={handleShareClose}
open={isShareModalOpen}
/>
)}
</>
);
},
);
ContextMenu.displayName = 'ContextMenu';
export default ContextMenu;

View file

@ -15,7 +15,7 @@ enum Tab {
Text = 'text',
}
interface ShareModalProps {
export interface ShareModalProps {
message: UIChatMessage;
onCancel: () => void;
open: boolean;

View file

@ -1,49 +1,338 @@
import { type ActionIconGroupEvent } from '@lobehub/ui';
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type ActionIconGroupEvent,
type ActionIconGroupItemType,
type DropdownItem,
type GenericItemType,
createRawModal,
showContextMenu,
} from '@lobehub/ui';
import { App } from 'antd';
import isEqual from 'fast-deep-equal';
import type { MouseEvent, ReactNode } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { MSG_CONTENT_CLASSNAME } from '@/features/Conversation/ChatItem/components/MessageContent';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
interface ContextMenuState {
position: { x: number; y: number };
selectedText?: string;
visible: boolean;
import ShareMessageModal from '../components/ShareMessageModal';
import {
dataSelectors,
messageStateSelectors,
useConversationStore,
useConversationStoreApi,
} from '../store';
import { useChatListActionsBar } from './useChatListActionsBar';
interface ActionMenuItem extends ActionIconGroupItemType {
children?: { key: string; label: ReactNode }[];
disable?: boolean;
popupClassName?: string;
}
type MenuItem = ActionMenuItem | { type: 'divider' };
type ContextMenuEvent = ActionIconGroupEvent & { selectedText?: string };
interface UseChatItemContextMenuProps {
editing?: boolean;
id: string;
onActionClick: (action: ActionIconGroupEvent) => void;
inPortalThread: boolean;
topic?: string | null;
}
export const useChatItemContextMenu = ({
onActionClick,
editing,
}: Omit<UseChatItemContextMenuProps, 'id'>) => {
id,
inPortalThread,
topic,
}: UseChatItemContextMenuProps) => {
const contextMenuMode = useUserStore(userGeneralSettingsSelectors.contextMenuMode);
const { message } = App.useApp();
const { t } = useTranslation('common');
const [contextMenuState, setContextMenuState] = useState<ContextMenuState>({
position: { x: 0, y: 0 },
visible: false,
});
const selectedTextRef = useRef<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
const storeApi = useConversationStoreApi();
const [role, error, isCollapsed, hasThread, isRegenerating] = useConversationStore((s) => {
const item = dataSelectors.getDisplayMessageById(id)(s);
return [
item?.role,
item?.error,
messageStateSelectors.isMessageCollapsed(id)(s),
messageStateSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
];
}, isEqual);
const isThreadMode = useConversationStore(messageStateSelectors.isThreadMode);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const actionsBar = useChatListActionsBar({ hasThread, isRegenerating });
const inThread = isThreadMode || inPortalThread;
const [
toggleMessageEditing,
deleteMessage,
regenerateUserMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
copyMessage,
openThreadCreator,
resendThreadMessage,
delAndResendThreadMessage,
toggleMessageCollapsed,
] = useConversationStore((s) => [
s.toggleMessageEditing,
s.deleteMessage,
s.regenerateUserMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
s.copyMessage,
s.openThreadCreator,
s.resendThreadMessage,
s.delAndResendThreadMessage,
s.toggleMessageCollapsed,
]);
const getMessage = useCallback(
() => dataSelectors.getDisplayMessageById(id)(storeApi.getState()),
[id, storeApi],
);
const menuItems = useMemo<MenuItem[]>(() => {
if (!role) return [];
const {
branching,
collapse,
copy,
del,
delAndRegenerate,
divider,
edit,
expand,
regenerate,
share,
translate,
tts,
} = actionsBar;
if (role === 'assistant') {
if (error) {
return [edit, copy, divider, del, divider, regenerate].filter(Boolean) as MenuItem[];
}
const collapseAction = isCollapsed ? expand : collapse;
const list: MenuItem[] = [edit, copy, collapseAction];
if (!inThread && !isGroupSession) list.push(branching);
list.push(
divider,
tts,
translate,
divider,
share,
divider,
regenerate,
delAndRegenerate,
del,
);
return list.filter(Boolean) as MenuItem[];
}
if (role === 'assistantGroup') {
if (error) {
return [edit, copy, divider, del, divider, regenerate].filter(Boolean) as MenuItem[];
}
const collapseAction = isCollapsed ? expand : collapse;
const list: MenuItem[] = [
edit,
copy,
collapseAction,
divider,
share,
divider,
regenerate,
del,
];
return list.filter(Boolean) as MenuItem[];
}
if (role === 'user') {
const list: MenuItem[] = [edit, copy];
if (!inThread) list.push(branching);
list.push(divider, tts, translate, divider, regenerate, del);
return list.filter(Boolean) as MenuItem[];
}
return [];
}, [actionsBar, error, inThread, isCollapsed, isGroupSession, role]);
const handleShare = useCallback(() => {
const item = getMessage();
if (!item || item.role !== 'assistant') return;
createRawModal(
ShareMessageModal,
{
message: item,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [getMessage]);
const handleAction = useCallback(
async (action: ContextMenuEvent) => {
const item = getMessage();
if (!item) return;
switch (action.key) {
case 'edit': {
toggleMessageEditing(id, true);
break;
}
case 'copy': {
await copyMessage(id, item.content);
message.success(t('copySuccess'));
break;
}
case 'expand':
case 'collapse': {
toggleMessageCollapsed(id);
break;
}
case 'branching': {
if (!topic) {
message.warning(t('branchingRequiresSavedTopic'));
break;
}
openThreadCreator(id);
break;
}
case 'del': {
deleteMessage(id);
break;
}
case 'regenerate': {
if (inPortalThread) {
resendThreadMessage(id);
} else if (role === 'assistant') {
regenerateAssistantMessage(id);
} else {
regenerateUserMessage(id);
}
if (item.error) deleteMessage(id);
break;
}
case 'delAndRegenerate': {
if (inPortalThread) {
delAndResendThreadMessage(id);
} else {
delAndRegenerateMessage(id);
}
break;
}
case 'tts': {
ttsMessage(id);
break;
}
case 'share': {
handleShare();
break;
}
}
if (action.keyPath?.[0] === 'translate') {
const lang = action.keyPath.at(-1);
if (lang) translateMessage(id, lang);
}
},
[
copyMessage,
deleteMessage,
delAndRegenerateMessage,
delAndResendThreadMessage,
getMessage,
handleShare,
id,
inPortalThread,
message,
openThreadCreator,
regenerateAssistantMessage,
regenerateUserMessage,
resendThreadMessage,
role,
t,
toggleMessageCollapsed,
toggleMessageEditing,
topic,
translateMessage,
ttsMessage,
],
);
const handleMenuClick = useCallback(
(info: ActionIconGroupEvent) => {
handleAction({
...info,
selectedText: selectedTextRef.current,
} as ContextMenuEvent);
},
[handleAction],
);
const contextMenuItems = useMemo<GenericItemType[]>(() => {
if (!menuItems) return [];
return menuItems.filter(Boolean).map((item) => {
if ('type' in item && item.type === 'divider') return { type: 'divider' as const };
const actionItem = item as ActionMenuItem;
const children = actionItem.children?.map((child) => ({
key: child.key,
label: child.label,
onClick: handleMenuClick,
}));
const disabled =
actionItem.disabled ??
(typeof actionItem.disable === 'boolean' ? actionItem.disable : undefined);
return {
children,
danger: actionItem.danger,
disabled,
icon: actionItem.icon,
key: actionItem.key,
label: actionItem.label,
onClick: children ? undefined : handleMenuClick,
} satisfies DropdownItem;
});
}, [handleMenuClick, menuItems]);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
// Don't show context menu if disabled in settings
(event: MouseEvent<HTMLDivElement>) => {
if (contextMenuMode === 'disabled') {
return;
}
// Don't show context menu in editing mode
if (editing) {
return;
}
// Check if the clicked element or its parents have an id containing "msg_"
let target = event.target as HTMLElement;
let hasMessageId = false;
@ -55,82 +344,23 @@ export const useChatItemContextMenu = ({
target = target.parentElement as HTMLElement;
}
if (!hasMessageId) {
if (!hasMessageId || contextMenuItems.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
// Get selected text
const selection = window.getSelection();
const selectedText = selection?.toString().trim() || '';
selectedTextRef.current = selection?.toString().trim() || '';
setContextMenuState({
position: { x: event.clientX, y: event.clientY },
selectedText,
visible: true,
});
console.log(contextMenuItems);
showContextMenu(contextMenuItems);
},
[contextMenuMode, editing],
[contextMenuItems, contextMenuMode, editing],
);
const hideContextMenu = useCallback(() => {
setContextMenuState((prev) => ({ ...prev, visible: false }));
}, []);
const handleMenuClick = useCallback(
(action: ActionIconGroupEvent) => {
if (action.key === 'quote' && contextMenuState.selectedText) {
// Handle quote action - this will be integrated with ChatInput
onActionClick({
...action,
selectedText: contextMenuState.selectedText,
} as ActionIconGroupEvent & { selectedText: string });
} else {
onActionClick(action);
}
hideContextMenu();
},
[contextMenuState.selectedText, onActionClick, hideContextMenu],
);
// Close context menu when clicking outside
useEffect(() => {
const handleClickOutside = () => {
if (contextMenuState.visible) {
hideContextMenu();
}
};
const handleScroll = (event: Event) => {
if (contextMenuState.visible) {
// Check if the scroll event is from a dropdown sub-menu
const target = event.target as HTMLElement;
if (target && target.classList && target.classList.contains('ant-dropdown-menu-sub')) {
return; // Don't hide the context menu when scrolling within sub-menu
}
hideContextMenu();
}
};
if (contextMenuState.visible) {
document.addEventListener('click', handleClickOutside);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [contextMenuState.visible, hideContextMenu]);
return {
containerRef,
contextMenuMode,
contextMenuState,
handleContextMenu,
handleMenuClick,
hideContextMenu,
};
};

View file

@ -1,6 +1,16 @@
'use client';
import { Block, type BlockProps, Center, Flexbox, Icon, type IconProps, Text } from '@lobehub/ui';
import {
Block,
type BlockProps,
Center,
ContextMenuTrigger,
Flexbox,
type GenericItemType,
Icon,
type IconProps,
Text,
} from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import { type ReactNode, memo } from 'react';
@ -18,6 +28,11 @@ const styles = createStaticStyles(({ css }) => ({
margin-inline-end: 2px;
opacity: 0;
transition: opacity 0.2s ${cssVar.motionEaseOut};
&:has([data-popup-open]) {
width: unset;
opacity: 1;
}
}
&:hover {
@ -32,6 +47,7 @@ const styles = createStaticStyles(({ css }) => ({
export interface NavItemProps extends Omit<BlockProps, 'children' | 'title'> {
actions?: ReactNode;
active?: boolean;
contextMenuItems?: GenericItemType[] | (() => GenericItemType[]);
disabled?: boolean;
extra?: ReactNode;
icon?: IconProps['icon'];
@ -40,13 +56,25 @@ export interface NavItemProps extends Omit<BlockProps, 'children' | 'title'> {
}
const NavItem = memo<NavItemProps>(
({ className, actions, active, icon, title, onClick, disabled, loading, extra, ...rest }) => {
({
className,
actions,
contextMenuItems,
active,
icon,
title,
onClick,
disabled,
loading,
extra,
...rest
}) => {
const iconColor = active ? cssVar.colorText : cssVar.colorTextDescription;
const textColor = active ? cssVar.colorText : cssVar.colorTextSecondary;
const variant = active ? 'filled' : 'borderless';
const iconComponent = loading ? Loader2Icon : icon;
return (
const Content = (
<Block
align={'center'}
className={cx(styles.container, className)}
@ -102,6 +130,8 @@ const NavItem = memo<NavItemProps>(
</Flexbox>
</Block>
);
if (!contextMenuItems) return Content;
return <ContextMenuTrigger items={contextMenuItems}>{Content}</ContextMenuTrigger>;
},
);

View file

@ -1,27 +1,19 @@
import { ActionIcon, Dropdown, type MenuProps } from '@lobehub/ui';
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
interface ActionsProps {
dropdownMenu: MenuProps['items'];
dropdownMenu: DropdownItem[] | (() => DropdownItem[]);
}
const Actions = memo<ActionsProps>(({ dropdownMenu }) => {
if (!dropdownMenu || dropdownMenu.length === 0) return null;
if (!dropdownMenu || (typeof dropdownMenu !== 'function' && dropdownMenu.length === 0))
return null;
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<DropdownMenu items={dropdownMenu}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -28,6 +28,7 @@ const TopicItem = memo<TopicItemProps>(
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active}
contextMenuItems={dropdownMenu}
onClick={() => {
onTopicChange(topicId);
onClose();

View file

@ -1,7 +1,7 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { Trash2 } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
@ -12,7 +12,10 @@ interface UseDropdownMenuProps {
topicTitle: string;
}
export const useDropdownMenu = ({ onClose, topicId }: UseDropdownMenuProps): MenuProps['items'] => {
export const useDropdownMenu = ({
onClose,
topicId,
}: UseDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['common', 'topic']);
const { modal } = App.useApp();
const removeTopic = useChatStore((s) => s.removeTopic);
@ -32,7 +35,7 @@ export const useDropdownMenu = ({ onClose, topicId }: UseDropdownMenuProps): Men
});
};
return useMemo(
return useCallback(
() =>
[
{

View file

@ -1,42 +1,19 @@
import { ActionIcon, Dropdown } from '@lobehub/ui';
import { ActionIcon, DropdownMenu as DropdownMenuUI } from '@lobehub/ui';
import { type ItemType } from 'antd/es/menu/interface';
import { MoreHorizontalIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useFileItemDropdown } from './useFileItemDropdown';
import { memo } from 'react';
interface DropdownMenuProps {
fileType: string;
filename: string;
id: string;
knowledgeBaseId?: string;
onRenameStart?: () => void;
sourceType?: string;
url: string;
className?: string;
items: ItemType[] | (() => ItemType[]);
}
const DropdownMenu = memo<DropdownMenuProps>(
({ id, knowledgeBaseId, url, filename, fileType, sourceType, onRenameStart }) => {
const [isOpen, setIsOpen] = useState(false);
// Only compute dropdown items when dropdown is actually open
// This prevents expensive hook execution for all 20-25 visible items
const { menuItems } = useFileItemDropdown({
enabled: isOpen,
fileType,
filename,
id,
knowledgeBaseId,
onRenameStart,
sourceType,
url,
});
return (
<Dropdown menu={{ items: menuItems }} onOpenChange={setIsOpen} open={isOpen}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
);
},
);
const DropdownMenu = memo<DropdownMenuProps>(({ items, className }) => {
return (
<DropdownMenuUI items={items}>
<ActionIcon className={className} icon={MoreHorizontalIcon} size={'small'} />
</DropdownMenuUI>
);
});
export default DropdownMenu;

View file

@ -10,7 +10,7 @@ import {
PencilIcon,
Trash,
} from 'lucide-react';
import { useMemo } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import RepoIcon from '@/components/LibIcon';
@ -33,11 +33,10 @@ interface UseFileItemDropdownParams {
}
interface UseFileItemDropdownReturn {
menuItems: ItemType[];
menuItems: () => ItemType[];
}
export const useFileItemDropdown = ({
enabled = true,
id,
knowledgeBaseId,
url,
@ -65,10 +64,7 @@ export const useFileItemDropdown = ({
const isFolder = fileType === 'custom/folder';
const isPage = sourceType === 'document' || fileType === 'custom/document';
const menuItems = useMemo(() => {
// Skip building menu items when dropdown is not open (performance optimization)
if (!enabled) return [];
const menuItems = useCallback(() => {
// Filter out current knowledge base and create submenu items
const availableKnowledgeBases = (knowledgeBases || []).filter(
(kb) => kb.id !== knowledgeBaseId,
@ -264,7 +260,7 @@ export const useFileItemDropdown = ({
},
] as ItemType[]
).filter(Boolean);
}, [enabled, inKnowledgeBase, isFolder, knowledgeBases, knowledgeBaseId, id]);
}, [inKnowledgeBase, isFolder, knowledgeBases, knowledgeBaseId, id]);
return { menuItems };
};

View file

@ -1,4 +1,4 @@
import { Button, Center, Checkbox, Flexbox, Icon } from '@lobehub/ui';
import { Button, Center, Checkbox, ContextMenuTrigger, Flexbox, Icon } from '@lobehub/ui';
import { App, Input } from 'antd';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import dayjs from 'dayjs';
@ -23,6 +23,7 @@ import { formatSize } from '@/utils/format';
import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
import DropdownMenu from '../../ItemDropdown/DropdownMenu';
import { useFileItemDropdown } from '../../ItemDropdown/useFileItemDropdown';
import ChunksBadge from './ChunkTag';
dayjs.extend(relativeTime);
@ -37,10 +38,6 @@ const styles = createStaticStyles(({ css }) => {
&:hover {
background: ${cssVar.colorFillTertiary};
.file-list-item-hover {
opacity: 1;
}
}
`,
@ -58,12 +55,14 @@ const styles = createStaticStyles(({ css }) => {
opacity: 0.5;
`,
hover: cx(
'file-list-item-hover',
css`
opacity: 0;
`,
),
hover: css`
opacity: 0;
&[data-popup-open],
.file-list-item-group:hover & {
opacity: 1;
}
`,
item: css`
padding-block: 0;
padding-inline: 0 24px;
@ -324,169 +323,172 @@ const FileListItem = memo<FileListItemProps>(
}
}, [pendingRenameItemId, id, isFolder, resourceManagerState]);
const { menuItems } = useFileItemDropdown({
fileType,
filename: name,
id,
knowledgeBaseId: resourceManagerState.libraryId,
onRenameStart: isFolder ? handleRenameStart : undefined,
sourceType,
url,
});
return (
<Flexbox
align={'center'}
className={cx(
styles.container,
selected && styles.selected,
isDragging && styles.dragging,
isOver && styles.dragOver,
)}
data-drop-target-id={id}
data-is-folder={String(isFolder)}
draggable={!!resourceManagerState.libraryId}
height={48}
horizontal
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
onDrop={handleDrop}
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
}}
>
<ContextMenuTrigger items={menuItems}>
<Flexbox
align={'center'}
className={styles.item}
distribution={'space-between'}
flex={1}
className={cx(
styles.container,
'file-list-item-group',
selected && styles.selected,
isDragging && styles.dragging,
isOver && styles.dragOver,
)}
data-drop-target-id={id}
data-is-folder={String(isFolder)}
draggable={!!resourceManagerState.libraryId}
height={48}
horizontal
onClick={handleItemClick}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
onDrop={handleDrop}
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
}}
>
<Flexbox align={'center'} className={styles.nameContainer} horizontal>
<Center
height={48}
onClick={(e) => {
e.stopPropagation();
onSelectedChange(id, !selected, e.shiftKey, index);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{ paddingInline: 4 }}
>
<Checkbox checked={selected} />
</Center>
<Flexbox
align={'center'}
justify={'center'}
style={{ fontSize: 24, marginInline: 8, width: 24 }}
>
{isFolder ? (
<Icon icon={FolderIcon} size={24} />
) : isPage ? (
emoji ? (
<span style={{ fontSize: 24 }}>{emoji}</span>
) : (
<Center height={24} width={24}>
<Icon icon={FileText} size={24} />
</Center>
)
) : (
<FileIcon fileName={name} fileType={fileType} size={24} />
)}
</Flexbox>
{isRenaming && isFolder ? (
<Input
onBlur={handleRenameConfirm}
onChange={(e) => setRenamingValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRenameConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
handleRenameCancel();
}
}}
onPointerDown={(e) => e.stopPropagation()}
ref={inputRef}
size="small"
style={{ flex: 1, maxWidth: 400 }}
value={renamingValue}
/>
) : (
<span className={styles.name}>{name || t('file:pageList.untitled')}</span>
)}
</Flexbox>
<Flexbox
align={'center'}
gap={8}
className={styles.item}
distribution={'space-between'}
flex={1}
horizontal
onClick={(e) => {
e.stopPropagation();
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={handleItemClick}
>
{!isFolder &&
(fileStoreState.isCreatingFileParseTask ||
isNull(chunkingStatus) ||
!chunkingStatus ? (
<div
className={fileStoreState.isCreatingFileParseTask ? undefined : styles.hover}
title={t(
isSupportedForChunking
? 'FileManager.actions.chunkingTooltip'
: 'FileManager.actions.chunkingUnsupported',
)}
>
<Button
disabled={!isSupportedForChunking}
icon={FileBoxIcon}
loading={fileStoreState.isCreatingFileParseTask}
onClick={() => {
fileStoreState.parseFiles([id]);
}}
size={'small'}
type={'text'}
>
{t(
fileStoreState.isCreatingFileParseTask
? 'FileManager.actions.createChunkingTask'
: 'FileManager.actions.chunking',
)}
</Button>
</div>
<Flexbox align={'center'} className={styles.nameContainer} horizontal>
<Center
height={48}
onClick={(e) => {
e.stopPropagation();
onSelectedChange(id, !selected, e.shiftKey, index);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{ paddingInline: 4 }}
>
<Checkbox checked={selected} />
</Center>
<Flexbox
align={'center'}
justify={'center'}
style={{ fontSize: 24, marginInline: 8, width: 24 }}
>
{isFolder ? (
<Icon icon={FolderIcon} size={24} />
) : isPage ? (
emoji ? (
<span style={{ fontSize: 24 }}>{emoji}</span>
) : (
<Center height={24} width={24}>
<Icon icon={FileText} size={24} />
</Center>
)
) : (
<FileIcon fileName={name} fileType={fileType} size={24} />
)}
</Flexbox>
{isRenaming && isFolder ? (
<Input
onBlur={handleRenameConfirm}
onChange={(e) => setRenamingValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRenameConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
handleRenameCancel();
}
}}
onPointerDown={(e) => e.stopPropagation()}
ref={inputRef}
size="small"
style={{ flex: 1, maxWidth: 400 }}
value={renamingValue}
/>
) : (
<div style={{ cursor: 'default' }}>
<ChunksBadge
chunkCount={chunkCount}
chunkingError={chunkingError}
chunkingStatus={chunkingStatus}
embeddingError={embeddingError}
embeddingStatus={embeddingStatus}
finishEmbedding={finishEmbedding}
id={id}
/>
</div>
))}
<div className={styles.hover}>
<DropdownMenu
fileType={fileType}
filename={name}
id={id}
knowledgeBaseId={resourceManagerState.libraryId}
onRenameStart={isFolder ? handleRenameStart : undefined}
sourceType={sourceType}
url={url}
/>
</div>
<span className={styles.name}>{name || t('file:pageList.untitled')}</span>
)}
</Flexbox>
<Flexbox
align={'center'}
gap={8}
horizontal
onClick={(e) => {
e.stopPropagation();
}}
onPointerDown={(e) => e.stopPropagation()}
>
{!isFolder &&
(fileStoreState.isCreatingFileParseTask ||
isNull(chunkingStatus) ||
!chunkingStatus ? (
<div
className={fileStoreState.isCreatingFileParseTask ? undefined : styles.hover}
title={t(
isSupportedForChunking
? 'FileManager.actions.chunkingTooltip'
: 'FileManager.actions.chunkingUnsupported',
)}
>
<Button
disabled={!isSupportedForChunking}
icon={FileBoxIcon}
loading={fileStoreState.isCreatingFileParseTask}
onClick={() => {
fileStoreState.parseFiles([id]);
}}
size={'small'}
type={'text'}
>
{t(
fileStoreState.isCreatingFileParseTask
? 'FileManager.actions.createChunkingTask'
: 'FileManager.actions.chunking',
)}
</Button>
</div>
) : (
<div style={{ cursor: 'default' }}>
<ChunksBadge
chunkCount={chunkCount}
chunkingError={chunkingError}
chunkingStatus={chunkingStatus}
embeddingError={embeddingError}
embeddingStatus={embeddingStatus}
finishEmbedding={finishEmbedding}
id={id}
/>
</div>
))}
<DropdownMenu className={styles.hover} items={menuItems} />
</Flexbox>
</Flexbox>
{!isDragging && (
<>
<Flexbox className={styles.item} width={FILE_DATE_WIDTH}>
{displayTime}
</Flexbox>
<Flexbox className={styles.item} width={FILE_SIZE_WIDTH}>
{isFolder || isPage ? '-' : formatSize(size)}
</Flexbox>
</>
)}
</Flexbox>
{!isDragging && (
<>
<Flexbox className={styles.item} width={FILE_DATE_WIDTH}>
{displayTime}
</Flexbox>
<Flexbox className={styles.item} width={FILE_SIZE_WIDTH}>
{isFolder || isPage ? '-' : formatSize(size)}
</Flexbox>
</>
)}
</Flexbox>
</ContextMenuTrigger>
);
},
);

View file

@ -1,4 +1,4 @@
import { Checkbox } from '@lobehub/ui';
import { Checkbox, showContextMenu } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
@ -13,6 +13,7 @@ import { documentService } from '@/services/document';
import { type FileListItem } from '@/types/files';
import DropdownMenu from '../../ItemDropdown/DropdownMenu';
import { useFileItemDropdown } from '../../ItemDropdown/useFileItemDropdown';
import DefaultFileItem from './DefaultFileItem';
import ImageFileItem from './ImageFileItem';
import MarkdownFileItem from './MarkdownFileItem';
@ -346,6 +347,15 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
}
}, [isMarkdown, isPage, url, isInView, markdownContent, id]);
const { menuItems } = useFileItemDropdown({
fileType,
filename: name,
id,
knowledgeBaseId,
sourceType,
url,
});
return (
<div
className={cx(
@ -357,6 +367,10 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
data-drop-target-id={id}
data-is-folder={isFolder}
draggable={!!knowledgeBaseId}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(menuItems());
}}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@ -379,13 +393,7 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<DropdownMenu
fileType={fileType}
filename={name}
id={id}
knowledgeBaseId={knowledgeBaseId}
url={url}
/>
<DropdownMenu items={menuItems} />
</div>
<div

View file

@ -10,14 +10,15 @@ interface ActionIconWithChevronProps extends ComponentProps<typeof Button> {
}
const ActionIconWithChevron = memo<ActionIconWithChevronProps>(
({ icon, title, style, disabled, type = 'text', ...rest }) => {
({ icon, title, style, disabled, className, ...rest }) => {
return (
<Button
{...rest}
className={className}
disabled={disabled}
style={{ paddingInline: 4, ...style }}
title={title}
type={type}
{...rest}
type={'text'}
>
<Flexbox align={'center'} gap={4} horizontal>
<Icon color={cssVar.colorIcon} icon={icon} size={18} />

View file

@ -1,6 +1,5 @@
import { Icon } from '@lobehub/ui';
import { App, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { type DropdownItem, DropdownMenu, Icon } from '@lobehub/ui';
import { App } from 'antd';
import {
BookMinusIcon,
BookPlusIcon,
@ -36,8 +35,8 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(
const libraryId = useResourceManagerStore((s) => s.libraryId);
const menuItems = useMemo<MenuProps['items']>(() => {
const items: MenuProps['items'] = [];
const menuItems = useMemo<DropdownItem[]>(() => {
const items: DropdownItem[] = [];
// Show delete library option only when in a knowledge base and no files selected
if (libraryId && selectCount === 0) {
@ -138,18 +137,13 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(
}, [libraryId, selectCount, onActionClick, t, modal, message]);
return (
<Dropdown
disabled={disabled}
menu={{ items: menuItems }}
placement="bottomLeft"
trigger={['click']}
>
<DropdownMenu items={menuItems} placement="bottomLeft" triggerProps={{ disabled }}>
<ActionIconWithChevron
disabled={disabled}
icon={CircleEllipsisIcon}
title={t('FileManager.actions.batchActions', 'Batch actions')}
/>
</Dropdown>
</DropdownMenu>
);
},
);

View file

@ -1,5 +1,4 @@
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { type DropdownItem, DropdownMenu } from '@lobehub/ui';
import { ArrowDownAZ } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -22,22 +21,23 @@ const SortDropdown = memo(() => {
[t],
);
const menuItems: MenuProps['items'] = sortOptions.map((option) => ({
const menuItems: DropdownItem[] = sortOptions.map((option) => ({
key: option.key,
label: option.label,
onClick: () => setSorter(option.key as 'name' | 'createdAt' | 'size'),
style:
option.key === (sorter || 'createdAt')
? { backgroundColor: 'var(--ant-control-item-bg-active)' }
: {},
}));
const currentSortLabel =
sortOptions.find((option) => option.key === sorter)?.label || t('FileManager.sort.dateAdded');
return (
<Dropdown
menu={{ items: menuItems, selectedKeys: [sorter || 'createdAt'] }}
trigger={['click']}
>
<DropdownMenu items={menuItems}>
<ActionIconWithChevron icon={ArrowDownAZ} title={currentSortLabel} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,6 +1,4 @@
import { Icon } from '@lobehub/ui';
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { type DropdownItem, DropdownMenu, Icon } from '@lobehub/ui';
import { Grid3x3Icon, ListIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -20,31 +18,30 @@ const ViewSwitcher = memo(() => {
const currentViewLabel =
viewMode === 'list' ? t('FileManager.view.list') : t('FileManager.view.masonry');
const menuItems = useMemo<MenuProps['items']>(() => {
const menuItems = useMemo<DropdownItem[]>(() => {
return [
{
icon: <Icon icon={ListIcon} />,
key: 'list',
label: t('FileManager.view.list'),
onClick: () => setViewMode('list'),
style: viewMode === 'list' ? { backgroundColor: 'var(--ant-control-item-bg-active)' } : {},
},
{
icon: <Icon icon={Grid3x3Icon} />,
key: 'masonry',
label: t('FileManager.view.masonry'),
onClick: () => setViewMode('masonry'),
style:
viewMode === 'masonry' ? { backgroundColor: 'var(--ant-control-item-bg-active)' } : {},
},
];
}, [setViewMode, t]);
}, [setViewMode, t, viewMode]);
return (
<Dropdown
menu={{ items: menuItems, selectedKeys: [viewMode] }}
placement="bottomRight"
trigger={['click']}
>
<DropdownMenu items={menuItems} placement="bottomRight">
<ActionIconWithChevron icon={currentViewIcon} title={currentViewLabel} />
</Dropdown>
</DropdownMenu>
);
});

View file

@ -1,7 +1,7 @@
'use client';
import { CaretDownFilled, LoadingOutlined } from '@ant-design/icons';
import { ActionIcon, Block, Dropdown, Flexbox, Icon } from '@lobehub/ui';
import { ActionIcon, Block, Flexbox, Icon, showContextMenu } from '@lobehub/ui';
import { App, Input } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react';
@ -328,100 +328,97 @@ const FileTreeRow = memo<{
return (
<Flexbox gap={2}>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Block
align={'center'}
className={cx(
styles.treeItem,
isOver && styles.fileItemDragOver,
isDragging && styles.dragging,
)}
clickable
data-drop-target-id={item.id}
data-is-folder={String(item.isFolder)}
draggable
gap={8}
height={36}
horizontal
onClick={() => handleFolderClick(item.id, item.slug)}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
onDrop={handleDrop}
paddingInline={4}
style={{
paddingInlineStart: level * 12 + 4,
}}
variant={isActive ? 'filled' : 'borderless'}
>
{isLoading ? (
<Block
align={'center'}
className={cx(
styles.treeItem,
isOver && styles.fileItemDragOver,
isDragging && styles.dragging,
)}
clickable
data-drop-target-id={item.id}
data-is-folder={String(item.isFolder)}
draggable
gap={8}
height={36}
horizontal
onClick={() => handleFolderClick(item.id, item.slug)}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(menuItems());
}}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
onDrop={handleDrop}
paddingInline={4}
style={{
paddingInlineStart: level * 12 + 4,
}}
variant={isActive ? 'filled' : 'borderless'}
>
{isLoading ? (
<ActionIcon icon={LoadingOutlined as any} size={'small'} spin style={{ width: 20 }} />
) : (
<motion.div
animate={{ rotate: isExpanded ? 0 : -90 }}
initial={false}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<ActionIcon
icon={LoadingOutlined as any}
icon={CaretDownFilled as any}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
size={'small'}
spin
style={{ width: 20 }}
/>
</motion.div>
)}
<Flexbox
align={'center'}
flex={1}
gap={8}
horizontal
style={{ minHeight: 28, minWidth: 0, overflow: 'hidden' }}
>
<Icon icon={isExpanded ? FolderOpenIcon : FolderIcon} size={18} />
{isRenaming ? (
<Input
onBlur={handleRenameConfirm}
onChange={(e) => setRenamingValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRenameConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
handleRenameCancel();
}
}}
onPointerDown={(e) => e.stopPropagation()}
ref={inputRef}
size="small"
style={{ flex: 1 }}
value={renamingValue}
/>
) : (
<motion.div
animate={{ rotate: isExpanded ? 0 : -90 }}
initial={false}
transition={{ duration: 0.2, ease: 'easeInOut' }}
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<ActionIcon
icon={CaretDownFilled as any}
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
size={'small'}
style={{ width: 20 }}
/>
</motion.div>
{item.name}
</span>
)}
<Flexbox
align={'center'}
flex={1}
gap={8}
horizontal
style={{ minHeight: 28, minWidth: 0, overflow: 'hidden' }}
>
<Icon icon={isExpanded ? FolderOpenIcon : FolderIcon} size={18} />
{isRenaming ? (
<Input
onBlur={handleRenameConfirm}
onChange={(e) => setRenamingValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleRenameConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
handleRenameCancel();
}
}}
onPointerDown={(e) => e.stopPropagation()}
ref={inputRef}
size="small"
style={{ flex: 1 }}
value={renamingValue}
/>
) : (
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name}
</span>
)}
</Flexbox>
</Block>
</Dropdown>
</Flexbox>
</Block>
</Flexbox>
);
}
@ -430,52 +427,54 @@ const FileTreeRow = memo<{
const isActive = selectedKey === itemKey;
return (
<Flexbox gap={2}>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Block
<Block
align={'center'}
className={cx(styles.treeItem, isDragging && styles.dragging)}
clickable
data-drop-target-id={item.id}
data-is-folder={false}
draggable
gap={8}
height={36}
horizontal
onClick={handleItemClick}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(menuItems());
}}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
paddingInline={4}
style={{
paddingInlineStart: level * 12 + 4,
}}
variant={isActive ? 'filled' : 'borderless'}
>
<div style={{ width: 20 }} />
<Flexbox
align={'center'}
className={cx(styles.treeItem, isDragging && styles.dragging)}
clickable
data-drop-target-id={item.id}
data-is-folder={false}
draggable
flex={1}
gap={8}
height={36}
horizontal
onClick={handleItemClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
paddingInline={4}
style={{
paddingInlineStart: level * 12 + 4,
}}
variant={isActive ? 'filled' : 'borderless'}
style={{ minHeight: 28, minWidth: 0, overflow: 'hidden' }}
>
<div style={{ width: 20 }} />
<Flexbox
align={'center'}
flex={1}
gap={8}
horizontal
style={{ minHeight: 28, minWidth: 0, overflow: 'hidden' }}
{item.sourceType === 'document' ? (
<Icon icon={FileText} size={18} />
) : (
<FileIcon fileName={item.name} fileType={item.fileType} size={18} />
)}
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.sourceType === 'document' ? (
<Icon icon={FileText} size={18} />
) : (
<FileIcon fileName={item.name} fileType={item.fileType} size={18} />
)}
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name}
</span>
</Flexbox>
</Block>
</Dropdown>
{item.name}
</span>
</Flexbox>
</Block>
</Flexbox>
);
},

View file

@ -1,5 +1,5 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { ModalHost } from '@lobehub/ui';
import { ContextMenuHost, ModalHost, TooltipGroup } from '@lobehub/ui';
import { LazyMotion, domMax } from 'motion/react';
import { type ReactNode, Suspense } from 'react';
@ -66,8 +66,11 @@ const GlobalLayout = async ({
<GroupWizardProvider>
<DragUploadProvider>
<LazyMotion features={domMax}>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
<TooltipGroup layoutAnimation={false}>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
</TooltipGroup>
<ModalHost />
<ContextMenuHost />
</LazyMotion>
</DragUploadProvider>
</GroupWizardProvider>

View file

@ -1,3 +1,4 @@
import { CLASSNAMES } from '@lobehub/ui';
import { type Theme, css } from 'antd-style';
// fix ios input keyboard
@ -55,4 +56,9 @@ export default ({ token }: { prefixCls: string; token: Theme }) => css`
button {
-webkit-app-region: no-drag;
}
.${CLASSNAMES.ContextTrigger}[data-popup-open],
.${CLASSNAMES.DropdownMenuTrigger}[data-popup-open] {
background: ${token.colorFillTertiary};
}
`;