🐛 fix(upload): resolve file upload button unresponsive issue (#11588)

* 🐛 fix(upload): resolve file upload button unresponsive issue

The file upload dropdown menu was not properly handling the interaction
between the dropdown and the Upload component, causing the menu to block
file selection events.

Changes:
- Add controlled open state for upload dropdown
- Mark upload menu items with closeOnClick: false to prevent premature closing
- Manually close dropdown after file selection completes
- Enhance ActionDropdown to support interactive elements with proper event handling
- Add scheduleClose functionality for delayed menu closing

Closes LOBE-3503

* 🔧 chore: update package dependencies and enhance VSCode settings

- Bump version of @lobehub/ui to ^4.22.0 in package.json.
- Update VSCode settings to exclude additional locale directories from search, improving performance and relevance.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-18 23:36:15 +08:00
committed by GitHub
parent 83bb343066
commit 76fd478752
9 changed files with 114 additions and 27 deletions

21
.vscode/settings.json vendored
View File

@@ -26,9 +26,24 @@
],
"npm.packageManager": "pnpm",
"search.exclude": {
"**/node_modules": true
// useless to search this big folder
// "locales": true
"**/node_modules": true,
// useless to search this big folder, exclude all locales except en-US and zh-CN
"locales/ar/**": true,
"locales/bg-BG/**": true,
"locales/de-DE/**": true,
"locales/es-ES/**": true,
"locales/fa-IR/**": true,
"locales/fr-FR/**": true,
"locales/it-IT/**": true,
"locales/ja-JP/**": true,
"locales/ko-KR/**": true,
"locales/nl-NL/**": true,
"locales/pl-PL/**": true,
"locales/pt-BR/**": true,
"locales/ru-RU/**": true,
"locales/tr-TR/**": true,
"locales/vi-VN/**": true,
"locales/zh-TW/**": true
},
"stylelint.validate": [
"css",

View File

@@ -35,12 +35,12 @@
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
"build:vercel": "npm run prebuild && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"clean:node_modules": "bash -lc 'set -e; echo \"Removing all node_modules...\"; rm -rf node_modules; pnpm -r exec rm -rf node_modules; rm -rf apps/desktop/node_modules; echo \"All node_modules removed.\"'",
"db:generate": "drizzle-kit generate && npm run workflow:dbml",
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
@@ -87,11 +87,11 @@
"start": "next start -p 3210",
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "npm run test-app && npm run test-server",
"test-app": "vitest run",
"test-app:coverage": "vitest --coverage --silent='passed-only'",
"test:e2e": "pnpm --filter @lobechat/e2e-tests test",
"test:e2e:smoke": "pnpm --filter @lobechat/e2e-tests test:smoke",
"test:update": "vitest -u",
"test-app": "vitest run",
"test-app:coverage": "vitest --coverage --silent='passed-only'",
"tunnel:cloudflare": "cloudflared tunnel --url http://localhost:3010",
"tunnel:ngrok": "ngrok http http://localhost:3011",
"type-check": "tsgo --noEmit",
@@ -207,7 +207,7 @@
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "0.29.0",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.21.0",
"@lobehub/ui": "^4.22.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.1",

View File

@@ -1,14 +1,15 @@
import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontal } from 'lucide-react';
import { memo } from 'react';
import { memo, useState } from 'react';
import { useTopicActionsDropdownMenu } from './useDropdownMenu';
const Actions = memo(() => {
const menuItems = useTopicActionsDropdownMenu();
const [open, setOpen] = useState(false);
const menuItems = useTopicActionsDropdownMenu({ onUploadClose: () => setOpen(false) });
return (
<DropdownMenu items={menuItems}>
<DropdownMenu items={menuItems} onOpenChange={setOpen} open={open}>
<ActionIcon icon={MoreHorizontal} size={'small'} />
</DropdownMenu>
);

View File

@@ -21,9 +21,16 @@ const hotArea = css`
}
`;
export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
interface UseTopicActionsDropdownMenuOptions {
onUploadClose?: () => void;
}
export const useTopicActionsDropdownMenu = (
options: UseTopicActionsDropdownMenuOptions = {},
): MenuProps['items'] => {
const { t } = useTranslation(['topic', 'common']);
const { modal } = App.useApp();
const { onUploadClose } = options;
const [removeUnstarredTopic, removeAllTopic, importTopic] = useChatStore((s) => [
s.removeUnstarredTopic,
@@ -33,6 +40,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
const handleImport = useCallback(
async (file: File) => {
onUploadClose?.();
try {
const text = await file.text();
// Validate JSON format
@@ -46,7 +54,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
}
return false; // Prevent default upload behavior
},
[importTopic, modal, t],
[importTopic, modal, onUploadClose, t],
);
const [topicDisplayMode, updatePreference] = useUserStore((s) => [
@@ -101,6 +109,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
<div className={cx(hotArea)}>{t('actions.import')}</div>
</Upload>
),
...(onUploadClose ? { closeOnClick: false } : null),
},
{
type: 'divider' as const,
@@ -143,6 +152,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
updatePreference,
updateSystemStatus,
handleImport,
onUploadClose,
removeUnstarredTopic,
removeAllTopic,
t,

View File

@@ -1,14 +1,15 @@
import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontal } from 'lucide-react';
import { memo } from 'react';
import { memo, useState } from 'react';
import { useTopicActionsDropdownMenu } from './useDropdownMenu';
const Actions = memo(() => {
const menuItems = useTopicActionsDropdownMenu();
const [open, setOpen] = useState(false);
const menuItems = useTopicActionsDropdownMenu({ onUploadClose: () => setOpen(false) });
return (
<DropdownMenu items={menuItems}>
<DropdownMenu items={menuItems} onOpenChange={setOpen} open={open}>
<ActionIcon icon={MoreHorizontal} size={'small'} />
</DropdownMenu>
);

View File

@@ -21,9 +21,16 @@ const hotArea = css`
}
`;
export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
interface UseTopicActionsDropdownMenuOptions {
onUploadClose?: () => void;
}
export const useTopicActionsDropdownMenu = (
options: UseTopicActionsDropdownMenuOptions = {},
): MenuProps['items'] => {
const { t } = useTranslation(['topic', 'common']);
const { modal } = App.useApp();
const { onUploadClose } = options;
const [removeUnstarredTopic, removeAllTopic, importTopic] = useChatStore((s) => [
s.removeUnstarredTopic,
@@ -33,6 +40,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
const handleImport = useCallback(
async (file: File) => {
onUploadClose?.();
try {
const text = await file.text();
// Validate JSON format
@@ -46,7 +54,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
}
return false; // Prevent default upload behavior
},
[importTopic, modal, t],
[importTopic, modal, onUploadClose, t],
);
const [topicDisplayMode, updatePreference] = useUserStore((s) => [
@@ -101,6 +109,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
<div className={cx(hotArea)}>{t('actions.import')}</div>
</Upload>
),
...(onUploadClose ? { closeOnClick: false } : null),
},
{
type: 'divider' as const,
@@ -143,6 +152,7 @@ export const useTopicActionsDropdownMenu = (): MenuProps['items'] => {
updatePreference,
updateSystemStatus,
handleImport,
onUploadClose,
removeUnstarredTopic,
removeAllTopic,
t,

View File

@@ -1,5 +1,5 @@
import { validateVideoFileSize } from '@lobechat/utils/client';
import { Icon, type ItemType, type MenuProps, Tooltip } from '@lobehub/ui';
import { Icon, type ItemType, Tooltip } from '@lobehub/ui';
import { Upload } from 'antd';
import { css, cx } from 'antd-style';
import isEqual from 'fast-deep-equal';
@@ -21,6 +21,7 @@ import { preferenceSelectors } from '@/store/user/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import type { ActionDropdownMenuItems } from '../components/ActionDropdown';
import CheckboxItem from '../components/CheckboxWithLoading';
const hotArea = css`
@@ -48,6 +49,7 @@ const FileUpload = memo(() => {
s.updateGuideState,
]);
const [modalOpen, setModalOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [updating, setUpdating] = useState(false);
const files = useAgentStore((s) => agentByIdSelectors.getAgentFilesById(agentId)(s), isEqual);
@@ -61,8 +63,9 @@ const FileUpload = memo(() => {
s.toggleKnowledgeBase,
]);
const uploadItems: MenuProps['items'] = [
const uploadItems: ActionDropdownMenuItems = [
{
closeOnClick: false,
disabled: !canUploadImage,
icon: ImageUp,
key: 'upload-image',
@@ -70,6 +73,7 @@ const FileUpload = memo(() => {
<Upload
accept={'image/*'}
beforeUpload={async (file) => {
setDropdownOpen(false);
await upload([file]);
return false;
@@ -86,6 +90,7 @@ const FileUpload = memo(() => {
),
},
{
closeOnClick: false,
icon: FileUp,
key: 'upload-file',
label: (
@@ -105,6 +110,7 @@ const FileUpload = memo(() => {
return false;
}
setDropdownOpen(false);
await upload([file]);
return false;
@@ -117,6 +123,7 @@ const FileUpload = memo(() => {
),
},
{
closeOnClick: false,
icon: FolderUp,
key: 'upload-folder',
label: (
@@ -136,6 +143,7 @@ const FileUpload = memo(() => {
return false;
}
setDropdownOpen(false);
await upload([file]);
return false;
@@ -214,7 +222,7 @@ const FileUpload = memo(() => {
},
);
const items: MenuProps['items'] = [
const items: ActionDropdownMenuItems = [
...uploadItems,
...(knowledgeItems.length > 0 ? knowledgeItems : []),
];
@@ -229,6 +237,8 @@ const FileUpload = memo(() => {
}}
icon={Paperclip}
loading={updating}
onOpenChange={setDropdownOpen}
open={dropdownOpen}
showTooltip={false}
title={t('upload.action.tooltip')}
trigger={'both'}

View File

@@ -8,6 +8,7 @@ import {
type DropdownMenuProps,
DropdownMenuRoot,
DropdownMenuTrigger,
type MenuItemType,
type MenuProps,
type PopoverTrigger,
renderDropdownMenuItems,
@@ -16,6 +17,7 @@ import { createStaticStyles, cx } from 'antd-style';
import {
type CSSProperties,
type ReactNode,
isValidElement,
memo,
useCallback,
useEffect,
@@ -34,8 +36,15 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
type ActionDropdownMenu = Omit<Pick<MenuProps, 'className' | 'onClick' | 'style'>, 'items'> & {
items: MenuProps['items'] | (() => MenuProps['items']);
export type ActionDropdownMenuItem = MenuItemType;
export type ActionDropdownMenuItems = MenuProps<ActionDropdownMenuItem>['items'];
type ActionDropdownMenu = Omit<
Pick<MenuProps<ActionDropdownMenuItem>, 'className' | 'onClick' | 'style'>,
'items'
> & {
items: ActionDropdownMenuItems | (() => ActionDropdownMenuItems);
};
export interface ActionDropdownProps extends Omit<DropdownMenuProps, 'items'> {
@@ -116,7 +125,7 @@ const ActionDropdown = memo<ActionDropdownProps>(
}, [openOnHover, triggerProps]);
const decorateMenuItems = useCallback(
(items: MenuProps['items']): MenuProps['items'] => {
(items: ActionDropdownMenuItems): ActionDropdownMenuItems => {
if (!items) return items;
return items.map((item) => {
@@ -136,10 +145,24 @@ const ActionDropdown = memo<ActionDropdownProps>(
};
}
const itemOnClick = 'onClick' in item ? item.onClick : undefined;
const closeOnClick = 'closeOnClick' in item ? item.closeOnClick : undefined;
const keepOpenOnClick = closeOnClick === false;
const itemLabel = 'label' in item ? item.label : undefined;
const shouldKeepOpen = isValidElement(itemLabel);
const resolvedCloseOnClick = closeOnClick ?? (shouldKeepOpen ? false : undefined);
return {
...item,
...(resolvedCloseOnClick !== undefined ? { closeOnClick: resolvedCloseOnClick } : null),
onClick: (info) => {
if (keepOpenOnClick) {
info.domEvent.stopPropagation();
menu.onClick?.(info);
itemOnClick?.(info);
return;
}
info.domEvent.preventDefault();
menu.onClick?.(info);
itemOnClick?.(info);

View File

@@ -5,7 +5,7 @@ import { Notion } from '@lobehub/icons';
import { Button, DropdownMenu, Icon, type MenuProps } from '@lobehub/ui';
import { Upload } from 'antd';
import { FilePenLine, FileUp, FolderIcon, FolderUp, Link, Plus } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { type ChangeEvent, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
@@ -23,6 +23,7 @@ const AddButton = () => {
const pushDockFileList = useFileStore((s) => s.pushDockFileList);
const uploadFolderWithStructure = useFileStore((s) => s.uploadFolderWithStructure);
const createResourceAndSync = useFileStore((s) => s.createResourceAndSync);
const [menuOpen, setMenuOpen] = useState(false);
// TODO: Migrate Notion import to use createResource
// Keep old functions temporarily for components not yet migrated
@@ -121,6 +122,13 @@ const AddButton = () => {
t,
uploadFolderWithStructure,
});
const handleFolderUploadWithClose = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setMenuOpen(false);
return handleFolderUpload(event);
},
[handleFolderUpload],
);
const items = useMemo<MenuProps['items']>(
() => [
@@ -144,11 +152,13 @@ const AddButton = () => {
type: 'divider',
},
{
closeOnClick: false,
icon: <Icon icon={FileUp} />,
key: 'upload-file',
label: (
<Upload
beforeUpload={async (file) => {
setMenuOpen(false);
await pushDockFileList([file], libraryId, currentFolderId ?? undefined);
return false;
@@ -161,6 +171,7 @@ const AddButton = () => {
),
},
{
closeOnClick: false,
icon: <Icon icon={FolderUp} />,
key: 'upload-folder',
label: <label htmlFor="folder-upload-input">{t('header.actions.uploadFolder')}</label>,
@@ -211,7 +222,13 @@ const AddButton = () => {
return (
<>
<DropdownMenu items={items} placement="bottomRight" trigger="both">
<DropdownMenu
items={items}
onOpenChange={setMenuOpen}
open={menuOpen}
placement="bottomRight"
trigger="both"
>
<Button data-no-highlight icon={Plus} type="primary">
{t('addLibrary')}
</Button>
@@ -233,7 +250,7 @@ const AddButton = () => {
<input
id="folder-upload-input"
multiple
onChange={handleFolderUpload}
onChange={handleFolderUploadWithClose}
style={{ display: 'none' }}
type="file"
// @ts-expect-error - webkitdirectory is not in the React types