From deb97c4149892f85cd35dc39a5c6fb47f8c43a05 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:09:02 +0800 Subject: [PATCH] refactor: improve scrollbar handling in plugin and model selector UI (#35630) --- .../create/step-two/language-select/index.tsx | 1 - .../model-selector/__tests__/popup.spec.tsx | 25 +- .../model-selector/index.tsx | 2 +- .../model-selector/marketplace-section.tsx | 98 ++++++++ .../model-selector/popup-empty-state.tsx | 39 ++++ .../model-selector/popup-item.tsx | 3 +- .../model-selector/popup-layout.tsx | 130 +++++++++++ .../model-selector/popup.tsx | 215 +++++------------- .../components/plugin-section.tsx | 12 +- .../components/plugin-task-list.tsx | 12 +- .../plugin-page/plugin-tasks/index.tsx | 2 +- 11 files changed, 370 insertions(+), 169 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx diff --git a/web/app/components/datasets/create/step-two/language-select/index.tsx b/web/app/components/datasets/create/step-two/language-select/index.tsx index fdef23ff27..bd1eee3df6 100644 --- a/web/app/components/datasets/create/step-two/language-select/index.tsx +++ b/web/app/components/datasets/create/step-two/language-select/index.tsx @@ -42,7 +42,6 @@ const LanguageSelect: FC = ({ placement="bottom-start" sideOffset={4} popupClassName="w-max" - listClassName="no-scrollbar" > {supportedLanguages.map(({ prompt_name }) => ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx index 318b5bcd73..a440313b3c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx @@ -219,8 +219,8 @@ describe('Popup', () => { expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument() }) - it('should show compatible-only helper banner when scope features are applied', () => { - const { container } = renderPopup( + it('should show compatible-only helper text when scope features are applied', () => { + renderPopup( { expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument() expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument() - expect(container.querySelector('.i-ri-information-2-fill'))!.toBeInTheDocument() + }) + + it('should keep search and footer outside the scrollable model list', () => { + renderPopup( + , + ) + + const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' }) + const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel') + const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ }) + + expect(scrollRegion)!.toBeInTheDocument() + expect(scrollRegion).not.toContainElement(searchInput) + expect(scrollRegion).not.toContainElement(settingsButton) + expect(scrollRegion).toContainElement(screen.getByTestId('compatible-models-banner')) }) it('should filter by scope features including toolCall and non-toolCall checks', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index 9241c592f5..835821fd59 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -88,7 +88,7 @@ const ModelSelector: FC = ({ placement="bottom-start" sideOffset={4} className={popupClassName} - popupClassName="overflow-hidden rounded-lg" + popupClassName="overflow-hidden rounded-xl" popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }} > void + onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise +} + +const MarketplaceSection: FC = ({ + marketplaceProviders, + marketplaceCollapsed, + installingProvider, + isMarketplacePluginsLoading, + theme, + onMarketplaceCollapsedChange, + onInstallPlugin, +}) => { + const { t } = useTranslation() + + if (marketplaceProviders.length === 0) + return null + + return ( + <> +
+
+
+
+
+
onMarketplaceCollapsedChange(!marketplaceCollapsed)} + > + {t('modelProvider.selector.fromMarketplace', { ns: 'common' })} + +
+
+ {!marketplaceCollapsed && ( +
+ {marketplaceProviders.map((key) => { + const Icon = providerIconMap[key] + const isInstalling = installingProvider === key + return ( +
+
+ + {modelNameMap[key]} +
+ +
+ ) + })} + + + {t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })} + + + +
+ )} +
+ + ) +} + +export default MarketplaceSection diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx new file mode 100644 index 0000000000..dafd26387b --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx @@ -0,0 +1,39 @@ +import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' + +type ModelSelectorEmptyStateProps = { + onConfigure: () => void +} + +const ModelSelectorEmptyState: FC = ({ + onConfigure, +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+
+

+ {t('modelProvider.selector.noProviderConfigured', { ns: 'common' })} +

+

+ {t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })} +

+
+ +
+ ) +} + +export default ModelSelectorEmptyState diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index 72c52a9429..ff9e6575bb 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -107,7 +107,8 @@ const PopupItem: FC = ({ return (
-
+ {/* Keep the sticky provider header above model rows while the list scrolls. */} +
setCollapsed(prev => !prev)} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx new file mode 100644 index 0000000000..50bd098af1 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx @@ -0,0 +1,130 @@ +import type { FC, ReactNode } from 'react' +import { + ScrollAreaContent, + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@langgenius/dify-ui/scroll-area' +import { useTranslation } from 'react-i18next' + +type ModelSelectorPopupFrameProps = { + children: ReactNode +} + +export const ModelSelectorPopupFrame: FC = ({ + children, +}) => { + return ( +
+ {children} +
+ ) +} + +type ModelSelectorSearchHeaderProps = { + searchText: string + onSearchTextChange: (value: string) => void +} + +export const ModelSelectorSearchHeader: FC = ({ + searchText, + onSearchTextChange, +}) => { + const { t } = useTranslation() + + return ( +
+
+ + onSearchTextChange(e.target.value)} + /> + { + searchText && ( + onSearchTextChange('')} + /> + ) + } +
+
+ ) +} + +type ModelSelectorScrollBodyProps = { + children: ReactNode + label: string +} + +export const ModelSelectorScrollBody: FC = ({ + children, + label, +}) => { + return ( + + + + {children} + + + {/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */} + + + + + ) +} + +export const CompatibleModelsNotice = () => { + const { t } = useTranslation() + + return ( +
+ {t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })} +
+ ) +} + +type ModelProviderSettingsFooterProps = { + onOpenSettings: () => void +} + +export const ModelProviderSettingsFooter: FC = ({ + onOpenSettings, +}) => { + const { t } = useTranslation() + + return ( +
+ +
+ ) +} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index 47ddb55b6c..e2224d18a8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -5,8 +5,6 @@ import type { ModelItem, } from '../declarations' import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { useTheme } from 'next-themes' import { useCallback, useMemo, useState } from 'react' @@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' import { supportFunctionCall } from '@/utils/tool-call' -import { getMarketplaceUrl } from '@/utils/var' import { CustomConfigurationStatusEnum, ModelFeatureEnum, @@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks' import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert' import { useTrialCredits } from '../provider-added-card/use-trial-credits' import { providerSupportsCredits } from '../supports-credits' -import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils' +import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils' +import MarketplaceSection from './marketplace-section' +import ModelSelectorEmptyState from './popup-empty-state' import PopupItem from './popup-item' +import { + CompatibleModelsNotice, + ModelProviderSettingsFooter, + ModelSelectorPopupFrame, + ModelSelectorScrollBody, + ModelSelectorSearchHeader, +} from './popup-layout' type PopupProps = { defaultModel?: DefaultModel @@ -181,166 +187,59 @@ const Popup: FC = ({ return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key)) }, [modelProviders]) + const handleOpenSettings = useCallback(() => { + onHide() + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) + }, [onHide, setShowAccountSettingModal]) + return ( -
-
-
- - setSearchText(e.target.value)} - /> - { - searchText && ( - setSearchText('')} - /> - ) - } -
- {scopeFeatures.length > 0 && ( -
- -

- {t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })} -

-
- )} -
+ + {showCreditsExhaustedAlert && ( )} -
- { - filteredModelList.map(model => ( - +
+ { + filteredModelList.map(model => ( + + )) + } + {!filteredModelList.length && !installedModelList.length && ( + - )) - } - {!filteredModelList.length && !installedModelList.length && ( -
-
- + )} + {!filteredModelList.length && installedModelList.length > 0 && ( +
+ {`No model found for \u201C${searchText}\u201D`}
-
-

- {t('modelProvider.selector.noProviderConfigured', { ns: 'common' })} -

-

- {t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })} -

-
- -
- )} - {!filteredModelList.length && installedModelList.length > 0 && ( -
- {`No model found for \u201C${searchText}\u201D`} -
- )} - {marketplaceProviders.length > 0 && ( - <> -
-
-
-
setMarketplaceCollapsed(prev => !prev)} - > - {t('modelProvider.selector.fromMarketplace', { ns: 'common' })} - -
-
- {!marketplaceCollapsed && ( - <> - {marketplaceProviders.map((key) => { - const Icon = providerIconMap[key] - const isInstalling = installingProvider === key - return ( -
-
- - {modelNameMap[key]} -
- -
- ) - })} - - - {t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })} - - - - - )} -
- - )} -
-
{ - onHide() - setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) - }} - > - - {t('modelProvider.selector.modelProviderSettings', { ns: 'common' })} -
-
+ )} + {scopeFeatures.length > 0 && ( + + )} + +
+ + + ) } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx index 0d0c793741..7533d06d5f 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx @@ -1,6 +1,7 @@ import type { FC, ReactNode } from 'react' import type { PluginStatus } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import PluginItem from './plugin-item' type PluginSectionProps = { @@ -43,7 +44,14 @@ const PluginSection: FC = ({ ) {headerAction}
-
+ {plugins.map(plugin => ( = ({ : undefined} /> ))} -
+ ) } diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx index 24fcf85cde..cc2eed1dbb 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { PluginStatus } from '@/app/components/plugins/types' import { Button } from '@langgenius/dify-ui/button' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { useTranslation } from 'react-i18next' import { useGetLanguage } from '@/context/i18n' import ErrorPluginItem from './error-plugin-item' @@ -86,7 +87,14 @@ const PluginTaskList: FC = ({ {t('task.clearAll', { ns: 'plugin' })}
-
+ {errorPlugins.map(plugin => ( = ({ onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)} /> ))} -
+ )}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index f3102c4909..00fcb7e072 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -117,7 +117,7 @@ const PluginTasks = () => {