Allow deleting single messages

This commit is contained in:
Matt Rubens
2025-01-06 23:27:17 -05:00
parent ae2e429fc3
commit 87ba95b288
3 changed files with 210 additions and 9 deletions

View File

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

View File

@@ -689,21 +689,65 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break break
case "deleteMessage": { case "deleteMessage": {
const answer = await vscode.window.showInformationMessage( 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 }, { modal: true },
"Yes", "Just this message",
"No" "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 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) const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
if (messageIndex !== -1) { if (messageIndex !== -1) {
const { historyItem } = await this.getTaskWithId(this.cline.taskId) const { historyItem } = await this.getTaskWithId(this.cline.taskId)
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)) await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
if (apiConversationHistoryIndex !== -1) { if (apiConversationHistoryIndex !== -1) {
await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)) await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
} }
}
await this.initClineWithHistoryItem(historyItem) await this.initClineWithHistoryItem(historyItem)
} }
} }

View File

@@ -59,6 +59,9 @@ jest.mock('vscode', () => ({
joinPath: jest.fn(), joinPath: jest.fn(),
file: jest.fn() file: jest.fn()
}, },
window: {
showInformationMessage: jest.fn(),
},
workspace: { workspace: {
getConfiguration: jest.fn().mockReturnValue({ getConfiguration: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue([]), get: jest.fn().mockReturnValue([]),
@@ -123,7 +126,11 @@ jest.mock('../../Cline', () => {
Cline: jest.fn().mockImplementation(() => ({ Cline: jest.fn().mockImplementation(() => ({
abortTask: jest.fn(), abortTask: jest.fn(),
handleWebviewAskResponse: 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') const result = await extractTextFromFile('test.js')
expect(result).toBe('1 | const x = 1;\n2 | const y = 2;\n3 | const z = 3;') 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()
})
})
}) })