Message edit/delete overhaul (#5538)

* improved chat row first pass

* big UI improvements

* working functionality

* tests working

* ok finally tests working for real!

* translations

* add back hidden flag

* remove option to skip notif

* fixed image issue

* ui fix

* put back edit flag

* oops test fix

* reduce margins

* code review
This commit is contained in:
Will Li 2025-07-17 07:16:57 -07:00 committed by GitHub
parent 6cf376f832
commit fb374b3e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 1461 additions and 711 deletions

View file

@ -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()

View file

@ -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",
})
})
})
})

View file

@ -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<void> => {
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<void> => {
// 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<void> => {
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<void> => {
// 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<void> => {
// 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<void> => {
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()

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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 फॉर्मेट",

View file

@ -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",

View file

@ -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",

View file

@ -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フォーマットが無効です",

View file

@ -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 형식",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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 данных",

View file

@ -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ı",

View file

@ -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ệ",

View file

@ -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配置格式无效",

View file

@ -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 格式無效",

View file

@ -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<

View file

@ -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

View file

@ -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<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
chatButtonClicked: "chat",
settingsButtonClicked: "settings",
@ -56,16 +80,24 @@ const App = () => {
const [showAnnouncement, setShowAnnouncement] = useState(false)
const [tab, setTab] = useState<Tab>("chat")
const [humanRelayDialogState, setHumanRelayDialogState] = useState<{
isOpen: boolean
requestId: string
promptText: string
}>({
const [humanRelayDialogState, setHumanRelayDialogState] = useState<HumanRelayDialogState>({
isOpen: false,
requestId: "",
promptText: "",
})
const [deleteMessageDialogState, setDeleteMessageDialogState] = useState<DeleteMessageDialogState>({
isOpen: false,
messageTs: 0,
})
const [editMessageDialogState, setEditMessageDialogState] = useState<EditMessageDialogState>({
isOpen: false,
messageTs: 0,
text: "",
images: [],
})
const settingsRef = useRef<SettingsViewRef>(null)
const chatViewRef = useRef<ChatViewRef>(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)}
/>
<HumanRelayDialog
<MemoizedHumanRelayDialog
isOpen={humanRelayDialogState.isOpen}
requestId={humanRelayDialogState.requestId}
promptText={humanRelayDialogState.promptText}
@ -207,6 +252,30 @@ const App = () => {
onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
/>
<MemoizedDeleteMessageDialog
open={deleteMessageDialogState.isOpen}
onOpenChange={(open) => setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: open }))}
onConfirm={() => {
vscode.postMessage({
type: "deleteMessageConfirm",
messageTs: deleteMessageDialogState.messageTs,
})
setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: false }))
}}
/>
<MemoizedEditMessageDialog
open={editMessageDialogState.isOpen}
onOpenChange={(open) => setEditMessageDialogState((prev) => ({ ...prev, isOpen: open }))}
onConfirm={() => {
vscode.postMessage({
type: "editMessageConfirm",
messageTs: editMessageDialogState.messageTs,
text: editMessageDialogState.text,
images: editMessageDialogState.images,
})
setEditMessageDialogState((prev) => ({ ...prev, isOpen: false }))
}}
/>
</>
)
}

View file

@ -1,4 +1,5 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { appendImages } from "@src/utils/imageUtils"
import { McpExecution } from "./McpExecution"
import { useSize } from "react-use"
import { useTranslation, Trans } from "react-i18next"
@ -6,6 +7,7 @@ import deepEqual from "fast-deep-equal"
import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import type { ClineMessage } from "@roo-code/types"
import { Mode } from "@roo/modes"
import { ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool } from "@roo/ExtensionMessage"
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
@ -20,6 +22,9 @@ import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanu
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
import { Button } from "@src/components/ui"
import ChatTextArea from "./ChatTextArea"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
import CodeAccordian from "../common/CodeAccordian"
@ -109,14 +114,29 @@ export const ChatRowContent = ({
editable,
}: ChatRowContentProps) => {
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>(mode || "code")
const [editImages, setEditImages] = useState<string[]>([])
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 = ({
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap">
{isEditing ? (
<div className="flex flex-col gap-2 p-2">
<textarea
className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded-xs"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
rows={5}
autoFocus
<ChatTextArea
inputValue={editedContent}
setInputValue={setEditedContent}
sendingDisabled={false}
selectApiConfigDisabled={true}
placeholderText={t("chat:editMessage.placeholder")}
selectedImages={editImages}
setSelectedImages={setEditImages}
onSend={handleSaveEdit}
onSelectImages={handleSelectImages}
shouldDisableImages={false}
mode={editMode}
setMode={setEditMode}
modeShortcutText=""
isEditMode={true}
onCancel={handleCancelEdit}
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={handleCancelEdit}>
{t("chat:cancel.title")}
</Button>
<Button variant="default" size="sm" onClick={handleSaveEdit}>
{t("chat:save.title")}
</Button>
</div>
</div>
) : (
<div className="flex justify-between">

View file

@ -29,6 +29,7 @@ import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { cn } from "@/lib/utils"
import { usePromptHistory } from "./hooks/usePromptHistory"
import { EditModeControls } from "./EditModeControls"
interface ChatTextAreaProps {
inputValue: string
@ -45,6 +46,9 @@ interface ChatTextAreaProps {
mode: Mode
setMode: (value: Mode) => void
modeShortcutText: string
// Edit mode props
isEditMode?: boolean
onCancel?: () => void
}
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
@ -64,6 +68,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
mode,
setMode,
modeShortcutText,
isEditMode = false,
onCancel,
},
ref,
) => {
@ -796,6 +802,378 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
// Common mode selector handler
const handleModeChange = useCallback(
(value: Mode) => {
setMode(value)
vscode.postMessage({ type: "mode", text: value })
},
[setMode],
)
// Helper function to render mode selector
const renderModeSelector = () => (
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={handleModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
)
// Helper function to get API config dropdown options
const getApiConfigOptions = useMemo(() => {
const pinnedConfigs = (listApiConfigMeta || [])
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name,
type: DropdownOptionType.ITEM,
pinned: true,
}))
.sort((a, b) => a.label.localeCompare(b.label))
const unpinnedConfigs = (listApiConfigMeta || [])
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name,
type: DropdownOptionType.ITEM,
pinned: false,
}))
.sort((a, b) => a.label.localeCompare(b.label))
const hasPinnedAndUnpinned = pinnedConfigs.length > 0 && unpinnedConfigs.length > 0
return [
...pinnedConfigs,
...(hasPinnedAndUnpinned
? [
{
value: "sep-pinned",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
]
: []),
...unpinnedConfigs,
{
value: "sep-2",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
{
value: "settingsButtonClicked",
label: t("chat:edit"),
type: DropdownOptionType.ACTION,
},
]
}, [listApiConfigMeta, pinnedApiConfigs, t])
// Helper function to handle API config change
const handleApiConfigChange = useCallback((value: string) => {
if (value === "settingsButtonClicked") {
vscode.postMessage({
type: "loadApiConfiguration",
text: value,
values: { section: "providers" },
})
} else {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}
}, [])
// Helper function to render API config item
const renderApiConfigItem = useCallback(
({ type, value, label, pinned }: any) => {
if (type !== DropdownOptionType.ITEM) {
return label
}
const config = listApiConfigMeta?.find((c) => c.id === value)
const isCurrentConfig = config?.name === currentApiConfigName
return (
<div className="flex justify-between gap-2 w-full h-5">
<div
className={cn("truncate min-w-0 overflow-hidden", {
"font-medium": isCurrentConfig,
})}>
{label}
</div>
<div className="flex justify-end w-10 flex-shrink-0">
<div
className={cn("size-5 p-1", {
"block group-hover:hidden": !pinned,
hidden: !isCurrentConfig,
})}>
<Check className="size-3" />
</div>
<StandardTooltip content={pinned ? t("chat:unpin") : t("chat:pin")}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
togglePinnedApiConfig(value)
vscode.postMessage({
type: "toggleApiConfigPin",
text: value,
})
}}
className={cn("size-5", {
"hidden group-hover:flex": !pinned,
"bg-accent": pinned,
})}>
<Pin className="size-3 p-0.5 opacity-50" />
</Button>
</StandardTooltip>
</div>
</div>
)
},
[listApiConfigMeta, currentApiConfigName, t, togglePinnedApiConfig],
)
// Helper function to render non-edit mode controls
const renderNonEditModeControls = () => (
<div className={cn("flex", "justify-between", "items-center", "mt-auto")}>
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
<div className="shrink-0">{renderModeSelector()}</div>
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
<SelectDropdown
value={currentConfigId}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
disableSearch={false}
placeholder={displayName}
options={getApiConfigOptions}
onChange={handleApiConfigChange}
triggerClassName="w-full text-ellipsis overflow-hidden"
itemClassName="group"
renderItem={renderApiConfigItem}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
aria-label={t("chat:stopTts")}
onClick={() => vscode.postMessage({ type: "stopTts" })}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<VolumeX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
<IndexingStatusBadge />
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
)
// Helper function to render the text area section
const renderTextAreaSection = () => (
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"py-2",
"px-[9px]",
"z-10",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={3}
maxRows={15}
autoFocus={true}
className={cn(
"w-full",
"text-vscode-input-foreground",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"cursor-text",
isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isDraggingOver
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
: "bg-vscode-input-background",
"transition-background-color duration-150 ease-in-out",
"will-change-background-color",
"min-h-[90px]",
"box-border",
"rounded",
"resize-none",
"overflow-x-hidden",
"overflow-y-auto",
"pr-9",
"flex-none flex-grow",
"z-[2]",
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
/>
<div className="absolute top-1 right-1 z-30">
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? handleEnhancePrompt : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
</StandardTooltip>
</div>
{!isEditMode && (
<div className="absolute bottom-1 right-1 z-30">
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? onSend : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
)}
{!inputValue && !isEditMode && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8"
style={{
bottom: "0.25rem",
color: "var(--vscode-tab-inactiveForeground)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
)
return (
<div
className={cn(
@ -804,12 +1182,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"flex-col",
"gap-1",
"bg-editor-background",
"px-1.5",
isEditMode ? "px-0" : "px-1.5",
"pb-1",
"outline-none",
"border",
"border-none",
"w-[calc(100%-16px)]",
isEditMode ? "w-full" : "w-[calc(100%-16px)]",
"ml-auto",
"mr-auto",
"box-border",
@ -870,165 +1248,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
</div>
)}
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"py-2",
"px-[9px]",
"z-10",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={3}
maxRows={15}
autoFocus={true}
className={cn(
"w-full",
"text-vscode-input-foreground",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"cursor-text",
"py-1.5 px-2",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isDraggingOver
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
: "bg-vscode-input-background",
"transition-background-color duration-150 ease-in-out",
"will-change-background-color",
"min-h-[90px]",
"box-border",
"rounded",
"resize-none",
"overflow-x-hidden",
"overflow-y-auto",
"pr-9",
"flex-none flex-grow",
"z-[2]",
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
/>
<div className="absolute top-1 right-1 z-30">
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? handleEnhancePrompt : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
</StandardTooltip>
</div>
<div className="absolute bottom-1 right-1 z-30">
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? onSend : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
{!inputValue && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8"
style={{
bottom: "0.25rem",
color: "var(--vscode-tab-inactiveForeground)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
{renderTextAreaSection()}
</div>
{isEditMode && (
<EditModeControls
mode={mode}
onModeChange={handleModeChange}
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
onCancel={onCancel}
onSend={onSend}
onSelectImages={onSelectImages}
sendingDisabled={sendingDisabled}
shouldDisableImages={shouldDisableImages}
/>
)}
</div>
{selectedImages.length > 0 && (
@ -1043,186 +1280,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
)}
<div className={cn("flex", "justify-between", "items-center", "mt-auto")}>
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
<div className="shrink-0">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={(value) => {
setMode(value)
vscode.postMessage({ type: "mode", text: value })
}}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
<SelectDropdown
value={currentConfigId}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
disableSearch={false}
placeholder={displayName}
options={[
// Pinned items first.
...(listApiConfigMeta || [])
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name, // Keep name for comparison with currentApiConfigName.
type: DropdownOptionType.ITEM,
pinned: true,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
// If we have pinned items and unpinned items, add a separator.
...(pinnedApiConfigs &&
Object.keys(pinnedApiConfigs).length > 0 &&
(listApiConfigMeta || []).some((config) => !pinnedApiConfigs[config.id])
? [
{
value: "sep-pinned",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
]
: []),
// Unpinned items sorted alphabetically.
...(listApiConfigMeta || [])
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name, // Keep name for comparison with currentApiConfigName.
type: DropdownOptionType.ITEM,
pinned: false,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
{
value: "sep-2",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
{
value: "settingsButtonClicked",
label: t("chat:edit"),
type: DropdownOptionType.ACTION,
},
]}
onChange={(value) => {
if (value === "settingsButtonClicked") {
vscode.postMessage({
type: "loadApiConfiguration",
text: value,
values: { section: "providers" },
})
} else {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}
}}
triggerClassName="w-full text-ellipsis overflow-hidden"
itemClassName="group"
renderItem={({ type, value, label, pinned }) => {
if (type !== DropdownOptionType.ITEM) {
return label
}
const config = listApiConfigMeta?.find((c) => c.id === value)
const isCurrentConfig = config?.name === currentApiConfigName
return (
<div className="flex justify-between gap-2 w-full h-5">
<div
className={cn("truncate min-w-0 overflow-hidden", {
"font-medium": isCurrentConfig,
})}>
{label}
</div>
<div className="flex justify-end w-10 flex-shrink-0">
<div
className={cn("size-5 p-1", {
"block group-hover:hidden": !pinned,
hidden: !isCurrentConfig,
})}>
<Check className="size-3" />
</div>
<StandardTooltip content={pinned ? t("chat:unpin") : t("chat:pin")}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
togglePinnedApiConfig(value)
vscode.postMessage({
type: "toggleApiConfigPin",
text: value,
})
}}
className={cn("size-5", {
"hidden group-hover:flex": !pinned,
"bg-accent": pinned,
})}>
<Pin className="size-3 p-0.5 opacity-50" />
</Button>
</StandardTooltip>
</div>
</div>
)
}}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
aria-label={t("chat:stopTts")}
onClick={() => vscode.postMessage({ type: "stopTts" })}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<VolumeX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
<IndexingStatusBadge />
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
{!isEditMode && renderNonEditModeControls()}
</div>
)
},

View file

@ -9,6 +9,7 @@ import useSound from "use-sound"
import { LRUCache } from "lru-cache"
import { useDebounceEffect } from "@src/utils/useDebounceEffect"
import { appendImages } from "@src/utils/imageUtils"
import type { ClineAsk, ClineMessage } from "@roo-code/types"
@ -722,10 +723,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
break
case "selectedImages":
const newImages = message.images ?? []
if (newImages.length > 0) {
// Only handle selectedImages if it's not for editing context
// When context is "edit", ChatRow will handle the images
if (message.context !== "edit") {
setSelectedImages((prevImages) =>
[...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE),
appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE),
)
}
break

View file

@ -0,0 +1,115 @@
import React from "react"
import { Mode } from "@roo/modes"
import { Button, StandardTooltip } from "@/components/ui"
import { Image, SendHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import ModeSelector from "./ModeSelector"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface EditModeControlsProps {
mode: Mode
onModeChange: (value: Mode) => void
modeShortcutText: string
customModes: any
customModePrompts: any
onCancel?: () => void
onSend: () => void
onSelectImages: () => void
sendingDisabled: boolean
shouldDisableImages: boolean
}
export const EditModeControls: React.FC<EditModeControlsProps> = ({
mode,
onModeChange,
modeShortcutText,
customModes,
customModePrompts,
onCancel,
onSend,
onSelectImages,
sendingDisabled,
shouldDisableImages,
}) => {
const { t } = useAppTranslation()
return (
<div
className={cn(
"flex",
"items-center",
"justify-between",
"absolute",
"bottom-2",
"left-2",
"right-2",
"z-30",
)}>
<div className={cn("flex", "items-center", "gap-1", "flex-1", "min-w-0")}>
<div className="shrink-0">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={onModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0", "ml-2")}>
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={sendingDisabled}
className="text-xs bg-vscode-toolbar-hoverBackground hover:bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground">
Cancel
</Button>
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
<StandardTooltip content={t("chat:save.tooltip")}>
<button
aria-label={t("chat:save.tooltip")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? onSend : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
)
}

View file

@ -0,0 +1,62 @@
import React from "react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@src/components/ui"
interface MessageModificationConfirmationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
type: "edit" | "delete"
}
export const MessageModificationConfirmationDialog: React.FC<MessageModificationConfirmationDialogProps> = ({
open,
onOpenChange,
onConfirm,
type,
}) => {
const { t } = useAppTranslation()
const isEdit = type === "edit"
const title = isEdit ? t("common:confirmation.editMessage") : t("common:confirmation.deleteMessage")
const description = isEdit ? t("common:confirmation.editWarning") : t("common:confirmation.deleteWarning")
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-base">{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col gap-2">
<AlertDialogCancel className="bg-vscode-button-secondaryBackground hover:bg-vscode-button-secondaryHoverBackground text-vscode-button-secondaryForeground border-vscode-button-border">
{t("common:answers.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-vscode-button-background hover:bg-vscode-button-hoverBackground text-vscode-button-foreground border-vscode-button-border">
{t("common:confirmation.proceed")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// Export convenience components for backward compatibility
export const EditMessageDialog: React.FC<Omit<MessageModificationConfirmationDialogProps, "type">> = (props) => (
<MessageModificationConfirmationDialog {...props} type="edit" />
)
export const DeleteMessageDialog: React.FC<Omit<MessageModificationConfirmationDialogProps, "type">> = (props) => (
<MessageModificationConfirmationDialog {...props} type="delete" />
)

View file

@ -920,4 +920,54 @@ describe("ChatTextArea", () => {
expect(apiConfigDropdown).toHaveAttribute("disabled")
})
})
describe("edit mode integration", () => {
it("should render edit mode UI when isEditMode is true", () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
taskHistory: [],
cwd: "/test/workspace",
customModes: [],
customModePrompts: {},
})
render(<ChatTextArea {...defaultProps} isEditMode={true} />)
// The edit mode UI should be rendered
// We can verify this by checking for the presence of elements that are unique to edit mode
const cancelButton = screen.getByRole("button", { name: /cancel/i })
expect(cancelButton).toBeInTheDocument()
// Should show save button instead of send button
const saveButton = screen.getByRole("button", { name: /save/i })
expect(saveButton).toBeInTheDocument()
// Should not show send button in edit mode
const sendButton = screen.queryByRole("button", { name: /send.*message/i })
expect(sendButton).not.toBeInTheDocument()
})
it("should not render edit mode UI when isEditMode is false", () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
taskHistory: [],
cwd: "/test/workspace",
})
render(<ChatTextArea {...defaultProps} isEditMode={false} />)
// The edit mode UI should not be rendered
const cancelButton = screen.queryByRole("button", { name: /cancel/i })
expect(cancelButton).not.toBeInTheDocument()
// Should show send button when not in edit mode
const sendButton = screen.getByRole("button", { name: /send.*message/i })
expect(sendButton).toBeInTheDocument()
// Should not show save button when not in edit mode
const saveButton = screen.queryByRole("button", { name: /save/i })
expect(saveButton).not.toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,138 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { EditModeControls } from "../EditModeControls"
import { Mode } from "@roo/modes"
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock the UI components
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
}))
// Mock ModeSelector
vi.mock("../ModeSelector", () => ({
default: ({ value, onChange, title }: any) => (
<select value={value} onChange={(e) => onChange(e.target.value)} title={title}>
<option value="code">Code</option>
<option value="architect">Architect</option>
</select>
),
}))
describe("EditModeControls", () => {
const defaultProps = {
mode: "code" as Mode,
onModeChange: vi.fn(),
modeShortcutText: "Ctrl+M",
customModes: [],
customModePrompts: {},
onCancel: vi.fn(),
onSend: vi.fn(),
onSelectImages: vi.fn(),
sendingDisabled: false,
shouldDisableImages: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
it("renders all controls correctly", () => {
render(<EditModeControls {...defaultProps} />)
// Check for mode selector
expect(screen.getByTitle("chat:selectMode")).toBeInTheDocument()
// Check for Cancel button
expect(screen.getByText("Cancel")).toBeInTheDocument()
// Check for image button
expect(screen.getByTitle("chat:addImages")).toBeInTheDocument()
// Check for send button
expect(screen.getByTitle("chat:save.tooltip")).toBeInTheDocument()
})
it("calls onCancel when Cancel button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const cancelButton = screen.getByText("Cancel")
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it("calls onSend when send button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const sendButton = screen.getByLabelText("chat:save.tooltip")
fireEvent.click(sendButton)
expect(defaultProps.onSend).toHaveBeenCalledTimes(1)
})
it("calls onSelectImages when image button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const imageButton = screen.getByLabelText("chat:addImages")
fireEvent.click(imageButton)
expect(defaultProps.onSelectImages).toHaveBeenCalledTimes(1)
})
it("disables buttons when sendingDisabled is true", () => {
render(<EditModeControls {...defaultProps} sendingDisabled={true} />)
const cancelButton = screen.getByText("Cancel")
const sendButton = screen.getByLabelText("chat:save.tooltip")
expect(cancelButton).toBeDisabled()
expect(sendButton).toBeDisabled()
})
it("disables image button when shouldDisableImages is true", () => {
render(<EditModeControls {...defaultProps} shouldDisableImages={true} />)
const imageButton = screen.getByLabelText("chat:addImages")
expect(imageButton).toBeDisabled()
})
it("does not call onSelectImages when image button is disabled", () => {
render(<EditModeControls {...defaultProps} shouldDisableImages={true} />)
const imageButton = screen.getByLabelText("chat:addImages")
fireEvent.click(imageButton)
expect(defaultProps.onSelectImages).not.toHaveBeenCalled()
})
it("does not call onSend when send button is disabled", () => {
render(<EditModeControls {...defaultProps} sendingDisabled={true} />)
const sendButton = screen.getByLabelText("chat:save.tooltip")
fireEvent.click(sendButton)
expect(defaultProps.onSend).not.toHaveBeenCalled()
})
it("calls onModeChange when mode is changed", () => {
render(<EditModeControls {...defaultProps} />)
const modeSelector = screen.getByTitle("chat:selectMode")
fireEvent.change(modeSelector, { target: { value: "architect" } })
expect(defaultProps.onModeChange).toHaveBeenCalledWith("architect")
})
})

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Desar",
"tooltip": "Desa els canvis del fitxer"
"tooltip": "Desa els canvis del missatge"
},
"reject": {
"title": "Rebutjar",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Versió {{version}} - Feu clic per veure les notes de llançament"
},
"editMessage": {
"placeholder": "Edita el teu missatge..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI de dades de la imatge copiada al porta-retalls"
}
},
"confirmation": {
"deleteMessage": "Eliminar missatge",
"deleteWarning": "Eliminar aquest missatge eliminarà tots els missatges posteriors de la conversa. Vols continuar?",
"editMessage": "Editar missatge",
"editWarning": "Editar aquest missatge eliminarà tots els missatges posteriors de la conversa. Vols continuar?",
"proceed": "Continuar"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Speichern",
"tooltip": "Dateiänderungen speichern"
"tooltip": "Nachrichtenänderungen speichern"
},
"reject": {
"title": "Ablehnen",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Version {{version}} - Klicken Sie, um die Versionshinweise anzuzeigen"
},
"editMessage": {
"placeholder": "Bearbeite deine Nachricht..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "Bild-Daten-URI in die Zwischenablage kopiert"
}
},
"confirmation": {
"deleteMessage": "Nachricht löschen",
"deleteWarning": "Das Löschen dieser Nachricht wird alle nachfolgenden Nachrichten in der Unterhaltung löschen. Möchtest du fortfahren?",
"editMessage": "Nachricht bearbeiten",
"editWarning": "Das Bearbeiten dieser Nachricht wird alle nachfolgenden Nachrichten in der Unterhaltung löschen. Möchtest du fortfahren?",
"proceed": "Fortfahren"
}
}

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Save",
"tooltip": "Save the file changes"
"tooltip": "Save the message changes"
},
"tokenProgress": {
"availableSpace": "Available space: {{amount}} tokens",
@ -87,6 +87,9 @@
"title": "Cancel",
"tooltip": "Cancel the current operation"
},
"editMessage": {
"placeholder": "Edit your message..."
},
"scrollToBottom": "Scroll to bottom of chat",
"about": "Generate, refactor, and debug code with AI assistance. Check out our <DocsLink>documentation</DocsLink> to learn more.",
"onboarding": "Your task list in this workspace is empty.",

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "Image data URI copied to clipboard"
}
},
"confirmation": {
"deleteMessage": "Delete Message",
"deleteWarning": "Deleting this message will delete all subsequent messages in the conversation. Do you want to proceed?",
"editMessage": "Edit Message",
"editWarning": "Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
"proceed": "Proceed"
}
}

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Guardar",
"tooltip": "Guardar los cambios del archivo"
"tooltip": "Guardar los cambios del mensaje"
},
"tokenProgress": {
"availableSpace": "Espacio disponible: {{amount}} tokens",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Versión {{version}} - Haz clic para ver las notas de la versión"
},
"editMessage": {
"placeholder": "Edita tu mensaje..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI de datos de imagen copiada al portapapeles"
}
},
"confirmation": {
"deleteMessage": "Eliminar mensaje",
"deleteWarning": "Eliminar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿Deseas continuar?",
"editMessage": "Editar mensaje",
"editWarning": "Editar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿Deseas continuar?",
"proceed": "Continuar"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Enregistrer",
"tooltip": "Sauvegarder les modifications du fichier"
"tooltip": "Enregistrer les modifications du message"
},
"reject": {
"title": "Rejeter",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Version {{version}} - Cliquez pour voir les notes de version"
},
"editMessage": {
"placeholder": "Modifiez votre message..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI de données d'image copiée dans le presse-papiers"
}
},
"confirmation": {
"deleteMessage": "Supprimer le message",
"deleteWarning": "Supprimer ce message supprimera tous les messages suivants dans la conversation. Voulez-vous continuer ?",
"editMessage": "Modifier le message",
"editWarning": "Modifier ce message supprimera tous les messages suivants dans la conversation. Voulez-vous continuer ?",
"proceed": "Continuer"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "सहेजें",
"tooltip": "फ़ाइल परिवर्तन सहेजें"
"tooltip": "संदेश के बदलाव सहेजें"
},
"reject": {
"title": "अस्वीकार करें",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "संस्करण {{version}} - रिलीज़ नोट्स देखने के लिए क्लिक करें"
},
"editMessage": {
"placeholder": "अपना संदेश संपादित करें..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "इमेज डेटा URI क्लिपबोर्ड में कॉपी हो गया"
}
},
"confirmation": {
"deleteMessage": "संदेश हटाएं",
"deleteWarning": "इस संदेश को हटाने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?",
"editMessage": "संदेश संपादित करें",
"editWarning": "इस संदेश को संपादित करने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?",
"proceed": "जारी रखें"
}
}

View file

@ -45,7 +45,7 @@
},
"save": {
"title": "Simpan",
"tooltip": "Simpan perubahan file"
"tooltip": "Simpan perubahan pesan"
},
"tokenProgress": {
"availableSpace": "Ruang tersedia: {{amount}} token",
@ -328,5 +328,8 @@
},
"versionIndicator": {
"ariaLabel": "Versi {{version}} - Klik untuk melihat catatan rilis"
},
"editMessage": {
"placeholder": "Edit pesan Anda..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "Data URI gambar disalin ke clipboard"
}
},
"confirmation": {
"deleteMessage": "Hapus Pesan",
"deleteWarning": "Menghapus pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu ingin melanjutkan?",
"editMessage": "Edit Pesan",
"editWarning": "Mengedit pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu ingin melanjutkan?",
"proceed": "Lanjutkan"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Salva",
"tooltip": "Salva le modifiche al file"
"tooltip": "Salva le modifiche del messaggio"
},
"reject": {
"title": "Rifiuta",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Versione {{version}} - Clicca per visualizzare le note di rilascio"
},
"editMessage": {
"placeholder": "Modifica il tuo messaggio..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI dati immagine copiato negli appunti"
}
},
"confirmation": {
"deleteMessage": "Elimina Messaggio",
"deleteWarning": "Eliminando questo messaggio verranno eliminati tutti i messaggi successivi nella conversazione. Vuoi procedere?",
"editMessage": "Modifica Messaggio",
"editWarning": "Modificando questo messaggio verranno eliminati tutti i messaggi successivi nella conversazione. Vuoi procedere?",
"proceed": "Procedi"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "保存",
"tooltip": "ファイル変更を保存"
"tooltip": "メッセージの変更を保存"
},
"reject": {
"title": "拒否",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "バージョン {{version}} - クリックしてリリースノートを表示"
},
"editMessage": {
"placeholder": "メッセージを編集..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "画像データURIをクリップボードにコピーしました"
}
},
"confirmation": {
"deleteMessage": "メッセージを削除",
"deleteWarning": "このメッセージを削除すると、会話内の後続のメッセージもすべて削除されます。続行しますか?",
"editMessage": "メッセージを編集",
"editWarning": "このメッセージを編集すると、会話内の後続のメッセージもすべて削除されます。続行しますか?",
"proceed": "続行"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "저장",
"tooltip": "파일 변경사항 저장"
"tooltip": "메시지 변경사항 저장"
},
"reject": {
"title": "거부",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "버전 {{version}} - 릴리스 노트를 보려면 클릭하세요"
},
"editMessage": {
"placeholder": "메시지 편집..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "이미지 데이터 URI가 클립보드에 복사됨"
}
},
"confirmation": {
"deleteMessage": "메시지 삭제",
"deleteWarning": "이 메시지를 삭제하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?",
"editMessage": "메시지 편집",
"editWarning": "이 메시지를 편집하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?",
"proceed": "계속"
}
}

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Opslaan",
"tooltip": "Bestandswijzigingen opslaan"
"tooltip": "Berichtwijzigingen opslaan"
},
"tokenProgress": {
"availableSpace": "Beschikbare ruimte: {{amount}} tokens",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Versie {{version}} - Klik om release notes te bekijken"
},
"editMessage": {
"placeholder": "Bewerk je bericht..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "Afbeelding data-URI gekopieerd naar klembord"
}
},
"confirmation": {
"deleteMessage": "Bericht Verwijderen",
"deleteWarning": "Het verwijderen van dit bericht zal alle volgende berichten in het gesprek verwijderen. Wil je doorgaan?",
"editMessage": "Bericht Bewerken",
"editWarning": "Het bewerken van dit bericht zal alle volgende berichten in het gesprek verwijderen. Wil je doorgaan?",
"proceed": "Doorgaan"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Zapisz",
"tooltip": "Zapisz zmiany w pliku"
"tooltip": "Zapisz zmiany wiadomości"
},
"reject": {
"title": "Odrzuć",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Wersja {{version}} - Kliknij, aby wyświetlić informacje o wydaniu"
},
"editMessage": {
"placeholder": "Edytuj swoją wiadomość..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI danych obrazu skopiowane do schowka"
}
},
"confirmation": {
"deleteMessage": "Usuń Wiadomość",
"deleteWarning": "Usunięcie tej wiadomości spowoduje usunięcie wszystkich kolejnych wiadomości w rozmowie. Czy chcesz kontynuować?",
"editMessage": "Edytuj Wiadomość",
"editWarning": "Edycja tej wiadomości spowoduje usunięcie wszystkich kolejnych wiadomości w rozmowie. Czy chcesz kontynuować?",
"proceed": "Kontynuuj"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Salvar",
"tooltip": "Salvar as alterações do arquivo"
"tooltip": "Salvar as alterações da mensagem"
},
"reject": {
"title": "Rejeitar",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Versão {{version}} - Clique para ver as notas de lançamento"
},
"editMessage": {
"placeholder": "Edite sua mensagem..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI de dados da imagem copiada para a área de transferência"
}
},
"confirmation": {
"deleteMessage": "Excluir Mensagem",
"deleteWarning": "Excluir esta mensagem irá excluir todas as mensagens subsequentes na conversa. Deseja prosseguir?",
"editMessage": "Editar Mensagem",
"editWarning": "Editar esta mensagem irá excluir todas as mensagens subsequentes na conversa. Deseja prosseguir?",
"proceed": "Prosseguir"
}
}

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Сохранить",
"tooltip": "Сохранить изменения в файле"
"tooltip": "Сохранить изменения сообщения"
},
"tokenProgress": {
"availableSpace": "Доступно места: {{amount}} токенов",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Версия {{version}} - Нажмите, чтобы просмотреть примечания к выпуску"
},
"editMessage": {
"placeholder": "Редактировать сообщение..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI данных изображения скопирован в буфер обмена"
}
},
"confirmation": {
"deleteMessage": "Удалить Сообщение",
"deleteWarning": "Удаление этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите продолжить?",
"editMessage": "Редактировать Сообщение",
"editWarning": "Редактирование этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите продолжить?",
"proceed": "Продолжить"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Kaydet",
"tooltip": "Dosya değişikliklerini kaydet"
"tooltip": "Mesaj değişikliklerini kaydet"
},
"reject": {
"title": "Reddet",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Sürüm {{version}} - Sürüm notlarını görüntülemek için tıklayın"
},
"editMessage": {
"placeholder": "Mesajını düzenle..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "Görsel veri URI'si panoya kopyalandı"
}
},
"confirmation": {
"deleteMessage": "Mesajı Sil",
"deleteWarning": "Bu mesajı silmek, konuşmadaki sonraki tüm mesajları da silecektir. Devam etmek istiyor musun?",
"editMessage": "Mesajı Düzenle",
"editWarning": "Bu mesajı düzenlemek, konuşmadaki sonraki tüm mesajları da silecektir. Devam etmek istiyor musun?",
"proceed": "Devam Et"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Lưu",
"tooltip": "Lưu các thay đổi tệp"
"tooltip": "Lưu các thay đổi tin nhắn"
},
"reject": {
"title": "Từ chối",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "Phiên bản {{version}} - Nhấp để xem ghi chú phát hành"
},
"editMessage": {
"placeholder": "Chỉnh sửa tin nhắn của bạn..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "URI dữ liệu hình ảnh đã được sao chép vào clipboard"
}
},
"confirmation": {
"deleteMessage": "Xóa Tin Nhắn",
"deleteWarning": "Xóa tin nhắn này sẽ xóa tất cả các tin nhắn tiếp theo trong cuộc trò chuyện. Bạn có muốn tiếp tục không?",
"editMessage": "Chỉnh Sửa Tin Nhắn",
"editWarning": "Chỉnh sửa tin nhắn này sẽ xóa tất cả cá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"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "保存",
"tooltip": "保存文件更改"
"tooltip": "保存消息更改"
},
"reject": {
"title": "拒绝",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "版本 {{version}} - 点击查看发布说明"
},
"editMessage": {
"placeholder": "编辑消息..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "图片数据 URI 已复制到剪贴板"
}
},
"confirmation": {
"deleteMessage": "删除消息",
"deleteWarning": "删除此消息将删除对话中的所有后续消息。是否继续?",
"editMessage": "编辑消息",
"editWarning": "编辑此消息将删除对话中的所有后续消息。是否继续?",
"proceed": "继续"
}
}

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "儲存",
"tooltip": "儲存檔案變更"
"tooltip": "儲存訊息變更"
},
"reject": {
"title": "拒絕",
@ -322,5 +322,8 @@
},
"versionIndicator": {
"ariaLabel": "版本 {{version}} - 點擊查看發布說明"
},
"editMessage": {
"placeholder": "編輯訊息..."
}
}

View file

@ -51,5 +51,12 @@
"success": {
"imageDataUriCopied": "圖片資料 URI 已複製到剪貼簿"
}
},
"confirmation": {
"deleteMessage": "刪除訊息",
"deleteWarning": "刪除此訊息將刪除對話中的所有後續訊息。是否繼續?",
"editMessage": "編輯訊息",
"editWarning": "編輯此訊息將刪除對話中的所有後續訊息。是否繼續?",
"proceed": "繼續"
}
}

View file

@ -0,0 +1,17 @@
/**
* Utility function to append new images to existing images array
* while respecting the maximum image limit
*
* @param currentImages - The current array of images
* @param newImages - The new images to append
* @param maxImages - The maximum number of images allowed
* @returns The updated images array
*/
export function appendImages(currentImages: string[], newImages: string[] | undefined, maxImages: number): string[] {
const imagesToAdd = newImages ?? []
if (imagesToAdd.length === 0) {
return currentImages
}
return [...currentImages, ...imagesToAdd].slice(0, maxImages)
}