Просмотр исходного кода

Merge pull request #287 from RooVetGit/single_delete

Allow deleting single messages
Matt Rubens 11 месяцев назад
Родитель
Сommit
02ed1b1e6d

+ 5 - 0
.changeset/funny-candles-exist.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Allow deleting single messages or all subsequent messages

+ 52 - 8
src/core/webview/ClineProvider.ts

@@ -689,21 +689,65 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						break
 					case "deleteMessage": {
 						const answer = await vscode.window.showInformationMessage(
-							"Are you sure you want to delete this message and all subsequent messages?",
+							"What would you like to delete?",
 							{ modal: true },
-							"Yes",
-							"No"
+							"Just this message",
+							"This and all subsequent messages",
 						)
-						if (answer === "Yes" && this.cline && typeof message.value === 'number' && message.value) {
+						if ((answer === "Just this message" || answer === "This and all subsequent messages") &&
+							this.cline && typeof message.value === 'number' && message.value) {
 							const timeCutoff = message.value - 1000; // 1 second buffer before the message to delete
-							const messageIndex = this.cline.clineMessages.findIndex(msg =>  msg.ts && msg.ts >= timeCutoff)
+							const messageIndex = this.cline.clineMessages.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
 							const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
+							
 							if (messageIndex !== -1) {
 								const { historyItem } = await this.getTaskWithId(this.cline.taskId)
-								await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
-								if (apiConversationHistoryIndex !== -1) {
-									await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
+								
+								if (answer === "Just this message") {
+									// Find the next user message first
+									const nextUserMessage = this.cline.clineMessages
+										.slice(messageIndex + 1)
+										.find(msg => msg.type === "say" && msg.say === "user_feedback")
+									
+									// Handle UI messages
+									if (nextUserMessage) {
+										// Find absolute index of next user message
+										const nextUserMessageIndex = this.cline.clineMessages.findIndex(msg => msg === nextUserMessage)
+										// Keep messages before current message and after next user message
+										await this.cline.overwriteClineMessages([
+											...this.cline.clineMessages.slice(0, messageIndex),
+											...this.cline.clineMessages.slice(nextUserMessageIndex)
+										])
+									} else {
+										// If no next user message, keep only messages before current message
+										await this.cline.overwriteClineMessages(
+											this.cline.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 this.cline.overwriteApiConversationHistory([
+												...this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
+												...this.cline.apiConversationHistory.filter(msg => msg.ts && msg.ts >= nextUserMessage.ts)
+											])
+										} else {
+											// If no next user message, keep only messages before current API message
+											await this.cline.overwriteApiConversationHistory(
+												this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)
+											)
+										}
+									}
+								} else if (answer === "This and all subsequent messages") {
+									// Delete this message and all that follow
+									await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
+									if (apiConversationHistoryIndex !== -1) {
+										await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
+									}
 								}
+								
 								await this.initClineWithHistoryItem(historyItem)
 							}
 						}

+ 153 - 1
src/core/webview/__tests__/ClineProvider.test.ts

@@ -59,6 +59,9 @@ jest.mock('vscode', () => ({
         joinPath: jest.fn(),
         file: jest.fn()
     },
+    window: {
+        showInformationMessage: jest.fn(),
+    },
     workspace: {
         getConfiguration: jest.fn().mockReturnValue({
             get: jest.fn().mockReturnValue([]),
@@ -123,7 +126,11 @@ jest.mock('../../Cline', () => {
         Cline: jest.fn().mockImplementation(() => ({
             abortTask: jest.fn(),
             handleWebviewAskResponse: jest.fn(),
-            clineMessages: []
+            clineMessages: [],
+            apiConversationHistory: [],
+            overwriteClineMessages: jest.fn(),
+            overwriteApiConversationHistory: jest.fn(),
+            taskId: 'test-task-id'
         }))
     }
 })
@@ -380,4 +387,149 @@ describe('ClineProvider', () => {
         const result = await extractTextFromFile('test.js')
         expect(result).toBe('1 | const x = 1;\n2 | const y = 2;\n3 | const z = 3;')
     })
+
+    describe('deleteMessage', () => {
+        beforeEach(() => {
+            // Mock window.showInformationMessage
+            ;(vscode.window.showInformationMessage as jest.Mock) = jest.fn()
+            provider.resolveWebviewView(mockWebviewView)
+        })
+
+        test('handles "Just this message" deletion correctly', async () => {
+            // Mock user selecting "Just this message"
+            ;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Just this message')
+
+            // Setup mock messages
+            const mockMessages = [
+                { ts: 1000, type: 'say', say: 'user_feedback' },     // User message 1
+                { ts: 2000, type: 'say', say: 'tool' },             // Tool message
+                { ts: 3000, type: 'say', say: 'text', value: 4000 }, // Message to delete
+                { ts: 4000, type: 'say', say: 'browser_action' },    // Response to delete
+                { ts: 5000, type: 'say', say: 'user_feedback' },     // Next user message
+                { ts: 6000, type: 'say', say: 'user_feedback' }      // Final message
+            ]
+
+            const mockApiHistory = [
+                { ts: 1000 },
+                { ts: 2000 },
+                { ts: 3000 },
+                { ts: 4000 },
+                { ts: 5000 },
+                { ts: 6000 }
+            ]
+
+            // Setup Cline instance with mock data
+            const mockCline = {
+                clineMessages: mockMessages,
+                apiConversationHistory: mockApiHistory,
+                overwriteClineMessages: jest.fn(),
+                overwriteApiConversationHistory: jest.fn(),
+                taskId: 'test-task-id',
+                abortTask: jest.fn(),
+                handleWebviewAskResponse: jest.fn()
+            }
+            // @ts-ignore - accessing private property for testing
+            provider.cline = mockCline
+
+            // Mock getTaskWithId
+            ;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
+                historyItem: { id: 'test-task-id' }
+            })
+
+            // Trigger message deletion
+            const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).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 jest.Mock).mockResolvedValue('This and all subsequent messages')
+
+            // 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' }
+            ]
+
+            const mockApiHistory = [
+                { ts: 1000 },
+                { ts: 2000 },
+                { ts: 3000 },
+                { ts: 4000 }
+            ]
+
+            // Setup Cline instance with mock data
+            const mockCline = {
+                clineMessages: mockMessages,
+                apiConversationHistory: mockApiHistory,
+                overwriteClineMessages: jest.fn(),
+                overwriteApiConversationHistory: jest.fn(),
+                taskId: 'test-task-id',
+                abortTask: jest.fn(),
+                handleWebviewAskResponse: jest.fn()
+            }
+            // @ts-ignore - accessing private property for testing
+            provider.cline = mockCline
+
+            // Mock getTaskWithId
+            ;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
+                historyItem: { id: 'test-task-id' }
+            })
+
+            // Trigger message deletion
+            const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+            await messageHandler({ type: 'deleteMessage', value: 3000 })
+
+            // Verify only messages before the deleted message were kept
+            expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
+                mockMessages[0]
+            ])
+
+            // Verify only API messages before the deleted message were kept
+            expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
+                mockApiHistory[0]
+            ])
+        })
+
+        test('handles Cancel correctly', async () => {
+            // Mock user selecting "Cancel"
+            ;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Cancel')
+
+            const mockCline = {
+                clineMessages: [{ ts: 1000 }, { ts: 2000 }],
+                apiConversationHistory: [{ ts: 1000 }, { ts: 2000 }],
+                overwriteClineMessages: jest.fn(),
+                overwriteApiConversationHistory: jest.fn(),
+                taskId: 'test-task-id'
+            }
+            // @ts-ignore - accessing private property for testing
+            provider.cline = mockCline
+
+            // Trigger message deletion
+            const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).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()
+        })
+    })
 })