mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a249da25bb | |||
| 05cedabdcc | |||
| 374b3188e9 |
@@ -46,6 +46,7 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
|
||||
translate,
|
||||
collapse,
|
||||
expand,
|
||||
select,
|
||||
} = useChatListActionsBar({ hasThread, isRegenerating });
|
||||
|
||||
const hasTools = !!tools;
|
||||
@@ -76,6 +77,8 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
|
||||
delAndResendThreadMessage,
|
||||
toggleMessageEditing,
|
||||
toggleMessageCollapsed,
|
||||
toggleMessageSelectionMode,
|
||||
updateMessageSelection,
|
||||
] = useChatStore((s) => [
|
||||
s.deleteMessage,
|
||||
s.regenerateAssistantMessage,
|
||||
@@ -88,6 +91,8 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
|
||||
s.delAndResendThreadMessage,
|
||||
s.toggleMessageEditing,
|
||||
s.toggleMessageCollapsed,
|
||||
s.toggleMessageSelectionMode,
|
||||
s.updateMessageSelection,
|
||||
]);
|
||||
const { message } = App.useApp();
|
||||
const virtuaRef = use(VirtuaContext);
|
||||
@@ -162,6 +167,12 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
|
||||
setShareModal(true);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'select': {
|
||||
toggleMessageSelectionMode(true);
|
||||
updateMessageSelection(id, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.keyPath.at(-1) === 'translate') {
|
||||
@@ -193,6 +204,7 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
|
||||
translate,
|
||||
divider,
|
||||
share,
|
||||
select,
|
||||
// exportPDF,
|
||||
divider,
|
||||
regenerate,
|
||||
|
||||
@@ -39,6 +39,8 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
|
||||
openThreadCreator,
|
||||
resendThreadMessage,
|
||||
delAndResendThreadMessage,
|
||||
toggleMessageSelectionMode,
|
||||
updateMessageSelection,
|
||||
] = useChatStore((s) => [
|
||||
// !!s.activeThreadId,
|
||||
threadSelectors.hasThreadBySourceMsgId(id)(s),
|
||||
@@ -54,11 +56,13 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
|
||||
s.openThreadCreator,
|
||||
s.resendThreadMessage,
|
||||
s.delAndResendThreadMessage,
|
||||
s.toggleMessageSelectionMode,
|
||||
s.updateMessageSelection,
|
||||
]);
|
||||
|
||||
// const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
|
||||
|
||||
const { regenerate, edit, copy, divider, del, tts, translate } = useChatListActionsBar({
|
||||
const { regenerate, edit, copy, divider, del, tts, translate, select } = useChatListActionsBar({
|
||||
hasThread,
|
||||
isRegenerating,
|
||||
});
|
||||
@@ -132,6 +136,12 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
|
||||
ttsMessage(id);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'select': {
|
||||
toggleMessageSelectionMode(true);
|
||||
updateMessageSelection(id, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.keyPath.at(-1) === 'translate') {
|
||||
@@ -149,7 +159,7 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
|
||||
<ActionIconGroup
|
||||
items={items}
|
||||
menu={{
|
||||
items: [edit, copy, divider, tts, translate, divider, regenerate, del],
|
||||
items: [edit, copy, divider, tts, translate, divider, select, divider, regenerate, del],
|
||||
}}
|
||||
onActionClick={onActionClick}
|
||||
size={'small'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { Checkbox } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
@@ -21,7 +22,7 @@ import SupervisorMessage from './Supervisor';
|
||||
import ToolMessage from './Tool';
|
||||
import UserMessage from './User';
|
||||
|
||||
const useStyles = createStyles(({ css, prefixCls }) => ({
|
||||
const useStyles = createStyles(({ css, token, prefixCls }) => ({
|
||||
loading: css`
|
||||
opacity: 0.6;
|
||||
`,
|
||||
@@ -32,6 +33,10 @@ const useStyles = createStyles(({ css, prefixCls }) => ({
|
||||
max-height: 900px;
|
||||
}
|
||||
`,
|
||||
selected: css`
|
||||
background: ${token.colorFillTertiary};
|
||||
padding-inline: 8px;
|
||||
`,
|
||||
}));
|
||||
|
||||
export interface ChatListItemProps {
|
||||
@@ -43,6 +48,7 @@ export interface ChatListItemProps {
|
||||
inPortalThread?: boolean;
|
||||
index: number;
|
||||
isLatestItem?: boolean;
|
||||
showSelection?: boolean;
|
||||
}
|
||||
|
||||
const Item = memo<ChatListItemProps>(
|
||||
@@ -55,14 +61,22 @@ const Item = memo<ChatListItemProps>(
|
||||
inPortalThread = false,
|
||||
index,
|
||||
isLatestItem,
|
||||
showSelection,
|
||||
}) => {
|
||||
const { styles, cx } = useStyles();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [role, isMessageCreating] = useChatStore((s) => [
|
||||
displayMessageSelectors.getDisplayMessageById(id)(s)?.role,
|
||||
messageStateSelectors.isMessageCreating(id)(s),
|
||||
]);
|
||||
const [role, isMessageCreating, isMessageSelectionMode, isSelected, updateMessageSelection] =
|
||||
useChatStore((s) => [
|
||||
displayMessageSelectors.getDisplayMessageById(id)(s)?.role,
|
||||
messageStateSelectors.isMessageCreating(id)(s),
|
||||
s.isMessageSelectionMode,
|
||||
s.messageSelectionIds.includes(id),
|
||||
s.updateMessageSelection,
|
||||
]);
|
||||
|
||||
const showCheckbox = showSelection === false ? false : isMessageSelectionMode;
|
||||
const isActive = isSelected && showCheckbox;
|
||||
|
||||
// ======================= Performance Optimization ======================= //
|
||||
// these useMemo/useCallback are all for the performance optimization
|
||||
@@ -168,13 +182,34 @@ const Item = memo<ChatListItemProps>(
|
||||
<InPortalThreadContext.Provider value={inPortalThread}>
|
||||
{enableHistoryDivider && <History />}
|
||||
<Flexbox
|
||||
className={cx(styles.message, className, isMessageCreating && styles.loading)}
|
||||
className={cx(
|
||||
styles.message,
|
||||
className,
|
||||
isMessageCreating && styles.loading,
|
||||
isActive && styles.selected,
|
||||
)}
|
||||
data-index={index}
|
||||
gap={8}
|
||||
horizontal
|
||||
onContextMenu={onContextMenu}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
marginLeft: isActive ? -8 : undefined,
|
||||
marginRight: isActive ? -8 : undefined,
|
||||
}}
|
||||
>
|
||||
{renderContent}
|
||||
{endRender}
|
||||
{showCheckbox && (
|
||||
<Flexbox align={'center'} justify={'center'}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => updateMessageSelection(id, e.target.checked)}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
<Flexbox style={{ flex: 1, minWidth: 0 }}>
|
||||
{renderContent}
|
||||
{endRender}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</InPortalThreadContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Button, Icon } from '@lobehub/ui';
|
||||
import { ListEnd } from 'lucide-react';
|
||||
import { ListEnd, X } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { useStyles } from './style';
|
||||
|
||||
@@ -13,17 +16,39 @@ export interface BackBottomProps {
|
||||
const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
|
||||
const { t } = useTranslation('chat');
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
|
||||
const [isMessageSelectionMode] = useChatStore((s) => [
|
||||
s.isMessageSelectionMode,
|
||||
s.toggleMessageSelectionMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cx(styles.container, visible && styles.visible)}
|
||||
icon={<Icon icon={ListEnd} />}
|
||||
onClick={onScrollToBottom}
|
||||
size={'small'}
|
||||
<Flexbox
|
||||
className={cx(styles.container, (visible || isMessageSelectionMode) && styles.visible)}
|
||||
gap={8}
|
||||
>
|
||||
{t('backToBottom', { defaultValue: 'Back to bottom' })}
|
||||
</Button>
|
||||
{isMessageSelectionMode && (
|
||||
<Button
|
||||
className={styles.button}
|
||||
icon={<Icon icon={X} />}
|
||||
onClick={() => {
|
||||
useChatStore.getState().clearMessageSelection();
|
||||
}}
|
||||
size={'small'}
|
||||
>
|
||||
{t('exitSelection')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={cx(styles.button, !visible && styles.hide)}
|
||||
icon={<Icon icon={ListEnd} />}
|
||||
onClick={onScrollToBottom}
|
||||
size={'small'}
|
||||
>
|
||||
{t('backToBottom', { defaultValue: 'Back to bottom' })}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,32 +2,41 @@ import { createStyles } from 'antd-style';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
export const useStyles = createStyles(({ token, css, stylish, cx, responsive }) => ({
|
||||
container: cx(
|
||||
button: cx(
|
||||
stylish.blur,
|
||||
css`
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
inset-block-end: 16px;
|
||||
inset-inline-end: 16px;
|
||||
transform: translateY(16px);
|
||||
|
||||
padding-inline: 12px !important;
|
||||
border-color: ${token.colorFillTertiary} !important;
|
||||
border-radius: 16px !important;
|
||||
|
||||
opacity: 0;
|
||||
background: ${rgba(token.colorBgContainer, 0.5)};
|
||||
|
||||
${responsive.mobile} {
|
||||
inset-inline-end: 0;
|
||||
border-inline-end: none;
|
||||
border-start-end-radius: 0 !important;
|
||||
border-end-end-radius: 0 !important;
|
||||
&:last-child {
|
||||
border-inline-end: none;
|
||||
border-start-end-radius: 0 !important;
|
||||
border-end-end-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
),
|
||||
container: css`
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
inset-block-end: 16px;
|
||||
inset-inline-end: 16px;
|
||||
transform: translateY(16px);
|
||||
|
||||
opacity: 0;
|
||||
|
||||
${responsive.mobile} {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
`,
|
||||
hide: css`
|
||||
display: none;
|
||||
`,
|
||||
visible: css`
|
||||
pointer-events: all;
|
||||
transform: translateY(0);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ActionIconGroupItemType } from '@lobehub/ui';
|
||||
import { css, cx } from 'antd-style';
|
||||
import {
|
||||
ArrowDownFromLine,
|
||||
CheckSquare,
|
||||
Copy,
|
||||
DownloadIcon,
|
||||
Edit,
|
||||
@@ -39,6 +40,7 @@ interface ChatListActionsBar {
|
||||
expand: ActionIconGroupItemType;
|
||||
export: ActionIconGroupItemType;
|
||||
regenerate: ActionIconGroupItemType;
|
||||
select: ActionIconGroupItemType;
|
||||
share: ActionIconGroupItemType;
|
||||
translate: ActionIconGroupItemType;
|
||||
tts: ActionIconGroupItemType;
|
||||
@@ -119,6 +121,11 @@ export const useChatListActionsBar = ({
|
||||
label: t('regenerate'),
|
||||
spin: isRegenerating,
|
||||
},
|
||||
select: {
|
||||
icon: CheckSquare,
|
||||
key: 'select',
|
||||
label: t('selectMessage', { ns: 'chat' }),
|
||||
},
|
||||
share: {
|
||||
icon: Share2,
|
||||
key: 'share',
|
||||
|
||||
@@ -6,12 +6,18 @@ import { useChatStore } from '@/store/chat';
|
||||
import { chatSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const ChatList = memo(() => {
|
||||
const ids = useChatStore(chatSelectors.mainDisplayChatIDs);
|
||||
const ids = useChatStore((s) => {
|
||||
const allIds = chatSelectors.mainDisplayChatIDs(s);
|
||||
if (s.isMessageSelectionMode && s.messageSelectionIds.length > 0) {
|
||||
return allIds.filter((id) => s.messageSelectionIds.includes(id));
|
||||
}
|
||||
return allIds;
|
||||
});
|
||||
|
||||
return (
|
||||
<Flexbox height={'100%'} style={{ paddingTop: 24, position: 'relative' }} width={'100%'}>
|
||||
{ids.map((id, index) => (
|
||||
<ChatItem id={id} index={index} key={id} />
|
||||
<ChatItem id={id} index={index} key={id} showSelection={false} />
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,13 @@ const ShareText = memo(() => {
|
||||
];
|
||||
|
||||
const [systemRole] = useAgentStore((s) => [agentSelectors.currentAgentSystemRole(s)]);
|
||||
const messages = useChatStore(displayMessageSelectors.activeDisplayMessages, isEqual);
|
||||
const messages = useChatStore((s) => {
|
||||
const msgs = displayMessageSelectors.activeDisplayMessages(s);
|
||||
if (s.isMessageSelectionMode && s.messageSelectionIds.length > 0) {
|
||||
return msgs.filter((m) => s.messageSelectionIds.includes(m.id));
|
||||
}
|
||||
return msgs;
|
||||
}, isEqual);
|
||||
const topic = useChatStore(topicSelectors.currentActiveTopic, isEqual);
|
||||
|
||||
const title = topic?.title || t('shareModal.exportTitle');
|
||||
|
||||
@@ -45,6 +45,7 @@ export default {
|
||||
},
|
||||
duplicateTitle: '{{title}} 副本',
|
||||
emptyAgent: '暂无助手',
|
||||
exitSelection: '退出选择',
|
||||
extendParams: {
|
||||
disableContextCaching: {
|
||||
desc: '单条对话生成成本最高可降低 90%,响应速度提升 4 倍(<1>了解更多</1>)。开启后将自动禁用历史消息数限制',
|
||||
@@ -320,6 +321,7 @@ export default {
|
||||
},
|
||||
searchAgentPlaceholder: '搜索助手...',
|
||||
searchAgents: '搜索助手...',
|
||||
selectMessage: '选择消息',
|
||||
selectedAgents: '已选助手',
|
||||
sendPlaceholder: '输入聊天内容...',
|
||||
sessionGroup: {
|
||||
|
||||
@@ -1069,4 +1069,186 @@ describe('chatMessage actions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleMessageSelectionMode', () => {
|
||||
it('should enable selection mode', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleMessageSelectionMode(true);
|
||||
});
|
||||
|
||||
expect(result.current.isMessageSelectionMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable selection mode', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleMessageSelectionMode(true);
|
||||
result.current.toggleMessageSelectionMode(false);
|
||||
});
|
||||
|
||||
expect(result.current.isMessageSelectionMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should auto-collapse all messages when entering selection mode', async () => {
|
||||
const messageId1 = 'message-1';
|
||||
const messageId2 = 'message-2';
|
||||
const messageId3 = 'message-3';
|
||||
|
||||
const messages = [
|
||||
{ id: messageId1, role: 'user', content: 'Message 1', metadata: {} },
|
||||
{ id: messageId2, role: 'assistant', content: 'Message 2', metadata: {} },
|
||||
{ id: messageId3, role: 'user', content: 'Message 3', metadata: { collapsed: true } },
|
||||
] as UIChatMessage[];
|
||||
|
||||
const key = messageMapKey('session-id', 'topic-id');
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: 'session-id',
|
||||
activeTopicId: 'topic-id',
|
||||
messagesMap: {
|
||||
[key]: messages,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const toggleMessageCollapsedSpy = vi.spyOn(result.current, 'toggleMessageCollapsed');
|
||||
|
||||
await act(async () => {
|
||||
result.current.toggleMessageSelectionMode(true);
|
||||
});
|
||||
|
||||
// Should collapse message 1 and 2 (message 3 is already collapsed)
|
||||
expect(toggleMessageCollapsedSpy).toHaveBeenCalledWith(messageId1, true);
|
||||
expect(toggleMessageCollapsedSpy).toHaveBeenCalledWith(messageId2, true);
|
||||
expect(toggleMessageCollapsedSpy).not.toHaveBeenCalledWith(messageId3, true);
|
||||
expect(result.current.isMessageSelectionMode).toBe(true);
|
||||
// Should track originally collapsed messages
|
||||
expect(result.current.messageOriginallyCollapsedIds).toEqual([messageId3]);
|
||||
});
|
||||
|
||||
it('should restore original collapse state when exiting selection mode', async () => {
|
||||
const messageId1 = 'message-1';
|
||||
const messageId2 = 'message-2';
|
||||
const messageId3 = 'message-3';
|
||||
|
||||
const messages = [
|
||||
{ id: messageId1, role: 'user', content: 'Message 1', metadata: {} },
|
||||
{ id: messageId2, role: 'assistant', content: 'Message 2', metadata: {} },
|
||||
{ id: messageId3, role: 'user', content: 'Message 3', metadata: { collapsed: true } },
|
||||
] as UIChatMessage[];
|
||||
|
||||
const key = messageMapKey('session-id', 'topic-id');
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: 'session-id',
|
||||
activeTopicId: 'topic-id',
|
||||
messagesMap: {
|
||||
[key]: messages,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
// Enter selection mode
|
||||
await act(async () => {
|
||||
result.current.toggleMessageSelectionMode(true);
|
||||
});
|
||||
|
||||
// Update messages map to reflect collapsed state
|
||||
const collapsedMessages = [
|
||||
{ id: messageId1, role: 'user', content: 'Message 1', metadata: { collapsed: true } },
|
||||
{ id: messageId2, role: 'assistant', content: 'Message 2', metadata: { collapsed: true } },
|
||||
{ id: messageId3, role: 'user', content: 'Message 3', metadata: { collapsed: true } },
|
||||
] as UIChatMessage[];
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
messagesMap: {
|
||||
[key]: collapsedMessages,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const toggleMessageCollapsedSpy = vi.spyOn(result.current, 'toggleMessageCollapsed');
|
||||
|
||||
// Exit selection mode
|
||||
await act(async () => {
|
||||
result.current.toggleMessageSelectionMode(false);
|
||||
});
|
||||
|
||||
// Should expand message 1 and 2 (they were not originally collapsed)
|
||||
expect(toggleMessageCollapsedSpy).toHaveBeenCalledWith(messageId1, false);
|
||||
expect(toggleMessageCollapsedSpy).toHaveBeenCalledWith(messageId2, false);
|
||||
// Should NOT expand message 3 (it was originally collapsed)
|
||||
expect(toggleMessageCollapsedSpy).not.toHaveBeenCalledWith(messageId3, false);
|
||||
expect(result.current.isMessageSelectionMode).toBe(false);
|
||||
expect(result.current.messageOriginallyCollapsedIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should restore collapse state when calling clearMessageSelection', async () => {
|
||||
const messageId1 = 'message-1';
|
||||
const messageId2 = 'message-2';
|
||||
const messageId3 = 'message-3';
|
||||
|
||||
const messages = [
|
||||
{ id: messageId1, role: 'user', content: 'Message 1', metadata: {} },
|
||||
{ id: messageId2, role: 'assistant', content: 'Message 2', metadata: {} },
|
||||
{ id: messageId3, role: 'user', content: 'Message 3', metadata: { collapsed: true } },
|
||||
] as UIChatMessage[];
|
||||
|
||||
const key = messageMapKey('session-id', 'topic-id');
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeId: 'session-id',
|
||||
activeTopicId: 'topic-id',
|
||||
messagesMap: {
|
||||
[key]: messages,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
// Enter selection mode
|
||||
await act(async () => {
|
||||
result.current.toggleMessageSelectionMode(true);
|
||||
});
|
||||
|
||||
// Update messages map to reflect collapsed state
|
||||
const collapsedMessages = [
|
||||
{ id: messageId1, role: 'user', content: 'Message 1', metadata: { collapsed: true } },
|
||||
{ id: messageId2, role: 'assistant', content: 'Message 2', metadata: { collapsed: true } },
|
||||
{ id: messageId3, role: 'user', content: 'Message 3', metadata: { collapsed: true } },
|
||||
] as UIChatMessage[];
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
messagesMap: {
|
||||
[key]: collapsedMessages,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const toggleMessageCollapsedSpy = vi.spyOn(result.current, 'toggleMessageCollapsed');
|
||||
|
||||
// Clear selection
|
||||
await act(async () => {
|
||||
result.current.clearMessageSelection();
|
||||
});
|
||||
|
||||
// Should expand message 1 and 2 (they were not originally collapsed)
|
||||
expect(toggleMessageCollapsedSpy).toHaveBeenCalledWith(messageId1, false);
|
||||
expect(toggleMessageCollapsedSpy).toHaveBeenCalledWith(messageId2, false);
|
||||
// Should NOT expand message 3 (it was originally collapsed)
|
||||
expect(toggleMessageCollapsedSpy).not.toHaveBeenCalledWith(messageId3, false);
|
||||
expect(result.current.isMessageSelectionMode).toBe(false);
|
||||
expect(result.current.messageSelectionIds).toEqual([]);
|
||||
expect(result.current.messageOriginallyCollapsedIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Action, setNamespace } from '@/utils/storeDebug';
|
||||
|
||||
import type { ChatStoreState } from '../../../initialState';
|
||||
import { preventLeavingFn, toggleBooleanList } from '../../../utils';
|
||||
import { displayMessageSelectors } from '../selectors/displayMessage';
|
||||
|
||||
const n = setNamespace('m');
|
||||
|
||||
@@ -13,6 +14,11 @@ const n = setNamespace('m');
|
||||
* Handles loading states, active session tracking, etc.
|
||||
*/
|
||||
export interface MessageRuntimeStateAction {
|
||||
/**
|
||||
* Clear message selection
|
||||
*/
|
||||
clearMessageSelection: () => void;
|
||||
|
||||
/**
|
||||
* helper to toggle the loading state of the array,used by these three toggleXXXLoading
|
||||
*/
|
||||
@@ -39,6 +45,14 @@ export interface MessageRuntimeStateAction {
|
||||
* Update active session type
|
||||
*/
|
||||
internal_updateActiveSessionType: (sessionType?: 'agent' | 'group') => void;
|
||||
/**
|
||||
* Toggle message selection mode
|
||||
*/
|
||||
toggleMessageSelectionMode: (enabled: boolean) => void;
|
||||
/**
|
||||
* Update message selection
|
||||
*/
|
||||
updateMessageSelection: (id: string, selected: boolean) => void;
|
||||
}
|
||||
|
||||
export const messageRuntimeState: StateCreator<
|
||||
@@ -47,6 +61,26 @@ export const messageRuntimeState: StateCreator<
|
||||
[],
|
||||
MessageRuntimeStateAction
|
||||
> = (set, get) => ({
|
||||
clearMessageSelection: () => {
|
||||
// Restore original collapse state when clearing selection
|
||||
const messages = displayMessageSelectors.activeDisplayMessages(get());
|
||||
const originallyCollapsedIds = get().messageOriginallyCollapsedIds;
|
||||
|
||||
// Expand all messages that were not originally collapsed
|
||||
messages.forEach((message) => {
|
||||
const wasOriginallyCollapsed = originallyCollapsedIds.includes(message.id);
|
||||
if (!wasOriginallyCollapsed && message.metadata?.collapsed) {
|
||||
get().toggleMessageCollapsed(message.id, false);
|
||||
}
|
||||
});
|
||||
|
||||
set(
|
||||
{ isMessageSelectionMode: false, messageOriginallyCollapsedIds: [], messageSelectionIds: [] },
|
||||
false,
|
||||
n('clearMessageSelection'),
|
||||
);
|
||||
},
|
||||
|
||||
internal_toggleLoadingArrays: (key, loading, id, action) => {
|
||||
const abortControllerKey = `${key}AbortController`;
|
||||
if (loading) {
|
||||
@@ -96,6 +130,7 @@ export const messageRuntimeState: StateCreator<
|
||||
|
||||
// Before switching sessions, cancel all pending supervisor decisions
|
||||
get().internal_cancelAllSupervisorDecisions();
|
||||
get().clearMessageSelection();
|
||||
|
||||
set({ activeId }, false, n(`updateActiveId/${activeId}`));
|
||||
},
|
||||
@@ -105,4 +140,60 @@ export const messageRuntimeState: StateCreator<
|
||||
|
||||
set({ activeSessionType: sessionType }, false, n('updateActiveSessionType'));
|
||||
},
|
||||
|
||||
toggleMessageSelectionMode: (enabled) => {
|
||||
if (enabled) {
|
||||
// When entering selection mode, track originally collapsed messages and collapse all
|
||||
const messages = displayMessageSelectors.activeDisplayMessages(get());
|
||||
const originallyCollapsedIds: string[] = [];
|
||||
|
||||
// Identify which messages are already collapsed
|
||||
messages.forEach((message) => {
|
||||
if (message.metadata?.collapsed) {
|
||||
originallyCollapsedIds.push(message.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Collapse all messages that are not already collapsed
|
||||
messages.forEach((message) => {
|
||||
if (!message.metadata?.collapsed) {
|
||||
get().toggleMessageCollapsed(message.id, true);
|
||||
}
|
||||
});
|
||||
|
||||
set(
|
||||
{ isMessageSelectionMode: true, messageOriginallyCollapsedIds: originallyCollapsedIds },
|
||||
false,
|
||||
n('toggleMessageSelectionMode/enter'),
|
||||
);
|
||||
} else {
|
||||
// When exiting selection mode, restore original collapse state
|
||||
const messages = displayMessageSelectors.activeDisplayMessages(get());
|
||||
const originallyCollapsedIds = get().messageOriginallyCollapsedIds;
|
||||
|
||||
// Expand all messages that were not originally collapsed
|
||||
messages.forEach((message) => {
|
||||
const wasOriginallyCollapsed = originallyCollapsedIds.includes(message.id);
|
||||
if (!wasOriginallyCollapsed && message.metadata?.collapsed) {
|
||||
get().toggleMessageCollapsed(message.id, false);
|
||||
}
|
||||
});
|
||||
|
||||
set(
|
||||
{ isMessageSelectionMode: false, messageOriginallyCollapsedIds: [] },
|
||||
false,
|
||||
n('toggleMessageSelectionMode/exit'),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
updateMessageSelection: (id, selected) => {
|
||||
set(
|
||||
{
|
||||
messageSelectionIds: toggleBooleanList(get().messageSelectionIds, id, selected),
|
||||
},
|
||||
false,
|
||||
n('updateMessageSelection'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface ChatMessageState {
|
||||
*/
|
||||
groupsInit: boolean;
|
||||
isCreatingMessage: boolean;
|
||||
/**
|
||||
* Whether the message selection mode is enabled
|
||||
*/
|
||||
isMessageSelectionMode: boolean;
|
||||
/**
|
||||
* is the message is editing
|
||||
*/
|
||||
@@ -40,6 +44,15 @@ export interface ChatMessageState {
|
||||
* is the message is creating or updating in the service
|
||||
*/
|
||||
messageLoadingIds: string[];
|
||||
/**
|
||||
* Message IDs that were originally collapsed before entering selection mode
|
||||
* Used to restore collapse state when exiting selection mode
|
||||
*/
|
||||
messageOriginallyCollapsedIds: string[];
|
||||
/**
|
||||
* Selected message IDs
|
||||
*/
|
||||
messageSelectionIds: string[];
|
||||
/**
|
||||
* whether messages have fetched
|
||||
*/
|
||||
@@ -74,8 +87,11 @@ export const initialMessageState: ChatMessageState = {
|
||||
groupMaps: {},
|
||||
groupsInit: false,
|
||||
isCreatingMessage: false,
|
||||
isMessageSelectionMode: false,
|
||||
messageEditingIds: [],
|
||||
messageLoadingIds: [],
|
||||
messageOriginallyCollapsedIds: [],
|
||||
messageSelectionIds: [],
|
||||
messagesInit: false,
|
||||
messagesMap: {},
|
||||
supervisorDebounceTimers: {},
|
||||
|
||||
Reference in New Issue
Block a user