Skip to content

Commit 481e0f5

Browse files
🤖 fix: allow image deletion when editing messages (#1700)
## Summary When editing a message with attached images, users could not remove those images - the remove (×) button was hidden. ## Changes - **Frontend**: Always pass `imageParts` during edit (even if empty) so the backend knows removals are intentional - **Backend**: Only preserve original attachments when `imageParts` is `undefined`, not when it's an explicit empty array - Enable the remove button in `ImageAttachments` during edit mode - Update toast message to say "Images cannot be added" (since removal now works) ## Testing Tested manually: 1. Sent a message with multiple images attached 2. Clicked "Edit" on that message 3. Deleted one image using the × button 4. Submitted the edit 5. Verified the deleted image was removed and the other image remained --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high` • Cost: `$0.88`_
1 parent 088380c commit 481e0f5

3 files changed

Lines changed: 103 additions & 10 deletions

File tree

src/browser/components/ChatInput/index.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,7 +1169,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
11691169
// When editing an existing message, we only allow changing the text.
11701170
// Don't preventDefault here so any clipboard text can still paste normally.
11711171
if (editingMessage) {
1172-
pushToast({ type: "error", message: "Images cannot be changed while editing a message." });
1172+
pushToast({ type: "error", message: "Images cannot be added while editing a message." });
11731173
return;
11741174
}
11751175

@@ -1256,7 +1256,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
12561256
if (imageFiles.length === 0) return;
12571257

12581258
if (editingMessage) {
1259-
pushToast({ type: "error", message: "Images cannot be changed while editing a message." });
1259+
pushToast({ type: "error", message: "Images cannot be added while editing a message." });
12601260
return;
12611261
}
12621262

@@ -1750,6 +1750,11 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
17501750
try {
17511751
// Prepare image parts if any
17521752
const imageParts = imageAttachmentsToImageParts(imageAttachments, { validate: true });
1753+
const sendImageParts = editingMessage
1754+
? imageParts
1755+
: imageParts.length > 0
1756+
? imageParts
1757+
: undefined;
17531758

17541759
// Prepare reviews data (used for both compaction continueMessage and normal send)
17551760
const reviewsData =
@@ -1816,7 +1821,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
18161821
...sendMessageOptions,
18171822
...compactionOptions,
18181823
editMessageId: editingMessage?.id,
1819-
imageParts: imageParts.length > 0 ? imageParts : undefined,
1824+
imageParts: sendImageParts,
18201825
muxMetadata,
18211826
},
18221827
});
@@ -2204,10 +2209,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
22042209
</div>
22052210

22062211
{/* Image attachments */}
2207-
<ImageAttachments
2208-
images={imageAttachments}
2209-
onRemove={editingMessage ? undefined : handleRemoveImage}
2210-
/>
2212+
<ImageAttachments images={imageAttachments} onRemove={handleRemoveImage} />
22112213

22122214
<div className="flex flex-col gap-0.5" data-component="ChatModeToggles">
22132215
{/* Editing indicator - workspace only */}

src/node/services/agentSession.editMessageId.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,97 @@ describe("AgentSession.sendMessage (editMessageId)", () => {
9191
expect(streamMessage.mock.calls).toHaveLength(1);
9292
});
9393

94+
it("clears image parts when editing with explicit empty imageParts", async () => {
95+
const workspaceId = "ws-test";
96+
97+
const config = {
98+
srcDir: "/tmp",
99+
getSessionDir: (_workspaceId: string) => "/tmp",
100+
} as unknown as Config;
101+
102+
const originalMessageId = "user-message-with-image";
103+
const originalImageUrl = "data:image/png;base64,AAAA";
104+
105+
const messages: MuxMessage[] = [
106+
createMuxMessage(originalMessageId, "user", "original", { historySequence: 0 }, [
107+
{ type: "file" as const, mediaType: "image/png", url: originalImageUrl },
108+
]),
109+
];
110+
let nextSeq = 1;
111+
112+
const truncateAfterMessage = mock((_workspaceId: string, _messageId: string) => {
113+
void _messageId;
114+
return Promise.resolve(Ok(undefined));
115+
});
116+
117+
const appendToHistory = mock((_workspaceId: string, message: MuxMessage) => {
118+
message.metadata = { ...(message.metadata ?? {}), historySequence: nextSeq++ };
119+
messages.push(message);
120+
return Promise.resolve(Ok(undefined));
121+
});
122+
123+
const getHistory = mock((_workspaceId: string): Promise<Result<MuxMessage[], string>> => {
124+
return Promise.resolve(Ok([...messages]));
125+
});
126+
127+
const historyService = {
128+
truncateAfterMessage,
129+
appendToHistory,
130+
getHistory,
131+
} as unknown as HistoryService;
132+
133+
const partialService = {
134+
commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))),
135+
} as unknown as PartialService;
136+
137+
const aiEmitter = new EventEmitter();
138+
const streamMessage = mock((_messages: MuxMessage[]) => {
139+
return Promise.resolve(Ok(undefined));
140+
});
141+
const aiService = Object.assign(aiEmitter, {
142+
isStreaming: mock((_workspaceId: string) => false),
143+
stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))),
144+
streamMessage: streamMessage as unknown as (
145+
...args: Parameters<AIService["streamMessage"]>
146+
) => Promise<Result<void, SendMessageError>>,
147+
}) as unknown as AIService;
148+
149+
const initStateManager = new EventEmitter() as unknown as InitStateManager;
150+
151+
const backgroundProcessManager = {
152+
cleanup: mock((_workspaceId: string) => Promise.resolve()),
153+
setMessageQueued: mock((_workspaceId: string, _queued: boolean) => {
154+
void _queued;
155+
}),
156+
} as unknown as BackgroundProcessManager;
157+
158+
const session = new AgentSession({
159+
workspaceId,
160+
config,
161+
historyService,
162+
partialService,
163+
aiService,
164+
initStateManager,
165+
backgroundProcessManager,
166+
});
167+
168+
const result = await session.sendMessage("edited", {
169+
model: "anthropic:claude-3-5-sonnet-latest",
170+
editMessageId: originalMessageId,
171+
imageParts: [],
172+
});
173+
174+
expect(result.success).toBe(true);
175+
expect(truncateAfterMessage.mock.calls).toHaveLength(1);
176+
expect(appendToHistory.mock.calls).toHaveLength(1);
177+
178+
const appendedMessage = appendToHistory.mock.calls[0][1];
179+
const appendedFileParts = appendedMessage.parts.filter(
180+
(part) => part.type === "file"
181+
) as Array<{ type: "file"; url: string; mediaType: string }>;
182+
183+
expect(appendedFileParts).toHaveLength(0);
184+
});
94185
it("preserves image parts when editing and imageParts are omitted", async () => {
95186
const workspaceId = "ws-test";
96187

src/node/services/agentSession.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,10 +409,10 @@ export class AgentSession {
409409
const trimmedMessage = message.trim();
410410
const imageParts = options?.imageParts;
411411

412-
// Edits are implemented as truncate+replace. If the frontend forgets to re-send
413-
// imageParts, we should preserve the original message's attachments.
412+
// Edits are implemented as truncate+replace. If the frontend omits imageParts,
413+
// preserve the original message's attachments.
414414
let preservedEditImageParts: MuxImagePart[] | undefined;
415-
if (options?.editMessageId && (!imageParts || imageParts.length === 0)) {
415+
if (options?.editMessageId && imageParts === undefined) {
416416
const historyResult = await this.historyService.getHistory(this.workspaceId);
417417
if (historyResult.success) {
418418
const targetMessage: MuxMessage | undefined = historyResult.data.find(

0 commit comments

Comments
 (0)