mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32f135a5e5 |
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user