feat(message): add a compact style for the model list in message groups (#2962)

* feat(message): add a compact style for the model list in message groups

* refactor: use button as action rather than state

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
This commit is contained in:
one 2025-03-09 22:04:18 +08:00 committed by GitHub
parent 8e36d29996
commit 5c4f0e8e8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 275 additions and 70 deletions

View File

@ -402,6 +402,8 @@
"message.multi_model_style.grid": "Grid layout", "message.multi_model_style.grid": "Grid layout",
"message.multi_model_style.horizontal": "Side by side", "message.multi_model_style.horizontal": "Side by side",
"message.multi_model_style.vertical": "Stacked view", "message.multi_model_style.vertical": "Stacked view",
"message.multi_model_style.fold.compress": "Switch to compact layout",
"message.multi_model_style.fold.expand": "Switch to expanded layout",
"message.style": "Message style", "message.style": "Message style",
"message.style.bubble": "Bubble", "message.style.bubble": "Bubble",
"message.style.plain": "Plain", "message.style.plain": "Plain",

View File

@ -402,6 +402,8 @@
"message.multi_model_style.grid": "カード表示", "message.multi_model_style.grid": "カード表示",
"message.multi_model_style.horizontal": "横並び", "message.multi_model_style.horizontal": "横並び",
"message.multi_model_style.vertical": "縦積み", "message.multi_model_style.vertical": "縦積み",
"message.multi_model_style.fold.compress": "緊湊配置に切り替える",
"message.multi_model_style.fold.expand": "展開配置に切り替える",
"message.style": "メッセージスタイル", "message.style": "メッセージスタイル",
"message.style.bubble": "バブル", "message.style.bubble": "バブル",
"message.style.plain": "プレーン", "message.style.plain": "プレーン",

View File

@ -402,6 +402,8 @@
"message.multi_model_style.grid": "Карточки", "message.multi_model_style.grid": "Карточки",
"message.multi_model_style.horizontal": "Горизонтальное расположение", "message.multi_model_style.horizontal": "Горизонтальное расположение",
"message.multi_model_style.vertical": "Вертикальное расположение", "message.multi_model_style.vertical": "Вертикальное расположение",
"message.multi_model_style.fold.compress": "Переключить на компактный макет",
"message.multi_model_style.fold.expand": "Переключить на расширенный макет",
"message.style": "Стиль сообщения", "message.style": "Стиль сообщения",
"message.style.bubble": "Пузырь", "message.style.bubble": "Пузырь",
"message.style.plain": "Простой", "message.style.plain": "Простой",

View File

@ -402,6 +402,8 @@
"message.multi_model_style.grid": "卡片布局", "message.multi_model_style.grid": "卡片布局",
"message.multi_model_style.horizontal": "横向排列", "message.multi_model_style.horizontal": "横向排列",
"message.multi_model_style.vertical": "纵向堆叠", "message.multi_model_style.vertical": "纵向堆叠",
"message.multi_model_style.fold.compress": "切换到紧凑排列",
"message.multi_model_style.fold.expand": "切换到展开排列",
"message.style": "消息样式", "message.style": "消息样式",
"message.style.bubble": "气泡", "message.style.bubble": "气泡",
"message.style.plain": "简洁", "message.style.plain": "简洁",

View File

@ -402,6 +402,8 @@
"message.multi_model_style.grid": "卡片設定", "message.multi_model_style.grid": "卡片設定",
"message.multi_model_style.horizontal": "橫向排列", "message.multi_model_style.horizontal": "橫向排列",
"message.multi_model_style.vertical": "縱向堆疊", "message.multi_model_style.vertical": "縱向堆疊",
"message.multi_model_style.fold.compress": "切換到緊湊排列",
"message.multi_model_style.fold.expand": "切換到展開排列",
"message.style": "訊息樣式", "message.style": "訊息樣式",
"message.style.bubble": "氣泡", "message.style.bubble": "氣泡",
"message.style.plain": "簡潔", "message.style.plain": "簡潔",

View File

@ -5,17 +5,15 @@ import {
FolderOutlined, FolderOutlined,
NumberOutlined NumberOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { MultiModelMessageStyle } from '@renderer/store/settings' import { MultiModelMessageStyle } from '@renderer/store/settings'
import { Message, Model } from '@renderer/types' import { Message } from '@renderer/types'
import { Button, Segmented as AntdSegmented, Tooltip } from 'antd' import { Button, Tooltip } from 'antd'
import { FC, memo } from 'react' import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import MessageGroupModelList from './MessageGroupModelList'
import MessageGroupSettings from './MessageGroupSettings' import MessageGroupSettings from './MessageGroupSettings'
interface Props { interface Props {
@ -61,25 +59,11 @@ const MessageGroupMenuBar: FC<Props> = ({
))} ))}
</LayoutContainer> </LayoutContainer>
{multiModelMessageStyle === 'fold' && ( {multiModelMessageStyle === 'fold' && (
<ModelsContainer> <MessageGroupModelList
<Segmented messages={messages}
value={selectedIndex.toString()} selectedIndex={selectedIndex}
onChange={(value) => { setSelectedIndex={setSelectedIndex}
setSelectedIndex(Number(value)) />
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false)
}}
options={messages.map((message, index) => ({
label: (
<SegmentedLabel>
<ModelAvatar model={message.model as Model} size={20} />
<ModelName>{message.model?.name}</ModelName>
</SegmentedLabel>
),
value: index.toString()
}))}
size="small"
/>
</ModelsContainer>
)} )}
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />} {multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
</HStack> </HStack>
@ -111,13 +95,13 @@ const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
const LayoutContainer = styled.div` const LayoutContainer = styled.div`
display: flex; display: flex;
gap: 10px; gap: 4px;
flex-direction: row; flex-direction: row;
` `
const LayoutOption = styled.div<{ $active: boolean }>` const LayoutOption = styled.div<{ $active: boolean }>`
cursor: pointer; cursor: pointer;
padding: 2px 10px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
background-color: ${({ $active }) => ($active ? 'var(--color-background-soft)' : 'transparent')}; background-color: ${({ $active }) => ($active ? 'var(--color-background-soft)' : 'transparent')};
@ -126,48 +110,4 @@ const LayoutOption = styled.div<{ $active: boolean }>`
} }
` `
const ModelsContainer = styled(Scrollbar)`
display: flex;
flex-direction: column;
justify-content: space-between;
&::-webkit-scrollbar {
display: none;
}
`
const Segmented = styled(AntdSegmented)`
&.ant-segmented {
background: transparent !important;
}
.ant-segmented-item {
background-color: transparent !important;
transition: none !important;
border-radius: var(--list-item-border-radius) !important;
box-shadow: none !important;
&:hover {
background: transparent !important;
}
}
.ant-segmented-thumb,
.ant-segmented-item-selected {
background-color: transparent !important;
border: 0.5px solid var(--color-border);
transition: none !important;
border-radius: var(--list-item-border-radius) !important;
box-shadow: none !important;
}
`
const SegmentedLabel = styled.div`
display: flex;
align-items: center;
gap: 5px;
padding: 3px 0;
`
const ModelName = styled.span`
font-weight: 500;
font-size: 12px;
`
export default memo(MessageGroupMenuBar) export default memo(MessageGroupMenuBar)

View File

@ -0,0 +1,255 @@
import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import Scrollbar from '@renderer/components/Scrollbar'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Message, Model } from '@renderer/types'
import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MessageGroupModelListProps {
messages: Message[]
selectedIndex: number
setSelectedIndex: (index: number) => void
}
type DisplayMode = 'compact' | 'expanded'
const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selectedIndex, setSelectedIndex }) => {
const { t } = useTranslation()
const [displayMode, setDisplayMode] = useState<DisplayMode>('expanded')
return (
<ModelsWrapper>
<DisplayModeToggle displayMode={displayMode}>
<Tooltip
title={
displayMode === 'compact'
? t('message.message.multi_model_style.fold.expand')
: t('message.message.multi_model_style.fold.compress')
}
placement="top">
{displayMode === 'compact' ? (
<ArrowsAltOutlined onClick={() => setDisplayMode('expanded')} />
) : (
<ShrinkOutlined onClick={() => setDisplayMode('compact')} />
)}
</Tooltip>
</DisplayModeToggle>
<ModelsContainer $displayMode={displayMode}>
{displayMode === 'compact' ? (
/* Compact style display */
<Avatar.Group className="avatar-group">
{messages.map((message, index) => (
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
<AvatarWrapper
className="avatar-wrapper"
isSelected={selectedIndex === index}
onClick={() => {
setSelectedIndex(index)
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[index].id, false)
}}>
<ModelAvatar model={message.model as Model} size={28} />
</AvatarWrapper>
</Tooltip>
))}
</Avatar.Group>
) : (
/* Expanded style display */
<Segmented
value={selectedIndex.toString()}
onChange={(value) => {
setSelectedIndex(Number(value))
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false)
}}
options={messages.map((message, index) => ({
label: (
<SegmentedLabel>
<ModelAvatar model={message.model as Model} size={20} />
<ModelName>{message.model?.name}</ModelName>
</SegmentedLabel>
),
value: index.toString()
}))}
size="small"
/>
)}
</ModelsContainer>
</ModelsWrapper>
)
}
const ModelsWrapper = styled.div`
position: relative;
display: flex;
flex: 1;
overflow: hidden;
`
const DisplayModeToggle = styled.div<{ displayMode: DisplayMode }>`
position: absolute;
left: 4px; /* Add more space on the left */
top: 50%;
transform: translateY(-50%);
z-index: 5;
width: 28px; /* Increase width */
height: 28px; /* Add height */
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
border-radius: 4px;
padding: 2px;
/* Add hover effect */
&:hover {
background-color: var(--color-hover);
}
`
const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>`
display: flex;
flex-direction: ${(props) => (props.$displayMode === 'expanded' ? 'column' : 'row')};
justify-content: ${(props) => (props.$displayMode === 'expanded' ? 'space-between' : 'flex-start')};
align-items: center;
overflow-x: auto;
flex: 1;
padding: 0 8px;
margin-left: 24px; /* Space for toggle button */
/* Hide scrollbar to match original code */
&::-webkit-scrollbar {
display: none;
}
/* Card mode styles */
.avatar-group.ant-avatar-group {
display: flex;
align-items: center;
flex-wrap: nowrap;
position: relative;
padding: 6px 4px;
/* Base style - default overlapping effect */
& > * {
margin-left: -6px !important;
/* Separate transition properties to avoid conflicts */
transition:
transform 0.18s ease-out,
margin 0.18s ease-out !important;
position: relative;
/* Only use will-change for transform to reduce rendering overhead */
will-change: transform;
}
/* First element has no left margin */
& > *:first-child {
margin-left: 0 !important;
}
/* Using :has() selector to handle the element before the hovered one */
& > *:has(+ *:hover) {
margin-right: 2px !important;
/* Use transform instead of margin to reduce layout recalculations */
transform: translateX(-2px);
}
/* Element after the hovered one */
& > *:hover + * {
margin-left: 5px !important;
/* Avoid transform here to prevent jittering */
}
/* Second element after the hovered one */
& > *:hover + * + * {
margin-left: -4px !important;
}
}
`
const AvatarWrapper = styled.div<{ isSelected: boolean }>`
cursor: pointer;
display: inline-flex;
border-radius: 50%;
/* Keep z-index separate from transitions to avoid rendering issues */
z-index: ${(props) => (props.isSelected ? 2 : 0)};
background: var(--color-background);
/* Simplify transitions to reduce jittering */
transition:
transform 0.18s ease-out,
margin 0.18s ease-out,
box-shadow 0.18s ease-out,
filter 0.18s ease-out;
box-shadow: 0 0 0 1px var(--color-background);
/* Use CSS variables to define animation parameters for easy adjustment */
--hover-scale: 1.15;
--hover-x-offset: 6px;
--hover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
&:hover {
/* z-index is applied immediately, not part of the transition */
z-index: 10;
transform: translateX(var(--hover-x-offset)) scale(var(--hover-scale));
box-shadow: var(--hover-shadow);
filter: brightness(1.02);
margin-left: 8px !important;
margin-right: 4px !important;
}
${(props) =>
props.isSelected &&
`
border: 2px solid var(--color-primary);
z-index: 2;
&:hover {
/* z-index is applied immediately, not part of the transition */
z-index: 10;
border: 2px solid var(--color-primary);
filter: brightness(1.02);
transform: translateX(var(--hover-x-offset)) scale(var(--hover-scale));
margin-left: 8px !important;
margin-right: 4px !important;
}
`}
`
const Segmented = styled(AntdSegmented)`
width: 100%;
background-color: transparent !important;
.ant-segmented-item {
background-color: transparent !important;
transition: none !important;
border-radius: var(--list-item-border-radius) !important;
box-shadow: none !important;
&:hover {
background: transparent !important;
}
}
.ant-segmented-thumb,
.ant-segmented-item-selected {
background-color: transparent !important;
border: 0.5px solid var(--color-border);
transition: none !important;
border-radius: var(--list-item-border-radius) !important;
box-shadow: none !important;
}
`
const SegmentedLabel = styled.div`
display: flex;
align-items: center;
gap: 5px;
padding: 3px 0;
`
const ModelName = styled.span`
font-weight: 500;
font-size: 12px;
`
export default MessageGroupModelList