From 87ba95b2884e00200da8958ce9995b15cb9d9d39 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 6 Jan 2025 23:27:17 -0500 Subject: [PATCH] Allow deleting single messages --- .changeset/funny-candles-exist.md | 5 + src/core/webview/ClineProvider.ts | 60 ++++++- .../webview/__tests__/ClineProvider.test.ts | 154 +++++++++++++++++- 3 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 .changeset/funny-candles-exist.md diff --git a/.changeset/funny-candles-exist.md b/.changeset/funny-candles-exist.md new file mode 100644 index 0000000..87965a8 --- /dev/null +++ b/.changeset/funny-candles-exist.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Allow deleting single messages or all subsequent messages diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0c62ba6..c6b1eab 100644 --- a/src/core/webview/ClineProvider.ts +++ b/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) } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 2add65e..6283ba5 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/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() + }) + }) })