refactor: improve scrollbar handling in plugin and model selector UI (#35630)

This commit is contained in:
yyh 2026-04-28 14:09:02 +08:00 committed by fatelei
parent ea47036a5d
commit deb97c4149
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
11 changed files with 370 additions and 169 deletions

View File

@ -42,7 +42,6 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({
placement="bottom-start"
sideOffset={4}
popupClassName="w-max"
listClassName="no-scrollbar"
>
{supportedLanguages.map(({ prompt_name }) => (
<SelectItem key={prompt_name} value={prompt_name}>

View File

@ -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(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -231,7 +231,26 @@ describe('Popup', () => {
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(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
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', () => {

View File

@ -88,7 +88,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
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)' } }}
>
<Popup

View File

@ -0,0 +1,98 @@
import type { FC } from 'react'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { getMarketplaceUrl } from '@/utils/var'
import { modelNameMap, providerIconMap } from '../utils'
type MarketplaceSectionProps = {
marketplaceProviders: ModelProviderQuotaGetPaid[]
marketplaceCollapsed: boolean
installingProvider: ModelProviderQuotaGetPaid | null
isMarketplacePluginsLoading: boolean
theme?: string
onMarketplaceCollapsedChange: (collapsed: boolean) => void
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
}
const MarketplaceSection: FC<MarketplaceSectionProps> = ({
marketplaceProviders,
marketplaceCollapsed,
installingProvider,
isMarketplacePluginsLoading,
theme,
onMarketplaceCollapsedChange,
onInstallPlugin,
}) => {
const { t } = useTranslation()
if (marketplaceProviders.length === 0)
return null
return (
<>
<div className="py-2">
<div className="h-px bg-divider-subtle" />
</div>
<div>
<div className="flex h-[22px] items-center pr-2 pl-4">
<div
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</div>
{!marketplaceCollapsed && (
<div className="px-1 pb-1">
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
onClick={() => onInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
</a>
</div>
)}
</div>
</>
)
}
export default MarketplaceSection

View File

@ -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<ModelSelectorEmptyStateProps> = ({
onConfigure,
}) => {
const { t } = useTranslation()
return (
<div className="mx-2 flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
<div className="flex flex-col gap-1">
<p className="system-sm-medium text-text-secondary">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="system-xs-regular text-text-tertiary">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={onConfigure}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)
}
export default ModelSelectorEmptyState

View File

@ -107,7 +107,8 @@ const PopupItem: FC<PopupItemProps> = ({
return (
<div className="mb-1">
<div className="sticky top-12 z-2 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
{/* Keep the sticky provider header above model rows while the list scrolls. */}
<div className="sticky top-0 z-1 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center"
onClick={() => setCollapsed(prev => !prev)}

View File

@ -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<ModelSelectorPopupFrameProps> = ({
children,
}) => {
return (
<div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg">
{children}
</div>
)
}
type ModelSelectorSearchHeaderProps = {
searchText: string
onSearchTextChange: (value: string) => void
}
export const ModelSelectorSearchHeader: FC<ModelSelectorSearchHeaderProps> = ({
searchText,
onSearchTextChange,
}) => {
const { t } = useTranslation()
return (
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
<div className={`
flex h-8 items-center rounded-lg border px-2
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<span
className={`
mr-0.5 i-ri-search-line h-4 w-4 shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
<input
className="block h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-hidden"
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
value={searchText}
onChange={e => onSearchTextChange(e.target.value)}
/>
{
searchText && (
<span
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => onSearchTextChange('')}
/>
)
}
</div>
</div>
)
}
type ModelSelectorScrollBodyProps = {
children: ReactNode
label: string
}
export const ModelSelectorScrollBody: FC<ModelSelectorScrollBodyProps> = ({
children,
label,
}) => {
return (
<ScrollAreaRoot className="relative min-h-0 overflow-hidden overscroll-contain">
<ScrollAreaViewport
aria-label={label}
className="max-h-[calc(min(624px,var(--available-height,624px))-84px)] overscroll-contain"
role="region"
>
<ScrollAreaContent className="min-w-0">
{children}
</ScrollAreaContent>
</ScrollAreaViewport>
{/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */}
<ScrollAreaScrollbar className="z-2 data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)
}
export const CompatibleModelsNotice = () => {
const { t } = useTranslation()
return (
<div
data-testid="compatible-models-banner"
className="px-4 py-2 system-xs-regular text-text-tertiary"
>
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
</div>
)
}
type ModelProviderSettingsFooterProps = {
onOpenSettings: () => void
}
export const ModelProviderSettingsFooter: FC<ModelProviderSettingsFooterProps> = ({
onOpenSettings,
}) => {
const { t } = useTranslation()
return (
<div className="shrink-0 border-t border-divider-subtle p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={onOpenSettings}
>
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</button>
</div>
)
}

View File

@ -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<PopupProps> = ({
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 (
<div className="no-scrollbar max-h-[480px] overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg pt-3 pr-2 pb-1 pl-3">
<div className={`
flex h-8 items-center rounded-lg border pr-[10px] pl-[9px]
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<span
className={`
mr-[7px] i-ri-search-line h-[14px] w-[14px] shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
<input
className="block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-hidden"
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
{
searchText && (
<span
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => setSearchText('')}
/>
)
}
</div>
{scopeFeatures.length > 0 && (
<div
data-testid="compatible-models-banner"
className="mt-2 flex items-center gap-1 rounded-lg bg-background-section-burn px-2.5 py-2"
>
<span className="i-ri-information-2-fill h-4 w-4 shrink-0 text-text-accent" />
<p className="system-xs-medium text-text-secondary">
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
</p>
</div>
)}
</div>
<ModelSelectorPopupFrame>
<ModelSelectorSearchHeader
searchText={searchText}
onSearchTextChange={setSearchText}
/>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="pr-1 pb-1 pl-3">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
<div className="pb-1">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState
onConfigure={handleOpenSettings}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<div className="flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
<div className="flex flex-col gap-1">
<p className="system-sm-medium text-text-secondary">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="system-xs-regular text-text-tertiary">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
)}
{marketplaceProviders.length > 0 && (
<>
<div className="mx-2 my-1 border-t border-divider-subtle" />
<div className="mb-1">
<div className="flex h-[22px] items-center px-3">
<div
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
onClick={() => setMarketplaceCollapsed(prev => !prev)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</div>
{!marketplaceCollapsed && (
<>
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
onClick={() => handleInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
</a>
</>
)}
</div>
</>
)}
</div>
<div
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</div>
</div>
)}
{scopeFeatures.length > 0 && (
<CompatibleModelsNotice />
)}
<MarketplaceSection
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
/>
</div>
</ModelSelectorScrollBody>
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
</ModelSelectorPopupFrame>
)
}

View File

@ -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<PluginSectionProps> = ({
)
{headerAction}
</div>
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ScrollArea
className="max-h-[300px] overflow-hidden"
label={title}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
>
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
@ -59,7 +67,7 @@ const PluginSection: FC<PluginSectionProps> = ({
: undefined}
/>
))}
</div>
</ScrollArea>
</>
)
}

View File

@ -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<PluginTaskListProps> = ({
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ScrollArea
className="max-h-[300px] overflow-hidden"
label={t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
>
{errorPlugins.map(plugin => (
<ErrorPluginItem
key={plugin.plugin_unique_identifier}
@ -96,7 +104,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
</div>
</ScrollArea>
</>
)}
</div>

View File

@ -117,7 +117,7 @@ const PluginTasks = () => {
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="[scrollbar-width:none] overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none [&::-webkit-scrollbar]:hidden"
popupClassName="overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<PluginTaskList
runningPlugins={runningPlugins}