From d7ae5f84a62e10b302b8c65c4488d0926cafe2e3 Mon Sep 17 00:00:00 2001 From: TeAmo <93769000+Jetiaime@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:17:30 +0800 Subject: [PATCH] fix(desktop): allow removing images when editing user messages (#9979) Co-authored-by: TeAmo Co-authored-by: Douwe M Osinga --- .../__tests__/chatSessionController.test.ts | 31 +++-- ui/desktop/src/acp/__tests__/prompt.test.ts | 16 ++- ui/desktop/src/acp/chatSessionController.ts | 30 ++--- ui/desktop/src/acp/prompt.ts | 2 + ui/desktop/src/components/BaseChat.tsx | 16 ++- .../src/components/ProgressiveMessageList.tsx | 22 +++- ui/desktop/src/components/UserMessage.tsx | 117 ++++++++++++++---- ui/desktop/src/hooks/useChatSession.ts | 23 +++- ui/desktop/src/hooks/useChatSessionTypes.ts | 5 +- ui/desktop/src/i18n/messages/de.json | 6 + ui/desktop/src/i18n/messages/en.json | 6 + ui/desktop/src/i18n/messages/es.json | 6 + ui/desktop/src/i18n/messages/fr.json | 6 + ui/desktop/src/i18n/messages/hi.json | 6 + ui/desktop/src/i18n/messages/id.json | 6 + ui/desktop/src/i18n/messages/it.json | 6 + ui/desktop/src/i18n/messages/ja.json | 6 + ui/desktop/src/i18n/messages/ko.json | 6 + ui/desktop/src/i18n/messages/ms.json | 6 + ui/desktop/src/i18n/messages/pt.json | 6 + ui/desktop/src/i18n/messages/ru.json | 6 + ui/desktop/src/i18n/messages/tr.json | 8 +- ui/desktop/src/i18n/messages/vi.json | 6 + ui/desktop/src/i18n/messages/zh-CN.json | 6 + ui/desktop/src/i18n/messages/zh-TW.json | 6 + ui/desktop/src/types/message.ts | 19 ++- 26 files changed, 310 insertions(+), 69 deletions(-) diff --git a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts index 77c165bd3..86f761274 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts @@ -275,10 +275,17 @@ describe('acpChatSessionController.updateMessage', () => { }; await expect( - acpChatSessionController.updateMessage(SESSION_ID, existingMessage.id, 'Updated', 'edit', { - getCurrentSnapshot: () => currentSnapshot, - onFinish: vi.fn(), - }) + acpChatSessionController.updateMessage( + SESSION_ID, + existingMessage.id, + 'Updated', + 'edit', + [], + { + getCurrentSnapshot: () => currentSnapshot, + onFinish: vi.fn(), + } + ) ).rejects.toThrow('Cannot submit while prompt cancellation is pending'); expect(acpChatSessionActions.setChatState).not.toHaveBeenCalledWith( @@ -301,10 +308,17 @@ describe('acpChatSessionController.updateMessage', () => { }; await expect( - acpChatSessionController.updateMessage(SESSION_ID, existingMessage.id, 'Updated', 'edit', { - getCurrentSnapshot: () => currentSnapshot, - onFinish: vi.fn(), - }) + acpChatSessionController.updateMessage( + SESSION_ID, + existingMessage.id, + 'Updated', + 'edit', + [], + { + getCurrentSnapshot: () => currentSnapshot, + onFinish: vi.fn(), + } + ) ).resolves.toBeUndefined(); expect(acpChatSessionActions.setChatState).not.toHaveBeenCalledWith( @@ -346,6 +360,7 @@ describe('acpChatSessionController.updateMessage', () => { existingMessage.id, 'Updated', 'edit', + [], { getCurrentSnapshot: () => activeSnapshot, onFinish: vi.fn(), diff --git a/ui/desktop/src/acp/__tests__/prompt.test.ts b/ui/desktop/src/acp/__tests__/prompt.test.ts index e4f104bbf..f08a4b16f 100644 --- a/ui/desktop/src/acp/__tests__/prompt.test.ts +++ b/ui/desktop/src/acp/__tests__/prompt.test.ts @@ -10,14 +10,26 @@ describe('messageToAcpPromptContent', () => { created: 123, content: [ { type: 'text', text: 'Describe this' }, - { type: 'image', data: 'abc123', mimeType: 'image/png' }, + { + type: 'image', + data: 'abc123', + mimeType: 'image/png', + _meta: { source: 'acp' }, + annotations: { priority: 0.5 }, + }, ], metadata: { userVisible: true, agentVisible: true }, }; expect(messageToAcpPromptContent(message)).toEqual([ { type: 'text', text: 'Describe this' }, - { type: 'image', data: 'abc123', mimeType: 'image/png' }, + { + type: 'image', + data: 'abc123', + mimeType: 'image/png', + _meta: { source: 'acp' }, + annotations: { priority: 0.5 }, + }, ]); }); diff --git a/ui/desktop/src/acp/chatSessionController.ts b/ui/desktop/src/acp/chatSessionController.ts index a5cc8fdb2..b80f1aa8c 100644 --- a/ui/desktop/src/acp/chatSessionController.ts +++ b/ui/desktop/src/acp/chatSessionController.ts @@ -5,7 +5,12 @@ import { ChatState } from '../types/chatState'; import type { Session } from '../types/session'; import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; -import { createUserMessage, getPendingToolConfirmationIds, type Message } from '../types/message'; +import { + createUserMessage, + getPendingToolConfirmationIds, + type ImageData, + type Message, +} from '../types/message'; import { acpChatSessionActions, acpChatSessionStore, @@ -55,7 +60,8 @@ export interface AcpChatSessionController { sessionId: string, messageId: string, newContent: string, - editType: 'fork' | 'edit' | undefined, + editType: 'fork' | 'edit', + retainedImages: ImageData[], options: AcpSubmitMessageOptions ): Promise; } @@ -87,7 +93,8 @@ function assertNoPendingPromptCancellation(sessionId: string): void { async function forkSessionWithEditedMessage( sessionId: string, message: Message, - editedMessage: string + editedMessage: string, + editedImages: ImageData[] ): Promise { const targetSessionId = await acpForkSession(sessionId, message.created); @@ -96,6 +103,7 @@ async function forkSessionWithEditedMessage( newSessionId: targetSessionId, shouldStartAgent: true, editedMessage, + editedImages, }, }); window.dispatchEvent(event); @@ -233,12 +241,12 @@ async function updateMessage( sessionId: string, messageId: string, newContent: string, - editType: 'fork' | 'edit' | undefined, + editType: 'fork' | 'edit', + retainedImages: ImageData[], options: AcpSubmitMessageOptions ): Promise { assertNoPendingPromptCancellation(sessionId); - const resolvedEditType = editType ?? 'fork'; const currentSnapshot = options.getCurrentSnapshot(); const storedSnapshot = acpChatSessionStore.getSnapshot(sessionId); const activePromptAttemptId = storedSnapshot?.activePromptAttemptId; @@ -249,8 +257,8 @@ async function updateMessage( throw new Error(`Message with id ${messageId} not found in current messages`); } - if (resolvedEditType === 'fork') { - await forkSessionWithEditedMessage(sessionId, message, newContent); + if (editType === 'fork') { + await forkSessionWithEditedMessage(sessionId, message, newContent, retainedImages); return; } @@ -303,13 +311,7 @@ async function updateMessage( await acpTruncateSessionConversation(sessionId, message.created); const truncatedMessages = currentMessages.filter((m) => m.created < message.created); - const updatedUserMessage = createUserMessage(newContent); - - for (const content of message.content) { - if (content.type === 'image') { - updatedUserMessage.content.push(content); - } - } + const updatedUserMessage = createUserMessage(newContent, retainedImages); const messagesForUI = [...truncatedMessages, updatedUserMessage]; acpChatSessionActions.setMessages(sessionId, messagesForUI); diff --git a/ui/desktop/src/acp/prompt.ts b/ui/desktop/src/acp/prompt.ts index 23e3c287c..93fb13ba2 100644 --- a/ui/desktop/src/acp/prompt.ts +++ b/ui/desktop/src/acp/prompt.ts @@ -50,6 +50,8 @@ export function messageToAcpPromptContent(message: Message): ContentBlock[] { type: 'image', data: content.data, mimeType: content.mimeType, + ...(content._meta ? { _meta: content._meta } : {}), + ...(content.annotations ? { annotations: content.annotations } : {}), }); break; } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 5d6b09a04..7abc9e837 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -23,7 +23,12 @@ import { RecipeWarningModal } from './ui/RecipeWarningModal'; import { scanRecipe } from '../recipe'; import type { Recipe } from '../recipe'; import RecipeActivities from './recipes/RecipeActivities'; -import { getTextAndImageContent, type Message, type UserInput } from '../types/message'; +import { + getTextAndImageContent, + type ImageData, + type Message, + type UserInput, +} from '../types/message'; import { substituteParameters } from '../utils/parameterSubstitution'; import { useAutoSubmit } from '../hooks/useAutoSubmit'; import { Goose } from './icons'; @@ -304,11 +309,12 @@ export default function BaseChat({ const handleSessionForked = (event: Event) => { const customEvent = event as CustomEvent<{ newSessionId: string; - shouldStartAgent?: boolean; - editedMessage?: string; + shouldStartAgent: boolean; + editedMessage: string; + editedImages: ImageData[]; }>; window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail; + const { newSessionId, shouldStartAgent, editedMessage, editedImages } = customEvent.detail; const params = new URLSearchParams(); params.set('resumeSessionId', newSessionId); @@ -319,7 +325,7 @@ export default function BaseChat({ navigate(`/pair?${params.toString()}`, { state: { disableAnimation: true, - initialMessage: editedMessage ? { msg: editedMessage, images: [] } : undefined, + initialMessage: { msg: editedMessage, images: editedImages }, }, }); }; diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 0a5b094ad..b7762ecd9 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -26,7 +26,12 @@ import { CreditsExhaustedNotification, getCreditsExhaustedNotification, } from './context_management/CreditsExhaustedNotification'; -import type { Message, NotificationEvent, SystemNotificationContent } from '../types/message'; +import type { + ImageData, + Message, + NotificationEvent, + SystemNotificationContent, +} from '../types/message'; import LoadingGoose from './LoadingGoose'; import { ChatType } from '../types/chat'; import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaining'; @@ -59,7 +64,12 @@ interface ProgressiveMessageListProps { // Custom render function for messages renderMessage?: (message: Message, index: number) => React.ReactNode | null; isStreamingMessage?: boolean; // Whether messages are currently being streamed - onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void; + onMessageUpdate?: ( + messageId: string, + newContent: string, + editType: 'fork' | 'edit', + retainedImages: ImageData[] + ) => void; onRenderingComplete?: () => void; // Callback when all messages are rendered submitElicitationResponse?: ( elicitationId: string, @@ -270,15 +280,17 @@ export default function ProgressiveMessageList({ const previousResolvedModel = currentResolvedModel ? getPreviousResolvedModel(index) : null; const showModelChangeDisclosure = Boolean( currentResolvedModel && - previousResolvedModel && - currentResolvedModel !== previousResolvedModel + previousResolvedModel && + currentResolvedModel !== previousResolvedModel ); const messageKey = message.id ?? `msg-${index}-${message.created}`; return ( - {showModelChangeDisclosure && currentResolvedModel && previousResolvedModel && + {showModelChangeDisclosure && + currentResolvedModel && + previousResolvedModel && renderModelChangeDisclosure(previousResolvedModel, currentResolvedModel)}
Edit in Place updates this session • Fork Session creates a new session', + defaultMessage: + 'Edit in Place updates this session • Fork Session creates a new session', }, cancel: { id: 'userMessage.cancel', @@ -69,11 +76,24 @@ const i18n = defineMessages({ id: 'userMessage.editMessageTitle', defaultMessage: 'Edit message', }, + removeImageFromEdit: { + id: 'userMessage.removeImageFromEdit', + defaultMessage: 'Remove image from message', + }, + editImagesHeading: { + id: 'userMessage.editImagesHeading', + defaultMessage: 'Attached images:', + }, }); interface UserMessageProps { message: Message; - onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void; + onMessageUpdate?: ( + messageId: string, + newContent: string, + editType: 'fork' | 'edit', + retainedImages: ImageData[] + ) => void; } export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) { @@ -87,32 +107,39 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro const { textContent, imagePaths } = getTextAndImageContent(message); const timestamp = formatMessageTimestamp(message.created); - // Effect to handle message content changes and ensure persistence + const messageImages: ImageData[] = imageDataFromMessage(message); + + const [removedImageIndices, setRemovedImageIndices] = useState>(new Set()); + useEffect(() => { - // If we're not editing, update the edit content to match the current message if (!isEditing) { setEditContent(textContent); } }, [message.content, textContent, message.id, isEditing]); - // Initialize edit mode with current message content const initializeEditMode = useCallback(() => { setEditContent(textContent); setError(null); + setRemovedImageIndices(new Set()); window.electron.logInfo(`Entering edit mode with content: ${textContent}`); }, [textContent]); - // Handle edit button click + const handleRemoveImage = useCallback((index: number) => { + setRemovedImageIndices((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + }, []); + const handleEditClick = useCallback(() => { const newEditingState = !isEditing; setIsEditing(newEditingState); - // Initialize edit content when entering edit mode if (newEditingState) { initializeEditMode(); window.electron.logInfo(`Edit interface shown for message: ${message.id}`); - // Focus the textarea after a brief delay to ensure it's rendered setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); @@ -127,43 +154,54 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro window.electron.logInfo(`Edit state toggled: ${newEditingState} for message: ${message.id}`); }, [isEditing, initializeEditMode, message.id]); - // Handle content changes in edit mode const handleContentChange = useCallback((e: React.ChangeEvent) => { const newContent = e.target.value; setEditContent(newContent); - setError(null); // Clear any previous errors + setError(null); window.electron.logInfo(`Content changed: ${newContent}`); }, []); const handleSave = useCallback( - (editType: 'fork' | 'edit' = 'fork') => { - if (editContent.trim().length === 0) { + (editType: 'fork' | 'edit') => { + const retainedImages = messageImages.filter((_, index) => !removedImageIndices.has(index)); + + if (editContent.trim().length === 0 && retainedImages.length === 0) { setError(intl.formatMessage(i18n.emptyError)); return; } setIsEditing(false); - if (editType === 'edit' && editContent.trim() === textContent.trim()) { + if ( + editType === 'edit' && + editContent.trim() === textContent.trim() && + retainedImages.length === messageImages.length + ) { return; } if (onMessageUpdate && message.id) { - onMessageUpdate(message.id, editContent, editType); + onMessageUpdate(message.id, editContent, editType, retainedImages); } }, - [editContent, textContent, onMessageUpdate, message.id, intl] + [ + editContent, + textContent, + onMessageUpdate, + message.id, + intl, + messageImages, + removedImageIndices, + ] ); - // Handle cancel action const handleCancel = useCallback(() => { window.electron.logInfo('Cancel clicked - reverting to original content'); setIsEditing(false); - setEditContent(textContent); // Reset to original content + setEditContent(textContent); setError(null); }, [textContent]); - // Handle keyboard events for accessibility const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { window.electron.logInfo( @@ -176,13 +214,12 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); window.electron.logInfo('Cmd+Enter detected, calling handleSave'); - handleSave(); + handleSave('fork'); } }, [handleCancel, handleSave] ); - // Auto-resize textarea based on content useEffect(() => { if (textareaRef.current && isEditing) { textareaRef.current.style.height = 'auto'; @@ -194,7 +231,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
{isEditing ? ( - // Truly wide, centered, in-place edit box replacing the bubble