Compare commits

...

3 Commits

Author SHA1 Message Date
Rene Wang a249da25bb feat: Auto collapse message when enter select mode 2025-11-21 11:54:07 +08:00
Rene Wang 05cedabdcc feat: select message to share 2025-11-21 11:54:07 +08:00
Rene Wang 374b3188e9 feat: select message to share 2025-11-21 11:54:06 +08:00
12 changed files with 438 additions and 37 deletions
@@ -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'}
+43 -8
View File
@@ -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>
);
+7 -1
View File
@@ -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');
+2
View File
@@ -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: {},