Compare commits

...

1 Commits

Author SHA1 Message Date
AmAzing- 32f135a5e5 Fix message edit send behavior 2026-04-23 13:20:20 +08:00
11 changed files with 534 additions and 31 deletions
@@ -0,0 +1,223 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MessageContent from './index';
const mockEditorModal = vi.fn();
interface MockMessage {
content: string;
createdAt: number;
id: string;
role: 'assistant' | 'user';
updatedAt: number;
}
interface MockConversationState {
deleteMessage: ReturnType<typeof vi.fn>;
displayMessages: MockMessage[];
processingMessageIds: string[];
regenerateUserMessage: ReturnType<typeof vi.fn>;
toggleMessageEditing: ReturnType<typeof vi.fn>;
updateMessageContent: ReturnType<typeof vi.fn>;
}
const conversationState = {
deleteMessage: vi.fn(),
displayMessages: [] as MockMessage[],
processingMessageIds: [] as string[],
regenerateUserMessage: vi.fn(),
toggleMessageEditing: vi.fn(),
updateMessageContent: vi.fn(),
} satisfies MockConversationState;
vi.mock('@/features/EditorModal', () => ({
EditorModal: (
props: {
onConfirm?: (value: string, editorData?: unknown) => Promise<void>;
} & Record<string, unknown>,
) => {
mockEditorModal(props);
return (
<button type="button" onClick={() => props.onConfirm?.('updated content', { root: {} })}>
confirm edit
</button>
);
},
}));
vi.mock('@/features/Conversation/store', () => ({
dataSelectors: {
getDisplayMessageById:
(id: string) =>
(state: MockConversationState): MockMessage | undefined =>
state.displayMessages.find((message) => message.id === id),
isLatestUserMessage:
(id: string) =>
(state: MockConversationState): boolean => {
for (let index = state.displayMessages.length - 1; index >= 0; index -= 1) {
const message = state.displayMessages[index];
if (message.role === 'user') return message.id === id;
}
return false;
},
},
messageStateSelectors: {
isMessageProcessing:
(id: string) =>
(state: MockConversationState): boolean =>
state.processingMessageIds.includes(id),
},
useConversationStore: (selector: (state: typeof conversationState) => unknown) =>
selector(conversationState),
useConversationStoreApi: () => ({
getState: () => conversationState,
}),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'save') return 'Save';
if (key === 'send') return 'Send';
if (key === 'ok') return 'OK';
if (key === 'cancel') return 'Cancel';
return key;
},
}),
}));
describe('Conversation MessageContent', () => {
beforeEach(() => {
mockEditorModal.mockClear();
conversationState.processingMessageIds = [];
});
it('uses Send and regenerates when editing the latest user message', async () => {
conversationState.deleteMessage = vi.fn().mockResolvedValue(undefined);
conversationState.regenerateUserMessage = vi.fn().mockResolvedValue(undefined);
conversationState.toggleMessageEditing = vi.fn();
conversationState.updateMessageContent = vi.fn().mockResolvedValue(undefined);
conversationState.displayMessages = [
{ content: 'older user', createdAt: 1, id: 'user-1', role: 'user', updatedAt: 1 },
{
content: 'older assistant',
createdAt: 2,
id: 'assistant-1',
role: 'assistant',
updatedAt: 2,
},
{ content: 'latest user', createdAt: 3, id: 'user-2', role: 'user', updatedAt: 3 },
];
render(<MessageContent editing id={'user-2'} message={'latest user'} />);
await waitFor(() =>
expect(mockEditorModal).toHaveBeenLastCalledWith(
expect.objectContaining({ okText: 'Send', open: true }),
),
);
fireEvent.click(await screen.findByRole('button', { name: 'confirm edit' }));
await waitFor(() => {
expect(conversationState.updateMessageContent).toHaveBeenCalledWith(
'user-2',
'updated content',
{
editorData: { root: {} },
},
);
expect(conversationState.toggleMessageEditing).toHaveBeenCalledWith('user-2', false);
expect(conversationState.regenerateUserMessage).toHaveBeenCalledWith('user-2');
});
expect(conversationState.deleteMessage).not.toHaveBeenCalled();
});
it('falls back to Save when the latest user message is still processing', async () => {
conversationState.deleteMessage = vi.fn().mockResolvedValue(undefined);
conversationState.regenerateUserMessage = vi.fn().mockResolvedValue(undefined);
conversationState.toggleMessageEditing = vi.fn();
conversationState.updateMessageContent = vi.fn().mockResolvedValue(undefined);
conversationState.processingMessageIds = ['user-2'];
conversationState.displayMessages = [
{ content: 'older user', createdAt: 1, id: 'user-1', role: 'user', updatedAt: 1 },
{
content: 'older assistant',
createdAt: 2,
id: 'assistant-1',
role: 'assistant',
updatedAt: 2,
},
{ content: 'latest user', createdAt: 3, id: 'user-2', role: 'user', updatedAt: 3 },
];
render(<MessageContent editing id={'user-2'} message={'latest user'} />);
await waitFor(() =>
expect(mockEditorModal).toHaveBeenLastCalledWith(
expect.objectContaining({ okText: 'Save', open: true }),
),
);
fireEvent.click(await screen.findByRole('button', { name: 'confirm edit' }));
await waitFor(() => {
expect(conversationState.updateMessageContent).toHaveBeenCalledWith(
'user-2',
'updated content',
{
editorData: { root: {} },
},
);
expect(conversationState.toggleMessageEditing).toHaveBeenCalledWith('user-2', false);
});
expect(conversationState.regenerateUserMessage).not.toHaveBeenCalled();
});
it('keeps Save behavior for non-latest user messages', async () => {
conversationState.deleteMessage = vi.fn().mockResolvedValue(undefined);
conversationState.regenerateUserMessage = vi.fn().mockResolvedValue(undefined);
conversationState.toggleMessageEditing = vi.fn();
conversationState.updateMessageContent = vi.fn().mockResolvedValue(undefined);
conversationState.displayMessages = [
{ content: 'older user', createdAt: 1, id: 'user-1', role: 'user', updatedAt: 1 },
{
content: 'assistant reply',
createdAt: 2,
id: 'assistant-1',
role: 'assistant',
updatedAt: 2,
},
{ content: 'latest user', createdAt: 3, id: 'user-2', role: 'user', updatedAt: 3 },
];
render(<MessageContent editing id={'user-1'} message={'older user'} />);
await waitFor(() =>
expect(mockEditorModal).toHaveBeenLastCalledWith(
expect.objectContaining({ okText: 'Save', open: true }),
),
);
fireEvent.click(await screen.findByRole('button', { name: 'confirm edit' }));
await waitFor(() => {
expect(conversationState.updateMessageContent).toHaveBeenCalledWith(
'user-1',
'updated content',
{
editorData: { root: {} },
},
);
expect(conversationState.toggleMessageEditing).toHaveBeenCalledWith('user-1', false);
});
expect(conversationState.regenerateUserMessage).not.toHaveBeenCalled();
});
});
@@ -2,8 +2,14 @@ import { Flexbox } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { type ReactNode } from 'react';
import { memo, Suspense, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { dataSelectors, useConversationStore } from '@/features/Conversation/store';
import {
dataSelectors,
messageStateSelectors,
useConversationStore,
useConversationStoreApi,
} from '@/features/Conversation/store';
import dynamic from '@/libs/next/dynamic';
import { type ChatItemProps } from '../../type';
@@ -59,20 +65,62 @@ const MessageContent = memo<MessageContentProps>(
className,
variant,
}) => {
const [toggleMessageEditing, updateMessageContent] = useConversationStore((s) => [
s.toggleMessageEditing,
s.updateMessageContent,
]);
const { t } = useTranslation('common');
const storeApi = useConversationStoreApi();
const [deleteMessage, regenerateUserMessage, toggleMessageEditing, updateMessageContent] =
useConversationStore((s) => [
s.deleteMessage,
s.regenerateUserMessage,
s.toggleMessageEditing,
s.updateMessageContent,
]);
const editorData = useConversationStore(
(s) => dataSelectors.getDisplayMessageById(id)(s)?.editorData,
);
const hasMessageError = useConversationStore(
(s) => !!dataSelectors.getDisplayMessageById(id)(s)?.error,
);
const isLatestUserMessage = useConversationStore(dataSelectors.isLatestUserMessage(id));
const isMessageProcessing = useConversationStore(messageStateSelectors.isMessageProcessing(id));
const onEditingChange = useCallback(
(edit: boolean) => toggleMessageEditing(id, edit),
[id, toggleMessageEditing],
);
const handleConfirm = useCallback(
async (value: string, newEditorData?: unknown) => {
await updateMessageContent(id, value, {
editorData: newEditorData as Record<string, any> | undefined,
});
onEditingChange(false);
const currentState = storeApi.getState();
const shouldSendEditedMessage =
dataSelectors.isLatestUserMessage(id)(currentState) &&
!messageStateSelectors.isMessageProcessing(id)(currentState);
if (!shouldSendEditedMessage) return;
const regeneratePromise = regenerateUserMessage(id);
if (hasMessageError) await deleteMessage(id);
await regeneratePromise;
},
[
deleteMessage,
hasMessageError,
id,
onEditingChange,
regenerateUserMessage,
storeApi,
updateMessageContent,
],
);
return (
<>
<Flexbox
@@ -93,15 +141,11 @@ const MessageContent = memo<MessageContentProps>(
{editing && (
<EditorModal
editorData={editorData}
okText={isLatestUserMessage && !isMessageProcessing ? t('send') : t('save')}
open={editing}
value={message ? String(message) : ''}
onCancel={() => onEditingChange(false)}
onConfirm={async (value, newEditorData) => {
await updateMessageContent(id, value, {
editorData: newEditorData as Record<string, any> | undefined,
});
onEditingChange(false);
}}
onConfirm={handleConfirm}
/>
)}
</Suspense>
@@ -0,0 +1,66 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const conversationState = {
isProcessing: false,
toggleMessageEditing: vi.fn(),
};
vi.mock('../../../../store', () => ({
messageStateSelectors: {
isMessageProcessing:
(_id: string) =>
(state: typeof conversationState): boolean =>
state.isProcessing,
},
useConversationStore: (selector: (state: typeof conversationState) => unknown) =>
selector(conversationState),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const { editAction } = await import('./edit');
describe('editAction', () => {
it('disables editing while the message is processing', () => {
conversationState.isProcessing = true;
conversationState.toggleMessageEditing = vi.fn();
const { result } = renderHook(() =>
editAction.useBuild({
data: { id: 'msg-1' } as any,
id: 'msg-1',
role: 'user',
}),
);
expect(result.current?.disabled).toBe(true);
result.current?.handleClick?.();
expect(conversationState.toggleMessageEditing).not.toHaveBeenCalled();
});
it('opens editing when the message is idle', () => {
conversationState.isProcessing = false;
conversationState.toggleMessageEditing = vi.fn();
const { result } = renderHook(() =>
editAction.useBuild({
data: { id: 'msg-1' } as any,
id: 'msg-1',
role: 'user',
}),
);
expect(result.current?.disabled).toBe(false);
result.current?.handleClick?.();
expect(conversationState.toggleMessageEditing).toHaveBeenCalledWith('msg-1', true);
});
});
@@ -2,7 +2,7 @@ import { Edit } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useConversationStore } from '../../../../store';
import { messageStateSelectors, useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const editAction = defineAction({
@@ -10,20 +10,22 @@ export const editAction = defineAction({
useBuild: (ctx) => {
const { t } = useTranslation('common');
const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing);
const targetId = ctx.role === 'group' ? ctx.contentBlock?.id : ctx.id;
const isMessageProcessing = useConversationStore(
messageStateSelectors.isMessageProcessing(targetId || ''),
);
return useMemo(() => {
// group edits the inner content block; other roles edit the message itself
const targetId = ctx.role === 'group' ? ctx.contentBlock?.id : ctx.id;
return {
disabled: !targetId || isMessageProcessing,
handleClick: () => {
if (!targetId) return;
if (!targetId || isMessageProcessing) return;
toggleMessageEditing(targetId, true);
},
icon: Edit,
key: 'edit',
label: t('edit'),
};
}, [t, ctx.role, ctx.id, ctx.contentBlock?.id, toggleMessageEditing]);
}, [isMessageProcessing, t, targetId, toggleMessageEditing]);
},
});
@@ -59,21 +59,25 @@ export const useChatItemContextMenu = ({
const storeApi = useConversationStoreApi();
const [role, error, isCollapsed, hasThread, isRegenerating] = useConversationStore((s) => {
const item = dataSelectors.getDisplayMessageById(id)(s);
return [
item?.role,
item?.error,
messageStateSelectors.isMessageCollapsed(id)(s),
messageStateSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
];
}, isEqual);
const [role, error, isCollapsed, hasThread, isProcessing, isRegenerating] = useConversationStore(
(s) => {
const item = dataSelectors.getDisplayMessageById(id)(s);
return [
item?.role,
item?.error,
messageStateSelectors.isMessageCollapsed(id)(s),
messageStateSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageProcessing(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
];
},
isEqual,
);
const isThreadMode = useConversationStore(messageStateSelectors.isThreadMode);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const actionsBar = useChatListActionsBar({ hasThread, isRegenerating });
const actionsBar = useChatListActionsBar({ hasThread, isProcessing, isRegenerating });
const inThread = isThreadMode || inPortalThread;
const [
@@ -218,6 +222,7 @@ export const useChatItemContextMenu = ({
switch (action.key) {
case 'edit': {
if (isProcessing) break;
toggleMessageEditing(id, true);
break;
}
@@ -287,6 +292,7 @@ export const useChatItemContextMenu = ({
handleShare,
id,
inPortalThread,
isProcessing,
message,
openThreadCreator,
regenerateAssistantMessage,
@@ -0,0 +1,24 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const { useChatListActionsBar } = await import('./useChatListActionsBar');
describe('useChatListActionsBar', () => {
it('disables the edit action while the message is processing', () => {
const { result } = renderHook(() => useChatListActionsBar({ isProcessing: true }));
expect(result.current.edit.disabled).toBe(true);
});
it('keeps the edit action enabled when the message is idle', () => {
const { result } = renderHook(() => useChatListActionsBar({ isProcessing: false }));
expect(result.current.edit.disabled).toBe(false);
});
});
@@ -47,10 +47,12 @@ interface ChatListActionsBar {
export const useChatListActionsBar = ({
hasThread,
isContinuing,
isProcessing,
isRegenerating,
}: {
hasThread?: boolean;
isContinuing?: boolean;
isProcessing?: boolean;
isRegenerating?: boolean;
} = {}): ChatListActionsBar => {
const { t } = useTranslation(['common', 'chat']);
@@ -98,6 +100,7 @@ export const useChatListActionsBar = ({
type: 'divider',
},
edit: {
disabled: isProcessing,
icon: Edit,
key: 'edit',
label: t('edit'),
@@ -140,6 +143,6 @@ export const useChatListActionsBar = ({
label: t('tts.action', { ns: 'chat' }),
},
}),
[hasThread, isContinuing, isRegenerating],
[hasThread, isContinuing, isProcessing, isRegenerating],
);
};
@@ -0,0 +1,60 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const conversationState = {
isProcessing: false,
toggleMessageEditing: vi.fn(),
};
vi.mock('../store', () => ({
messageStateSelectors: {
isMessageProcessing:
(_id: string) =>
(state: typeof conversationState): boolean =>
state.isProcessing,
},
useConversationStore: (selector: (state: typeof conversationState) => unknown) =>
selector(conversationState),
}));
const { useDoubleClickEdit } = await import('./useDoubleClickEdit');
describe('useDoubleClickEdit', () => {
it('does not open editing when the message is still processing', () => {
conversationState.isProcessing = true;
conversationState.toggleMessageEditing = vi.fn();
const { result } = renderHook(() =>
useDoubleClickEdit({
error: undefined,
id: 'msg-1',
role: 'user',
}),
);
result.current({
altKey: true,
} as unknown as React.MouseEvent<HTMLDivElement>);
expect(conversationState.toggleMessageEditing).not.toHaveBeenCalled();
});
it('opens editing when the message is idle and the shortcut is used', () => {
conversationState.isProcessing = false;
conversationState.toggleMessageEditing = vi.fn();
const { result } = renderHook(() =>
useDoubleClickEdit({
error: undefined,
id: 'msg-1',
role: 'user',
}),
);
result.current({
altKey: true,
} as unknown as React.MouseEvent<HTMLDivElement>);
expect(conversationState.toggleMessageEditing).toHaveBeenCalledWith('msg-1', true);
});
});
@@ -1,7 +1,7 @@
import { type MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { useConversationStore } from '../store';
import { messageStateSelectors, useConversationStore } from '../store';
interface UseDoubleClickEditProps {
disableEditing?: boolean;
@@ -17,6 +17,7 @@ export const useDoubleClickEdit = ({
id,
}: UseDoubleClickEditProps) => {
const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing);
const isMessageProcessing = useConversationStore(messageStateSelectors.isMessageProcessing(id));
return useCallback<MouseEventHandler<HTMLDivElement>>(
(e) => {
@@ -24,6 +25,7 @@ export const useDoubleClickEdit = ({
disableEditing ||
error ||
id === 'default' ||
isMessageProcessing ||
!e.altKey ||
!['assistant', 'user'].includes(role)
)
@@ -31,6 +33,6 @@ export const useDoubleClickEdit = ({
toggleMessageEditing(id, true);
},
[role, disableEditing, toggleMessageEditing, id],
[role, disableEditing, error, id, isMessageProcessing, toggleMessageEditing],
);
};
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/store/chat', () => ({
useChatStore: {
getState: vi.fn(() => ({})),
},
}));
vi.mock('@/store/chat/selectors', () => ({
topicSelectors: {
currentActiveTopicSummary: vi.fn(() => undefined),
},
}));
const { dataSelectors } = await import('./selectors');
type SelectorState = Parameters<ReturnType<typeof dataSelectors.isLatestUserMessage>>[0];
describe('dataSelectors.isLatestUserMessage', () => {
it('returns true for the latest user message even if assistant messages follow it', () => {
const state = {
displayMessages: [
{ content: 'first user', createdAt: 1, id: 'user-1', role: 'user', updatedAt: 1 },
{
content: 'assistant reply',
createdAt: 2,
id: 'assistant-1',
role: 'assistant',
updatedAt: 2,
},
{ content: 'latest user', createdAt: 3, id: 'user-2', role: 'user', updatedAt: 3 },
{
content: 'latest assistant',
createdAt: 4,
id: 'assistant-2',
role: 'assistant',
updatedAt: 4,
},
],
} as unknown as SelectorState;
expect(dataSelectors.isLatestUserMessage('user-2')(state)).toBe(true);
});
it('returns false for older user messages and non-user messages', () => {
const state = {
displayMessages: [
{ content: 'first user', createdAt: 1, id: 'user-1', role: 'user', updatedAt: 1 },
{
content: 'assistant reply',
createdAt: 2,
id: 'assistant-1',
role: 'assistant',
updatedAt: 2,
},
{ content: 'latest user', createdAt: 3, id: 'user-2', role: 'user', updatedAt: 3 },
],
} as unknown as SelectorState;
expect(dataSelectors.isLatestUserMessage('user-1')(state)).toBe(false);
expect(dataSelectors.isLatestUserMessage('assistant-1')(state)).toBe(false);
});
});
@@ -30,6 +30,15 @@ const getDisplayMessageById = (id: string) => (s: State) => {
const getDbMessageById = (id: string) => (s: State) => s.dbMessages.find((m) => m.id === id);
const getDbMessageByToolCallId = (id: string) => (s: State) =>
s.dbMessages.find((m) => m.tool_call_id === id);
const isLatestUserMessage = (id: string) => (s: State) => {
for (let index = s.displayMessages.length - 1; index >= 0; index -= 1) {
const message = s.displayMessages[index];
if (message.role === 'user') return message.id === id;
}
return false;
};
/**
* Helper to find last message ID in an AssistantContentBlock
@@ -132,6 +141,7 @@ export const dataSelectors = {
getDbMessageByToolCallId,
getDisplayMessageById,
getGroupLatestMessageWithoutTools,
isLatestUserMessage,
messagesInit,
pendingInterventions,
skipFetch,