refactor: update Scrollbar component and integrate horizontal scrolling in TabContainer and KnowledgeBaseInput (#9988)

* refactor: update Scrollbar component and integrate horizontal scrolling in TabContainer and KnowledgeBaseInput

- Renamed Props interface to ScrollbarProps for clarity.
- Implemented useHorizontalScroll hook in TabContainer to manage horizontal scrolling.
- Removed deprecated scroll handling logic and replaced it with the new hook.
- Enhanced KnowledgeBaseInput to utilize horizontal scrolling for better UI management.
- Cleaned up unused imports and components for improved code maintainability.

* refactor: update dependencies type in useHorizontalScroll hook to readonly unknown[] for better type safety

* feat: add scrollDistance parameter to useHorizontalScroll hook for customizable scrolling behavior

* refactor: replace useHorizontalScroll with HorizontalScrollContainer in TabContainer, KnowledgeBaseInput, and MentionModelsInput components

- Updated TabContainer to utilize HorizontalScrollContainer for improved scrolling functionality.
- Refactored KnowledgeBaseInput and MentionModelsInput to replace the custom horizontal scroll implementation with HorizontalScrollContainer, simplifying the code and enhancing maintainability.

* refactor(HorizontalScrollContainer): remove paddingRight prop and update scroll handling

- Removed the unused paddingRight prop from HorizontalScrollContainerProps and its implementation.
- Updated handleScrollRight to accept the event parameter and stop propagation.
- Simplified the Container styled component by eliminating the padding-right style.

* fix: sync issue

* fix: isLeftNavbar inputbar display issue

* feat(HorizontalScrollContainer): add scroll end detection and disable button hover effect

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
This commit is contained in:
SuYao 2025-09-11 16:56:37 +08:00 committed by GitHub
parent 7fec4c0dac
commit 66115ca306
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 326 additions and 125 deletions

View File

@ -0,0 +1,179 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
/**
*
* @param children
* @param dependencies
* @param scrollDistance
* @param className
* @param gap
* @param expandable
*/
export interface HorizontalScrollContainerProps {
children: React.ReactNode
dependencies?: readonly unknown[]
scrollDistance?: number
className?: string
gap?: string
expandable?: boolean
}
const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
children,
dependencies = [],
scrollDistance = 200,
className,
gap = '8px',
expandable = false
}) => {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScroll, setCanScroll] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [isScrolledToEnd, setIsScrolledToEnd] = useState(false)
const handleScrollRight = (event: React.MouseEvent) => {
scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' })
event.stopPropagation()
}
const handleContainerClick = (e: React.MouseEvent) => {
if (expandable) {
// 确保不是点击了其他交互元素(如 tag 的关闭按钮)
const target = e.target as HTMLElement
if (!target.closest('[data-no-expand]')) {
setIsExpanded(!isExpanded)
}
}
}
const checkScrollability = () => {
const scrollElement = scrollRef.current
if (scrollElement) {
const parentElement = scrollElement.parentElement
const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth
// 确保容器不会超出可用宽度
const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth)
setCanScroll(canScrollValue)
// 检查是否滚动到最右侧
if (canScrollValue) {
const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1
setIsScrolledToEnd(isAtEnd)
} else {
setIsScrolledToEnd(false)
}
}
}
useEffect(() => {
const scrollElement = scrollRef.current
if (!scrollElement) return
checkScrollability()
const handleScroll = () => {
checkScrollability()
}
const resizeObserver = new ResizeObserver(checkScrollability)
resizeObserver.observe(scrollElement)
scrollElement.addEventListener('scroll', handleScroll)
window.addEventListener('resize', checkScrollability)
return () => {
resizeObserver.disconnect()
scrollElement.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', checkScrollability)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)
return (
<Container
className={className}
$expandable={expandable}
$disableHoverButton={isScrolledToEnd}
onClick={expandable ? handleContainerClick : undefined}>
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
{children}
</ScrollContent>
{canScroll && !isExpanded && !isScrolledToEnd && (
<ScrollButton onClick={handleScrollRight} className="scroll-right-button">
<ChevronRight size={14} />
</ScrollButton>
)}
</Container>
)
}
const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>`
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
position: relative;
cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')};
${(props) =>
!props.$disableHoverButton &&
`
&:hover {
.scroll-right-button {
opacity: 1;
}
}
`}
`
const ScrollContent = styled(Scrollbar)<{
$gap: string
$isExpanded?: boolean
$expandable?: boolean
}>`
display: flex;
overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')};
overflow-y: hidden;
white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')};
gap: ${(props) => props.$gap};
flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')};
&::-webkit-scrollbar {
display: none;
}
`
const ScrollButton = styled.div`
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease-in-out;
cursor: pointer;
background: var(--color-background);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
color: var(--color-text-2);
&:hover {
color: var(--color-text);
background: var(--color-list-item);
}
`
export default HorizontalScrollContainer

View File

@ -32,6 +32,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
// 添加更新整个列表的方法
const updateList = useCallback((newList: QuickPanelListItem[]) => {
setList(newList)
}, [])
const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
@ -85,6 +90,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
open,
close,
updateItemSelection,
updateList,
isVisible,
symbol,
@ -103,6 +109,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
open,
close,
updateItemSelection,
updateList,
isVisible,
symbol,
list,

View File

@ -68,6 +68,7 @@ export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly updateList: (newList: QuickPanelListItem[]) => void
readonly isVisible: boolean
readonly symbol: string
readonly list: QuickPanelListItem[]

View File

@ -2,12 +2,12 @@ import { throttle } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
export interface ScrollbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
ref?: React.Ref<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const Scrollbar: FC<ScrollbarProps> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)

View File

@ -1,6 +1,6 @@
import { PlusOutlined } from '@ant-design/icons'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { isMac } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
@ -14,9 +14,8 @@ import type { Tab } from '@renderer/store/tabs'
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
import { ThemeMode } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Button, Tooltip } from 'antd'
import { Tooltip } from 'antd'
import {
ChevronRight,
FileSearch,
Folder,
Hammer,
@ -33,7 +32,7 @@ import {
Terminal,
X
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
@ -98,8 +97,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const { hideMinappPopup } = useMinappPopup()
const { minapps } = useMinapps()
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const [canScroll, setCanScroll] = useState(false)
const getTabId = (path: string): string => {
if (path === '/') return 'home'
@ -175,31 +172,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
navigate(tab.path)
}
const handleScrollRight = () => {
scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' })
}
useEffect(() => {
const scrollElement = scrollRef.current
if (!scrollElement) return
const checkScrollability = () => {
setCanScroll(scrollElement.scrollWidth > scrollElement.clientWidth)
}
checkScrollability()
const resizeObserver = new ResizeObserver(checkScrollability)
resizeObserver.observe(scrollElement)
window.addEventListener('resize', checkScrollability)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', checkScrollability)
}
}, [tabs])
const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs])
const { onSortEnd } = useDndReorder<Tab>({
@ -212,46 +184,39 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
return (
<Container>
<TabsBar $isFullscreen={isFullscreen}>
<TabsArea>
<TabsScroll ref={scrollRef}>
<Sortable
items={visibleTabs}
itemKey="id"
layout="list"
horizontal
gap={'6px'}
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
className="close-button"
data-no-dnd
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}>
<X size={12} />
</CloseButton>
)}
</Tab>
)}
/>
</TabsScroll>
{canScroll && (
<ScrollButton onClick={handleScrollRight} className="scroll-right-button" shape="circle" size="small">
<ChevronRight size={16} />
</ScrollButton>
)}
<HorizontalScrollContainer dependencies={[tabs]} gap="6px" className="tab-scroll-container">
<Sortable
items={visibleTabs}
itemKey="id"
layout="list"
horizontal
gap={'6px'}
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
className="close-button"
data-no-dnd
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}>
<X size={12} />
</CloseButton>
)}
</Tab>
)}
/>
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
<PlusOutlined />
</AddTabButton>
</TabsArea>
</HorizontalScrollContainer>
<RightButtonsContainer>
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
@ -307,36 +272,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
z-index: 1;
-webkit-app-region: no-drag;
}
`
const TabsArea = styled.div`
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
gap: 6px;
padding-right: 2rem;
position: relative;
.tab-scroll-container {
-webkit-app-region: drag;
-webkit-app-region: drag;
> * {
-webkit-app-region: no-drag;
}
&:hover {
.scroll-right-button {
opacity: 1;
> * {
-webkit-app-region: no-drag;
}
}
`
const TabsScroll = styled(Scrollbar)`
&::-webkit-scrollbar {
display: none;
}
`
const Tab = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
@ -414,22 +359,6 @@ const AddTabButton = styled.div`
}
`
const ScrollButton = styled(Button)`
position: absolute;
right: 4rem;
top: 50%;
transform: translateY(-50%);
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease-in-out;
border: none;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
`
const RightButtonsContainer = styled.div`
display: flex;
align-items: center;

View File

@ -43,6 +43,7 @@ const Chat: FC<Props> = (props) => {
const { showTopics } = useShowTopics()
const { isMultiSelectMode } = useChatContext(props.activeTopic)
const { isTopNavbar } = useNavbarPosition()
const chatMaxWidth = useChatMaxWidth()
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@ -153,7 +154,7 @@ const Chat: FC<Props> = (props) => {
vertical
flex={1}
justify="space-between"
style={{ maxWidth: '100%', height: mainHeight }}>
style={{ maxWidth: chatMaxWidth, height: mainHeight }}>
<Messages
key={props.activeTopic.id}
assistant={assistant}
@ -215,7 +216,7 @@ const Container = styled.div`
height: calc(100vh - var(--navbar-height));
flex: 1;
[navbar-position='top'] & {
height: calc(100vh - var(--navbar-height) -6px);
height: calc(100vh - var(--navbar-height) - 6px);
background-color: var(--color-background);
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;

View File

@ -61,6 +61,8 @@ import styled from 'styled-components'
import NarrowLayout from '../Messages/NarrowLayout'
import AttachmentPreview from './AttachmentPreview'
import InputbarTools, { InputbarToolsRef } from './InputbarTools'
import KnowledgeBaseInput from './KnowledgeBaseInput'
import MentionModelsInput from './MentionModelsInput'
import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount'
@ -765,6 +767,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(bases ?? [])
}
const handleRemoveModel = (model: Model) => {
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
}
const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => {
const newKnowledgeBases = assistant.knowledge_bases?.filter((kb) => kb.id !== knowledgeBase.id)
updateAssistant({
...assistant,
knowledge_bases: newKnowledgeBases
})
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
}
const onEnableGenerateImage = () => {
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
}
@ -851,6 +866,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')}
ref={containerRef}>
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
{selectedKnowledgeBases.length > 0 && (
<KnowledgeBaseInput
selectedKnowledgeBases={selectedKnowledgeBases}
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
/>
)}
{mentionedModels.length > 0 && (
<MentionModelsInput selectedModels={mentionedModels} onRemoveModel={handleRemoveModel} />
)}
<Textarea
value={text}
onChange={onChange}

View File

@ -93,6 +93,14 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
}
}, [openQuickPanel, quickPanel])
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
useEffect(() => {
if (quickPanel.isVisible && quickPanel.symbol === '#') {
// 直接使用重新计算的 baseItems因为它已经包含了最新的 isSelected 状态
quickPanel.updateList(baseItems)
}
}, [selectedBases, quickPanel, baseItems])
useImperativeHandle(ref, () => ({
openQuickPanel
}))

View File

@ -1,4 +1,5 @@
import { FileSearchOutlined } from '@ant-design/icons'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import CustomTag from '@renderer/components/Tags/CustomTag'
import { KnowledgeBase } from '@renderer/types'
import { FC } from 'react'
@ -10,16 +11,18 @@ const KnowledgeBaseInput: FC<{
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase }) => {
return (
<Container>
{selectedKnowledgeBases.map((knowledgeBase) => (
<CustomTag
icon={<FileSearchOutlined />}
color="#3d9d0f"
key={knowledgeBase.id}
closable
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
{knowledgeBase.name}
</CustomTag>
))}
<HorizontalScrollContainer dependencies={[selectedKnowledgeBases]} expandable>
{selectedKnowledgeBases.map((knowledgeBase) => (
<CustomTag
icon={<FileSearchOutlined />}
color="#3d9d0f"
key={knowledgeBase.id}
closable
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
{knowledgeBase.name}
</CustomTag>
))}
</HorizontalScrollContainer>
</Container>
)
}
@ -27,9 +30,6 @@ const KnowledgeBaseInput: FC<{
const Container = styled.div`
width: 100%;
padding: 5px 15px 5px 15px;
display: flex;
flex-wrap: wrap;
gap: 4px 4px;
`
export default KnowledgeBaseInput

View File

@ -294,6 +294,14 @@ const MentionModelsButton: FC<Props> = ({
}
}, [files, quickPanel])
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
useEffect(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
// 直接使用重新计算的 modelItems因为它已经包含了最新的 isSelected 状态
quickPanel.updateList(modelItems)
}
}, [mentionedModels, quickPanel, modelItems])
useImperativeHandle(ref, () => ({
openQuickPanel
}))

View File

@ -0,0 +1,44 @@
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import CustomTag from '@renderer/components/Tags/CustomTag'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import { FC } from 'react'
import styled from 'styled-components'
const MentionModelsInput: FC<{
selectedModels: Model[]
onRemoveModel: (model: Model) => void
}> = ({ selectedModels, onRemoveModel }) => {
const { providers } = useProviders()
const getProviderName = (model: Model) => {
const provider = providers.find((p) => p.id === model?.provider)
return provider ? getFancyProviderName(provider) : ''
}
return (
<Container>
<HorizontalScrollContainer dependencies={[selectedModels]} expandable>
{selectedModels.map((model) => (
<CustomTag
icon={<i className="iconfont icon-at" />}
color="#1677ff"
key={getModelUniqId(model)}
closable
onClose={() => onRemoveModel(model)}>
{model.name} ({getProviderName(model)})
</CustomTag>
))}
</HorizontalScrollContainer>
</Container>
)
}
const Container = styled.div`
width: 100%;
padding: 5px 15px 5px 15px;
`
export default MentionModelsInput