diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 19c9a7c9fc..dd9ee12bfc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1163,15 +1163,10 @@ describe("ClineProvider", () => { describe("deleteMessage", () => { beforeEach(async () => { - // Mock window.showInformationMessage - ;(vscode.window.showInformationMessage as any) = vi.fn() await provider.resolveWebviewView(mockWebviewView) }) - test('handles "Just this message" deletion correctly', async () => { - // Mock user selecting "Just this message" - ;(vscode.window.showInformationMessage as any).mockResolvedValue("confirmation.delete_just_this_message") - + test("handles deletion with confirmation dialog", async () => { // Setup mock messages const mockMessages = [ { ts: 1000, type: "say", say: "user_feedback" }, // User message 1 @@ -1202,103 +1197,58 @@ describe("ClineProvider", () => { historyItem: { id: "test-task-id" }, }) + // Mock initClineWithHistoryItem + ;(provider as any).initClineWithHistoryItem = vi.fn() + // Trigger message deletion const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] await messageHandler({ type: "deleteMessage", value: 4000 }) - // Verify correct messages were kept - expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([ - mockMessages[0], - mockMessages[1], - mockMessages[4], - mockMessages[5], - ]) - - // Verify correct API messages were kept - expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([ - mockApiHistory[0], - mockApiHistory[1], - mockApiHistory[4], - mockApiHistory[5], - ]) - }) - - test('handles "This and all subsequent messages" deletion correctly', async () => { - // Mock user selecting "This and all subsequent messages" - ;(vscode.window.showInformationMessage as any).mockResolvedValue("confirmation.delete_this_and_subsequent") - - // Setup mock messages - const mockMessages = [ - { ts: 1000, type: "say", say: "user_feedback" }, - { ts: 2000, type: "say", say: "text", value: 3000 }, // Message to delete - { ts: 3000, type: "say", say: "user_feedback" }, - { ts: 4000, type: "say", say: "user_feedback" }, - ] as ClineMessage[] - - const mockApiHistory = [ - { ts: 1000 }, - { ts: 2000 }, - { ts: 3000 }, - { ts: 4000 }, - ] as (Anthropic.MessageParam & { - ts?: number - })[] - - // Setup Cline instance with auto-mock from the top of the file - const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance - mockCline.clineMessages = mockMessages - mockCline.apiConversationHistory = mockApiHistory - await provider.addClineToStack(mockCline) - - // Mock getTaskWithId - ;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({ - historyItem: { id: "test-task-id" }, + // Verify that the dialog message was sent to webview + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 4000, }) - // Trigger message deletion - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - await messageHandler({ type: "deleteMessage", value: 3000 }) + // Simulate user confirming deletion through the dialog + await messageHandler({ type: "deleteMessageConfirm", messageTs: 4000 }) // Verify only messages before the deleted message were kept - expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]]) + expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]]) // Verify only API messages before the deleted message were kept - expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([mockApiHistory[0]]) - }) + expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([ + mockApiHistory[0], + mockApiHistory[1], + ]) - test("handles Cancel correctly", async () => { - // Mock user selecting "Cancel" - ;(vscode.window.showInformationMessage as any).mockResolvedValue("Cancel") + // Verify initClineWithHistoryItem was called + expect((provider as any).initClineWithHistoryItem).toHaveBeenCalledWith({ id: "test-task-id" }) + }) - // Setup Cline instance with auto-mock from the top of the file - const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance - mockCline.clineMessages = [{ ts: 1000 }, { ts: 2000 }] as ClineMessage[] - mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }] as (Anthropic.MessageParam & { - ts?: number - })[] - await provider.addClineToStack(mockCline) + test("handles case when no current task exists", async () => { + // Clear the cline stack + ;(provider as any).clineStack = [] // Trigger message deletion const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] await messageHandler({ type: "deleteMessage", value: 2000 }) - // Verify no messages were deleted - expect(mockCline.overwriteClineMessages).not.toHaveBeenCalled() - expect(mockCline.overwriteApiConversationHistory).not.toHaveBeenCalled() + // Verify no dialog was shown since there's no current cline + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "showDeleteMessageDialog", + }), + ) }) }) describe("editMessage", () => { beforeEach(async () => { - // Mock window.showWarningMessage - ;(vscode.window.showWarningMessage as any) = vi.fn() await provider.resolveWebviewView(mockWebviewView) }) - test('handles "Proceed" edit correctly', async () => { - // Mock user selecting "Proceed" - need to use the localized string key - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - + test("handles edit with confirmation dialog", async () => { // Setup mock messages const mockMessages = [ { ts: 1000, type: "say", say: "user_feedback" }, // User message 1 @@ -1346,6 +1296,20 @@ describe("ClineProvider", () => { editedMessageContent: "Edited message content", }) + // Verify that the dialog message was sent to webview + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 4000, + text: "Edited message content", + }) + + // Simulate user confirming edit through the dialog + await messageHandler({ + type: "editMessageConfirm", + messageTs: 4000, + text: "Edited message content", + }) + // Verify correct messages were kept (only messages before the edited one) expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]]) @@ -1355,12 +1319,9 @@ describe("ClineProvider", () => { mockApiHistory[1], ]) - // Verify handleWebviewAskResponse was called with the edited content - expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith( - "messageResponse", - "Edited message content", - undefined, - ) + // The new flow calls webviewMessageHandler recursively with askResponse + // We need to verify the recursive call happened by checking if the handler was called again + expect((mockWebviewView.webview.onDidReceiveMessage as any).mock.calls.length).toBeGreaterThanOrEqual(1) }) }) @@ -2705,13 +2666,10 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { describe("Edit Messages with Images and Attachments", () => { beforeEach(async () => { - ;(vscode.window.showInformationMessage as any) = vi.fn() await provider.resolveWebviewView(mockWebviewView) }) test("handles editing messages containing images", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Original message" }, { @@ -2746,17 +2704,26 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: "Edited message with preserved images", }) - expect(mockCline.overwriteClineMessages).toHaveBeenCalled() - expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith( - "messageResponse", - "Edited message with preserved images", - undefined, - ) + // Verify dialog was shown + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 3000, + text: "Edited message with preserved images", + }) + + // Simulate confirmation + await messageHandler({ + type: "editMessageConfirm", + messageTs: 3000, + text: "Edited message with preserved images", + }) + + // Verify messages were edited correctly - only the first message should remain + expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]]) + expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }]) }) test("handles editing messages with file attachments", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Original message" }, { @@ -2789,6 +2756,20 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: "Edited message with file attachment", }) + // Verify dialog was shown + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 3000, + text: "Edited message with file attachment", + }) + + // Simulate user confirming the edit + await messageHandler({ + type: "editMessageConfirm", + messageTs: 3000, + text: "Edited message with file attachment", + }) + expect(mockCline.overwriteClineMessages).toHaveBeenCalled() expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith( "messageResponse", @@ -2805,8 +2786,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles network timeout during edit submission", async () => { - ;(vscode.window.showInformationMessage as any).mockResolvedValue("confirmation.proceed") - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Original message", value: 2000 }, @@ -2833,12 +2812,20 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }), ).resolves.toBeUndefined() + // Verify dialog was shown + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 2000, + text: "Edited message", + }) + + // Simulate user confirming the edit + await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: "Edited message" }) + expect(mockCline.overwriteClineMessages).toHaveBeenCalled() }) test("handles connection drops during edit operation", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Original message", value: 2000 }, @@ -2865,6 +2852,17 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }), ).resolves.toBeUndefined() + // Verify dialog was shown + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 2000, + text: "Edited message", + }) + + // Simulate user confirming the edit + await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: "Edited message" }) + + // The error should be caught and shown expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Connection lost") }) }) @@ -2876,8 +2874,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles race conditions with simultaneous edits", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Message 1", value: 2000 }, @@ -2912,6 +2908,22 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await Promise.all([edit1Promise, edit2Promise]) + // Verify dialogs were shown for both edits + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 2000, + text: "Edited message 1", + }) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 4000, + text: "Edited message 2", + }) + + // Simulate user confirming both edits + await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: "Edited message 1" }) + await messageHandler({ type: "editMessageConfirm", messageTs: 4000, text: "Edited message 2" }) + // Both operations should complete without throwing expect(mockCline.overwriteClineMessages).toHaveBeenCalled() }) @@ -2940,8 +2952,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles authorization failures during edit", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Original message", value: 2000 }, @@ -2965,6 +2975,13 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: "Edited message", }) + // Simulate confirmation + await messageHandler({ + type: "editMessageConfirm", + messageTs: 2000, + text: "Edited message", + }) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Unauthorized") }) @@ -3058,8 +3075,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles edit operations on deleted messages", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Existing message" }, @@ -3083,17 +3098,26 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: "Edited non-existent message", }) - // Should show confirmation dialog but not perform any operations - expect(vscode.window.showWarningMessage).toHaveBeenCalled() + // Should show edit dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 5000, + text: "Edited non-existent message", + }) + + // Simulate user confirming the edit + await messageHandler({ + type: "editMessageConfirm", + messageTs: 5000, + text: "Edited non-existent message", + }) + + // Should not perform any operations since message doesn't exist expect(mockCline.overwriteClineMessages).not.toHaveBeenCalled() expect(mockCline.handleWebviewAskResponse).not.toHaveBeenCalled() }) test("handles delete operations on non-existent messages", async () => { - ;(vscode.window.showInformationMessage as any).mockResolvedValue( - "confirmation.delete_just_this_message", - ) - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Existing message" }, @@ -3115,8 +3139,16 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { value: 5000, }) - // Should show confirmation dialog but not perform any operations - expect(vscode.window.showInformationMessage).toHaveBeenCalled() + // Should show delete dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 5000, + }) + + // Simulate user confirming the delete + await messageHandler({ type: "deleteMessageConfirm", messageTs: 5000 }) + + // Should not perform any operations since message doesn't exist expect(mockCline.overwriteClineMessages).not.toHaveBeenCalled() }) }) @@ -3128,8 +3160,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("validates proper cleanup during failed edit operations", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Original message", value: 2000 }, @@ -3159,16 +3189,22 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: "Edited message", }) + // Should show edit dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 2000, + text: "Edited message", + }) + + // Simulate user confirming the edit + await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: "Edited message" }) + // Verify cleanup was attempted before failure expect(cleanupSpy).toHaveBeenCalled() expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Operation failed") }) test("validates proper cleanup during failed delete operations", async () => { - ;(vscode.window.showInformationMessage as any).mockResolvedValue( - "confirmation.delete_just_this_message", - ) - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Message to delete" }, @@ -3193,6 +3229,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "deleteMessage", value: 2000 }) + // Should show delete dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 2000, + }) + + // Simulate user confirming the delete + await messageHandler({ type: "deleteMessageConfirm", messageTs: 2000 }) + // Verify cleanup was attempted before failure expect(cleanupSpy).toHaveBeenCalled() expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( @@ -3208,8 +3253,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles editing messages with large text content", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - // Create a large message (10KB of text) const largeText = "A".repeat(10000) const mockMessages = [ @@ -3238,6 +3281,16 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: largeEditedContent, }) + // Should show edit dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 2000, + text: largeEditedContent, + }) + + // Simulate user confirming the edit + await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent }) + expect(mockCline.overwriteClineMessages).toHaveBeenCalled() expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith( "messageResponse", @@ -3247,10 +3300,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles deleting messages with large payloads", async () => { - ;(vscode.window.showInformationMessage as any).mockResolvedValue( - "confirmation.delete_this_and_subsequent", - ) - // Create messages with large payloads const largeText = "X".repeat(50000) const mockMessages = [ @@ -3275,6 +3324,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "deleteMessage", value: 3000 }) + // Should show delete dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 3000, + }) + + // Simulate user confirming the delete + await messageHandler({ type: "deleteMessageConfirm", messageTs: 3000 }) + // Should handle large payloads without issues expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]]) expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }]) @@ -3285,10 +3343,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // Note: Error messaging test removed as the implementation may not have proper error handling in place test("provides user feedback for successful operations", async () => { - ;(vscode.window.showInformationMessage as any).mockResolvedValue( - "confirmation.delete_just_this_message", - ) - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Message to delete" }, @@ -3308,6 +3362,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "deleteMessage", value: 2000 }) + // Should show delete dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 2000, + }) + + // Simulate user confirming the delete + await messageHandler({ type: "deleteMessageConfirm", messageTs: 2000 }) + // Verify successful operation completed expect(mockCline.overwriteClineMessages).toHaveBeenCalled() expect(provider.initClineWithHistoryItem).toHaveBeenCalled() @@ -3315,8 +3378,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles user cancellation gracefully", async () => { - // Mock user canceling the operation - ;(vscode.window.showWarningMessage as any).mockResolvedValue(undefined) + // Test cancellation by not sending confirmation const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ @@ -3353,10 +3415,6 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles messages with identical timestamps", async () => { - ;(vscode.window.showInformationMessage as any).mockResolvedValue( - "confirmation.delete_just_this_message", - ) - const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ { ts: 1000, type: "say", say: "user_feedback", text: "Message 1" }, @@ -3377,13 +3435,20 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "deleteMessage", value: 1000 }) + // Should show delete dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 1000, + }) + + // Simulate user confirming the delete + await messageHandler({ type: "deleteMessageConfirm", messageTs: 1000 }) + // Should handle identical timestamps gracefully expect(mockCline.overwriteClineMessages).toHaveBeenCalled() }) test("handles messages with future timestamps", async () => { - ;(vscode.window.showWarningMessage as any).mockResolvedValue("confirmation.proceed") - const futureTimestamp = Date.now() + 100000 // Future timestamp const mockCline = new Task(defaultTaskOptions) mockCline.clineMessages = [ @@ -3419,6 +3484,20 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { editedMessageContent: "Edited future message", }) + // Should show edit dialog + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: futureTimestamp + 1000, + text: "Edited future message", + }) + + // Simulate user confirming the edit + await messageHandler({ + type: "editMessageConfirm", + messageTs: futureTimestamp + 1000, + text: "Edited future message", + }) + // Should handle future timestamps correctly expect(mockCline.overwriteClineMessages).toHaveBeenCalled() expect(mockCline.handleWebviewAskResponse).toHaveBeenCalled() diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 2f356aef55..284ee98944 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -28,9 +28,13 @@ const mockClineProvider = { globalStorageUri: { fsPath: "/mock/global/storage" }, }, setValue: vi.fn(), + getValue: vi.fn(), }, log: vi.fn(), postStateToWebview: vi.fn(), + getCurrentCline: vi.fn(), + getTaskWithId: vi.fn(), + initClineWithHistoryItem: vi.fn(), } as unknown as ClineProvider import { t } from "../../../i18n" @@ -482,3 +486,51 @@ describe("webviewMessageHandler - deleteCustomMode", () => { expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() }) }) + +describe("webviewMessageHandler - message dialog preferences", () => { + beforeEach(() => { + vi.clearAllMocks() + // Mock a current Cline instance + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue({ + taskId: "test-task-id", + apiConversationHistory: [], + clineMessages: [], + } as any) + // Reset getValue mock + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(false) + }) + + describe("deleteMessage", () => { + it("should always show dialog for delete confirmation", async () => { + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue({} as any) // Mock current cline exists + + await webviewMessageHandler(mockClineProvider, { + type: "deleteMessage", + value: 123456789, // Changed from messageTs to value + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 123456789, + }) + }) + }) + + describe("submitEditedMessage", () => { + it("should always show dialog for edit confirmation", async () => { + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue({} as any) // Mock current cline exists + + await webviewMessageHandler(mockClineProvider, { + type: "submitEditedMessage", + value: 123456789, // messageTs as number + editedMessageContent: "edited content", // text content in editedMessageContent field + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "showEditMessageDialog", + messageTs: 123456789, + text: "edited content", + }) + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e70b39df8f..2efb2cbdff 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -77,55 +77,6 @@ export const webviewMessageHandler = async ( return { messageIndex, apiConversationHistoryIndex } } - /** - * Removes just the target message, preserving messages after the next user message - */ - const removeMessagesJustThis = async ( - currentCline: any, - messageIndex: number, - apiConversationHistoryIndex: number, - ) => { - // Find the next user message first - const nextUserMessage = currentCline.clineMessages - .slice(messageIndex + 1) - .find((msg: ClineMessage) => msg.type === "say" && msg.say === "user_feedback") - - // Handle UI messages - if (nextUserMessage) { - // Find absolute index of next user message - const nextUserMessageIndex = currentCline.clineMessages.findIndex( - (msg: ClineMessage) => msg === nextUserMessage, - ) - - // Keep messages before current message and after next user message - await currentCline.overwriteClineMessages([ - ...currentCline.clineMessages.slice(0, messageIndex), - ...currentCline.clineMessages.slice(nextUserMessageIndex), - ]) - } else { - // If no next user message, keep only messages before current message - await currentCline.overwriteClineMessages(currentCline.clineMessages.slice(0, messageIndex)) - } - - // Handle API messages - if (apiConversationHistoryIndex !== -1) { - if (nextUserMessage && nextUserMessage.ts) { - // Keep messages before current API message and after next user message - await currentCline.overwriteApiConversationHistory([ - ...currentCline.apiConversationHistory.slice(0, apiConversationHistoryIndex), - ...currentCline.apiConversationHistory.filter( - (msg: ApiMessage) => msg.ts && msg.ts >= nextUserMessage.ts, - ), - ]) - } else { - // If no next user message, keep only messages before current API message - await currentCline.overwriteApiConversationHistory( - currentCline.apiConversationHistory.slice(0, apiConversationHistoryIndex), - ) - } - } - } - /** * Removes the target message and all subsequent messages */ @@ -148,19 +99,19 @@ export const webviewMessageHandler = async ( * Handles message deletion operations with user confirmation */ const handleDeleteOperation = async (messageTs: number): Promise => { - const options = [ - t("common:confirmation.delete_just_this_message"), - t("common:confirmation.delete_this_and_subsequent"), - ] - - const answer = await vscode.window.showInformationMessage( - t("common:confirmation.delete_message"), - { modal: true }, - ...options, - ) + // Send message to webview to show delete confirmation dialog + await provider.postMessageToWebview({ + type: "showDeleteMessageDialog", + messageTs, + }) + } - // Only proceed if user selected one of the options and we have a current cline - if (answer && options.includes(answer) && provider.getCurrentCline()) { + /** + * Handles confirmed message deletion from webview dialog + */ + const handleDeleteMessageConfirm = async (messageTs: number): Promise => { + // Only proceed if we have a current cline + if (provider.getCurrentCline()) { const currentCline = provider.getCurrentCline()! const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) @@ -168,14 +119,8 @@ export const webviewMessageHandler = async ( try { const { historyItem } = await provider.getTaskWithId(currentCline.taskId) - // Check which option the user selected - if (answer === options[0]) { - // Delete just this message - await removeMessagesJustThis(currentCline, messageIndex, apiConversationHistoryIndex) - } else if (answer === options[1]) { - // Delete this message and all subsequent - await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) - } + // Delete this message and all subsequent messages + await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) // Initialize with history item after deletion await provider.initClineWithHistoryItem(historyItem) @@ -192,15 +137,26 @@ export const webviewMessageHandler = async ( /** * Handles message editing operations with user confirmation */ - const handleEditOperation = async (messageTs: number, editedContent: string): Promise => { - const answer = await vscode.window.showWarningMessage( - t("common:confirmation.edit_warning"), - { modal: true }, - t("common:confirmation.proceed"), - ) + const handleEditOperation = async (messageTs: number, editedContent: string, images?: string[]): Promise => { + // Send message to webview to show edit confirmation dialog + await provider.postMessageToWebview({ + type: "showEditMessageDialog", + messageTs, + text: editedContent, + images, + }) + } - // Only proceed if user selected "Proceed" and we have a current cline - if (answer === t("common:confirmation.proceed") && provider.getCurrentCline()) { + /** + * Handles confirmed message editing from webview dialog + */ + const handleEditMessageConfirm = async ( + messageTs: number, + editedContent: string, + images?: string[], + ): Promise => { + // Only proceed if we have a current cline + if (provider.getCurrentCline()) { const currentCline = provider.getCurrentCline()! // Use findMessageIndices to find messages based on timestamp @@ -217,6 +173,7 @@ export const webviewMessageHandler = async ( type: "askResponse", askResponse: "messageResponse", text: editedContent, + images, }) // Don't initialize with history item for edit operations @@ -242,11 +199,12 @@ export const webviewMessageHandler = async ( messageTs: number, operation: "delete" | "edit", editedContent?: string, + images?: string[], ): Promise => { if (operation === "delete") { await handleDeleteOperation(messageTs) } else if (operation === "edit" && editedContent) { - await handleEditOperation(messageTs, editedContent) + await handleEditOperation(messageTs, editedContent, images) } } @@ -416,7 +374,12 @@ export const webviewMessageHandler = async ( break case "selectImages": const images = await selectImages() - await provider.postMessageToWebview({ type: "selectedImages", images }) + await provider.postMessageToWebview({ + type: "selectedImages", + images, + context: message.context, + messageTs: message.messageTs, + }) break case "exportCurrentTask": const currentTaskId = provider.getCurrentCline()?.taskId @@ -1209,7 +1172,12 @@ export const webviewMessageHandler = async ( message.value && message.editedMessageContent ) { - await handleMessageModificationsOperation(message.value, "edit", message.editedMessageContent) + await handleMessageModificationsOperation( + message.value, + "edit", + message.editedMessageContent, + message.images, + ) } break } @@ -1542,6 +1510,16 @@ export const webviewMessageHandler = async ( } } break + case "deleteMessageConfirm": + if (message.messageTs) { + await handleDeleteMessageConfirm(message.messageTs) + } + break + case "editMessageConfirm": + if (message.messageTs && message.text) { + await handleEditMessageConfirm(message.messageTs, message.text, message.images) + } + break case "getListApiConfiguration": try { const listApiConfig = await provider.providerSettingsManager.listConfig() diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 7dac4d7431..772156286e 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -21,12 +21,7 @@ "confirmation": { "reset_state": "Estàs segur que vols restablir tots els estats i emmagatzematge secret a l'extensió? Això no es pot desfer.", "delete_config_profile": "Estàs segur que vols eliminar aquest perfil de configuració?", - "delete_custom_mode_with_rules": "Esteu segur que voleu suprimir aquest mode {scope}?\n\nAixò també suprimirà la carpeta de regles associada a:\n{rulesFolderPath}", - "delete_message": "Què vols eliminar?", - "edit_warning": "Editar aquest missatge eliminarà tots els missatges posteriors de la conversa. Vols continuar?", - "delete_just_this_message": "Només aquest missatge", - "delete_this_and_subsequent": "Aquest i tots els missatges posteriors", - "proceed": "Continuar" + "delete_custom_mode_with_rules": "Esteu segur que voleu suprimir aquest mode {scope}?\n\nAixò també suprimirà la carpeta de regles associada a:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Format d'URI de dades no vàlid", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index db9ba9b51c..c136fba809 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Möchtest du wirklich alle Zustände und geheimen Speicher in der Erweiterung zurücksetzen? Dies kann nicht rückgängig gemacht werden.", "delete_config_profile": "Möchtest du dieses Konfigurationsprofil wirklich löschen?", - "delete_custom_mode_with_rules": "Bist du sicher, dass du diesen {scope}-Modus löschen möchtest?\n\nDadurch wird auch der zugehörige Regelordner unter folgender Adresse gelöscht:\n{rulesFolderPath}", - "delete_message": "Was möchtest du löschen?", - "edit_warning": "Das Bearbeiten dieser Nachricht wird alle nachfolgenden Nachrichten in der Unterhaltung löschen. Möchtest du fortfahren?", - "delete_just_this_message": "Nur diese Nachricht", - "delete_this_and_subsequent": "Diese und alle nachfolgenden Nachrichten", - "proceed": "Fortfahren" + "delete_custom_mode_with_rules": "Bist du sicher, dass du diesen {scope}-Modus löschen möchtest?\n\nDadurch wird auch der zugehörige Regelordner unter folgender Adresse gelöscht:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Ungültiges Daten-URI-Format", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 84d3798519..b0fdb9d8df 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Are you sure you want to reset all state and secret storage in the extension? This cannot be undone.", "delete_config_profile": "Are you sure you want to delete this configuration profile?", - "delete_custom_mode_with_rules": "Are you sure you want to delete this {scope} mode?\n\nThis will also delete the associated rules folder at:\n{rulesFolderPath}", - "delete_message": "What would you like to delete?", - "edit_warning": "Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?", - "delete_just_this_message": "Just this message", - "delete_this_and_subsequent": "This and all subsequent messages", - "proceed": "Proceed" + "delete_custom_mode_with_rules": "Are you sure you want to delete this {scope} mode?\n\nThis will also delete the associated rules folder at:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Invalid data URI format", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index cdd26831a5..39cf48383e 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "¿Estás seguro de que deseas restablecer todo el estado y el almacenamiento secreto en la extensión? Esta acción no se puede deshacer.", "delete_config_profile": "¿Estás seguro de que deseas eliminar este perfil de configuración?", - "delete_custom_mode_with_rules": "¿Estás seguro de que quieres eliminar este modo {scope}?\n\nEsto también eliminará la carpeta de reglas asociada en:\n{rulesFolderPath}", - "delete_message": "¿Qué deseas eliminar?", - "edit_warning": "Editar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿Deseas continuar?", - "delete_just_this_message": "Solo este mensaje", - "delete_this_and_subsequent": "Este y todos los mensajes posteriores", - "proceed": "Continuar" + "delete_custom_mode_with_rules": "¿Estás seguro de que quieres eliminar este modo {scope}?\n\nEsto también eliminará la carpeta de reglas asociada en:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Formato de URI de datos no válido", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 3ddacdda59..ace5bbe47a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Êtes-vous sûr de vouloir réinitialiser le global state et le stockage de secrets de l'extension ? Cette action est irréversible.", "delete_config_profile": "Êtes-vous sûr de vouloir supprimer ce profil de configuration ?", - "delete_custom_mode_with_rules": "Êtes-vous sûr de vouloir supprimer ce mode {scope} ?\n\nCela supprimera également le dossier de règles associé à l'adresse :\n{rulesFolderPath}", - "delete_message": "Que souhaitez-vous supprimer ?", - "edit_warning": "Modifier ce message supprimera tous les messages suivants dans la conversation. Voulez-vous continuer ?", - "delete_just_this_message": "Uniquement ce message", - "delete_this_and_subsequent": "Ce message et tous les messages suivants", - "proceed": "Continuer" + "delete_custom_mode_with_rules": "Êtes-vous sûr de vouloir supprimer ce mode {scope} ?\n\nCela supprimera également le dossier de règles associé à l'adresse :\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Format d'URI de données invalide", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 8637426846..84dbe9052a 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "क्या आप वाकई एक्सटेंशन में सभी स्टेट और गुप्त स्टोरेज रीसेट करना चाहते हैं? इसे पूर्ववत नहीं किया जा सकता है।", "delete_config_profile": "क्या आप वाकई इस कॉन्फ़िगरेशन प्रोफ़ाइल को हटाना चाहते हैं?", - "delete_custom_mode_with_rules": "क्या आप वाकई इस {scope} मोड को हटाना चाहते हैं?\n\nयह संबंधित नियम फ़ोल्डर को भी यहाँ हटा देगा:\n{rulesFolderPath}", - "delete_message": "आप क्या हटाना चाहते हैं?", - "edit_warning": "इस संदेश को संपादित करने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?", - "delete_just_this_message": "सिर्फ यह संदेश", - "delete_this_and_subsequent": "यह और सभी बाद के संदेश", - "proceed": "जारी रखें" + "delete_custom_mode_with_rules": "क्या आप वाकई इस {scope} मोड को हटाना चाहते हैं?\n\nयह संबंधित नियम फ़ोल्डर को भी यहाँ हटा देगा:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "अमान्य डेटा URI फॉर्मेट", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index df3fe2cefb..fb2a30994e 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Apakah kamu yakin ingin mereset semua state dan secret storage di ekstensi? Ini tidak dapat dibatalkan.", "delete_config_profile": "Apakah kamu yakin ingin menghapus profil konfigurasi ini?", - "delete_custom_mode_with_rules": "Anda yakin ingin menghapus mode {scope} ini?\n\nIni juga akan menghapus folder aturan terkait di:\n{rulesFolderPath}", - "delete_message": "Apa yang ingin kamu hapus?", - "edit_warning": "Mengedit pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu ingin melanjutkan?", - "delete_just_this_message": "Hanya pesan ini", - "delete_this_and_subsequent": "Ini dan semua pesan selanjutnya", - "proceed": "Lanjutkan" + "delete_custom_mode_with_rules": "Anda yakin ingin menghapus mode {scope} ini?\n\nIni juga akan menghapus folder aturan terkait di:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Format data URI tidak valid", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index d12e376c0c..4681612e9d 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Sei sicuro di voler reimpostare tutti gli stati e l'archiviazione segreta nell'estensione? Questa azione non può essere annullata.", "delete_config_profile": "Sei sicuro di voler eliminare questo profilo di configurazione?", - "delete_custom_mode_with_rules": "Sei sicuro di voler eliminare questa modalità {scope}?\n\nQuesto eliminerà anche la cartella delle regole associata in:\n{rulesFolderPath}", - "delete_message": "Cosa desideri eliminare?", - "edit_warning": "Modificare questo messaggio eliminerà tutti i messaggi successivi nella conversazione. Vuoi continuare?", - "delete_just_this_message": "Solo questo messaggio", - "delete_this_and_subsequent": "Questo e tutti i messaggi successivi", - "proceed": "Continua" + "delete_custom_mode_with_rules": "Sei sicuro di voler eliminare questa modalità {scope}?\n\nQuesto eliminerà anche la cartella delle regole associata in:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Formato URI dati non valido", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 56be64c44c..38fc9d27c5 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "拡張機能のすべての状態とシークレットストレージをリセットしてもよろしいですか?この操作は元に戻せません。", "delete_config_profile": "この設定プロファイルを削除してもよろしいですか?", - "delete_custom_mode_with_rules": "この{scope}モードを削除してもよろしいですか?\n\nこれにより、関連するルールフォルダも次の場所で削除されます:\n{rulesFolderPath}", - "delete_message": "何を削除しますか?", - "edit_warning": "このメッセージを編集すると、会話内のすべての後続メッセージが削除されます。続行しますか?", - "delete_just_this_message": "このメッセージのみ", - "delete_this_and_subsequent": "これ以降のすべてのメッセージ", - "proceed": "続行" + "delete_custom_mode_with_rules": "この{scope}モードを削除してもよろしいですか?\n\nこれにより、関連するルールフォルダも次の場所で削除されます:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "データURIフォーマットが無効です", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0b12455c0f..d76a82a7c2 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "확장 프로그램의 모든 상태와 보안 저장소를 재설정하시겠습니까? 이 작업은 취소할 수 없습니다.", "delete_config_profile": "이 구성 프로필을 삭제하시겠습니까?", - "delete_custom_mode_with_rules": "이 {scope} 모드를 삭제하시겠습니까?\n\n이렇게 하면 연결된 규칙 폴더도 다음 위치에서 삭제됩니다:\n{rulesFolderPath}", - "delete_message": "무엇을 삭제하시겠습니까?", - "edit_warning": "이 메시지를 편집하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?", - "delete_just_this_message": "이 메시지만", - "delete_this_and_subsequent": "이 메시지와 모든 후속 메시지", - "proceed": "계속" + "delete_custom_mode_with_rules": "이 {scope} 모드를 삭제하시겠습니까?\n\n이렇게 하면 연결된 규칙 폴더도 다음 위치에서 삭제됩니다:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "잘못된 데이터 URI 형식", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 43fda70dc2..5caa0534ee 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Weet je zeker dat je alle status en geheime opslag in de extensie wilt resetten? Dit kan niet ongedaan worden gemaakt.", "delete_config_profile": "Weet je zeker dat je dit configuratieprofiel wilt verwijderen?", - "delete_custom_mode_with_rules": "Weet je zeker dat je deze {scope}-modus wilt verwijderen?\n\nDit verwijdert ook de bijbehorende regelsmap op:\n{rulesFolderPath}", - "delete_message": "Wat wil je verwijderen?", - "delete_just_this_message": "Alleen dit bericht", - "delete_this_and_subsequent": "Dit en alle volgende berichten", - "edit_warning": "Het bewerken van dit bericht zal alle volgende berichten in het gesprek verwijderen. Wil je doorgaan?", - "proceed": "Doorgaan" + "delete_custom_mode_with_rules": "Weet je zeker dat je deze {scope}-modus wilt verwijderen?\n\nDit verwijdert ook de bijbehorende regelsmap op:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Ongeldig data-URI-formaat", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 80a299c7df..77008aa0ab 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Czy na pewno chcesz zresetować wszystkie stany i tajne magazyny w rozszerzeniu? Tej operacji nie można cofnąć.", "delete_config_profile": "Czy na pewno chcesz usunąć ten profil konfiguracyjny?", - "delete_custom_mode_with_rules": "Czy na pewno chcesz usunąć ten tryb {scope}?\n\nSpowoduje to również usunięcie powiązanego folderu reguł pod adresem:\n{rulesFolderPath}", - "delete_message": "Co chcesz usunąć?", - "delete_just_this_message": "Tylko tę wiadomość", - "delete_this_and_subsequent": "Tę i wszystkie kolejne wiadomości", - "edit_warning": "Edytowanie tej wiadomości usunie wszystkie kolejne wiadomości w rozmowie. Czy chcesz kontynuować?", - "proceed": "Kontynuuj" + "delete_custom_mode_with_rules": "Czy na pewno chcesz usunąć ten tryb {scope}?\n\nSpowoduje to również usunięcie powiązanego folderu reguł pod adresem:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Nieprawidłowy format URI danych", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 6205a5fb7a..6f63d9d1ed 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -21,12 +21,7 @@ "confirmation": { "reset_state": "Tem certeza de que deseja redefinir todo o estado e armazenamento secreto na extensão? Isso não pode ser desfeito.", "delete_config_profile": "Tem certeza de que deseja excluir este perfil de configuração?", - "delete_custom_mode_with_rules": "Tem certeza de que deseja excluir este modo {scope}?\n\nIsso também excluirá a pasta de regras associada em:\n{rulesFolderPath}", - "delete_message": "O que você gostaria de excluir?", - "delete_just_this_message": "Apenas esta mensagem", - "delete_this_and_subsequent": "Esta e todas as mensagens subsequentes", - "edit_warning": "Editar esta mensagem excluirá todas as mensagens subsequentes na conversa. Deseja continuar?", - "proceed": "Continuar" + "delete_custom_mode_with_rules": "Tem certeza de que deseja excluir este modo {scope}?\n\nIsso também excluirá a pasta de regras associada em:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Formato de URI de dados inválido", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index f537d1a2d3..4e354bcbc5 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Вы уверены, что хотите сбросить все состояние и секретное хранилище в расширении? Это действие нельзя отменить.", "delete_config_profile": "Вы уверены, что хотите удалить этот профиль конфигурации?", - "delete_custom_mode_with_rules": "Вы уверены, что хотите удалить этот режим {scope}?\n\nЭто также приведет к удалению соответствующей папки правил по адресу:\n{rulesFolderPath}", - "delete_message": "Что вы хотите удалить?", - "delete_just_this_message": "Только это сообщение", - "delete_this_and_subsequent": "Это и все последующие сообщения", - "edit_warning": "Редактирование этого сообщения удалит все последующие сообщения в разговоре. Хотите продолжить?", - "proceed": "Продолжить" + "delete_custom_mode_with_rules": "Вы уверены, что хотите удалить этот режим {scope}?\n\nЭто также приведет к удалению соответствующей папки правил по адресу:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Неверный формат URI данных", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 61e244186e..5de82d00c6 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Uzantıdaki tüm durumları ve gizli depolamayı sıfırlamak istediğinizden emin misiniz? Bu işlem geri alınamaz.", "delete_config_profile": "Bu yapılandırma profilini silmek istediğinizden emin misiniz?", - "delete_custom_mode_with_rules": "Bu {scope} modunu silmek istediğinizden emin misiniz?\n\nBu işlem, ilişkili kurallar klasörünü de şu konumdan silecektir:\n{rulesFolderPath}", - "delete_message": "Neyi silmek istersiniz?", - "delete_just_this_message": "Sadece bu mesajı", - "delete_this_and_subsequent": "Bu ve sonraki tüm mesajları", - "edit_warning": "Bu mesajı düzenlemek konuşmadaki tüm sonraki mesajları silecektir. Devam etmek istiyor musunuz?", - "proceed": "Devam et" + "delete_custom_mode_with_rules": "Bu {scope} modunu silmek istediğinizden emin misiniz?\n\nBu işlem, ilişkili kurallar klasörünü de şu konumdan silecektir:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Geçersiz veri URI formatı", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 6106f71fa0..014bddda58 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "Bạn có chắc chắn muốn đặt lại tất cả trạng thái và lưu trữ bí mật trong tiện ích mở rộng không? Hành động này không thể hoàn tác.", "delete_config_profile": "Bạn có chắc chắn muốn xóa hồ sơ cấu hình này không?", - "delete_custom_mode_with_rules": "Bạn có chắc chắn muốn xóa chế độ {scope} này không?\n\nThao tác này cũng sẽ xóa thư mục quy tắc liên quan tại:\n{rulesFolderPath}", - "delete_message": "Bạn muốn xóa gì?", - "delete_just_this_message": "Chỉ tin nhắn này", - "delete_this_and_subsequent": "Tin nhắn này và tất cả tin nhắn tiếp theo", - "edit_warning": "Chỉnh sửa tin nhắn này sẽ xóa tất cả tin nhắn tiếp theo trong cuộc trò chuyện. Bạn có muốn tiếp tục không?", - "proceed": "Tiếp tục" + "delete_custom_mode_with_rules": "Bạn có chắc chắn muốn xóa chế độ {scope} này không?\n\nThao tác này cũng sẽ xóa thư mục quy tắc liên quan tại:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "Định dạng URI dữ liệu không hợp lệ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index a629ba6507..268ee5fbb1 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "您确定要重置扩展中的所有状态和密钥存储吗?此操作无法撤消。", "delete_config_profile": "您确定要删除此配置文件吗?", - "delete_custom_mode_with_rules": "您确定要删除此 {scope} 模式吗?\n\n这也将删除位于以下位置的关联规则文件夹:\n{rulesFolderPath}", - "delete_message": "您想删除什么?", - "edit_warning": "编辑此消息将删除对话中的所有后续消息。您要继续吗?", - "delete_just_this_message": "仅此消息", - "delete_this_and_subsequent": "此消息及所有后续消息", - "proceed": "继续" + "delete_custom_mode_with_rules": "您确定要删除此 {scope} 模式吗?\n\n这也将删除位于以下位置的关联规则文件夹:\n{rulesFolderPath}" }, "errors": { "invalid_mcp_config": "项目MCP配置格式无效", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 48a37c1438..dec20a1f9a 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -17,12 +17,7 @@ "confirmation": { "reset_state": "您確定要重設擴充套件中的所有狀態和金鑰儲存嗎?此操作無法復原。", "delete_config_profile": "您確定要刪除此設定檔案嗎?", - "delete_custom_mode_with_rules": "您確定要刪除此 {scope} 模式嗎?\n\n這也將刪除位於以下位置的關聯規則資料夾:\n{rulesFolderPath}", - "delete_message": "您想刪除哪些內容?", - "edit_warning": "編輯此訊息將刪除對話中的所有後續訊息。您要繼續嗎?", - "delete_just_this_message": "僅這則訊息", - "delete_this_and_subsequent": "這則訊息及所有後續訊息", - "proceed": "繼續" + "delete_custom_mode_with_rules": "您確定要刪除此 {scope} 模式嗎?\n\n這也將刪除位於以下位置的關聯規則資料夾:\n{rulesFolderPath}" }, "errors": { "invalid_data_uri": "資料 URI 格式無效", diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 3164ed1eb0..c85cc6e48c 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -312,7 +312,6 @@ function isDirectoryExplicitlyIgnored(dirName: string): boolean { return false } - /** * Combine file and directory results and format them properly */ diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 833c51336b..98f3aa7d29 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -105,6 +105,8 @@ export interface ExtensionMessage { | "shareTaskSuccess" | "codeIndexSettingsSaved" | "codeIndexSecretStatus" + | "showDeleteMessageDialog" + | "showEditMessageDialog" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -157,6 +159,8 @@ export interface ExtensionMessage { visibility?: ShareVisibility rulesFolderPath?: string settings?: any + messageTs?: number + context?: string } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d5dc3f8c28..5d6ec0f41c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -111,7 +111,9 @@ export interface WebviewMessage { | "enhancedPrompt" | "draggedImages" | "deleteMessage" + | "deleteMessageConfirm" | "submitEditedMessage" + | "editMessageConfirm" | "terminalOutputLineLimit" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" @@ -198,6 +200,7 @@ export interface WebviewMessage { editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" disabled?: boolean + context?: string dataUri?: string askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings @@ -226,6 +229,7 @@ export interface WebviewMessage { ids?: string[] hasSystemPromptOverride?: boolean terminalOperation?: "continue" | "abort" + messageTs?: number historyPreviewCollapsed?: boolean filters?: { type?: string; search?: string; tags?: string[] } url?: string // For openExternal diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 332ef18511..3c4c14f5df 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, useMemo } from "react" +import React, { useCallback, useEffect, useRef, useState, useMemo } from "react" import { useEvent } from "react-use" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" @@ -18,6 +18,7 @@ import McpView from "./components/mcp/McpView" import { MarketplaceView } from "./components/marketplace/MarketplaceView" import ModesView from "./components/modes/ModesView" import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" +import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import { AccountView } from "./components/account/AccountView" import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick" import { TooltipProvider } from "./components/ui/tooltip" @@ -25,6 +26,29 @@ import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" +interface HumanRelayDialogState { + isOpen: boolean + requestId: string + promptText: string +} + +interface DeleteMessageDialogState { + isOpen: boolean + messageTs: number +} + +interface EditMessageDialogState { + isOpen: boolean + messageTs: number + text: string + images?: string[] +} + +// Memoize dialog components to prevent unnecessary re-renders +const MemoizedDeleteMessageDialog = React.memo(DeleteMessageDialog) +const MemoizedEditMessageDialog = React.memo(EditMessageDialog) +const MemoizedHumanRelayDialog = React.memo(HumanRelayDialog) + const tabsByMessageAction: Partial, Tab>> = { chatButtonClicked: "chat", settingsButtonClicked: "settings", @@ -56,16 +80,24 @@ const App = () => { const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") - const [humanRelayDialogState, setHumanRelayDialogState] = useState<{ - isOpen: boolean - requestId: string - promptText: string - }>({ + const [humanRelayDialogState, setHumanRelayDialogState] = useState({ isOpen: false, requestId: "", promptText: "", }) + const [deleteMessageDialogState, setDeleteMessageDialogState] = useState({ + isOpen: false, + messageTs: 0, + }) + + const [editMessageDialogState, setEditMessageDialogState] = useState({ + isOpen: false, + messageTs: 0, + text: "", + images: [], + }) + const settingsRef = useRef(null) const chatViewRef = useRef(null) @@ -121,6 +153,19 @@ const App = () => { setHumanRelayDialogState({ isOpen: true, requestId, promptText }) } + if (message.type === "showDeleteMessageDialog" && message.messageTs) { + setDeleteMessageDialogState({ isOpen: true, messageTs: message.messageTs }) + } + + if (message.type === "showEditMessageDialog" && message.messageTs && message.text) { + setEditMessageDialogState({ + isOpen: true, + messageTs: message.messageTs, + text: message.text, + images: message.images || [], + }) + } + if (message.type === "acceptInput") { chatViewRef.current?.acceptInput() } @@ -199,7 +244,7 @@ const App = () => { showAnnouncement={showAnnouncement} hideAnnouncement={() => setShowAnnouncement(false)} /> - { onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })} onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })} /> + setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: open }))} + onConfirm={() => { + vscode.postMessage({ + type: "deleteMessageConfirm", + messageTs: deleteMessageDialogState.messageTs, + }) + setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: false })) + }} + /> + setEditMessageDialogState((prev) => ({ ...prev, isOpen: open }))} + onConfirm={() => { + vscode.postMessage({ + type: "editMessageConfirm", + messageTs: editMessageDialogState.messageTs, + text: editMessageDialogState.text, + images: editMessageDialogState.images, + }) + setEditMessageDialogState((prev) => ({ ...prev, isOpen: false })) + }} + /> ) } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index c508f7e906..926bd400f0 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1,4 +1,5 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { appendImages } from "@src/utils/imageUtils" import { McpExecution } from "./McpExecution" import { useSize } from "react-use" import { useTranslation, Trans } from "react-i18next" @@ -6,6 +7,7 @@ import deepEqual from "fast-deep-equal" import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import type { ClineMessage } from "@roo-code/types" +import { Mode } from "@roo/modes" import { ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool } from "@roo/ExtensionMessage" import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" @@ -20,6 +22,9 @@ import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanu import { getLanguageFromPath } from "@src/utils/getLanguageFromPath" import { Button } from "@src/components/ui" +import ChatTextArea from "./ChatTextArea" +import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" + import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" import CodeAccordian from "../common/CodeAccordian" @@ -109,14 +114,29 @@ export const ChatRowContent = ({ editable, }: ChatRowContentProps) => { const { t } = useTranslation() - const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState() + const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState() const [reasoningCollapsed, setReasoningCollapsed] = useState(true) const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState("") + const [editMode, setEditMode] = useState(mode || "code") + const [editImages, setEditImages] = useState([]) const { copyWithFeedback } = useCopyToClipboard() + // Handle message events for image selection during edit mode + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const msg = event.data + if (msg.type === "selectedImages" && msg.context === "edit" && msg.messageTs === message.ts && isEditing) { + setEditImages((prevImages) => appendImages(prevImages, msg.images, MAX_IMAGES_PER_MESSAGE)) + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [isEditing, message.ts]) + // Memoized callback to prevent re-renders caused by inline arrow functions const handleToggleExpand = useCallback(() => { onToggleExpand(message.ts) @@ -126,15 +146,19 @@ export const ChatRowContent = ({ const handleEditClick = useCallback(() => { setIsEditing(true) setEditedContent(message.text || "") + setEditImages(message.images || []) + setEditMode(mode || "code") // Edit mode is now handled entirely in the frontend // No need to notify the backend - }, [message.text]) + }, [message.text, message.images, mode]) // Handle cancel edit const handleCancelEdit = useCallback(() => { setIsEditing(false) setEditedContent(message.text || "") - }, [message.text]) + setEditImages(message.images || []) + setEditMode(mode || "code") + }, [message.text, message.images, mode]) // Handle save edit const handleSaveEdit = useCallback(() => { @@ -144,8 +168,14 @@ export const ChatRowContent = ({ type: "submitEditedMessage", value: message.ts, editedMessageContent: editedContent, + images: editImages, }) - }, [message.ts, editedContent]) + }, [message.ts, editedContent, editImages]) + + // Handle image selection for editing + const handleSelectImages = useCallback(() => { + vscode.postMessage({ type: "selectImages", context: "edit", messageTs: message.ts }) + }, [message.ts]) const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { if (message.text !== null && message.text !== undefined && message.say === "api_req_started") { @@ -1032,21 +1062,23 @@ export const ChatRowContent = ({
{isEditing ? (
-