diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index b4bcfa62d6..3da22d1be8 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -401,6 +401,14 @@ export class CustomModesManager { public async updateCustomMode(slug: string, config: ModeConfig): Promise { try { + // Validate the mode configuration before saving + const validationResult = modeConfigSchema.safeParse(config) + if (!validationResult.success) { + const errors = validationResult.error.errors.map((e) => e.message).join(", ") + logger.error(`Invalid mode configuration for ${slug}`, { errors: validationResult.error.errors }) + throw new Error(`Invalid mode configuration: ${errors}`) + } + const isProjectMode = config.source === "project" let targetPath: string diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 8d4f157f4d..6f0c9fe2bf 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -179,22 +179,12 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo // Add current time information with timezone. const now = new Date() - const formatter = new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: true, - }) - - const timeZone = formatter.resolvedOptions().timeZone + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` - details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})` + details += `\n\n# Current Time\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}` // Add context tokens information. const { contextTokens, totalCost } = getApiMetrics(cline.clineMessages) 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 that the dialog message was sent to webview + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "showDeleteMessageDialog", + messageTs: 4000, + }) - // Verify correct API messages were kept + // 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], mockMessages[1]]) + + // Verify only API messages before the deleted message were kept expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([ mockApiHistory[0], mockApiHistory[1], - mockApiHistory[4], - mockApiHistory[5], ]) + + // Verify initClineWithHistoryItem was called + expect((provider as any).initClineWithHistoryItem).toHaveBeenCalledWith({ id: "test-task-id" }) }) - 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" }, - }) - - // Trigger message deletion - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).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 any).mockResolvedValue("Cancel") - - // 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"), - ] + // Send message to webview to show delete confirmation dialog + await provider.postMessageToWebview({ + type: "showDeleteMessageDialog", + messageTs, + }) + } - const answer = await vscode.window.showInformationMessage( - t("common:confirmation.delete_message"), - { modal: true }, - ...options, - ) - - // 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/code-index/constants/index.ts b/src/services/code-index/constants/index.ts index c2567f5635..706a73935a 100644 --- a/src/services/code-index/constants/index.ts +++ b/src/services/code-index/constants/index.ts @@ -15,7 +15,7 @@ export const QDRANT_CODE_BLOCK_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479 export const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 // 1MB /**Directory Scanner */ -export const MAX_LIST_FILES_LIMIT = 3_000 +export const MAX_LIST_FILES_LIMIT_CODE_INDEX = 50_000 export const BATCH_SEGMENT_THRESHOLD = 60 // Number of code segments to batch for embeddings/upserts export const MAX_BATCH_RETRIES = 3 export const INITIAL_RETRY_DELAY_MS = 500 diff --git a/src/services/code-index/interfaces/file-processor.ts b/src/services/code-index/interfaces/file-processor.ts index f00c19c619..88b19007c3 100644 --- a/src/services/code-index/interfaces/file-processor.ts +++ b/src/services/code-index/interfaces/file-processor.ts @@ -38,7 +38,6 @@ export interface IDirectoryScanner { onBlocksIndexed?: (indexedCount: number) => void, onFileParsed?: (fileBlockCount: number) => void, ): Promise<{ - codeBlocks: CodeBlock[] stats: { processed: number skipped: number diff --git a/src/services/code-index/processors/__tests__/scanner.spec.ts b/src/services/code-index/processors/__tests__/scanner.spec.ts index f90a6c8159..4d4150b443 100644 --- a/src/services/code-index/processors/__tests__/scanner.spec.ts +++ b/src/services/code-index/processors/__tests__/scanner.spec.ts @@ -168,7 +168,16 @@ describe("DirectoryScanner", () => { expect(mockCodeParser.parseFile).not.toHaveBeenCalled() }) - it("should parse changed files and return code blocks", async () => { + it("should parse changed files and return empty codeBlocks array", async () => { + // Create scanner without embedder to test the non-embedding path + const scannerNoEmbeddings = new DirectoryScanner( + null as any, // No embedder + null as any, // No vector store + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + ) + const { listFiles } = await import("../../../glob/list-files") vi.mocked(listFiles).mockResolvedValue([["test/file1.js"], false]) const mockBlocks: any[] = [ @@ -185,8 +194,7 @@ describe("DirectoryScanner", () => { ] ;(mockCodeParser.parseFile as any).mockResolvedValue(mockBlocks) - const result = await scanner.scanDirectory("/test") - expect(result.codeBlocks).toEqual(mockBlocks) + const result = await scannerNoEmbeddings.scanDirectory("/test") expect(result.stats.processed).toBe(1) }) @@ -252,6 +260,15 @@ describe("DirectoryScanner", () => { }) it("should process markdown files alongside code files", async () => { + // Create scanner without embedder to test the non-embedding path + const scannerNoEmbeddings = new DirectoryScanner( + null as any, // No embedder + null as any, // No vector store + mockCodeParser, + mockCacheManager, + mockIgnoreInstance, + ) + const { listFiles } = await import("../../../glob/list-files") vi.mocked(listFiles).mockResolvedValue([["test/README.md", "test/app.js", "docs/guide.markdown"], false]) @@ -306,7 +323,7 @@ describe("DirectoryScanner", () => { return [] }) - const result = await scanner.scanDirectory("/test") + const result = await scannerNoEmbeddings.scanDirectory("/test") // Verify all files were processed expect(mockCodeParser.parseFile).toHaveBeenCalledTimes(3) @@ -314,16 +331,7 @@ describe("DirectoryScanner", () => { expect(mockCodeParser.parseFile).toHaveBeenCalledWith("test/app.js", expect.any(Object)) expect(mockCodeParser.parseFile).toHaveBeenCalledWith("docs/guide.markdown", expect.any(Object)) - // Verify code blocks include both markdown and code content - expect(result.codeBlocks).toHaveLength(3) - expect(result.codeBlocks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: "markdown_header_h1" }), - expect.objectContaining({ type: "function" }), - expect.objectContaining({ type: "markdown_header_h2" }), - ]), - ) - + // Verify processing still works without codeBlocks accumulation expect(result.stats.processed).toBe(3) }) diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index 538a1252d7..e6ca297399 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -17,7 +17,7 @@ import { t } from "../../../i18n" import { QDRANT_CODE_BLOCK_NAMESPACE, MAX_FILE_SIZE_BYTES, - MAX_LIST_FILES_LIMIT, + MAX_LIST_FILES_LIMIT_CODE_INDEX, BATCH_SEGMENT_THRESHOLD, MAX_BATCH_RETRIES, INITIAL_RETRY_DELAY_MS, @@ -51,13 +51,13 @@ export class DirectoryScanner implements IDirectoryScanner { onError?: (error: Error) => void, onBlocksIndexed?: (indexedCount: number) => void, onFileParsed?: (fileBlockCount: number) => void, - ): Promise<{ codeBlocks: CodeBlock[]; stats: { processed: number; skipped: number }; totalBlockCount: number }> { + ): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { const directoryPath = directory // Capture workspace context at scan start const scanWorkspace = getWorkspacePathForContext(directoryPath) // Get all files recursively (handles .gitignore automatically) - const [allPaths, _] = await listFiles(directoryPath, true, MAX_LIST_FILES_LIMIT) + const [allPaths, _] = await listFiles(directoryPath, true, MAX_LIST_FILES_LIMIT_CODE_INDEX) // Filter out directories (marked with trailing '/') const filePaths = allPaths.filter((p) => !p.endsWith("/")) @@ -85,7 +85,6 @@ export class DirectoryScanner implements IDirectoryScanner { // Initialize tracking variables const processedFiles = new Set() - const codeBlocks: CodeBlock[] = [] let processedCount = 0 let skippedCount = 0 @@ -98,7 +97,7 @@ export class DirectoryScanner implements IDirectoryScanner { let currentBatchBlocks: CodeBlock[] = [] let currentBatchTexts: string[] = [] let currentBatchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[] = [] - const activeBatchPromises: Promise[] = [] + const activeBatchPromises = new Set>() // Initialize block counter let totalBlockCount = 0 @@ -125,6 +124,7 @@ export class DirectoryScanner implements IDirectoryScanner { // Check against cache const cachedFileHash = this.cacheManager.getHash(filePath) + const isNewFile = !cachedFileHash if (cachedFileHash === currentFileHash) { // File is unchanged skippedCount++ @@ -135,7 +135,6 @@ export class DirectoryScanner implements IDirectoryScanner { const blocks = await this.codeParser.parseFile(filePath, { content, fileHash: currentFileHash }) const fileBlockCount = blocks.length onFileParsed?.(fileBlockCount) - codeBlocks.push(...blocks) processedCount++ // Process embeddings if configured @@ -146,20 +145,11 @@ export class DirectoryScanner implements IDirectoryScanner { const trimmedContent = block.content.trim() if (trimmedContent) { const release = await mutex.acquire() - totalBlockCount += fileBlockCount try { currentBatchBlocks.push(block) currentBatchTexts.push(trimmedContent) addedBlocksFromFile = true - if (addedBlocksFromFile) { - currentBatchFileInfos.push({ - filePath, - fileHash: currentFileHash, - isNew: !this.cacheManager.getHash(filePath), - }) - } - // Check if batch threshold is met if (currentBatchBlocks.length >= BATCH_SEGMENT_THRESHOLD) { // Copy current batch data and clear accumulators @@ -181,13 +171,33 @@ export class DirectoryScanner implements IDirectoryScanner { onBlocksIndexed, ), ) - activeBatchPromises.push(batchPromise) + activeBatchPromises.add(batchPromise) + + // Clean up completed promises to prevent memory accumulation + batchPromise.finally(() => { + activeBatchPromises.delete(batchPromise) + }) } } finally { release() } } } + + // Add file info once per file (outside the block loop) + if (addedBlocksFromFile) { + const release = await mutex.acquire() + try { + totalBlockCount += fileBlockCount + currentBatchFileInfos.push({ + filePath, + fileHash: currentFileHash, + isNew: isNewFile, + }) + } finally { + release() + } + } } else { // Only update hash if not being processed in a batch await this.cacheManager.updateHash(filePath, currentFileHash) @@ -232,7 +242,12 @@ export class DirectoryScanner implements IDirectoryScanner { const batchPromise = batchLimiter(() => this.processBatch(batchBlocks, batchTexts, batchFileInfos, scanWorkspace, onError, onBlocksIndexed), ) - activeBatchPromises.push(batchPromise) + activeBatchPromises.add(batchPromise) + + // Clean up completed promises to prevent memory accumulation + batchPromise.finally(() => { + activeBatchPromises.delete(batchPromise) + }) } finally { release() } @@ -280,7 +295,6 @@ export class DirectoryScanner implements IDirectoryScanner { } return { - codeBlocks, stats: { processed: processedCount, skipped: skippedCount, 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/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index ae363a7b63..2e987b7c49 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -6,6 +6,9 @@ import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useAppTranslation } from "@src/i18n/TranslationContext" import { AutoApproveToggle, AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle" +import { StandardTooltip } from "@src/components/ui" +import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState" +import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles" interface AutoApproveMenuProps { style?: React.CSSProperties @@ -17,16 +20,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { const { autoApprovalEnabled, setAutoApprovalEnabled, - alwaysAllowReadOnly, - alwaysAllowWrite, - alwaysAllowExecute, - alwaysAllowBrowser, - alwaysAllowMcp, - alwaysAllowModeSwitch, - alwaysAllowSubtasks, alwaysApproveResubmit, - alwaysAllowFollowupQuestions, - alwaysAllowUpdateTodoList, allowedMaxRequests, setAlwaysAllowReadOnly, setAlwaysAllowWrite, @@ -43,10 +37,24 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { const { t } = useAppTranslation() + const baseToggles = useAutoApprovalToggles() + + // AutoApproveMenu needs alwaysApproveResubmit in addition to the base toggles + const toggles = useMemo( + () => ({ + ...baseToggles, + alwaysApproveResubmit: alwaysApproveResubmit, + }), + [baseToggles, alwaysApproveResubmit], + ) + + const { hasEnabledOptions, effectiveAutoApprovalEnabled } = useAutoApprovalState(toggles, autoApprovalEnabled) + const onAutoApproveToggle = useCallback( (key: AutoApproveSetting, value: boolean) => { vscode.postMessage({ type: key, bool: value }) + // Update the specific toggle state switch (key) { case "alwaysAllowReadOnly": setAlwaysAllowReadOnly(value) @@ -79,8 +87,30 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { setAlwaysAllowUpdateTodoList(value) break } + + // Check if we need to update the master auto-approval state + // Create a new toggles state with the updated value + const updatedToggles = { + ...toggles, + [key]: value, + } + + const willHaveEnabledOptions = Object.values(updatedToggles).some((v) => !!v) + + // If enabling the first option, enable master auto-approval + if (value && !hasEnabledOptions && willHaveEnabledOptions) { + setAutoApprovalEnabled(true) + vscode.postMessage({ type: "autoApprovalEnabled", bool: true }) + } + // If disabling the last option, disable master auto-approval + else if (!value && hasEnabledOptions && !willHaveEnabledOptions) { + setAutoApprovalEnabled(false) + vscode.postMessage({ type: "autoApprovalEnabled", bool: false }) + } }, [ + toggles, + hasEnabledOptions, setAlwaysAllowReadOnly, setAlwaysAllowWrite, setAlwaysAllowExecute, @@ -91,43 +121,32 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { setAlwaysApproveResubmit, setAlwaysAllowFollowupQuestions, setAlwaysAllowUpdateTodoList, + setAutoApprovalEnabled, ], ) - const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []) + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) - const toggles = useMemo( - () => ({ - alwaysAllowReadOnly: alwaysAllowReadOnly, - alwaysAllowWrite: alwaysAllowWrite, - alwaysAllowExecute: alwaysAllowExecute, - alwaysAllowBrowser: alwaysAllowBrowser, - alwaysAllowMcp: alwaysAllowMcp, - alwaysAllowModeSwitch: alwaysAllowModeSwitch, - alwaysAllowSubtasks: alwaysAllowSubtasks, - alwaysApproveResubmit: alwaysApproveResubmit, - alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions, - alwaysAllowUpdateTodoList: alwaysAllowUpdateTodoList, - }), - [ - alwaysAllowReadOnly, - alwaysAllowWrite, - alwaysAllowExecute, - alwaysAllowBrowser, - alwaysAllowMcp, - alwaysAllowModeSwitch, - alwaysAllowSubtasks, - alwaysApproveResubmit, - alwaysAllowFollowupQuestions, - alwaysAllowUpdateTodoList, - ], - ) + // Disable main checkbox while menu is open or no options selected + const isCheckboxDisabled = useMemo(() => { + return !hasEnabledOptions || isExpanded + }, [hasEnabledOptions, isExpanded]) const enabledActionsList = Object.entries(toggles) .filter(([_key, value]) => !!value) .map(([key]) => t(autoApproveSettingsConfig[key as AutoApproveSetting].labelKey)) .join(", ") + // Update displayed text logic + const displayText = useMemo(() => { + if (!effectiveAutoApprovalEnabled || !hasEnabledOptions) { + return t("chat:autoApprove.none") + } + return enabledActionsList || t("chat:autoApprove.none") + }, [effectiveAutoApprovalEnabled, hasEnabledOptions, enabledActionsList, t]) + const handleOpenSettings = useCallback( () => window.postMessage({ type: "action", action: "settingsButtonClicked", values: { section: "autoApprove" } }), @@ -155,14 +174,26 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { }} onClick={toggleExpanded}>
e.stopPropagation()}> - { - const newValue = !(autoApprovalEnabled ?? false) - setAutoApprovalEnabled(newValue) - vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue }) - }} - /> + + { + if (hasEnabledOptions) { + const newValue = !(autoApprovalEnabled ?? false) + setAutoApprovalEnabled(newValue) + vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue }) + } + // If no options enabled, do nothing + }} + /> +
{ flex: 1, minWidth: 0, }}> - {enabledActionsList || t("chat:autoApprove.none")} + {displayText} { 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 ? (
-