Skip to content

Commit 87ba95b

Browse files
committed
Allow deleting single messages
1 parent ae2e429 commit 87ba95b

File tree

3 files changed

+210
-9
lines changed

3 files changed

+210
-9
lines changed

.changeset/funny-candles-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Allow deleting single messages or all subsequent messages

src/core/webview/ClineProvider.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -689,21 +689,65 @@ export class ClineProvider implements vscode.WebviewViewProvider {
689689
break
690690
case "deleteMessage": {
691691
const answer = await vscode.window.showInformationMessage(
692-
"Are you sure you want to delete this message and all subsequent messages?",
692+
"What would you like to delete?",
693693
{ modal: true },
694-
"Yes",
695-
"No"
694+
"Just this message",
695+
"This and all subsequent messages",
696696
)
697-
if (answer === "Yes" && this.cline && typeof message.value === 'number' && message.value) {
697+
if ((answer === "Just this message" || answer === "This and all subsequent messages") &&
698+
this.cline && typeof message.value === 'number' && message.value) {
698699
const timeCutoff = message.value - 1000; // 1 second buffer before the message to delete
699-
const messageIndex = this.cline.clineMessages.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
700+
const messageIndex = this.cline.clineMessages.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
700701
const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
702+
701703
if (messageIndex !== -1) {
702704
const { historyItem } = await this.getTaskWithId(this.cline.taskId)
703-
await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
704-
if (apiConversationHistoryIndex !== -1) {
705-
await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
705+
706+
if (answer === "Just this message") {
707+
// Find the next user message first
708+
const nextUserMessage = this.cline.clineMessages
709+
.slice(messageIndex + 1)
710+
.find(msg => msg.type === "say" && msg.say === "user_feedback")
711+
712+
// Handle UI messages
713+
if (nextUserMessage) {
714+
// Find absolute index of next user message
715+
const nextUserMessageIndex = this.cline.clineMessages.findIndex(msg => msg === nextUserMessage)
716+
// Keep messages before current message and after next user message
717+
await this.cline.overwriteClineMessages([
718+
...this.cline.clineMessages.slice(0, messageIndex),
719+
...this.cline.clineMessages.slice(nextUserMessageIndex)
720+
])
721+
} else {
722+
// If no next user message, keep only messages before current message
723+
await this.cline.overwriteClineMessages(
724+
this.cline.clineMessages.slice(0, messageIndex)
725+
)
726+
}
727+
728+
// Handle API messages
729+
if (apiConversationHistoryIndex !== -1) {
730+
if (nextUserMessage && nextUserMessage.ts) {
731+
// Keep messages before current API message and after next user message
732+
await this.cline.overwriteApiConversationHistory([
733+
...this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
734+
...this.cline.apiConversationHistory.filter(msg => msg.ts && msg.ts >= nextUserMessage.ts)
735+
])
736+
} else {
737+
// If no next user message, keep only messages before current API message
738+
await this.cline.overwriteApiConversationHistory(
739+
this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)
740+
)
741+
}
742+
}
743+
} else if (answer === "This and all subsequent messages") {
744+
// Delete this message and all that follow
745+
await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
746+
if (apiConversationHistoryIndex !== -1) {
747+
await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
748+
}
706749
}
750+
707751
await this.initClineWithHistoryItem(historyItem)
708752
}
709753
}

src/core/webview/__tests__/ClineProvider.test.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ jest.mock('vscode', () => ({
5959
joinPath: jest.fn(),
6060
file: jest.fn()
6161
},
62+
window: {
63+
showInformationMessage: jest.fn(),
64+
},
6265
workspace: {
6366
getConfiguration: jest.fn().mockReturnValue({
6467
get: jest.fn().mockReturnValue([]),
@@ -123,7 +126,11 @@ jest.mock('../../Cline', () => {
123126
Cline: jest.fn().mockImplementation(() => ({
124127
abortTask: jest.fn(),
125128
handleWebviewAskResponse: jest.fn(),
126-
clineMessages: []
129+
clineMessages: [],
130+
apiConversationHistory: [],
131+
overwriteClineMessages: jest.fn(),
132+
overwriteApiConversationHistory: jest.fn(),
133+
taskId: 'test-task-id'
127134
}))
128135
}
129136
})
@@ -380,4 +387,149 @@ describe('ClineProvider', () => {
380387
const result = await extractTextFromFile('test.js')
381388
expect(result).toBe('1 | const x = 1;\n2 | const y = 2;\n3 | const z = 3;')
382389
})
390+
391+
describe('deleteMessage', () => {
392+
beforeEach(() => {
393+
// Mock window.showInformationMessage
394+
;(vscode.window.showInformationMessage as jest.Mock) = jest.fn()
395+
provider.resolveWebviewView(mockWebviewView)
396+
})
397+
398+
test('handles "Just this message" deletion correctly', async () => {
399+
// Mock user selecting "Just this message"
400+
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Just this message')
401+
402+
// Setup mock messages
403+
const mockMessages = [
404+
{ ts: 1000, type: 'say', say: 'user_feedback' }, // User message 1
405+
{ ts: 2000, type: 'say', say: 'tool' }, // Tool message
406+
{ ts: 3000, type: 'say', say: 'text', value: 4000 }, // Message to delete
407+
{ ts: 4000, type: 'say', say: 'browser_action' }, // Response to delete
408+
{ ts: 5000, type: 'say', say: 'user_feedback' }, // Next user message
409+
{ ts: 6000, type: 'say', say: 'user_feedback' } // Final message
410+
]
411+
412+
const mockApiHistory = [
413+
{ ts: 1000 },
414+
{ ts: 2000 },
415+
{ ts: 3000 },
416+
{ ts: 4000 },
417+
{ ts: 5000 },
418+
{ ts: 6000 }
419+
]
420+
421+
// Setup Cline instance with mock data
422+
const mockCline = {
423+
clineMessages: mockMessages,
424+
apiConversationHistory: mockApiHistory,
425+
overwriteClineMessages: jest.fn(),
426+
overwriteApiConversationHistory: jest.fn(),
427+
taskId: 'test-task-id',
428+
abortTask: jest.fn(),
429+
handleWebviewAskResponse: jest.fn()
430+
}
431+
// @ts-ignore - accessing private property for testing
432+
provider.cline = mockCline
433+
434+
// Mock getTaskWithId
435+
;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
436+
historyItem: { id: 'test-task-id' }
437+
})
438+
439+
// Trigger message deletion
440+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
441+
await messageHandler({ type: 'deleteMessage', value: 4000 })
442+
443+
// Verify correct messages were kept
444+
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
445+
mockMessages[0],
446+
mockMessages[1],
447+
mockMessages[4],
448+
mockMessages[5]
449+
])
450+
451+
// Verify correct API messages were kept
452+
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
453+
mockApiHistory[0],
454+
mockApiHistory[1],
455+
mockApiHistory[4],
456+
mockApiHistory[5]
457+
])
458+
})
459+
460+
test('handles "This and all subsequent messages" deletion correctly', async () => {
461+
// Mock user selecting "This and all subsequent messages"
462+
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('This and all subsequent messages')
463+
464+
// Setup mock messages
465+
const mockMessages = [
466+
{ ts: 1000, type: 'say', say: 'user_feedback' },
467+
{ ts: 2000, type: 'say', say: 'text', value: 3000 }, // Message to delete
468+
{ ts: 3000, type: 'say', say: 'user_feedback' },
469+
{ ts: 4000, type: 'say', say: 'user_feedback' }
470+
]
471+
472+
const mockApiHistory = [
473+
{ ts: 1000 },
474+
{ ts: 2000 },
475+
{ ts: 3000 },
476+
{ ts: 4000 }
477+
]
478+
479+
// Setup Cline instance with mock data
480+
const mockCline = {
481+
clineMessages: mockMessages,
482+
apiConversationHistory: mockApiHistory,
483+
overwriteClineMessages: jest.fn(),
484+
overwriteApiConversationHistory: jest.fn(),
485+
taskId: 'test-task-id',
486+
abortTask: jest.fn(),
487+
handleWebviewAskResponse: jest.fn()
488+
}
489+
// @ts-ignore - accessing private property for testing
490+
provider.cline = mockCline
491+
492+
// Mock getTaskWithId
493+
;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
494+
historyItem: { id: 'test-task-id' }
495+
})
496+
497+
// Trigger message deletion
498+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
499+
await messageHandler({ type: 'deleteMessage', value: 3000 })
500+
501+
// Verify only messages before the deleted message were kept
502+
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
503+
mockMessages[0]
504+
])
505+
506+
// Verify only API messages before the deleted message were kept
507+
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
508+
mockApiHistory[0]
509+
])
510+
})
511+
512+
test('handles Cancel correctly', async () => {
513+
// Mock user selecting "Cancel"
514+
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Cancel')
515+
516+
const mockCline = {
517+
clineMessages: [{ ts: 1000 }, { ts: 2000 }],
518+
apiConversationHistory: [{ ts: 1000 }, { ts: 2000 }],
519+
overwriteClineMessages: jest.fn(),
520+
overwriteApiConversationHistory: jest.fn(),
521+
taskId: 'test-task-id'
522+
}
523+
// @ts-ignore - accessing private property for testing
524+
provider.cline = mockCline
525+
526+
// Trigger message deletion
527+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
528+
await messageHandler({ type: 'deleteMessage', value: 2000 })
529+
530+
// Verify no messages were deleted
531+
expect(mockCline.overwriteClineMessages).not.toHaveBeenCalled()
532+
expect(mockCline.overwriteApiConversationHistory).not.toHaveBeenCalled()
533+
})
534+
})
383535
})

0 commit comments

Comments
 (0)