diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 9caddbfae1..319fef57eb 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1173,7 +1173,7 @@ const ChatInputInner: React.FC = (props) => { // When editing an existing message, we only allow changing the text. // Don't preventDefault here so any clipboard text can still paste normally. if (editingMessage) { - pushToast({ type: "error", message: "Images cannot be changed while editing a message." }); + pushToast({ type: "error", message: "Images cannot be added while editing a message." }); return; } @@ -1260,7 +1260,7 @@ const ChatInputInner: React.FC = (props) => { if (imageFiles.length === 0) return; if (editingMessage) { - pushToast({ type: "error", message: "Images cannot be changed while editing a message." }); + pushToast({ type: "error", message: "Images cannot be added while editing a message." }); return; } @@ -1748,6 +1748,11 @@ const ChatInputInner: React.FC = (props) => { try { // Prepare image parts if any const imageParts = imageAttachmentsToImageParts(imageAttachments, { validate: true }); + const sendImageParts = editingMessage + ? imageParts + : imageParts.length > 0 + ? imageParts + : undefined; // Prepare reviews data (used for both compaction continueMessage and normal send) const reviewsData = @@ -1814,7 +1819,7 @@ const ChatInputInner: React.FC = (props) => { ...sendMessageOptions, ...compactionOptions, editMessageId: editingMessage?.id, - imageParts: imageParts.length > 0 ? imageParts : undefined, + imageParts: sendImageParts, muxMetadata, }, }); @@ -2202,10 +2207,7 @@ const ChatInputInner: React.FC = (props) => { {/* Image attachments */} - +
{/* Editing indicator - workspace only */} diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index 7dfd93fcdd..3b173b3363 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -91,6 +91,97 @@ describe("AgentSession.sendMessage (editMessageId)", () => { expect(streamMessage.mock.calls).toHaveLength(1); }); + it("clears image parts when editing with explicit empty imageParts", async () => { + const workspaceId = "ws-test"; + + const config = { + srcDir: "/tmp", + getSessionDir: (_workspaceId: string) => "/tmp", + } as unknown as Config; + + const originalMessageId = "user-message-with-image"; + const originalImageUrl = "data:image/png;base64,AAAA"; + + const messages: MuxMessage[] = [ + createMuxMessage(originalMessageId, "user", "original", { historySequence: 0 }, [ + { type: "file" as const, mediaType: "image/png", url: originalImageUrl }, + ]), + ]; + let nextSeq = 1; + + const truncateAfterMessage = mock((_workspaceId: string, _messageId: string) => { + void _messageId; + return Promise.resolve(Ok(undefined)); + }); + + const appendToHistory = mock((_workspaceId: string, message: MuxMessage) => { + message.metadata = { ...(message.metadata ?? {}), historySequence: nextSeq++ }; + messages.push(message); + return Promise.resolve(Ok(undefined)); + }); + + const getHistory = mock((_workspaceId: string): Promise> => { + return Promise.resolve(Ok([...messages])); + }); + + const historyService = { + truncateAfterMessage, + appendToHistory, + getHistory, + } as unknown as HistoryService; + + const partialService = { + commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + const streamMessage = mock((_messages: MuxMessage[]) => { + return Promise.resolve(Ok(undefined)); + }); + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + streamMessage: streamMessage as unknown as ( + ...args: Parameters + ) => Promise>, + }) as unknown as AIService; + + const initStateManager = new EventEmitter() as unknown as InitStateManager; + + const backgroundProcessManager = { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { + void _queued; + }), + } as unknown as BackgroundProcessManager; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const result = await session.sendMessage("edited", { + model: "anthropic:claude-3-5-sonnet-latest", + editMessageId: originalMessageId, + imageParts: [], + }); + + expect(result.success).toBe(true); + expect(truncateAfterMessage.mock.calls).toHaveLength(1); + expect(appendToHistory.mock.calls).toHaveLength(1); + + const appendedMessage = appendToHistory.mock.calls[0][1]; + const appendedFileParts = appendedMessage.parts.filter( + (part) => part.type === "file" + ) as Array<{ type: "file"; url: string; mediaType: string }>; + + expect(appendedFileParts).toHaveLength(0); + }); it("preserves image parts when editing and imageParts are omitted", async () => { const workspaceId = "ws-test"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 1ce4f2d513..089779ecb7 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -399,10 +399,10 @@ export class AgentSession { const trimmedMessage = message.trim(); const imageParts = options?.imageParts; - // Edits are implemented as truncate+replace. If the frontend forgets to re-send - // imageParts, we should preserve the original message's attachments. + // Edits are implemented as truncate+replace. If the frontend omits imageParts, + // preserve the original message's attachments. let preservedEditImageParts: MuxImagePart[] | undefined; - if (options?.editMessageId && (!imageParts || imageParts.length === 0)) { + if (options?.editMessageId && imageParts === undefined) { const historyResult = await this.historyService.getHistory(this.workspaceId); if (historyResult.success) { const targetMessage: MuxMessage | undefined = historyResult.data.find(