diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 19d4619bad..58d111a32a 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -591,7 +591,8 @@ class BaseClient { } else if (editedContent != null) { // Handle editedContent for content parts if (editedContent && latestMessage.content && Array.isArray(latestMessage.content)) { - const { index, text, type } = editedContent; + const { index, type } = editedContent; + const text = editedContent[type]; if (index >= 0 && index < latestMessage.content.length) { const contentPart = latestMessage.content[index]; if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 77848851a2..70f0a91c17 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -691,6 +691,43 @@ describe('BaseClient', () => { ); }); + it('applies edited reasoning content from its typed payload before regeneration', async () => { + const responseMessageId = 'response-with-reasoning'; + const newHistory = [ + ...messageHistory, + { + role: 'assistant', + isCreatedByUser: false, + messageId: responseMessageId, + parentMessageId: '3', + content: [ + { type: ContentTypes.THINK, think: 'Original reasoning', phase: 'analysis' }, + { type: ContentTypes.TEXT, text: 'Original response' }, + ], + }, + ]; + + TestClient = initializeFakeClient(apiKey, options, newHistory); + await TestClient.sendMessage('test message', { + isEdited: true, + overrideParentMessageId: 'user-message-id', + parentMessageId: '3', + responseMessageId, + editedContent: { + index: 0, + type: ContentTypes.THINK, + [ContentTypes.THINK]: 'Updated reasoning', + }, + }); + + const editedResponse = TestClient.currentMessages[TestClient.currentMessages.length - 1]; + expect(editedResponse.content[0]).toEqual({ + type: ContentTypes.THINK, + think: 'Updated reasoning', + phase: 'analysis', + }); + }); + test('setOptions is called with the correct arguments only when replaceOptions is set to true', async () => { TestClient.setOptions = jest.fn(); const opts = { conversationId: '123', parentMessageId: '456', replaceOptions: true }; diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js new file mode 100644 index 0000000000..5851c2a781 --- /dev/null +++ b/api/server/routes/__tests__/messages-content-edit.spec.js @@ -0,0 +1,144 @@ +const express = require('express'); +const request = require('supertest'); +const { ContentTypes } = require('librechat-data-provider'); + +jest.mock('@librechat/agents', () => ({ + sleep: jest.fn(), +})); + +jest.mock('@librechat/api', () => ({ + unescapeLaTeX: jest.fn((value) => value), + countTokens: jest.fn().mockResolvedValue(2), + sendFeedbackScore: jest.fn().mockResolvedValue(undefined), + traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`), + mergeQuotedTextForCount: jest.fn((text) => text), +})); + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('~/models', () => ({ + getMessages: jest.fn(), + updateMessage: jest.fn(), +})); + +jest.mock('~/server/services/Artifacts/update', () => ({ + findAllArtifacts: jest.fn(), + replaceArtifactContent: jest.fn(), +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (req, res, next) => next(), + validateMessageReq: (req, res, next) => next(), + configMiddleware: (req, res, next) => next(), + sendValidationResponse: jest.fn(), + prepareMessageRequestValidation: jest.fn(), +})); + +describe('PUT /:conversationId/:messageId content edit', () => { + let app; + const { getMessages, updateMessage } = require('~/models'); + + beforeAll(() => { + const messagesRouter = require('../messages'); + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { id: 'user-1' }; + next(); + }); + app.use('/api/messages', messagesRouter); + }); + + beforeEach(() => { + jest.clearAllMocks(); + updateMessage.mockResolvedValue({ messageId: 'message-1' }); + }); + + it('preserves content-part metadata when editing its text', async () => { + getMessages.mockResolvedValue([ + { + tokenCount: 10, + content: [ + { + type: ContentTypes.TEXT, + text: 'Original response', + phase: 'commentary', + agentId: 'agent-1', + tool_call_ids: ['tool-1'], + }, + ], + }, + ]); + + const response = await request(app) + .put('/api/messages/conversation-1/message-1') + .send({ index: 0, text: 'Edited response', model: 'gpt-5' }); + + expect(response.status).toBe(200); + expect(updateMessage).toHaveBeenCalledWith('user-1', { + messageId: 'message-1', + tokenCount: 10, + content: [ + { + type: ContentTypes.TEXT, + text: 'Edited response', + phase: 'commentary', + agentId: 'agent-1', + tool_call_ids: ['tool-1'], + }, + ], + }); + }); + + /** + * A text part is `string | { value, annotations }`. The Assistants thread sync + * persists the structured form with its file citations intact and the editor reads + * it through the same union, so writing the edit straight over the object dropped + * every citation. Counting the object rather than its value is the same mistake + * read back: the tokenizer measures `text.length`, which an object does not have, + * so the stored count became NaN. + */ + it('edits inside a structured text part instead of flattening it', async () => { + const { countTokens } = require('@librechat/api'); + const annotations = [ + { type: 'file_citation', text: 'source', file_citation: { file_id: 'file-1' } }, + ]; + + getMessages.mockResolvedValue([ + { + tokenCount: 10, + content: [ + { + type: ContentTypes.TEXT, + text: { value: 'Original response', annotations }, + }, + ], + }, + ]); + + const response = await request(app) + .put('/api/messages/conversation-1/message-1') + .send({ index: 0, text: 'Edited response', model: 'gpt-5' }); + + expect(response.status).toBe(200); + expect(updateMessage).toHaveBeenCalledWith('user-1', { + messageId: 'message-1', + tokenCount: 10, + content: [ + { + type: ContentTypes.TEXT, + text: { value: 'Edited response', annotations }, + }, + ], + }); + expect(countTokens).toHaveBeenCalledWith('Original response', 'gpt-5'); + }); +}); diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 0425b40eaa..0894e9bd1d 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -409,8 +409,19 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) = return res.status(400).json({ error: 'Cannot update non-text content' }); } - const oldText = updatedContent[index][currentPartType]; - updatedContent[index] = { type: currentPartType, [currentPartType]: text }; + /** A text part is `string | { value, annotations }`. The Assistants thread sync + * persists the structured form with its file citations intact, and the editor + * reads it through the same union, so an edit has to be written into `value` + * rather than over the whole part. The same object is what gets counted below, + * and the tokenizer measures `length`, which an object does not have. */ + const currentPart = updatedContent[index]; + const currentValue = currentPart[currentPartType]; + const isStructuredValue = currentValue != null && typeof currentValue === 'object'; + const oldText = isStructuredValue ? (currentValue.value ?? '') : currentValue; + updatedContent[index] = { + ...currentPart, + [currentPartType]: isStructuredValue ? { ...currentValue, value: text } : text, + }; let tokenCount = message.tokenCount; if (tokenCount !== undefined) { diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 07d7544de9..5fae782858 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -352,7 +352,7 @@ export type TOptions = { isContinued?: boolean; isEdited?: boolean; overrideMessages?: t.TMessage[]; - /** This value is only true when the user submits a message with "Save & Submit" for a user-created message */ + /** This value is only true when the user submits a message with "Update & rerun" for a user-created message */ isResubmission?: boolean; /** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */ overrideFiles?: t.TMessage['files']; diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 870e6d6cd2..7656949a65 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -11,10 +11,11 @@ import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils'; import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; -import { EditTextPart, EmptyText, AgentUpdate } from './Parts'; import { MessageContext, SearchContext } from '~/Providers'; import PendingSkillCall from './Parts/PendingSkillCall'; import ActivityPhaseGroup from './ActivityPhaseGroup'; +import EditContentParts from './EditContentParts'; +import { EmptyText, AgentUpdate } from './Parts'; import ApprovalProvider from './ApprovalContext'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; @@ -430,45 +431,24 @@ const ContentParts = memo(function ContentParts({ return null; } - // Edit mode: render editable text parts. Interim skill cards are a - // mid-stream concern, not relevant in edit mode. + // Interim skill cards are a mid-stream concern, not relevant in edit mode. if (edit === true && enterEdit && setSiblingIdx) { return ( - <> - {(content ?? []).map((part, localIdx) => { - if (!part) { - return null; - } - const idx = absoluteIndexAt(localIdx); - const isTextPart = - part?.type === ContentTypes.TEXT || - typeof (part as unknown as Agents.MessageContentText)?.text === 'string'; - const isThinkPart = - part?.type === ContentTypes.THINK || - typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think === 'string'; - if (!isTextPart && !isThinkPart) { - return null; - } - - const isToolCall = part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null; - if (isToolCall) { - return null; - } - - return ( - - ); - })} - + + + + renderPart(part, idx, isLastPart)} + /> + + ); } diff --git a/client/src/components/Chat/Messages/Content/EditContentParts.tsx b/client/src/components/Chat/Messages/Content/EditContentParts.tsx new file mode 100644 index 0000000000..fd2129aae6 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/EditContentParts.tsx @@ -0,0 +1,444 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ContentTypes } from 'librechat-data-provider'; +import { Alert, Button, TextareaAutosize } from '@librechat/client'; +import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query'; +import type { TMessageContentParts, TextData } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { useMessagesConversation, useMessagesOperations } from '~/Providers'; +import { splitMarkdownIntoBlocks } from './splitMarkdown'; +import { useGetAddedConvo } from '~/hooks/Chat'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import store from '~/store'; + +type EditableType = ContentTypes.TEXT | ContentTypes.THINK; + +type EditablePart = { + index: number; + localIndex: number; + type: EditableType; + original: string; +}; + +type EditContentPartsProps = { + content: Array; + contentIndexOffset?: number; + messageId: string; + isSubmitting: boolean; + enterEdit: (cancel?: boolean) => void | null | undefined; + siblingIdx: number | null; + setSiblingIdx: (value: number) => void; + renderReadOnlyPart: (part: TMessageContentParts, index: number, isLastPart: boolean) => ReactNode; +}; + +/** An editable part holds either a bare string or a `{ value, annotations }` object, + * which is how the Assistants thread sync stores a response that carries file + * citations. Both the read and the write below go through this, so an edit lands in + * the same shape it was read from. */ +const getPartValue = (part: TMessageContentParts): string | TextData => { + if (part.type === ContentTypes.TEXT) { + return part.text; + } + if (part.type === ContentTypes.THINK) { + return part.think; + } + return undefined; +}; + +const getPartText = (part: TMessageContentParts): string | undefined => { + const value = getPartValue(part); + return typeof value === 'string' ? value : value?.value; +}; + +const withPartText = (part: TMessageContentParts, text: string): string | TextData => { + const value = getPartValue(part); + return value != null && typeof value === 'object' ? { ...value, value: text } : text; +}; + +const containsArtifact = (text: string): boolean => { + if (!text.includes('artifact')) { + return false; + } + try { + return splitMarkdownIntoBlocks(text).some((block) => block.artifactCount > 0); + } catch { + return false; + } +}; + +export default function EditContentParts({ + content, + contentIndexOffset = 0, + messageId, + isSubmitting, + enterEdit, + siblingIdx, + setSiblingIdx, + renderReadOnlyPart, +}: EditContentPartsProps) { + const localize = useLocalize(); + const isRTL = useRecoilValue(store.chatDirection).toLowerCase() === 'rtl'; + const { conversation } = useMessagesConversation(); + const { ask, getMessages, setMessages } = useMessagesOperations(); + const getAddedConvo = useGetAddedConvo(); + const firstEditorRef = useRef(null); + const [saveError, setSaveError] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const updateMessageContentMutation = useUpdateMessageContentMutation( + conversation?.conversationId ?? '', + ); + + const editableParts = useMemo(() => { + const result: EditablePart[] = []; + content.forEach((part, localIndex) => { + if (!part || (part.type !== ContentTypes.TEXT && part.type !== ContentTypes.THINK)) { + return; + } + if (part.type === ContentTypes.TEXT && part.tool_call_ids != null) { + return; + } + const original = getPartText(part); + if (original == null || containsArtifact(original)) { + return; + } + result.push({ + index: localIndex + contentIndexOffset, + localIndex, + type: part.type, + original, + }); + }); + return result; + }, [content, contentIndexOffset]); + + const [drafts, setDrafts] = useState>(() => + Object.fromEntries(editableParts.map((part) => [part.index, part.original])), + ); + + const editableByLocalIndex = useMemo( + () => new Map(editableParts.map((part) => [part.localIndex, part])), + [editableParts], + ); + const changedParts = useMemo( + () => editableParts.filter((part) => drafts[part.index] !== part.original), + [drafts, editableParts], + ); + /** Emptying a part would persist a blank one, and nothing in the editor offers a + * way back: there is no delete-part affordance, so the only reading of a cleared + * box is an accident. The sibling `EditMessage` refuses the same edit through its + * form's `required` rule, so both editors hold the same line. */ + const hasBlankEdit = useMemo( + () => changedParts.some((part) => (drafts[part.index] ?? '').trim() === ''), + [changedParts, drafts], + ); + const editedMessage = getMessages()?.find((item) => item.messageId === messageId); + const rerunRequiresSave = editedMessage?.isCreatedByUser !== true && changedParts.length > 1; + const isBusy = isSubmitting || isSaving; + + useEffect(() => { + const editor = firstEditorRef.current; + if (!editor) { + return; + } + editor.focus(); + editor.setSelectionRange(editor.value.length, editor.value.length); + }, []); + + const applySavedParts = useCallback( + (savedParts: EditablePart[]) => { + const messages = getMessages(); + if (!messages || savedParts.length === 0) { + return; + } + const changedByLocalIndex = new Map( + savedParts.map((part) => [part.localIndex, { type: part.type, text: drafts[part.index] }]), + ); + setMessages( + messages.map((currentMessage) => { + if (currentMessage.messageId !== messageId || !Array.isArray(currentMessage.content)) { + return currentMessage; + } + return { + ...currentMessage, + content: currentMessage.content.map((part, localIndex) => { + const change = changedByLocalIndex.get(localIndex); + if (!part || !change || part.type !== change.type) { + return part; + } + return { + ...part, + [change.type]: withPartText(part, change.text), + } as TMessageContentParts; + }), + }; + }), + ); + }, + [drafts, getMessages, messageId, setMessages], + ); + + const saveChanges = useCallback(async () => { + if (changedParts.length === 0 || hasBlankEdit || isBusy) { + return; + } + setIsSaving(true); + setSaveError(false); + /** The endpoint takes one part per call and nothing rolls a write back, so a + * refused part leaves the earlier ones on the server. Recording what actually + * landed lets the failure reconcile the transcript with the server instead of + * claiming nothing was saved, and leaves only the refused parts still edited. */ + const savedParts: EditablePart[] = []; + try { + /** Each endpoint call replaces the full content array. Keep writes ordered so a + * later edit reads the content produced by the previous one instead of racing it. */ + for (const part of changedParts) { + await updateMessageContentMutation.mutateAsync({ + index: part.index, + conversationId: conversation?.conversationId ?? '', + text: drafts[part.index], + messageId, + }); + savedParts.push(part); + } + } catch { + setSaveError(true); + } finally { + applySavedParts(savedParts); + setIsSaving(false); + } + + if (savedParts.length === changedParts.length) { + enterEdit(true); + } + }, [ + applySavedParts, + changedParts, + conversation?.conversationId, + drafts, + enterEdit, + hasBlankEdit, + isBusy, + messageId, + updateMessageContentMutation, + ]); + + const updateAndRerun = useCallback(() => { + const firstChange = changedParts[0]; + if (!firstChange || !editedMessage || rerunRequiresSave || hasBlankEdit || isBusy) { + return; + } + const messages = getMessages(); + + /** `ask` refuses to send while another response is streaming and reports it by + * returning false. Closing the editor regardless would throw the drafts away for + * a rerun that never started, so a refused send leaves the editor as it was. */ + let refused = false; + + if (editedMessage.isCreatedByUser === true) { + const userText = editableParts + .filter((part) => part.type === ContentTypes.TEXT) + .map((part) => drafts[part.index]) + .join('\n'); + refused = + ask( + { + text: userText, + parentMessageId: editedMessage.parentMessageId, + conversationId: editedMessage.conversationId, + }, + { + overrideFiles: editedMessage.files, + overrideManualSkills: editedMessage.manualSkills, + overrideQuotes: editedMessage.quotes, + addedConvo: getAddedConvo() || undefined, + }, + ) === false; + } else { + const parentMessage = messages?.find( + (item) => item.messageId === editedMessage.parentMessageId, + ); + if (!parentMessage) { + return; + } + const editedContent = + firstChange.type === ContentTypes.THINK + ? { + index: firstChange.index, + type: ContentTypes.THINK as const, + [ContentTypes.THINK]: drafts[firstChange.index], + } + : { + index: firstChange.index, + type: ContentTypes.TEXT as const, + [ContentTypes.TEXT]: drafts[firstChange.index], + }; + refused = + ask( + { ...parentMessage }, + { + editedContent, + editedMessageId: messageId, + isRegenerate: true, + isEdited: true, + overrideManualSkills: parentMessage.manualSkills, + overrideQuotes: parentMessage.quotes, + addedConvo: getAddedConvo() || undefined, + }, + ) === false; + } + + if (refused) { + return; + } + + setSiblingIdx((siblingIdx ?? 0) - 1); + enterEdit(true); + }, [ + ask, + changedParts, + drafts, + editedMessage, + editableParts, + enterEdit, + getAddedConvo, + getMessages, + hasBlankEdit, + isBusy, + messageId, + rerunRequiresSave, + setSiblingIdx, + siblingIdx, + ]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + enterEdit(true); + return; + } + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + updateAndRerun(); + return; + } + if (event.key.toLowerCase() === 's' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + void saveChanges(); + } + }, + [enterEdit, saveChanges, updateAndRerun], + ); + + /** Both states share the footer's status slot so neither can add a row and + * shift the message below it. */ + const getStatusMessage = () => { + if (hasBlankEdit) { + return localize('com_ui_message_part_empty'); + } + if (rerunRequiresSave) { + return localize('com_ui_save_before_rerun'); + } + if (changedParts.length > 0) { + return localize('com_ui_unsaved_changes'); + } + return ''; + }; + + return ( +
+ {saveError && {localize('com_ui_save_message_error')}} + +
+ {content.map((part, localIndex) => { + if (!part) { + return null; + } + const editablePart = editableByLocalIndex.get(localIndex); + const absoluteIndex = localIndex + contentIndexOffset; + if (!editablePart) { + return ( +
+ {renderReadOnlyPart(part, absoluteIndex, localIndex === content.length - 1)} +
+ ); + } + const label = + editablePart.type === ContentTypes.THINK + ? localize('com_ui_thoughts') + : localize('com_ui_response'); + return ( + + ); + })} +
+ + {/* The actions wrap rather than hold one unbreakable row: on a 320px assistant + turn the identity column and page padding leave less width than the three + English labels need, and a translated label needs more still. */} +
+ + {getStatusMessage()} + +
+ + + +
+
+
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx index 2030c5ffc9..1fbd4d4efc 100644 --- a/client/src/components/Chat/Messages/Content/EditMessage.tsx +++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx @@ -1,14 +1,14 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { useForm } from 'react-hook-form'; -import { TextareaAutosize, TooltipAnchor } from '@librechat/client'; +import { Alert, Button, TextareaAutosize } from '@librechat/client'; import { useUpdateMessageMutation } from 'librechat-data-provider/react-query'; import type { TEditProps } from '~/common'; import { useMessagesOperations, useMessagesConversation } from '~/Providers'; import { useGetAddedConvo } from '~/hooks/Chat'; -import { cn, removeFocusRings } from '~/utils'; import { useLocalize } from '~/hooks'; import Container from './Container'; +import { cn } from '~/utils'; import store from '~/store'; const EditMessage = ({ @@ -22,6 +22,7 @@ const EditMessage = ({ }: TEditProps) => { const saveButtonRef = useRef(null); const submitButtonRef = useRef(null); + const [saveError, setSaveError] = useState(false); const { conversation } = useMessagesConversation(); const { getMessages, setMessages } = useMessagesOperations(); @@ -36,7 +37,13 @@ const EditMessage = ({ const getAddedConvo = useGetAddedConvo(); - const { register, handleSubmit, setValue } = useForm({ + const { + register, + handleSubmit, + setValue, + formState: { isDirty, isValid }, + } = useForm({ + mode: 'onChange', defaultValues: { text: text ?? '', }, @@ -51,9 +58,12 @@ const EditMessage = ({ } }, []); + /** `ask` refuses to send while another response is streaming and reports it by + * returning false. Closing the editor regardless would throw the draft away for + * a rerun that never started, so a refused send leaves the editor as it was. */ const resubmitMessage = (data: { text: string }) => { if (message.isCreatedByUser) { - ask( + const submitted = ask( { text: data.text, parentMessageId, @@ -72,6 +82,10 @@ const EditMessage = ({ }, ); + if (submitted === false) { + return; + } + setSiblingIdx((siblingIdx ?? 0) - 1); } else { const messages = getMessages(); @@ -80,7 +94,7 @@ const EditMessage = ({ if (!parentMessage) { return; } - ask( + const submitted = ask( { ...parentMessage }, { editedText: data.text, @@ -98,41 +112,58 @@ const EditMessage = ({ }, ); + if (submitted === false) { + return; + } + setSiblingIdx((siblingIdx ?? 0) - 1); } enterEdit(true); }; - const updateMessage = (data: { text: string }) => { - const messages = getMessages(); - if (!messages) { - return; - } - updateMessageMutation.mutate({ - conversationId: conversationId ?? '', - model: conversation?.model ?? 'gpt-3.5-turbo', - text: data.text, - messageId, - }); + const updateMessage = async (data: { text: string }) => { + setSaveError(false); + try { + await updateMessageMutation.mutateAsync({ + conversationId: conversationId ?? '', + model: conversation?.model ?? 'gpt-3.5-turbo', + text: data.text, + messageId, + }); - const isInMessages = messages.some((message) => message.messageId === messageId); - if (!isInMessages) { - message.text = data.text; - } else { - setMessages( - messages.map((msg) => - msg.messageId === messageId - ? { - ...msg, - text: data.text, - } - : msg, - ), + /** Read the thread after the request, not before it. An earlier turn stays + * editable while the newest answer streams, so a snapshot taken before the + * round trip is already behind by the time it would be written back, and + * writing it wholesale would drop every delta that landed in between. */ + const messages = getMessages(); + if (!messages) { + enterEdit(true); + return; + } + + const isInMessages = messages.some( + (currentMessage) => currentMessage.messageId === messageId, ); - } + if (!isInMessages) { + message.text = data.text; + } else { + setMessages( + messages.map((msg) => + msg.messageId === messageId + ? { + ...msg, + text: data.text, + } + : msg, + ), + ); + } - enterEdit(true); + enterEdit(true); + } catch { + setSaveError(true); + } }; const handleKeyDown = useCallback( @@ -156,13 +187,17 @@ const EditMessage = ({ const { ref, ...registerProps } = register('text', { required: true, onChange: (e) => { - setValue('text', e.target.value, { shouldValidate: true }); + setValue('text', e.target.value, { shouldDirty: true, shouldValidate: true }); }, }); return ( -
+
+ {saveError && {localize('com_ui_save_message_error')}} { @@ -172,53 +207,60 @@ const EditMessage = ({ onKeyDown={handleKeyDown} data-testid="message-text-editor" className={cn( - 'markdown prose dark:prose-invert light whitespace-pre-wrap break-words pl-3 md:pl-4', - 'm-0 w-full resize-none border-0 bg-transparent py-[10px]', - 'placeholder-text-secondary focus:ring-0 focus-visible:ring-0 md:py-3.5', + 'message-editor-text max-h-[65vh] min-h-24 w-full resize-y whitespace-pre-wrap', + 'break-words rounded-lg border border-border-medium bg-surface-tertiary-alt', + 'px-3 py-2 text-text-primary', + 'focus-visible:outline-none', isRTL ? 'text-right' : 'text-left', - 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', - removeFocusRings, + 'disabled:opacity-50 md:max-h-[75vh]', )} aria-label={localize('com_ui_message_input')} + aria-keyshortcuts="Control+Enter Meta+Enter Control+S Meta+S Escape" + disabled={isSubmitting || updateMessageMutation.isLoading} dir={isRTL ? 'rtl' : 'ltr'} /> -
-
- + + {isDirty ? localize('com_ui_unsaved_changes') : ''} + +
+ - } - /> - + - } - /> - enterEdit(true)}> - {localize('com_ui_cancel')} - - } - /> -
+ {updateMessageMutation.isLoading + ? localize('com_ui_saving') + : localize('com_ui_save')} + + +
+ +
); }; diff --git a/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx b/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx index 189c9a9268..d0deeb1fcc 100644 --- a/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx @@ -1,8 +1,5 @@ import { memo } from 'react'; -import { useAtomValue } from 'jotai'; import type { ReactNode } from 'react'; -import { fontSizeAtom } from '~/store/fontSize'; -import { cn } from '~/utils'; /** * Re-attributes response content to its author mid-message. A `SteerPart` @@ -18,15 +15,14 @@ const AuthorHeader = memo(function AuthorHeader({ icon: ReactNode; label: string; }) { - const fontSize = useAtomValue(fontSizeAtom); return (
-
+ -

{label}

+

{label}

); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx deleted file mode 100644 index 6575ad327b..0000000000 --- a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useRef, useEffect, useCallback, useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; -import { useForm } from 'react-hook-form'; -import { TextareaAutosize } from '@librechat/client'; -import { ContentTypes } from 'librechat-data-provider'; -import { Lightbulb, MessageSquare } from 'lucide-react'; -import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query'; -import type { Agents } from 'librechat-data-provider'; -import type { TEditProps } from '~/common'; -import { useMessagesOperations, useMessagesConversation } from '~/Providers'; -import Container from '~/components/Chat/Messages/Content/Container'; -import { useGetAddedConvo } from '~/hooks/Chat'; -import { cn, removeFocusRings } from '~/utils'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -const EditTextPart = ({ - part, - index, - messageId, - isSubmitting, - enterEdit, -}: Omit & { - index: number; - messageId: string; - part: Agents.MessageContentText | Agents.ReasoningDeltaUpdate; -}) => { - const localize = useLocalize(); - const { conversation } = useMessagesConversation(); - const { ask, getMessages, setMessages } = useMessagesOperations(); - - const { conversationId = '' } = conversation ?? {}; - const message = useMemo( - () => getMessages()?.find((msg) => msg.messageId === messageId), - [getMessages, messageId], - ); - - const chatDirection = useRecoilValue(store.chatDirection); - - const getAddedConvo = useGetAddedConvo(); - - const textAreaRef = useRef(null); - const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? ''); - - const isRTL = chatDirection?.toLowerCase() === 'rtl'; - - const { register, handleSubmit, setValue } = useForm({ - defaultValues: { - text: (ContentTypes.THINK in part ? part.think : part.text) || '', - }, - }); - - useEffect(() => { - const textArea = textAreaRef.current; - if (textArea) { - const length = textArea.value.length; - textArea.focus(); - textArea.setSelectionRange(length, length); - } - }, []); - - const resubmitMessage = (data: { text: string }) => { - const messages = getMessages(); - const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId); - - const editedContent = - part.type === ContentTypes.THINK - ? { - index, - type: ContentTypes.THINK as const, - [ContentTypes.THINK]: data.text, - } - : { - index, - type: ContentTypes.TEXT as const, - [ContentTypes.TEXT]: data.text, - }; - - if (!parentMessage) { - return; - } - ask( - { ...parentMessage }, - { - editedContent, - editedMessageId: messageId, - isRegenerate: true, - isEdited: true, - addedConvo: getAddedConvo() || undefined, - }, - ); - - enterEdit(true); - }; - - const updateMessage = (data: { text: string }) => { - const messages = getMessages(); - if (!messages) { - return; - } - updateMessageContentMutation.mutate({ - index, - conversationId: conversationId ?? '', - text: data.text, - messageId, - }); - - const isInMessages = messages.some((msg) => msg.messageId === messageId); - if (!isInMessages) { - return enterEdit(true); - } - - const updatedContent = message?.content?.map((part, idx) => { - if (part.type === ContentTypes.TEXT && idx === index) { - return { ...part, text: data.text }; - } - return part; - }); - - setMessages( - messages.map((msg) => - msg.messageId === messageId - ? { - ...msg, - content: updatedContent, - } - : msg, - ), - ); - - enterEdit(true); - }; - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - enterEdit(true); - } - }, - [enterEdit], - ); - - const { ref, ...registerProps } = register('text', { - required: true, - onChange: (e) => { - setValue('text', e.target.value, { shouldValidate: true }); - }, - }); - - return ( - - {part.type === ContentTypes.THINK && ( -
- - -
- )} - {part.type !== ContentTypes.THINK && ( -
- - -
- )} -
- { - ref(e); - textAreaRef.current = e; - }} - onKeyDown={handleKeyDown} - data-testid="message-text-editor" - className={cn( - 'markdown prose dark:prose-invert light whitespace-pre-wrap break-words pl-3 md:pl-4', - 'm-0 w-full resize-none border-0 bg-transparent py-[10px]', - 'placeholder-text-secondary focus:ring-0 focus-visible:ring-0 md:py-3.5', - isRTL ? 'text-right' : 'text-left', - 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', - removeFocusRings, - )} - aria-label={localize('com_ui_editable_message')} - dir={isRTL ? 'rtl' : 'ltr'} - /> -
-
- - - -
-
- ); -}; - -export default EditTextPart; diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx index 4361481d54..00c50f8e0a 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx @@ -1,26 +1,20 @@ import { memo, useMemo, useState, useCallback } from 'react'; -import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; -import { InfoHoverCard, ESide, UserIcon } from '@librechat/client'; +import { InfoHoverCard, ESide } from '@librechat/client'; import type { TFile, TMessage } from 'librechat-data-provider'; -import type { TMessageIcon } from '~/common'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; -import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import Image from '~/components/Chat/Messages/Content/Image'; -import { fontSizeAtom } from '~/store/fontSize'; import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; -const USER_ICON: TMessageIcon = { isCreatedByUser: true }; - /** * A mid-run steering message rendered as a standard user message inside the - * assistant response — same icon, author header, and text presentation as any + * assistant response, with the same compact surface and text presentation as any * user turn, placed where the words enter the run so the visible order equals * what the next turn replays (`ContentTypes.STEER` splits back into a * HumanMessage server-side). Only the server-applied part renders here, at its @@ -43,7 +37,6 @@ const SteerPart = memo(function SteerPart({ /** Read the atom rather than the auth context: AuthContextProvider mirrors the * user into it, and the public share route mounts outside that provider. */ const user = useRecoilValue(store.user); - const fontSize = useAtomValue(fontSizeAtom); const { isSharedConvo } = useShareContext(); const usernameDisplay = useRecoilValue(store.UsernameDisplay); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); @@ -82,51 +75,12 @@ const SteerPart = memo(function SteerPart({ return (
-
-
- {isSharedConvo === true ? ( - /** The atom still holds the viewer's identity when a signed-in user opens - * a share link, so rendering the identity-bearing avatar here would put - * the viewer's face on the sharer's steer. Mirrors Share/MessageIcon. */ -
- -
- ) : ( - - )} -
-
-
-

- {label} - {/* Subtle "?" explaining why a user message appears inside the - * response. Like the message hover buttons, it's revealed on - * hover/focus on hover-capable pointers, but stays visible on - * touch (no hover to reveal it) via [@media(hover:hover)]:opacity-0. */} - - - - -

-
+
+

{label}

+
{(imageFiles.length > 0 || otherFiles.length > 0) && (
{otherFiles.map((file) => ( @@ -151,12 +105,21 @@ const SteerPart = memo(function SteerPart({ className={cn( 'markdown prose message-content dark:prose-invert light w-full break-words', !enableUserMsgMarkdown && 'whitespace-pre-wrap', - 'dark:text-gray-20', + 'text-text-primary', )} > {enableUserMsgMarkdown ? : steer}
+
+ + + + +
{otherFiles.length > 0 && ( ({ default: ({ altText }: { altText: string }) => {altText}, })); -/** Seeds the user atom rather than mocking `useAuthContext`, and renders the real - * MessageIcon tree — mocking either one hid a crash on the share route, where - * neither an auth context nor a user exists. */ +/** Seeds the user atom rather than mocking `useAuthContext`, matching the share + * route where neither an auth context nor a user exists. */ const SEEDED_USER = { name: 'Danny', username: 'danny' }; function renderPart( @@ -58,11 +56,9 @@ function renderPart( user: { name: string; username: string } | null = SEEDED_USER, ) { return render( - - user && set(store.user, user as never)}> - - - , + user && set(store.user, user as never)}> + + , ); } @@ -89,10 +85,10 @@ describe('SteerPart author label', () => { expect(screen.getByText('com_user_message')).toBeInTheDocument(); }); - it('never renders the viewer identity on a shared steer avatar', () => { + it('never renders the viewer identity on a shared steer bubble', () => { /** The user atom is app-wide and survives navigation, so a signed-in viewer * opening a share link still has an identity in state. The shared steer must - * show the generic avatar regardless. */ + * keep generic attribution regardless. */ mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; renderPart(undefined, SEEDED_USER); @@ -130,12 +126,13 @@ describe('SteerPart presentation', () => { mockShareContext = {}; }); - it('presents the steer as a user message with an icon', () => { + it('presents the steer as a compact user bubble with accessible attribution', () => { renderPart(); - /** Asserts the real avatar rather than a stubbed one — the previous mock was - * what hid the auth-context crash inside this icon tree. */ - expect(screen.getByTitle('Danny')).toBeInTheDocument(); - expect(screen.getByText('steered words')).toBeInTheDocument(); + const message = screen.getByText('steered words'); + + expect(message.closest('.bg-surface-tertiary')).toHaveClass('rounded-theme-surface'); + expect(screen.getByRole('heading', { name: 'Danny' })).toHaveClass('sr-only'); + expect(screen.queryByTitle('Danny')).not.toBeInTheDocument(); }); it('anchors the steer for the message-nav rail', () => { diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index 68da02ac1b..527654a442 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -8,7 +8,6 @@ export { default as LogContent } from './LogContent'; export { default as ExecuteCode } from './ExecuteCode'; export { default as Summary } from './Summary'; export { default as AgentUpdate } from './AgentUpdate'; -export { default as EditTextPart } from './EditTextPart'; export { default as SkillCall } from './SkillCall'; export { default as ReadFileCall } from './ReadFileCall'; export { default as FileAuthoringCall } from './FileAuthoringCall'; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx index fa47cd8f78..9d9d1a5830 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx @@ -78,7 +78,6 @@ jest.mock('../Parts', () => ({ Reasoning: () =>
, Summary: () =>
, Text: ({ text }: { text?: string }) =>
{text}
, - EditTextPart: () =>
, })); jest.mock('../MemoryArtifacts', () => ({ diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index e0a5951a71..df52bb72d7 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -30,7 +30,6 @@ jest.mock('~/Providers', () => { }); jest.mock('../Parts', () => ({ - EditTextPart: () =>
, EmptyText: () =>
, AgentUpdate: ({ currentAgentId }: { currentAgentId: string }) => (
diff --git a/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx b/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx new file mode 100644 index 0000000000..b72f353174 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx @@ -0,0 +1,439 @@ +import React from 'react'; +import { ContentTypes } from 'librechat-data-provider'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { FileCitation, TMessage, TMessageContentParts } from 'librechat-data-provider'; +import EditContentParts from '../EditContentParts'; + +const mockMutateAsync = jest.fn(); +const mockSetMessages = jest.fn(); +const mockAsk = jest.fn(); +let mockChatDirection = 'LTR'; + +jest.mock('recoil', () => ({ + useRecoilValue: () => mockChatDirection, +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { chatDirection: {} }, +})); + +const message = { + messageId: 'assistant-1', + parentMessageId: 'user-1', + conversationId: 'conversation-1', + isCreatedByUser: false, +} as TMessage; + +const parentMessage = { + messageId: 'user-1', + conversationId: 'conversation-1', + isCreatedByUser: true, + text: 'Check the service', +} as TMessage; + +jest.mock('librechat-data-provider/react-query', () => ({ + useUpdateMessageContentMutation: () => ({ + mutateAsync: mockMutateAsync, + isLoading: false, + }), +})); + +jest.mock('~/Providers', () => ({ + useMessagesConversation: () => ({ + conversation: { conversationId: 'conversation-1' }, + }), + useMessagesOperations: () => ({ + ask: mockAsk, + getMessages: () => [parentMessage, message], + setMessages: mockSetMessages, + }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/hooks/Chat', () => ({ + useGetAddedConvo: () => () => null, +})); + +const content = [ + { type: ContentTypes.TEXT, text: 'Current response' }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { type: 'tool_call', name: 'status', args: '{}', output: 'ok' }, + }, + { type: ContentTypes.ERROR, error: 'A visible tool error' }, +] as TMessageContentParts[]; + +describe('EditContentParts', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMutateAsync.mockReset(); + mockMutateAsync.mockResolvedValue({}); + mockChatDirection = 'LTR'; + message.content = undefined; + }); + + it('uses one editor footer and keeps non-editable parts visible', () => { + render( +
{part.type}
} + />, + ); + + expect(screen.getAllByRole('textbox')).toHaveLength(1); + expect(screen.getByTestId(`read-only-${ContentTypes.TOOL_CALL}`)).toBeInTheDocument(); + expect(screen.getByTestId(`read-only-${ContentTypes.ERROR}`)).toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(3); + }); + + it('saves changed text parts before closing the editor', async () => { + const enterEdit = jest.fn(); + render( + null} + />, + ); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated response' } }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + + await waitFor(() => + expect(mockMutateAsync).toHaveBeenCalledWith({ + index: 0, + conversationId: 'conversation-1', + text: 'Updated response', + messageId: message.messageId, + }), + ); + expect(mockSetMessages).toHaveBeenCalledTimes(1); + expect(enterEdit).toHaveBeenCalledWith(true); + }); + + it('reruns an assistant response with the edited content value', () => { + const enterEdit = jest.fn(); + render( + null} + />, + ); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Rerun response' } }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_update_rerun' })); + + expect(mockAsk).toHaveBeenCalledWith( + parentMessage, + expect.objectContaining({ + editedContent: { + index: 0, + type: ContentTypes.TEXT, + [ContentTypes.TEXT]: 'Rerun response', + }, + }), + ); + expect(enterEdit).toHaveBeenCalledWith(true); + }); + + it('keeps the editor open with the drafts when a rerun is refused mid-stream', () => { + const enterEdit = jest.fn(); + const setSiblingIdx = jest.fn(); + mockAsk.mockReturnValue(false); + render( + null} + />, + ); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Refused rerun' } }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_update_rerun' })); + + expect(mockAsk).toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toHaveValue('Refused rerun'); + expect(setSiblingIdx).not.toHaveBeenCalled(); + expect(enterEdit).not.toHaveBeenCalled(); + }); + + it('requires multi-part assistant edits to be saved before rerunning', () => { + const multiPartContent = [ + { type: ContentTypes.TEXT, text: 'First response' }, + { type: ContentTypes.TEXT, text: 'Second response' }, + ] as TMessageContentParts[]; + + render( + null} + />, + ); + + const editors = screen.getAllByRole('textbox'); + fireEvent.change(editors[0], { target: { value: 'Updated first response' } }); + fireEvent.change(editors[1], { target: { value: 'Updated second response' } }); + + expect(screen.getByRole('button', { name: 'com_ui_update_rerun' })).toBeDisabled(); + expect(screen.getByText('com_ui_save_before_rerun')).toBeInTheDocument(); + }); + + it('reconciles the parts that were persisted when a later part is refused', async () => { + const enterEdit = jest.fn(); + const multiPartContent = [ + { type: ContentTypes.TEXT, text: 'First response' }, + { type: ContentTypes.TEXT, text: 'Second response' }, + ] as TMessageContentParts[]; + message.content = multiPartContent; + mockMutateAsync + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('the second write was refused')); + + render( + null} + />, + ); + + const editors = screen.getAllByRole('textbox'); + fireEvent.change(editors[0], { target: { value: 'Updated first response' } }); + fireEvent.change(editors[1], { target: { value: 'Updated second response' } }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + + expect(await screen.findByText('com_ui_save_message_error')).toBeInTheDocument(); + expect(mockMutateAsync).toHaveBeenCalledTimes(2); + + /** The first write is on the server whatever the second one did, so the transcript + * has to show it rather than report that the whole save was lost. */ + expect(mockSetMessages).toHaveBeenCalledTimes(1); + const reconciled = mockSetMessages.mock.calls[0][0] as TMessage[]; + const edited = reconciled.find((item) => item.messageId === message.messageId); + expect(edited?.content).toEqual([ + { type: ContentTypes.TEXT, text: 'Updated first response' }, + { type: ContentTypes.TEXT, text: 'Second response' }, + ]); + + /** The refused part is the only one still holding an unsaved draft, so the editor + * stays open on it and a retry does not rewrite what already landed. */ + expect(enterEdit).not.toHaveBeenCalled(); + expect(screen.getAllByRole('textbox')[1]).toHaveValue('Updated second response'); + }); + + /** + * The Assistants thread sync stores a response carrying file citations as + * `{ value, annotations }`, and the editor reads that through the same union it + * reads a bare string with. Writing the draft over the whole value dropped the + * citations from the transcript, and they stayed dropped until a refetch. + */ + it('edits inside a structured text part rather than flattening the cached one', async () => { + const annotations: FileCitation[] = [ + { + type: 'file_citation', + text: 'source', + start_index: 0, + end_index: 6, + file_citation: { file_id: 'file-1', quote: 'the cited passage' }, + }, + ]; + const structuredContent = [ + { type: ContentTypes.TEXT, text: { value: 'Cited response', annotations } }, + ] as TMessageContentParts[]; + message.content = structuredContent; + + render( + null} + />, + ); + + /** Read back through the union, so the editor opens on the text and not on the wrapper. */ + const editor = screen.getByRole('textbox'); + expect(editor).toHaveValue('Cited response'); + + fireEvent.change(editor, { target: { value: 'Edited cited response' } }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + + await waitFor(() => expect(mockSetMessages).toHaveBeenCalledTimes(1)); + const reconciled = mockSetMessages.mock.calls[0][0] as TMessage[]; + const edited = reconciled.find((item) => item.messageId === message.messageId); + expect(edited?.content).toEqual([ + { type: ContentTypes.TEXT, text: { value: 'Edited cited response', annotations } }, + ]); + }); + + it('keeps artifact-bearing text in the specialized read-only renderer', () => { + const artifactContent = [ + { + type: ContentTypes.TEXT, + text: ':::artifact{identifier="demo" type="text/html" title="Demo"}\n
\n:::', + }, + ] as TMessageContentParts[]; + + render( +
{part.type}
} + />, + ); + + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.getByTestId('artifact-part')).toBeInTheDocument(); + }); + + it('refuses to persist a part the editor has been emptied of', async () => { + const enterEdit = jest.fn(); + render( + null} + />, + ); + + const editor = screen.getByRole('textbox'); + fireEvent.change(editor, { target: { value: ' ' } }); + + expect(screen.getByRole('button', { name: 'com_ui_save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'com_ui_update_rerun' })).toBeDisabled(); + expect(screen.getByText('com_ui_message_part_empty')).toBeInTheDocument(); + + /** The shortcuts reach the save paths directly, so a disabled button is not + * enough on its own. */ + fireEvent.keyDown(editor, { key: 's', ctrlKey: true }); + fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true }); + + await waitFor(() => expect(mockMutateAsync).not.toHaveBeenCalled()); + expect(mockAsk).not.toHaveBeenCalled(); + expect(enterEdit).not.toHaveBeenCalled(); + }); + + it('lets a restored part save again after being emptied', async () => { + render( + null} + />, + ); + + const editor = screen.getByRole('textbox'); + fireEvent.change(editor, { target: { value: '' } }); + fireEvent.change(editor, { target: { value: 'Restored response' } }); + + expect(screen.getByRole('button', { name: 'com_ui_save' })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + + await waitFor(() => + expect(mockMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Restored response' }), + ), + ); + }); + + it('turns the editor around for a right-to-left chat direction', () => { + mockChatDirection = 'RTL'; + render( + null} + />, + ); + + const editor = screen.getByRole('textbox'); + expect(editor).toHaveAttribute('dir', 'rtl'); + expect(editor).toHaveClass('text-right'); + }); + + it('leaves the editor left-to-right by default', () => { + render( + null} + />, + ); + + const editor = screen.getByRole('textbox'); + expect(editor).toHaveAttribute('dir', 'ltr'); + expect(editor).toHaveClass('text-left'); + }); + + it('sizes the editor from the configured message font rather than pinning it', () => { + render( + null} + />, + ); + + const editor = screen.getByRole('textbox'); + expect(editor).toHaveClass('message-editor-text'); + expect(editor).not.toHaveClass('text-sm'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx b/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx new file mode 100644 index 0000000000..f0db525695 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { TMessage } from 'librechat-data-provider'; +import EditMessage from '../EditMessage'; + +const mockMutateAsync = jest.fn(); +const mockSetMessages = jest.fn(); +const mockGetMessages = jest.fn(); + +jest.mock('recoil', () => ({ + useRecoilValue: () => 'ltr', +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { chatDirection: {} }, +})); + +jest.mock('librechat-data-provider/react-query', () => ({ + useUpdateMessageMutation: () => ({ + mutateAsync: mockMutateAsync, + isLoading: false, + }), +})); + +jest.mock('~/Providers', () => ({ + useMessagesConversation: () => ({ + conversation: { conversationId: 'conversation-1', model: 'test-model' }, + }), + useMessagesOperations: () => ({ + getMessages: mockGetMessages, + setMessages: mockSetMessages, + }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/hooks/Chat', () => ({ + useGetAddedConvo: () => () => null, +})); + +jest.mock('../Container', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const message = { + messageId: 'user-1', + parentMessageId: 'root', + conversationId: 'conversation-1', + isCreatedByUser: true, + text: 'Original message', +} as TMessage; + +const assistantMessage = { + messageId: 'assistant-1', + parentMessageId: 'user-1', + conversationId: 'conversation-1', + isCreatedByUser: false, + text: 'Original answer', +} as TMessage; + +function renderEditor({ + enterEdit = jest.fn(), + ask = jest.fn(), + setSiblingIdx = jest.fn(), + editedMessage = message, +} = {}) { + render( + , + ); + return { ask, enterEdit, setSiblingIdx }; +} + +describe('EditMessage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetMessages.mockReturnValue([message]); + mockMutateAsync.mockResolvedValue({}); + }); + + it('sizes the editor from the configured message font rather than pinning it', () => { + renderEditor(); + + const editor = screen.getByTestId('message-text-editor'); + expect(editor).toHaveClass('message-editor-text'); + expect(editor).not.toHaveClass('text-sm'); + }); + + it('waits for a successful save before updating local state and closing', async () => { + const user = userEvent.setup(); + const { enterEdit } = renderEditor(); + + await user.clear(screen.getByTestId('message-text-editor')); + await user.type(screen.getByTestId('message-text-editor'), 'Updated message'); + await user.click(screen.getByRole('button', { name: 'com_ui_save' })); + + await waitFor(() => + expect(mockMutateAsync).toHaveBeenCalledWith({ + conversationId: message.conversationId, + model: 'test-model', + text: 'Updated message', + messageId: message.messageId, + }), + ); + expect(mockSetMessages).toHaveBeenCalledWith([ + expect.objectContaining({ messageId: message.messageId, text: 'Updated message' }), + ]); + expect(enterEdit).toHaveBeenCalledWith(true); + }); + + it('writes the save onto the thread as it stands when the request resolves', async () => { + const user = userEvent.setup(); + const streamedAnswer = { + messageId: 'assistant-streaming', + parentMessageId: message.messageId, + conversationId: 'conversation-1', + isCreatedByUser: false, + text: 'Half an answer', + } as TMessage; + + /** The answer keeps streaming into the cache while the request is in flight. */ + mockMutateAsync.mockImplementation(async () => { + mockGetMessages.mockReturnValue([message, streamedAnswer]); + return {}; + }); + + renderEditor(); + + await user.clear(screen.getByTestId('message-text-editor')); + await user.type(screen.getByTestId('message-text-editor'), 'Updated message'); + await user.click(screen.getByRole('button', { name: 'com_ui_save' })); + + await waitFor(() => expect(mockSetMessages).toHaveBeenCalled()); + expect(mockSetMessages).toHaveBeenCalledWith([ + expect.objectContaining({ messageId: message.messageId, text: 'Updated message' }), + expect.objectContaining({ messageId: streamedAnswer.messageId, text: 'Half an answer' }), + ]); + }); + + it('keeps the editor open with the draft when saving fails', async () => { + const user = userEvent.setup(); + mockMutateAsync.mockRejectedValue(new Error('Save failed')); + const { enterEdit } = renderEditor(); + + await user.clear(screen.getByTestId('message-text-editor')); + await user.type(screen.getByTestId('message-text-editor'), 'Unsaved message'); + await user.click(screen.getByRole('button', { name: 'com_ui_save' })); + + expect(await screen.findByText('com_ui_save_message_error')).toBeInTheDocument(); + expect(screen.getByTestId('message-text-editor')).toHaveValue('Unsaved message'); + expect(enterEdit).not.toHaveBeenCalled(); + }); + + it('submits the edited user message with its original context', async () => { + const user = userEvent.setup(); + const ask = jest.fn(); + const { enterEdit } = renderEditor({ ask }); + + await user.clear(screen.getByTestId('message-text-editor')); + await user.type(screen.getByTestId('message-text-editor'), 'Updated and rerun'); + await user.click(screen.getByRole('button', { name: 'com_ui_update_rerun' })); + + await waitFor(() => + expect(ask).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Updated and rerun', + parentMessageId: message.parentMessageId, + conversationId: message.conversationId, + }), + expect.objectContaining({ + overrideFiles: message.files, + overrideManualSkills: message.manualSkills, + overrideQuotes: message.quotes, + }), + ), + ); + expect(enterEdit).toHaveBeenCalledWith(true); + }); + + it('keeps the editor open with the draft when a rerun is refused mid-stream', async () => { + const user = userEvent.setup(); + const ask = jest.fn().mockReturnValue(false); + const { enterEdit, setSiblingIdx } = renderEditor({ ask }); + + await user.clear(screen.getByTestId('message-text-editor')); + await user.type(screen.getByTestId('message-text-editor'), 'Refused rerun'); + await user.click(screen.getByRole('button', { name: 'com_ui_update_rerun' })); + + await waitFor(() => expect(ask).toHaveBeenCalled()); + expect(screen.getByTestId('message-text-editor')).toHaveValue('Refused rerun'); + expect(setSiblingIdx).not.toHaveBeenCalled(); + expect(enterEdit).not.toHaveBeenCalled(); + }); + + it('keeps an assistant edit open when a refused rerun would discard it', async () => { + const user = userEvent.setup(); + mockGetMessages.mockReturnValue([message, assistantMessage]); + const ask = jest.fn().mockReturnValue(false); + const { enterEdit, setSiblingIdx } = renderEditor({ ask, editedMessage: assistantMessage }); + + await user.clear(screen.getByTestId('message-text-editor')); + await user.type(screen.getByTestId('message-text-editor'), 'Refused answer edit'); + await user.click(screen.getByRole('button', { name: 'com_ui_update_rerun' })); + + await waitFor(() => expect(ask).toHaveBeenCalled()); + expect(screen.getByTestId('message-text-editor')).toHaveValue('Refused answer edit'); + expect(setSiblingIdx).not.toHaveBeenCalled(); + expect(enterEdit).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Chat/Messages/Feedback.tsx b/client/src/components/Chat/Messages/Feedback.tsx index 24b521283d..af6b063797 100644 --- a/client/src/components/Chat/Messages/Feedback.tsx +++ b/client/src/components/Chat/Messages/Feedback.tsx @@ -21,6 +21,7 @@ import { ThumbUpIcon, ThumbDownIcon, } from '@librechat/client'; +import { hoverButtonClasses } from './styles'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -218,17 +219,8 @@ function FeedbackButtons({ ); } -function buttonClasses(isActive: boolean, isLast: boolean) { - return cn( - 'hover-button size-auto rounded-lg p-1.5 text-text-secondary-alt', - 'hover:text-text-primary hover:bg-surface-hover', - 'group-hover:visible group-focus-within:visible group-[.final-completion]:visible', - !isLast && - 'group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0', - 'focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:outline-none', - isActive && 'active text-text-primary bg-surface-hover', - ); -} +const buttonClasses = (isActive: boolean, isLast: boolean) => + hoverButtonClasses({ isActive, isLast }); export default function Feedback({ isLast = false, diff --git a/client/src/components/Chat/Messages/Fork.tsx b/client/src/components/Chat/Messages/Fork.tsx index 653d83ba22..341c7187fc 100644 --- a/client/src/components/Chat/Messages/Fork.tsx +++ b/client/src/components/Chat/Messages/Fork.tsx @@ -8,7 +8,7 @@ import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react'; import { Button, Label, Checkbox, useToastContext } from '@librechat/client'; import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks'; import { useForkConvoMutation } from '~/data-provider'; -import { cn } from '~/utils'; +import { hoverButtonClasses } from './styles'; import store from '~/store'; interface PopoverButtonProps { @@ -216,7 +216,6 @@ export default function Fork({ const { showToast } = useToastContext(); const [remember, setRemember] = useState(false); const { navigateToConvo } = useNavigateToConvo(); - const [isActive, setIsActive] = useState(false); const timeoutRef = useRef(null); const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting); const [activeSetting, setActiveSetting] = useState(optionLabels.default); @@ -225,16 +224,12 @@ export default function Fork({ const popoverStore = Ariakit.usePopoverStore({ placement: 'bottom', }); + /** Read the open state from the store rather than mirroring it: Escape and + * outside clicks close the popover without going through the trigger, so a + * hand-kept copy would leave the button reading as active forever. */ + const isActive = Ariakit.useStoreState(popoverStore, 'open'); - const buttonStyle = cn( - 'hover-button size-auto rounded-lg p-1.5 text-text-secondary-alt', - 'hover:text-text-primary hover:bg-surface-hover', - 'group-hover:visible group-focus-within:visible group-[.final-completion]:visible', - !isLast && - 'group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0', - 'focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:outline-none', - isActive && 'active text-text-primary bg-surface-hover', - ); + const buttonStyle = hoverButtonClasses({ isActive, isLast }); const forkConvo = useForkConvoMutation({ onSuccess: (data) => { @@ -346,7 +341,6 @@ export default function Fork({ }); } else { popoverStore.toggle(); - setIsActive(popoverStore.getState().open); } }} type="button" @@ -367,7 +361,6 @@ export default function Fork({ }} portal={true} unmountOnHide={true} - onClose={() => setIsActive(false)} >
{localize(activeSetting)} diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx index b1a627953c..98771fd2b6 100644 --- a/client/src/components/Chat/Messages/HoverButtons.tsx +++ b/client/src/components/Chat/Messages/HoverButtons.tsx @@ -12,6 +12,7 @@ import { import type { TConversation, TMessage, TFeedback } from 'librechat-data-provider'; import { useGenerationsByLatest, useLocalize } from '~/hooks'; import { Fork } from '~/components/Conversations'; +import { hoverButtonClasses } from './styles'; import MessageAudio from './MessageAudio'; import Feedback from './Feedback'; import { cn } from '~/utils'; @@ -38,8 +39,6 @@ type HoverButtonProps = { title: string; icon: React.ReactNode; isActive?: boolean; - isVisible?: boolean; - isDisabled?: boolean; isLast?: boolean; className?: string; buttonStyle?: string; @@ -85,26 +84,11 @@ const HoverButton = memo( title, icon, isActive = false, - isVisible = true, - isDisabled = false, isLast = false, className = '', dataTestId, }: HoverButtonProps) => { - const buttonStyle = cn( - 'hover-button size-auto rounded-lg p-1.5 text-text-secondary-alt', - 'hover:text-text-primary hover:bg-surface-hover', - 'group-hover:visible group-focus-within:visible group-[.final-completion]:visible', - !isLast && - isVisible && - 'group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0', - /** `!` is load-bearing: the shared Button sets `disabled:opacity-50`, which outranks a - * plain `opacity-0` and would leave a dimmed ghost of the hidden action on screen. */ - !isVisible && 'pointer-events-none !opacity-0', - 'focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:outline-none', - isActive && isVisible && 'active text-text-primary bg-surface-hover', - className, - ); + const buttonStyle = hoverButtonClasses({ isActive, isLast, className }); return ( {icon} @@ -172,6 +155,7 @@ const HoverButtons = ({ regenerateEnabled, continueSupported, forkingSupported, + isActiveStreamingMessage, isEditableEndpoint, } = generationCapabilities; @@ -181,22 +165,6 @@ const HoverButtons = ({ const { isCreatedByUser, error } = message; - if (error === true) { - return ( -
- {regenerateEnabled && ( - } - isLast={isLast} - dataTestId={isLast ? 'regenerate-generation-button' : undefined} - /> - )} -
- ); - } - const onEdit = () => { if (isEditing) { return enterEdit(true); @@ -209,7 +177,7 @@ const HoverButtons = ({ return (
{/* Text to Speech */} - {TextToSpeech && ( + {TextToSpeech && !error && !isActiveStreamingMessage && ( : } - isLast={isLast} - className={cn( - 'ml-0 flex items-center gap-1.5 text-xs', - isSubmitting && isCreatedByUser - ? 'group-hover:opacity-100 [@media(hover:hover)]:opacity-0' - : '', - )} - dataTestId={!isCreatedByUser ? 'copy-response-button' : undefined} - /> + {!isActiveStreamingMessage && ( + : } + isLast={isLast} + className={cn( + 'ml-0 flex items-center gap-1.5 text-xs', + isSubmitting && isCreatedByUser + ? 'group-hover:opacity-100 [@media(hover:hover)]:opacity-0' + : '', + )} + dataTestId={!isCreatedByUser ? 'copy-response-button' : undefined} + /> + )} {/* Edit Button */} - {isEditableEndpoint && ( + {isEditableEndpoint && !hideEditButton && ( } isActive={isEditing} - isVisible={!hideEditButton} - isDisabled={hideEditButton} isLast={isLast} className={isCreatedByUser ? '' : 'active'} /> )} {/* Fork Button */} - + {!error && !isActiveStreamingMessage && ( + + )} {/* Feedback Buttons */} - {!isCreatedByUser && handleFeedback != null && ( + {!error && !isActiveStreamingMessage && !isCreatedByUser && handleFeedback != null && ( )} diff --git a/client/src/components/Chat/Messages/Message.tsx b/client/src/components/Chat/Messages/Message.tsx index 9a1ce7313e..cd52066cdb 100644 --- a/client/src/components/Chat/Messages/Message.tsx +++ b/client/src/components/Chat/Messages/Message.tsx @@ -13,7 +13,7 @@ const MessageContainer = React.memo(function MessageContainer({ }) { return (
@@ -35,7 +35,7 @@ function Message(props: TMessageProps) { return ( -
+
diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 6091cb0993..151a70f913 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; @@ -11,10 +10,10 @@ import { } from '~/utils'; import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks'; import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader'; -import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; +import { revealOnRowHoverClasses, messageFooterClasses } from './styles'; +import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; -import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; import HoverButtons from './HoverButtons'; import SubRow from './SubRow'; @@ -43,7 +42,6 @@ function MessageParts(props: TMessageProps) { regenerateMessage, } = useMessageHelpers(props); - const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const { messageId = null, isCreatedByUser } = message ?? {}; @@ -97,112 +95,73 @@ function MessageParts(props: TMessageProps) { return null; } - const getChatWidthClass = () => { - if (maximizeChatSpace) { - return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'; - } - if (hasParallelContent) { - return 'md:max-w-[58rem] xl:max-w-[70rem]'; - } - return 'md:max-w-[47rem] xl:max-w-[55rem]'; - }; - - const baseClasses = { - common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu', - chat: getChatWidthClass(), - }; - return ( - <> -
-
-
- {!hasParallelContent && ( -
-
- -
-
- )} -
- {!hasParallelContent && ( -

- - {getHeaderPrefixForScreenReader(message, localize)} - - {name} - -

- )} -
-
- } - /> -
- {isLast && isSubmitting ? ( -
- ) : ( - - - regenerateMessage()} - copyToClipboard={copyToClipboard} - handleContinue={handleContinue} - latestMessageId={latestMessageId} - isLast={isLast} - /> - +
+
+ } + label={name} + timestamp={message.createdAt ?? message.clientTimestamp} + ariaLabel={getMessageAriaLabel(message, localize)} + headerPrefix={getHeaderPrefixForScreenReader(message, localize)} + isCreatedByUser={isCreatedByUser === true} + hasParallelContent={hasParallelContent} + fullWidth={maximizeChatSpace} + isEditing={edit} + footer={ + + {/* While the answer is generating every other action is withheld, which + would otherwise leave this counter sitting alone under a half-written + response. It reveals on hover there, like the actions it sits with. */} + -
-
-
+ /> + regenerateMessage()} + copyToClipboard={copyToClipboard} + handleContinue={handleContinue} + latestMessageId={latestMessageId} + isLast={isLast} + /> + + } + > + } + /> +
- +
); } diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index 6405f7bff8..42f972f6e5 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -1,46 +1,18 @@ import { memo, useMemo } from 'react'; -import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; -import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; +import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; +import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import Icon from '~/components/Chat/Messages/MessageIcon'; import { useAuthContext, useLocalize } from '~/hooks'; import SearchContent from './Content/SearchContent'; -import { fontSizeAtom } from '~/store/fontSize'; import SearchButtons from './SearchButtons'; import SubRow from './SubRow'; -import { cn } from '~/utils'; import store from '~/store'; -const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => ( -
-
-
- -
-
-
-); - -const MessageBody = ({ message, messageLabel, fontSize, authorHeader }) => ( -
-
- {messageLabel} - -
- - - - - -
-); - function searchFilesEqual(prev?: TMessage['files'], next?: TMessage['files']) { if (prev === next) { return true; @@ -94,7 +66,6 @@ export function areSearchMessagePropsEqual( } function SearchMessage({ message }: Pick) { - const fontSize = useAtomValue(fontSizeAtom); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); const { user } = useAuthContext(); const localize = useLocalize(); @@ -138,17 +109,26 @@ function SearchMessage({ message }: Pick) { } return ( -
-
-
- - -
+
+
+ } + label={messageLabel} + timestamp={message.createdAt ?? message.clientTimestamp} + ariaLabel={getMessageAriaLabel(message, localize)} + headerPrefix={getHeaderPrefixForScreenReader(message, localize)} + isCreatedByUser={message.isCreatedByUser === true} + className="final-completion" + footer={ + + + + + } + > + +
); diff --git a/client/src/components/Chat/Messages/SiblingSwitch.tsx b/client/src/components/Chat/Messages/SiblingSwitch.tsx index bdc9a6489c..4e566fb1c5 100644 --- a/client/src/components/Chat/Messages/SiblingSwitch.tsx +++ b/client/src/components/Chat/Messages/SiblingSwitch.tsx @@ -4,12 +4,15 @@ import type { TMessageProps } from '~/common'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -type TSiblingSwitchProps = Pick; +type TSiblingSwitchProps = Pick & { + className?: string; +}; export default function SiblingSwitch({ siblingIdx, siblingCount, setSiblingIdx, + className, }: TSiblingSwitchProps) { const localize = useLocalize(); @@ -36,7 +39,10 @@ export default function SiblingSwitch({ return siblingCount > 1 ? (