mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
edit/delete user message by the following commit:
commit 6384ca56bd30b5cadfa3b61cee3124f521177eaf Merge:0c481a3cf85268aaecAuthor: NaccOll <wuyoubin0504@gmail.com> Date: Mon Aug 25 18:00:51 2025 +0800 Merge branch 'will/edit-w-checkpoints' into main commit85268aaec7Author: Will Li <willeli2002@gmail.com> Date: Fri Jul 25 09:57:08 2025 -0700 remove some extra code commit0bd49d7e54Merge:a78aed87ad62a26057Author: Will Li <willeli2002@gmail.com> Date: Fri Jul 25 09:23:24 2025 -0700 Merge branch 'main' into will/edit-w-checkpoints commita78aed87aeAuthor: Will Li <willeli2002@gmail.com> Date: Wed Jul 23 04:06:27 2025 -0700 further simplification commitb8c5ddaa20Author: Will Li <willeli2002@gmail.com> Date: Wed Jul 23 03:17:54 2025 -0700 clean code more commit6657a7d894Merge:7baefefec9956cc1f4Author: Will Li <willeli2002@gmail.com> Date: Wed Jul 23 03:03:58 2025 -0700 Merge branch 'main' into will/edit-w-checkpoints commit7baefefec6Author: Will Li <willeli2002@gmail.com> Date: Wed Jul 23 01:20:46 2025 -0700 reduce checkpoint changes commitab46f94c26Merge:15ce9e35e5629199d5Author: Matt Rubens <mrubens@users.noreply.github.com> Date: Tue Jul 22 14:51:14 2025 -0400 Merge remote-tracking branch 'origin/main' into will/edit-w-checkpoints commit15ce9e35edMerge:09d0b29fa90148401eAuthor: Will Li <willeli2002@gmail.com> Date: Fri Jul 18 15:33:21 2025 -0700 merge main commit09d0b29fa0Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 17 08:41:08 2025 -0700 fix typo commit17658baa01Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 17 08:39:36 2025 -0700 fix weird autolint again commit2ab8d4982aAuthor: Will Li <willeli2002@gmail.com> Date: Thu Jul 17 08:35:05 2025 -0700 other merge fixes commit0e9954dfe7Merge:2c77f32befb374b3e9Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 17 08:34:29 2025 -0700 Merge branch 'main' into will/edit-w-checkpoints commit2c77f32be6Author: Will Li <willeli2002@gmail.com> Date: Mon Jul 14 15:56:18 2025 -0700 clean logging commit616c4b6f30Author: Will Li <willeli2002@gmail.com> Date: Mon Jul 14 15:42:08 2025 -0700 do translations commit701560823eAuthor: Will Li <willeli2002@gmail.com> Date: Mon Jul 14 15:26:05 2025 -0700 fix race cond and lint commitcaee1dc87fAuthor: Will Li <willeli2002@gmail.com> Date: Mon Jul 14 14:52:06 2025 -0700 tests, optimizations, refactors commitbccffac992Author: Will Li <willeli2002@gmail.com> Date: Mon Jul 14 11:41:27 2025 -0700 working commit930e70e26fAuthor: Will Li <willeli2002@gmail.com> Date: Sun Jul 13 18:03:51 2025 -0700 refactor commit6044c1af74Author: Will Li <willeli2002@gmail.com> Date: Sun Jul 13 15:26:24 2025 -0700 initial print fix commit0872b8b044Author: Will Li <willeli2002@gmail.com> Date: Sun Jul 13 10:36:45 2025 -0700 cleaner code commitd3c655d351Author: Will Li <willeli2002@gmail.com> Date: Sun Jul 13 00:19:49 2025 -0700 working version, still some default checkpointing bugs commit63c7c7c230Author: Will Li <willeli2002@gmail.com> Date: Fri Jul 11 15:56:33 2025 -0700 some merge fixes commit7b77d80a73Merge:ce0c1821d69c399673Author: Will Li <willeli2002@gmail.com> Date: Fri Jul 11 09:49:47 2025 -0700 Merge branch 'will/edit-delete-overhaul' into will/edit-w-checkpoints commit69c3996739Merge:4604211ff406b366f4Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 10 20:46:31 2025 -0700 Merge branch 'main' into will/edit-delete-overhaul commit4604211ffaAuthor: Will Li <willeli2002@gmail.com> Date: Thu Jul 10 20:26:35 2025 -0700 ui fix commit5edab52884Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 10 18:47:21 2025 -0700 fixed image issue commitf89d768101Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 10 17:26:36 2025 -0700 remove option to skip notif commitce0c1821d0Author: Will Li <willeli2002@gmail.com> Date: Thu Jul 10 17:10:12 2025 -0700 temp push commit67fd5a78adAuthor: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 17:08:10 2025 -0700 add back hidden flag commita17bf98ad6Author: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 17:06:43 2025 -0700 translations commited4a2a7b19Author: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 16:48:11 2025 -0700 ok finally tests working for real! commit303a9c8c1fAuthor: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 16:33:04 2025 -0700 tests working commit24a8c10048Author: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 15:32:50 2025 -0700 working functionality commitd926a771bcAuthor: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 13:02:18 2025 -0700 big UI improvements commitff8c188d3aAuthor: Will Li <willeli2002@gmail.com> Date: Wed Jul 9 09:00:43 2025 -0700 improved chat row first pass
This commit is contained in:
parent
e2c1a20a23
commit
a1f4a30cab
37 changed files with 2522 additions and 364 deletions
434
src/core/checkpoints/__tests__/checkpoint.test.ts
Normal file
434
src/core/checkpoints/__tests__/checkpoint.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { Task } from "../../task/Task"
|
||||
import { ClineProvider } from "../../webview/ClineProvider"
|
||||
import { checkpointSave, checkpointRestore, checkpointDiff, getCheckpointService } from "../index"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// Mock vscode
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showErrorMessage: vi.fn(),
|
||||
createTextEditorDecorationType: vi.fn(() => ({})),
|
||||
showInformationMessage: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
file: vi.fn((path: string) => ({ fsPath: path })),
|
||||
parse: vi.fn((uri: string) => ({ with: vi.fn(() => ({})) })),
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock("@roo-code/telemetry", () => ({
|
||||
TelemetryService: {
|
||||
instance: {
|
||||
captureCheckpointCreated: vi.fn(),
|
||||
captureCheckpointRestored: vi.fn(),
|
||||
captureCheckpointDiffed: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../../utils/path", () => ({
|
||||
getWorkspacePath: vi.fn(() => "/test/workspace"),
|
||||
}))
|
||||
|
||||
vi.mock("../../../services/checkpoints")
|
||||
|
||||
describe("Checkpoint functionality", () => {
|
||||
let mockProvider: any
|
||||
let mockTask: any
|
||||
let mockCheckpointService: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock checkpoint service
|
||||
mockCheckpointService = {
|
||||
isInitialized: true,
|
||||
saveCheckpoint: vi.fn().mockResolvedValue({ commit: "test-commit-hash" }),
|
||||
restoreCheckpoint: vi.fn().mockResolvedValue(undefined),
|
||||
getDiff: vi.fn().mockResolvedValue([]),
|
||||
on: vi.fn(),
|
||||
initShadowGit: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
// Create mock provider
|
||||
mockProvider = {
|
||||
context: {
|
||||
globalStorageUri: { fsPath: "/test/storage" },
|
||||
},
|
||||
log: vi.fn(),
|
||||
postMessageToWebview: vi.fn(),
|
||||
postStateToWebview: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
}
|
||||
|
||||
// Create mock task
|
||||
mockTask = {
|
||||
taskId: "test-task-id",
|
||||
enableCheckpoints: true,
|
||||
checkpointService: mockCheckpointService,
|
||||
checkpointServiceInitializing: false,
|
||||
providerRef: {
|
||||
deref: () => mockProvider,
|
||||
},
|
||||
clineMessages: [],
|
||||
apiConversationHistory: [],
|
||||
pendingUserMessageCheckpoint: undefined,
|
||||
say: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn(),
|
||||
overwriteApiConversationHistory: vi.fn(),
|
||||
combineMessages: vi.fn().mockReturnValue([]),
|
||||
}
|
||||
|
||||
// Update the mock to return our mockCheckpointService
|
||||
const checkpointsModule = await import("../../../services/checkpoints")
|
||||
vi.mocked(checkpointsModule.RepoPerTaskCheckpointService.create).mockReturnValue(mockCheckpointService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("checkpointSave", () => {
|
||||
it("should wait for checkpoint service initialization before saving", async () => {
|
||||
// Set up task with uninitialized service
|
||||
mockCheckpointService.isInitialized = false
|
||||
mockTask.checkpointService = mockCheckpointService
|
||||
|
||||
// Simulate service initialization after a delay
|
||||
setTimeout(() => {
|
||||
mockCheckpointService.isInitialized = true
|
||||
}, 100)
|
||||
|
||||
// Call checkpointSave
|
||||
const savePromise = checkpointSave(mockTask, true)
|
||||
|
||||
// Wait for the save to complete
|
||||
const result = await savePromise
|
||||
|
||||
// saveCheckpoint should have been called
|
||||
expect(mockCheckpointService.saveCheckpoint).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Task: test-task-id"),
|
||||
{ allowEmpty: true },
|
||||
)
|
||||
|
||||
// Result should contain the commit hash
|
||||
expect(result).toEqual({ commit: "test-commit-hash" })
|
||||
|
||||
// Task should still have checkpoints enabled
|
||||
expect(mockTask.enableCheckpoints).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle timeout when service doesn't initialize", async () => {
|
||||
// Service never initializes
|
||||
mockCheckpointService.isInitialized = false
|
||||
|
||||
// Call checkpointSave with a task that has no checkpoint service
|
||||
const taskWithNoService = {
|
||||
...mockTask,
|
||||
checkpointService: undefined,
|
||||
enableCheckpoints: false,
|
||||
}
|
||||
|
||||
const result = await checkpointSave(taskWithNoService, true)
|
||||
|
||||
// Result should be undefined
|
||||
expect(result).toBeUndefined()
|
||||
|
||||
// saveCheckpoint should not have been called
|
||||
expect(mockCheckpointService.saveCheckpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should preserve checkpoint data through message deletion flow", async () => {
|
||||
// Initialize service
|
||||
mockCheckpointService.isInitialized = true
|
||||
mockTask.checkpointService = mockCheckpointService
|
||||
|
||||
// Simulate saving checkpoint before user message
|
||||
const checkpointResult = await checkpointSave(mockTask, true)
|
||||
expect(checkpointResult).toEqual({ commit: "test-commit-hash" })
|
||||
|
||||
// Simulate setting pendingUserMessageCheckpoint
|
||||
if (checkpointResult && "commit" in checkpointResult) {
|
||||
mockTask.pendingUserMessageCheckpoint = {
|
||||
hash: checkpointResult.commit,
|
||||
timestamp: Date.now(),
|
||||
type: "user_message",
|
||||
}
|
||||
}
|
||||
|
||||
// Verify checkpoint data is preserved
|
||||
expect(mockTask.pendingUserMessageCheckpoint).toBeDefined()
|
||||
expect(mockTask.pendingUserMessageCheckpoint.hash).toBe("test-commit-hash")
|
||||
|
||||
// Simulate message deletion and reinitialization
|
||||
mockTask.clineMessages = []
|
||||
mockTask.checkpointService = mockCheckpointService // Keep service available
|
||||
mockTask.checkpointServiceInitializing = false
|
||||
|
||||
// Save checkpoint again after deletion
|
||||
const newCheckpointResult = await checkpointSave(mockTask, true)
|
||||
|
||||
// Should still work after reinitialization
|
||||
expect(newCheckpointResult).toEqual({ commit: "test-commit-hash" })
|
||||
expect(mockTask.enableCheckpoints).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle errors gracefully and disable checkpoints", async () => {
|
||||
mockCheckpointService.saveCheckpoint.mockRejectedValue(new Error("Save failed"))
|
||||
|
||||
const result = await checkpointSave(mockTask)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(mockTask.enableCheckpoints).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkpointRestore", () => {
|
||||
beforeEach(() => {
|
||||
mockTask.clineMessages = [
|
||||
{ ts: 1, say: "user", text: "Message 1" },
|
||||
{ ts: 2, say: "assistant", text: "Message 2" },
|
||||
{ ts: 3, say: "user", text: "Message 3" },
|
||||
]
|
||||
mockTask.apiConversationHistory = [
|
||||
{ ts: 1, role: "user", content: [{ type: "text", text: "Message 1" }] },
|
||||
{ ts: 2, role: "assistant", content: [{ type: "text", text: "Message 2" }] },
|
||||
{ ts: 3, role: "user", content: [{ type: "text", text: "Message 3" }] },
|
||||
]
|
||||
})
|
||||
|
||||
it("should restore checkpoint for delete operation", async () => {
|
||||
await checkpointRestore(mockTask, {
|
||||
ts: 2,
|
||||
commitHash: "abc123",
|
||||
mode: "restore",
|
||||
operation: "delete",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.restoreCheckpoint).toHaveBeenCalledWith("abc123")
|
||||
expect(mockTask.overwriteApiConversationHistory).toHaveBeenCalledWith([
|
||||
{ ts: 1, role: "user", content: [{ type: "text", text: "Message 1" }] },
|
||||
])
|
||||
expect(mockTask.overwriteClineMessages).toHaveBeenCalledWith([{ ts: 1, say: "user", text: "Message 1" }])
|
||||
expect(mockProvider.cancelTask).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should restore checkpoint for edit operation", async () => {
|
||||
await checkpointRestore(mockTask, {
|
||||
ts: 2,
|
||||
commitHash: "abc123",
|
||||
mode: "restore",
|
||||
operation: "edit",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.restoreCheckpoint).toHaveBeenCalledWith("abc123")
|
||||
expect(mockTask.overwriteApiConversationHistory).toHaveBeenCalledWith([
|
||||
{ ts: 1, role: "user", content: [{ type: "text", text: "Message 1" }] },
|
||||
])
|
||||
// For edit operation, should include the message being edited
|
||||
expect(mockTask.overwriteClineMessages).toHaveBeenCalledWith([
|
||||
{ ts: 1, say: "user", text: "Message 1" },
|
||||
{ ts: 2, say: "assistant", text: "Message 2" },
|
||||
])
|
||||
expect(mockProvider.cancelTask).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle preview mode without modifying messages", async () => {
|
||||
await checkpointRestore(mockTask, {
|
||||
ts: 2,
|
||||
commitHash: "abc123",
|
||||
mode: "preview",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.restoreCheckpoint).toHaveBeenCalledWith("abc123")
|
||||
expect(mockTask.overwriteApiConversationHistory).not.toHaveBeenCalled()
|
||||
expect(mockTask.overwriteClineMessages).not.toHaveBeenCalled()
|
||||
expect(mockProvider.cancelTask).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle missing message gracefully", async () => {
|
||||
await checkpointRestore(mockTask, {
|
||||
ts: 999, // Non-existent timestamp
|
||||
commitHash: "abc123",
|
||||
mode: "restore",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.restoreCheckpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should disable checkpoints on error", async () => {
|
||||
mockCheckpointService.restoreCheckpoint.mockRejectedValue(new Error("Restore failed"))
|
||||
|
||||
await checkpointRestore(mockTask, {
|
||||
ts: 2,
|
||||
commitHash: "abc123",
|
||||
mode: "restore",
|
||||
})
|
||||
|
||||
expect(mockTask.enableCheckpoints).toBe(false)
|
||||
expect(mockProvider.log).toHaveBeenCalledWith("[checkpointRestore] disabling checkpoints for this task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkpointDiff", () => {
|
||||
beforeEach(() => {
|
||||
mockTask.clineMessages = [
|
||||
{ ts: 1, say: "user", text: "Message 1" },
|
||||
{ ts: 2, say: "checkpoint_saved", text: "commit1" },
|
||||
{ ts: 3, say: "user", text: "Message 2" },
|
||||
{ ts: 4, say: "checkpoint_saved", text: "commit2" },
|
||||
]
|
||||
})
|
||||
|
||||
it("should show diff for full mode", async () => {
|
||||
const mockChanges = [
|
||||
{
|
||||
paths: { absolute: "/test/file.ts", relative: "file.ts" },
|
||||
content: { before: "old content", after: "new content" },
|
||||
},
|
||||
]
|
||||
mockCheckpointService.getDiff.mockResolvedValue(mockChanges)
|
||||
|
||||
await checkpointDiff(mockTask, {
|
||||
ts: 4,
|
||||
commitHash: "commit2",
|
||||
mode: "full",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.getDiff).toHaveBeenCalledWith({
|
||||
from: undefined,
|
||||
to: "commit2",
|
||||
})
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
|
||||
"vscode.changes",
|
||||
"Changes since task started",
|
||||
expect.any(Array),
|
||||
)
|
||||
})
|
||||
|
||||
it("should show diff for checkpoint mode with previous commit", async () => {
|
||||
const mockChanges = [
|
||||
{
|
||||
paths: { absolute: "/test/file.ts", relative: "file.ts" },
|
||||
content: { before: "old content", after: "new content" },
|
||||
},
|
||||
]
|
||||
mockCheckpointService.getDiff.mockResolvedValue(mockChanges)
|
||||
|
||||
await checkpointDiff(mockTask, {
|
||||
ts: 4,
|
||||
previousCommitHash: "commit1",
|
||||
commitHash: "commit2",
|
||||
mode: "checkpoint",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.getDiff).toHaveBeenCalledWith({
|
||||
from: "commit1",
|
||||
to: "commit2",
|
||||
})
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
|
||||
"vscode.changes",
|
||||
"Changes since previous checkpoint",
|
||||
expect.any(Array),
|
||||
)
|
||||
})
|
||||
|
||||
it("should find previous checkpoint automatically in checkpoint mode", async () => {
|
||||
const mockChanges = [
|
||||
{
|
||||
paths: { absolute: "/test/file.ts", relative: "file.ts" },
|
||||
content: { before: "old content", after: "new content" },
|
||||
},
|
||||
]
|
||||
mockCheckpointService.getDiff.mockResolvedValue(mockChanges)
|
||||
|
||||
await checkpointDiff(mockTask, {
|
||||
ts: 4,
|
||||
commitHash: "commit2",
|
||||
mode: "checkpoint",
|
||||
})
|
||||
|
||||
expect(mockCheckpointService.getDiff).toHaveBeenCalledWith({
|
||||
from: "commit1", // Should find the previous checkpoint
|
||||
to: "commit2",
|
||||
})
|
||||
})
|
||||
|
||||
it("should show information message when no changes found", async () => {
|
||||
mockCheckpointService.getDiff.mockResolvedValue([])
|
||||
|
||||
await checkpointDiff(mockTask, {
|
||||
ts: 4,
|
||||
commitHash: "commit2",
|
||||
mode: "full",
|
||||
})
|
||||
|
||||
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("No changes found.")
|
||||
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should disable checkpoints on error", async () => {
|
||||
mockCheckpointService.getDiff.mockRejectedValue(new Error("Diff failed"))
|
||||
|
||||
await checkpointDiff(mockTask, {
|
||||
ts: 4,
|
||||
commitHash: "commit2",
|
||||
mode: "full",
|
||||
})
|
||||
|
||||
expect(mockTask.enableCheckpoints).toBe(false)
|
||||
expect(mockProvider.log).toHaveBeenCalledWith("[checkpointDiff] disabling checkpoints for this task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCheckpointService", () => {
|
||||
it("should return existing service if available", () => {
|
||||
const service = getCheckpointService(mockTask)
|
||||
expect(service).toBe(mockCheckpointService)
|
||||
})
|
||||
|
||||
it("should return undefined if checkpoints are disabled", () => {
|
||||
mockTask.enableCheckpoints = false
|
||||
const service = getCheckpointService(mockTask)
|
||||
expect(service).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return undefined if service is still initializing", () => {
|
||||
mockTask.checkpointService = undefined
|
||||
mockTask.checkpointServiceInitializing = true
|
||||
const service = getCheckpointService(mockTask)
|
||||
expect(service).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should create new service if none exists", async () => {
|
||||
mockTask.checkpointService = undefined
|
||||
mockTask.checkpointServiceInitializing = false
|
||||
|
||||
const service = getCheckpointService(mockTask)
|
||||
|
||||
const checkpointsModule = await import("../../../services/checkpoints")
|
||||
expect(vi.mocked(checkpointsModule.RepoPerTaskCheckpointService.create)).toHaveBeenCalledWith({
|
||||
taskId: "test-task-id",
|
||||
workspaceDir: "/test/workspace",
|
||||
shadowDir: "/test/storage",
|
||||
log: expect.any(Function),
|
||||
})
|
||||
})
|
||||
|
||||
it("should disable checkpoints if workspace path is not found", async () => {
|
||||
const pathModule = await import("../../../utils/path")
|
||||
vi.mocked(pathModule.getWorkspacePath).mockReturnValue(null as any)
|
||||
|
||||
mockTask.checkpointService = undefined
|
||||
mockTask.checkpointServiceInitializing = false
|
||||
|
||||
const service = getCheckpointService(mockTask)
|
||||
|
||||
expect(service).toBeUndefined()
|
||||
expect(mockTask.enableCheckpoints).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -184,9 +184,13 @@ export type CheckpointRestoreOptions = {
|
|||
ts: number
|
||||
commitHash: string
|
||||
mode: "preview" | "restore"
|
||||
operation?: "delete" | "edit" // Optional to maintain backward compatibility
|
||||
}
|
||||
|
||||
export async function checkpointRestore(task: Task, { ts, commitHash, mode }: CheckpointRestoreOptions) {
|
||||
export async function checkpointRestore(
|
||||
task: Task,
|
||||
{ ts, commitHash, mode, operation = "delete" }: CheckpointRestoreOptions,
|
||||
) {
|
||||
const service = await getCheckpointService(task)
|
||||
|
||||
if (!service) {
|
||||
|
|
@ -215,7 +219,10 @@ export async function checkpointRestore(task: Task, { ts, commitHash, mode }: Ch
|
|||
task.combineMessages(deletedMessages),
|
||||
)
|
||||
|
||||
await task.overwriteClineMessages(task.clineMessages.slice(0, index + 1))
|
||||
// For delete operations, exclude the checkpoint message itself
|
||||
// For edit operations, include the checkpoint message (to be edited)
|
||||
const endIndex = operation === "edit" ? index + 1 : index
|
||||
await task.overwriteClineMessages(task.clineMessages.slice(0, endIndex))
|
||||
|
||||
// TODO: Verify that this is working as expected.
|
||||
await task.say(
|
||||
|
|
|
|||
|
|
@ -97,6 +97,20 @@ import { getUri } from "./getUri"
|
|||
* https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
|
||||
*/
|
||||
|
||||
export type ClineProviderEvents = {
|
||||
clineCreated: [cline: Task]
|
||||
}
|
||||
|
||||
interface PendingEditOperation {
|
||||
messageTs: number
|
||||
editedContent: string
|
||||
images?: string[]
|
||||
messageIndex: number
|
||||
apiConversationHistoryIndex: number
|
||||
timeoutId: NodeJS.Timeout
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export class ClineProvider
|
||||
extends EventEmitter<TaskProviderEvents>
|
||||
implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike
|
||||
|
|
@ -121,6 +135,8 @@ export class ClineProvider
|
|||
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
|
||||
|
||||
private recentTasksCache?: string[]
|
||||
private pendingOperations: Map<string, PendingEditOperation> = new Map()
|
||||
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
|
||||
|
||||
public isViewLaunched = false
|
||||
public settingsImportedAt?: number
|
||||
|
|
@ -440,6 +456,71 @@ export class ClineProvider
|
|||
// the 'parent' calling task).
|
||||
await this.getCurrentTask()?.completeSubtask(lastMessage)
|
||||
}
|
||||
// Pending Edit Operations Management
|
||||
|
||||
/**
|
||||
* Sets a pending edit operation with automatic timeout cleanup
|
||||
*/
|
||||
public setPendingEditOperation(
|
||||
operationId: string,
|
||||
editData: {
|
||||
messageTs: number
|
||||
editedContent: string
|
||||
images?: string[]
|
||||
messageIndex: number
|
||||
apiConversationHistoryIndex: number
|
||||
},
|
||||
): void {
|
||||
// Clear any existing operation with the same ID
|
||||
this.clearPendingEditOperation(operationId)
|
||||
|
||||
// Create timeout for automatic cleanup
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.clearPendingEditOperation(operationId)
|
||||
this.log(`[setPendingEditOperation] Automatically cleared stale pending operation: ${operationId}`)
|
||||
}, ClineProvider.PENDING_OPERATION_TIMEOUT_MS)
|
||||
|
||||
// Store the operation
|
||||
this.pendingOperations.set(operationId, {
|
||||
...editData,
|
||||
timeoutId,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
this.log(`[setPendingEditOperation] Set pending operation: ${operationId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a pending edit operation by ID
|
||||
*/
|
||||
private getPendingEditOperation(operationId: string): PendingEditOperation | undefined {
|
||||
return this.pendingOperations.get(operationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a specific pending edit operation
|
||||
*/
|
||||
private clearPendingEditOperation(operationId: string): boolean {
|
||||
const operation = this.pendingOperations.get(operationId)
|
||||
if (operation) {
|
||||
clearTimeout(operation.timeoutId)
|
||||
this.pendingOperations.delete(operationId)
|
||||
this.log(`[clearPendingEditOperation] Cleared pending operation: ${operationId}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending edit operations
|
||||
*/
|
||||
private clearAllPendingEditOperations(): void {
|
||||
for (const [operationId, operation] of this.pendingOperations) {
|
||||
clearTimeout(operation.timeoutId)
|
||||
}
|
||||
this.pendingOperations.clear()
|
||||
this.log(`[clearAllPendingEditOperations] Cleared all pending operations`)
|
||||
}
|
||||
|
||||
/*
|
||||
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
|
||||
|
|
@ -465,6 +546,10 @@ export class ClineProvider
|
|||
|
||||
this.log("Cleared all tasks")
|
||||
|
||||
// Clear all pending edit operations to prevent memory leaks
|
||||
this.clearAllPendingEditOperations()
|
||||
this.log("Cleared pending operations")
|
||||
|
||||
if (this.view && "dispose" in this.view) {
|
||||
this.view.dispose()
|
||||
this.log("Disposed webview")
|
||||
|
|
@ -805,6 +890,49 @@ export class ClineProvider
|
|||
`[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
||||
)
|
||||
|
||||
// Check if there's a pending edit after checkpoint restoration
|
||||
const operationId = `task-${task.taskId}`
|
||||
const pendingEdit = this.getPendingEditOperation(operationId)
|
||||
if (pendingEdit) {
|
||||
this.clearPendingEditOperation(operationId) // Clear the pending edit
|
||||
|
||||
this.log(`[createTaskWithHistoryItem] Processing pending edit after checkpoint restoration`)
|
||||
|
||||
// Process the pending edit after a short delay to ensure the task is fully initialized
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Find the message index in the restored state
|
||||
const { messageIndex, apiConversationHistoryIndex } = (() => {
|
||||
const messageIndex = task.clineMessages.findIndex((msg) => msg.ts === pendingEdit.messageTs)
|
||||
const apiConversationHistoryIndex = task.apiConversationHistory.findIndex(
|
||||
(msg) => msg.ts === pendingEdit.messageTs,
|
||||
)
|
||||
return { messageIndex, apiConversationHistoryIndex }
|
||||
})()
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
// Remove the target message and all subsequent messages
|
||||
await task.overwriteClineMessages(task.clineMessages.slice(0, messageIndex))
|
||||
|
||||
if (apiConversationHistoryIndex !== -1) {
|
||||
await task.overwriteApiConversationHistory(
|
||||
task.apiConversationHistory.slice(0, apiConversationHistoryIndex),
|
||||
)
|
||||
}
|
||||
|
||||
// Process the edited message
|
||||
await task.handleWebviewAskResponse(
|
||||
"messageResponse",
|
||||
pendingEdit.editedContent,
|
||||
pendingEdit.images,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`[createTaskWithHistoryItem] Error processing pending edit: ${error}`)
|
||||
}
|
||||
}, 100) // Small delay to ensure task is fully ready
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ vi.mock("axios", () => ({
|
|||
|
||||
vi.mock("../../../utils/safeWriteJson")
|
||||
|
||||
vi.mock("../../../utils/storage", () => ({
|
||||
getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"),
|
||||
getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"),
|
||||
getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"),
|
||||
}))
|
||||
|
||||
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
|
||||
CallToolResultSchema: {},
|
||||
ListResourcesResultSchema: {},
|
||||
|
|
@ -1179,8 +1185,8 @@ describe("ClineProvider", () => {
|
|||
const mockMessages = [
|
||||
{ ts: 1000, type: "say", say: "user_feedback" }, // User message 1
|
||||
{ ts: 2000, type: "say", say: "tool" }, // Tool message
|
||||
{ ts: 3000, type: "say", say: "text", value: 4000 }, // Message to delete
|
||||
{ ts: 4000, type: "say", say: "browser_action" }, // Response to delete
|
||||
{ ts: 3000, type: "say", say: "text" }, // Message before delete
|
||||
{ ts: 4000, type: "say", say: "browser_action" }, // Message to delete
|
||||
{ ts: 5000, type: "say", say: "user_feedback" }, // Next user message
|
||||
{ ts: 6000, type: "say", say: "user_feedback" }, // Final message
|
||||
] as ClineMessage[]
|
||||
|
|
@ -1216,22 +1222,30 @@ describe("ClineProvider", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 4000,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
// 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]])
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
|
||||
mockMessages[0],
|
||||
mockMessages[1],
|
||||
mockMessages[2],
|
||||
])
|
||||
|
||||
// Verify only API messages before the deleted message were kept
|
||||
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
|
||||
mockApiHistory[0],
|
||||
mockApiHistory[1],
|
||||
mockApiHistory[2],
|
||||
])
|
||||
|
||||
// Verify createTaskWithHistoryItem was called
|
||||
expect((provider as any).createTaskWithHistoryItem).toHaveBeenCalledWith({ id: "test-task-id" })
|
||||
// createTaskWithHistoryItem is only called when restoring checkpoints or aborting tasks
|
||||
expect((provider as any).createTaskWithHistoryItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("handles case when no current task exists", async () => {
|
||||
|
|
@ -1261,8 +1275,8 @@ describe("ClineProvider", () => {
|
|||
const mockMessages = [
|
||||
{ ts: 1000, type: "say", say: "user_feedback" }, // User message 1
|
||||
{ ts: 2000, type: "say", say: "tool" }, // Tool message
|
||||
{ ts: 3000, type: "say", say: "text", value: 4000 }, // Message to edit
|
||||
{ ts: 4000, type: "say", say: "browser_action" }, // Response to edit
|
||||
{ ts: 3000, type: "say", say: "text" }, // Message before edit
|
||||
{ ts: 4000, type: "say", say: "browser_action" }, // Message to edit
|
||||
{ ts: 5000, type: "say", say: "user_feedback" }, // Next user message
|
||||
{ ts: 6000, type: "say", say: "user_feedback" }, // Final message
|
||||
] as ClineMessage[]
|
||||
|
|
@ -1309,6 +1323,8 @@ describe("ClineProvider", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 4000,
|
||||
text: "Edited message content",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming edit through the dialog
|
||||
|
|
@ -1319,12 +1335,17 @@ describe("ClineProvider", () => {
|
|||
})
|
||||
|
||||
// Verify correct messages were kept (only messages before the edited one)
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]])
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
|
||||
mockMessages[0],
|
||||
mockMessages[1],
|
||||
mockMessages[2],
|
||||
])
|
||||
|
||||
// Verify correct API messages were kept (only messages before the edited one)
|
||||
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
|
||||
mockApiHistory[0],
|
||||
mockApiHistory[1],
|
||||
mockApiHistory[2],
|
||||
])
|
||||
|
||||
// The new flow calls webviewMessageHandler recursively with askResponse
|
||||
|
|
@ -3012,6 +3033,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 3000,
|
||||
text: "Edited message with preserved images",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate confirmation
|
||||
|
|
@ -3021,9 +3044,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
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 }])
|
||||
// Verify messages were edited correctly - messages up to the edited message should remain
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]])
|
||||
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }, { ts: 2000 }])
|
||||
})
|
||||
|
||||
test("handles editing messages with file attachments", async () => {
|
||||
|
|
@ -3064,6 +3087,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 3000,
|
||||
text: "Edited message with file attachment",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
@ -3120,6 +3145,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 2000,
|
||||
text: "Edited message",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
@ -3160,6 +3187,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 2000,
|
||||
text: "Edited message",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
@ -3216,11 +3245,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 2000,
|
||||
text: "Edited message 1",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showEditMessageDialog",
|
||||
messageTs: 4000,
|
||||
text: "Edited message 2",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming both edits
|
||||
|
|
@ -3406,6 +3439,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 5000,
|
||||
text: "Edited non-existent message",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
@ -3446,6 +3481,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 5000,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
// Simulate user confirming the delete
|
||||
|
|
@ -3497,6 +3533,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 2000,
|
||||
text: "Edited message",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
@ -3536,6 +3574,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 2000,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
// Simulate user confirming the delete
|
||||
|
|
@ -3589,6 +3628,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 2000,
|
||||
text: largeEditedContent,
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
@ -3631,18 +3672,23 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 3000,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
// 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 }])
|
||||
// Should handle large payloads without issues - keeps messages before the deleted one
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]])
|
||||
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }, { ts: 2000 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Messaging and User Feedback", () => {
|
||||
beforeEach(async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
})
|
||||
|
||||
// Note: Error messaging test removed as the implementation may not have proper error handling in place
|
||||
|
||||
test("provides user feedback for successful operations", async () => {
|
||||
|
|
@ -3669,6 +3715,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 2000,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
// Simulate user confirming the delete
|
||||
|
|
@ -3677,6 +3724,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
// Verify successful operation completed
|
||||
expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
|
||||
expect(provider.createTaskWithHistoryItem).toHaveBeenCalled()
|
||||
// createTaskWithHistoryItem is only called when restoring checkpoints or aborting tasks
|
||||
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -3742,6 +3790,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 1000,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
// Simulate user confirming the delete
|
||||
|
|
@ -3792,6 +3841,8 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: futureTimestamp + 1000,
|
||||
text: "Edited future message",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
|
||||
// Simulate user confirming the edit
|
||||
|
|
|
|||
242
src/core/webview/__tests__/checkpointRestoreHandler.spec.ts
Normal file
242
src/core/webview/__tests__/checkpointRestoreHandler.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { handleCheckpointRestoreOperation } from "../checkpointRestoreHandler"
|
||||
import { saveTaskMessages } from "../../task-persistence"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../task-persistence", () => ({
|
||||
saveTaskMessages: vi.fn(),
|
||||
}))
|
||||
vi.mock("p-wait-for")
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showErrorMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("checkpointRestoreHandler", () => {
|
||||
let mockProvider: any
|
||||
let mockCline: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup mock Cline instance
|
||||
mockCline = {
|
||||
taskId: "test-task-123",
|
||||
abort: false,
|
||||
abortTask: vi.fn(() => {
|
||||
mockCline.abort = true
|
||||
}),
|
||||
checkpointRestore: vi.fn(),
|
||||
clineMessages: [
|
||||
{ ts: 1, type: "user", say: "user", text: "First message" },
|
||||
{ ts: 2, type: "assistant", say: "assistant", text: "Response" },
|
||||
{
|
||||
ts: 3,
|
||||
type: "user",
|
||||
say: "user",
|
||||
text: "Checkpoint message",
|
||||
checkpoint: { hash: "abc123" },
|
||||
},
|
||||
{ ts: 4, type: "assistant", say: "assistant", text: "After checkpoint" },
|
||||
],
|
||||
}
|
||||
|
||||
// Setup mock provider
|
||||
mockProvider = {
|
||||
getCurrentTask: vi.fn(() => mockCline),
|
||||
postMessageToWebview: vi.fn(),
|
||||
getTaskWithId: vi.fn(() => ({
|
||||
historyItem: { id: "test-task-123", messages: mockCline.clineMessages },
|
||||
})),
|
||||
createTaskWithHistoryItem: vi.fn(),
|
||||
setPendingEditOperation: vi.fn(),
|
||||
contextProxy: {
|
||||
globalStorageUri: { fsPath: "/test/storage" },
|
||||
},
|
||||
}
|
||||
|
||||
// Mock pWaitFor to resolve immediately
|
||||
;(pWaitFor as any).mockImplementation(async (condition: () => boolean) => {
|
||||
// Simulate the condition being met
|
||||
return Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleCheckpointRestoreOperation", () => {
|
||||
it("should abort task before checkpoint restore for delete operations", async () => {
|
||||
// Simulate a task that hasn't been aborted yet
|
||||
mockCline.abort = false
|
||||
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "delete",
|
||||
})
|
||||
|
||||
// Verify abortTask was called before checkpointRestore
|
||||
expect(mockCline.abortTask).toHaveBeenCalled()
|
||||
expect(mockCline.checkpointRestore).toHaveBeenCalled()
|
||||
|
||||
// Verify the order of operations
|
||||
const abortOrder = mockCline.abortTask.mock.invocationCallOrder[0]
|
||||
const restoreOrder = mockCline.checkpointRestore.mock.invocationCallOrder[0]
|
||||
expect(abortOrder).toBeLessThan(restoreOrder)
|
||||
})
|
||||
|
||||
it("should not abort task if already aborted", async () => {
|
||||
// Simulate a task that's already aborted
|
||||
mockCline.abort = true
|
||||
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "delete",
|
||||
})
|
||||
|
||||
// Verify abortTask was not called
|
||||
expect(mockCline.abortTask).not.toHaveBeenCalled()
|
||||
expect(mockCline.checkpointRestore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle edit operations with pending edit data", async () => {
|
||||
const editData = {
|
||||
editedContent: "Edited content",
|
||||
images: ["image1.png"],
|
||||
apiConversationHistoryIndex: 2,
|
||||
}
|
||||
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "edit",
|
||||
editData,
|
||||
})
|
||||
|
||||
// Verify abortTask was NOT called for edit operations
|
||||
expect(mockCline.abortTask).not.toHaveBeenCalled()
|
||||
|
||||
// Verify pending edit operation was set
|
||||
expect(mockProvider.setPendingEditOperation).toHaveBeenCalledWith("task-test-task-123", {
|
||||
messageTs: 3,
|
||||
editedContent: "Edited content",
|
||||
images: ["image1.png"],
|
||||
messageIndex: 2,
|
||||
apiConversationHistoryIndex: 2,
|
||||
})
|
||||
|
||||
// Verify checkpoint restore was called with edit operation
|
||||
expect(mockCline.checkpointRestore).toHaveBeenCalledWith({
|
||||
ts: 3,
|
||||
commitHash: "abc123",
|
||||
mode: "restore",
|
||||
operation: "edit",
|
||||
})
|
||||
})
|
||||
|
||||
it("should save messages after delete operation", async () => {
|
||||
// Mock the checkpoint restore to simulate message deletion
|
||||
mockCline.checkpointRestore.mockImplementation(async () => {
|
||||
mockCline.clineMessages = mockCline.clineMessages.slice(0, 2)
|
||||
})
|
||||
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "delete",
|
||||
})
|
||||
|
||||
// Verify saveTaskMessages was called
|
||||
expect(saveTaskMessages).toHaveBeenCalledWith({
|
||||
messages: mockCline.clineMessages,
|
||||
taskId: "test-task-123",
|
||||
globalStoragePath: "/test/storage",
|
||||
})
|
||||
|
||||
// Verify createTaskWithHistoryItem was called
|
||||
expect(mockProvider.createTaskWithHistoryItem).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should reinitialize task with correct history item after delete", async () => {
|
||||
const expectedHistoryItem = {
|
||||
id: "test-task-123",
|
||||
messages: mockCline.clineMessages,
|
||||
}
|
||||
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "delete",
|
||||
})
|
||||
|
||||
// Verify getTaskWithId was called
|
||||
expect(mockProvider.getTaskWithId).toHaveBeenCalledWith("test-task-123")
|
||||
|
||||
// Verify createTaskWithHistoryItem was called with the correct history item
|
||||
expect(mockProvider.createTaskWithHistoryItem).toHaveBeenCalledWith(expectedHistoryItem)
|
||||
})
|
||||
|
||||
it("should not save messages or reinitialize for edit operation", async () => {
|
||||
const editData = {
|
||||
editedContent: "Edited content",
|
||||
images: [],
|
||||
apiConversationHistoryIndex: 2,
|
||||
}
|
||||
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "edit",
|
||||
editData,
|
||||
})
|
||||
|
||||
// Verify saveTaskMessages was NOT called for edit operation
|
||||
expect(saveTaskMessages).not.toHaveBeenCalled()
|
||||
|
||||
// Verify createTaskWithHistoryItem was NOT called for edit operation
|
||||
expect(mockProvider.createTaskWithHistoryItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
// Mock checkpoint restore to throw an error
|
||||
mockCline.checkpointRestore.mockRejectedValue(new Error("Checkpoint restore failed"))
|
||||
|
||||
// The function should throw and show an error message
|
||||
await expect(
|
||||
handleCheckpointRestoreOperation({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "delete",
|
||||
}),
|
||||
).rejects.toThrow("Checkpoint restore failed")
|
||||
|
||||
// Verify error message was shown
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||
"Error during checkpoint restore: Checkpoint restore failed",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { webviewMessageHandler } from "../webviewMessageHandler"
|
||||
import { saveTaskMessages } from "../../task-persistence"
|
||||
import { handleCheckpointRestoreOperation } from "../checkpointRestoreHandler"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../task-persistence")
|
||||
vi.mock("../checkpointRestoreHandler")
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showErrorMessage: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("webviewMessageHandler - checkpoint operations", () => {
|
||||
let mockProvider: any
|
||||
let mockCline: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup mock Cline instance
|
||||
mockCline = {
|
||||
taskId: "test-task-123",
|
||||
clineMessages: [
|
||||
{ ts: 1, type: "user", say: "user", text: "First message" },
|
||||
{ ts: 2, type: "assistant", say: "checkpoint_saved", text: "abc123" },
|
||||
{ ts: 3, type: "user", say: "user", text: "Message to delete" },
|
||||
{ ts: 4, type: "assistant", say: "assistant", text: "After message" },
|
||||
],
|
||||
apiConversationHistory: [
|
||||
{ ts: 1, role: "user", content: [{ type: "text", text: "First message" }] },
|
||||
{ ts: 3, role: "user", content: [{ type: "text", text: "Message to delete" }] },
|
||||
{ ts: 4, role: "assistant", content: [{ type: "text", text: "After message" }] },
|
||||
],
|
||||
checkpointRestore: vi.fn(),
|
||||
overwriteClineMessages: vi.fn(),
|
||||
overwriteApiConversationHistory: vi.fn(),
|
||||
}
|
||||
|
||||
// Setup mock provider
|
||||
mockProvider = {
|
||||
getCurrentTask: vi.fn(() => mockCline),
|
||||
postMessageToWebview: vi.fn(),
|
||||
getTaskWithId: vi.fn(() => ({
|
||||
historyItem: { id: "test-task-123", messages: mockCline.clineMessages },
|
||||
})),
|
||||
createTaskWithHistoryItem: vi.fn(),
|
||||
setPendingEditOperation: vi.fn(),
|
||||
contextProxy: {
|
||||
globalStorageUri: { fsPath: "/test/storage" },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("delete operations with checkpoint restoration", () => {
|
||||
it("should call handleCheckpointRestoreOperation for checkpoint deletes", async () => {
|
||||
// Mock handleCheckpointRestoreOperation
|
||||
;(handleCheckpointRestoreOperation as any).mockResolvedValue(undefined)
|
||||
|
||||
// Call the handler with delete confirmation
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "deleteMessageConfirm",
|
||||
messageTs: 3,
|
||||
restoreCheckpoint: true,
|
||||
})
|
||||
|
||||
// Verify handleCheckpointRestoreOperation was called with correct parameters
|
||||
expect(handleCheckpointRestoreOperation).toHaveBeenCalledWith({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "delete",
|
||||
})
|
||||
})
|
||||
|
||||
it("should save messages for non-checkpoint deletes", async () => {
|
||||
// Call the handler with delete confirmation (no checkpoint restoration)
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "deleteMessageConfirm",
|
||||
messageTs: 2,
|
||||
restoreCheckpoint: false,
|
||||
})
|
||||
|
||||
// Verify saveTaskMessages was called
|
||||
expect(saveTaskMessages).toHaveBeenCalledWith({
|
||||
messages: expect.any(Array),
|
||||
taskId: "test-task-123",
|
||||
globalStoragePath: "/test/storage",
|
||||
})
|
||||
|
||||
// Verify checkpoint restore was NOT called
|
||||
expect(mockCline.checkpointRestore).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("edit operations with checkpoint restoration", () => {
|
||||
it("should call handleCheckpointRestoreOperation for checkpoint edits", async () => {
|
||||
// Mock handleCheckpointRestoreOperation
|
||||
;(handleCheckpointRestoreOperation as any).mockResolvedValue(undefined)
|
||||
|
||||
// Call the handler with edit confirmation
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "editMessageConfirm",
|
||||
messageTs: 3,
|
||||
text: "Edited checkpoint message",
|
||||
restoreCheckpoint: true,
|
||||
})
|
||||
|
||||
// Verify handleCheckpointRestoreOperation was called with correct parameters
|
||||
expect(handleCheckpointRestoreOperation).toHaveBeenCalledWith({
|
||||
provider: mockProvider,
|
||||
currentCline: mockCline,
|
||||
messageTs: 3,
|
||||
messageIndex: 2,
|
||||
checkpoint: { hash: "abc123" },
|
||||
operation: "edit",
|
||||
editData: {
|
||||
editedContent: "Edited checkpoint message",
|
||||
images: undefined,
|
||||
apiConversationHistoryIndex: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -550,7 +550,10 @@ describe("webviewMessageHandler - message dialog preferences", () => {
|
|||
|
||||
describe("deleteMessage", () => {
|
||||
it("should always show dialog for delete confirmation", async () => {
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({} as any)
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
|
||||
clineMessages: [],
|
||||
apiConversationHistory: [],
|
||||
} as any) // Mock current cline with proper structure
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "deleteMessage",
|
||||
|
|
@ -560,13 +563,17 @@ describe("webviewMessageHandler - message dialog preferences", () => {
|
|||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs: 123456789,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("submitEditedMessage", () => {
|
||||
it("should always show dialog for edit confirmation", async () => {
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({} as any)
|
||||
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
|
||||
clineMessages: [],
|
||||
apiConversationHistory: [],
|
||||
} as any) // Mock current cline with proper structure
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "submitEditedMessage",
|
||||
|
|
@ -578,6 +585,8 @@ describe("webviewMessageHandler - message dialog preferences", () => {
|
|||
type: "showEditMessageDialog",
|
||||
messageTs: 123456789,
|
||||
text: "edited content",
|
||||
hasCheckpoint: false,
|
||||
images: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
104
src/core/webview/checkpointRestoreHandler.ts
Normal file
104
src/core/webview/checkpointRestoreHandler.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { Task } from "../task/Task"
|
||||
import { ClineProvider } from "./ClineProvider"
|
||||
import { saveTaskMessages } from "../task-persistence"
|
||||
import * as vscode from "vscode"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import { t } from "../../i18n"
|
||||
|
||||
export interface CheckpointRestoreConfig {
|
||||
provider: ClineProvider
|
||||
currentCline: Task
|
||||
messageTs: number
|
||||
messageIndex: number
|
||||
checkpoint: { hash: string }
|
||||
operation: "delete" | "edit"
|
||||
editData?: {
|
||||
editedContent: string
|
||||
images?: string[]
|
||||
apiConversationHistoryIndex: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles checkpoint restoration for both delete and edit operations.
|
||||
* This consolidates the common logic while handling operation-specific behavior.
|
||||
*/
|
||||
export async function handleCheckpointRestoreOperation(config: CheckpointRestoreConfig): Promise<void> {
|
||||
const { provider, currentCline, messageTs, checkpoint, operation, editData } = config
|
||||
|
||||
try {
|
||||
// For delete operations, ensure the task is properly aborted to handle any pending ask operations
|
||||
// This prevents "Current ask promise was ignored" errors
|
||||
// For edit operations, we don't abort because the checkpoint restore will handle it
|
||||
if (operation === "delete" && currentCline && !currentCline.abort) {
|
||||
currentCline.abortTask()
|
||||
// Wait a bit for the abort to complete
|
||||
await pWaitFor(() => currentCline.abort === true, {
|
||||
timeout: 1000,
|
||||
interval: 50,
|
||||
}).catch(() => {
|
||||
// Continue even if timeout - the abort flag should be set
|
||||
})
|
||||
}
|
||||
|
||||
// For edit operations, set up pending edit data before restoration
|
||||
if (operation === "edit" && editData) {
|
||||
const operationId = `task-${currentCline.taskId}`
|
||||
provider.setPendingEditOperation(operationId, {
|
||||
messageTs,
|
||||
editedContent: editData.editedContent,
|
||||
images: editData.images,
|
||||
messageIndex: config.messageIndex,
|
||||
apiConversationHistoryIndex: editData.apiConversationHistoryIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Perform the checkpoint restoration
|
||||
await currentCline.checkpointRestore({
|
||||
ts: messageTs,
|
||||
commitHash: checkpoint.hash,
|
||||
mode: "restore",
|
||||
operation,
|
||||
})
|
||||
|
||||
// For delete operations, we need to save messages and reinitialize
|
||||
// For edit operations, the reinitialization happens automatically
|
||||
// and processes the pending edit
|
||||
if (operation === "delete") {
|
||||
// Save the updated messages to disk after checkpoint restoration
|
||||
await saveTaskMessages({
|
||||
messages: currentCline.clineMessages,
|
||||
taskId: currentCline.taskId,
|
||||
globalStoragePath: provider.contextProxy.globalStorageUri.fsPath,
|
||||
})
|
||||
|
||||
// Get the updated history item and reinitialize
|
||||
const { historyItem } = await provider.getTaskWithId(currentCline.taskId)
|
||||
await provider.createTaskWithHistoryItem(historyItem)
|
||||
}
|
||||
// For edit operations, the task cancellation in checkpointRestore
|
||||
// will trigger reinitialization, which will process pendingEditAfterRestore
|
||||
} catch (error) {
|
||||
console.error(`Error in checkpoint restore (${operation}):`, error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error during checkpoint restore: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common checkpoint restore validation and initialization utility.
|
||||
* This can be used by any checkpoint restore flow that needs to wait for initialization.
|
||||
*/
|
||||
export async function waitForClineInitialization(provider: ClineProvider, timeoutMs: number = 3000): Promise<boolean> {
|
||||
try {
|
||||
await pWaitFor(() => provider.getCurrentTask()?.isInitialized === true, {
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(t("common:errors.checkpoint_timeout"))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,10 @@ import { CloudService } from "@roo-code/cloud"
|
|||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { type ApiMessage } from "../task-persistence/apiMessages"
|
||||
import { saveTaskMessages } from "../task-persistence"
|
||||
|
||||
import { ClineProvider } from "./ClineProvider"
|
||||
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
|
||||
import { changeLanguage, t } from "../../i18n"
|
||||
import { Package } from "../../shared/package"
|
||||
import { RouterName, toRouterName, ModelRecord } from "../../shared/api"
|
||||
|
|
@ -71,10 +73,10 @@ export const webviewMessageHandler = async (
|
|||
* Shared utility to find message indices based on timestamp
|
||||
*/
|
||||
const findMessageIndices = (messageTs: number, currentCline: any) => {
|
||||
const timeCutoff = messageTs - 1000 // 1 second buffer before the message
|
||||
const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts && msg.ts >= timeCutoff)
|
||||
// Find the exact message by timestamp, not the first one after a cutoff
|
||||
const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts === messageTs)
|
||||
const apiConversationHistoryIndex = currentCline.apiConversationHistory.findIndex(
|
||||
(msg: ApiMessage) => msg.ts && msg.ts >= timeCutoff,
|
||||
(msg: ApiMessage) => msg.ts === messageTs,
|
||||
)
|
||||
return { messageIndex, apiConversationHistoryIndex }
|
||||
}
|
||||
|
|
@ -101,38 +103,110 @@ export const webviewMessageHandler = async (
|
|||
* Handles message deletion operations with user confirmation
|
||||
*/
|
||||
const handleDeleteOperation = async (messageTs: number): Promise<void> => {
|
||||
// Check if there's a checkpoint before this message
|
||||
const currentCline = provider.getCurrentTask()
|
||||
let hasCheckpoint = false
|
||||
if (currentCline) {
|
||||
const { messageIndex } = findMessageIndices(messageTs, currentCline)
|
||||
if (messageIndex !== -1) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages
|
||||
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
|
||||
hasCheckpoint = checkpoints.length > 0
|
||||
} else {
|
||||
console.log("[webviewMessageHandler] Message not found! Looking for ts:", messageTs)
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to webview to show delete confirmation dialog
|
||||
await provider.postMessageToWebview({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs,
|
||||
hasCheckpoint,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles confirmed message deletion from webview dialog
|
||||
*/
|
||||
const handleDeleteMessageConfirm = async (messageTs: number): Promise<void> => {
|
||||
// Only proceed if we have a current task.
|
||||
if (provider.getCurrentTask()) {
|
||||
const currentCline = provider.getCurrentTask()!
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
const handleDeleteMessageConfirm = async (messageTs: number, restoreCheckpoint?: boolean): Promise<void> => {
|
||||
const currentCline = provider.getCurrentTask()
|
||||
if (!currentCline) {
|
||||
console.error("[handleDeleteMessageConfirm] No current cline available")
|
||||
return
|
||||
}
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
try {
|
||||
const { historyItem } = await provider.getTaskWithId(currentCline.taskId)
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
|
||||
// Delete this message and all subsequent messages
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
if (messageIndex === -1) {
|
||||
const errorMessage = `Message with timestamp ${messageTs} not found`
|
||||
console.error("[handleDeleteMessageConfirm]", errorMessage)
|
||||
await vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize with history item after deletion
|
||||
await provider.createTaskWithHistoryItem(historyItem)
|
||||
} catch (error) {
|
||||
console.error("Error in delete message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error deleting message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
try {
|
||||
const targetMessage = currentCline.clineMessages[messageIndex]
|
||||
|
||||
// If checkpoint restoration is requested, find and restore to the last checkpoint before this message
|
||||
if (restoreCheckpoint) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages
|
||||
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
|
||||
const lastCheckpoint = checkpoints[0]
|
||||
|
||||
if (lastCheckpoint && lastCheckpoint.text) {
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider,
|
||||
currentCline,
|
||||
messageTs: targetMessage.ts!,
|
||||
messageIndex,
|
||||
checkpoint: { hash: lastCheckpoint.text },
|
||||
operation: "delete",
|
||||
})
|
||||
} else {
|
||||
// No checkpoint found before this message
|
||||
console.log("[handleDeleteMessageConfirm] No checkpoint found before message")
|
||||
vscode.window.showWarningMessage("No checkpoint found before this message")
|
||||
}
|
||||
} else {
|
||||
// For non-checkpoint deletes, preserve checkpoint associations for remaining messages
|
||||
// Store checkpoints from messages that will be preserved
|
||||
const preservedCheckpoints = new Map<number, any>()
|
||||
for (let i = 0; i < messageIndex; i++) {
|
||||
const msg = currentCline.clineMessages[i]
|
||||
if (msg?.checkpoint && msg.ts) {
|
||||
preservedCheckpoints.set(msg.ts, msg.checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete this message and all subsequent messages
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
|
||||
// Restore checkpoint associations for preserved messages
|
||||
for (const [ts, checkpoint] of preservedCheckpoints) {
|
||||
const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts)
|
||||
if (msgIndex !== -1) {
|
||||
currentCline.clineMessages[msgIndex].checkpoint = checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated messages with restored checkpoints
|
||||
await saveTaskMessages({
|
||||
messages: currentCline.clineMessages,
|
||||
taskId: currentCline.taskId,
|
||||
globalStoragePath: provider.contextProxy.globalStorageUri.fsPath,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in delete message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error deleting message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,11 +214,31 @@ export const webviewMessageHandler = async (
|
|||
* Handles message editing operations with user confirmation
|
||||
*/
|
||||
const handleEditOperation = async (messageTs: number, editedContent: string, images?: string[]): Promise<void> => {
|
||||
// Check if there's a checkpoint before this message
|
||||
const currentCline = provider.getCurrentTask()
|
||||
let hasCheckpoint = false
|
||||
if (currentCline) {
|
||||
const { messageIndex } = findMessageIndices(messageTs, currentCline)
|
||||
if (messageIndex !== -1) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages
|
||||
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
|
||||
hasCheckpoint = checkpoints.length > 0
|
||||
} else {
|
||||
console.log("[webviewMessageHandler] Edit - Message not found in clineMessages!")
|
||||
}
|
||||
} else {
|
||||
console.log("[webviewMessageHandler] Edit - No currentCline available!")
|
||||
}
|
||||
|
||||
// Send message to webview to show edit confirmation dialog
|
||||
await provider.postMessageToWebview({
|
||||
type: "showEditMessageDialog",
|
||||
messageTs,
|
||||
text: editedContent,
|
||||
hasCheckpoint,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
|
@ -155,38 +249,105 @@ export const webviewMessageHandler = async (
|
|||
const handleEditMessageConfirm = async (
|
||||
messageTs: number,
|
||||
editedContent: string,
|
||||
restoreCheckpoint?: boolean,
|
||||
images?: string[],
|
||||
): Promise<void> => {
|
||||
// Only proceed if we have a current task.
|
||||
if (provider.getCurrentTask()) {
|
||||
const currentCline = provider.getCurrentTask()!
|
||||
const currentCline = provider.getCurrentTask()
|
||||
if (!currentCline) {
|
||||
console.error("[handleEditMessageConfirm] No current cline available")
|
||||
return
|
||||
}
|
||||
|
||||
// Use findMessageIndices to find messages based on timestamp
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
// Use findMessageIndices to find messages based on timestamp
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
try {
|
||||
// Edit this message and delete subsequent
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
if (messageIndex === -1) {
|
||||
const errorMessage = `Message with timestamp ${messageTs} not found`
|
||||
console.error("[handleEditMessageConfirm]", errorMessage)
|
||||
await vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the edited message as a regular user message
|
||||
// This will add it to the conversation and trigger an AI response
|
||||
webviewMessageHandler(provider, {
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
text: editedContent,
|
||||
images,
|
||||
try {
|
||||
const targetMessage = currentCline.clineMessages[messageIndex]
|
||||
|
||||
// If checkpoint restoration is requested, find and restore to the last checkpoint before this message
|
||||
if (restoreCheckpoint) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages
|
||||
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
|
||||
const lastCheckpoint = checkpoints[0]
|
||||
|
||||
if (lastCheckpoint && lastCheckpoint.text) {
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider,
|
||||
currentCline,
|
||||
messageTs: targetMessage.ts!,
|
||||
messageIndex,
|
||||
checkpoint: { hash: lastCheckpoint.text },
|
||||
operation: "edit",
|
||||
editData: {
|
||||
editedContent,
|
||||
images,
|
||||
apiConversationHistoryIndex,
|
||||
},
|
||||
})
|
||||
|
||||
// Don't initialize with history item for edit operations
|
||||
// The webviewMessageHandler will handle the conversation state
|
||||
} catch (error) {
|
||||
console.error("Error in edit message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error editing message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
// The task will be cancelled and reinitialized by checkpointRestore
|
||||
// The pending edit will be processed in the reinitialized task
|
||||
return
|
||||
} else {
|
||||
// No checkpoint found before this message
|
||||
console.log("[handleEditMessageConfirm] No checkpoint found before message")
|
||||
vscode.window.showWarningMessage("No checkpoint found before this message")
|
||||
// Continue with non-checkpoint edit
|
||||
}
|
||||
}
|
||||
|
||||
// For non-checkpoint edits, preserve checkpoint associations for remaining messages
|
||||
// Store checkpoints from messages that will be preserved
|
||||
const preservedCheckpoints = new Map<number, any>()
|
||||
for (let i = 0; i < messageIndex; i++) {
|
||||
const msg = currentCline.clineMessages[i]
|
||||
if (msg?.checkpoint && msg.ts) {
|
||||
preservedCheckpoints.set(msg.ts, msg.checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit this message and delete subsequent
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
|
||||
// Restore checkpoint associations for preserved messages
|
||||
for (const [ts, checkpoint] of preservedCheckpoints) {
|
||||
const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts)
|
||||
if (msgIndex !== -1) {
|
||||
currentCline.clineMessages[msgIndex].checkpoint = checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated messages with restored checkpoints
|
||||
await saveTaskMessages({
|
||||
messages: currentCline.clineMessages,
|
||||
taskId: currentCline.taskId,
|
||||
globalStoragePath: provider.contextProxy.globalStorageUri.fsPath,
|
||||
})
|
||||
|
||||
// Process the edited message as a regular user message
|
||||
webviewMessageHandler(provider, {
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
text: editedContent,
|
||||
images,
|
||||
})
|
||||
|
||||
// Don't initialize with history item for edit operations
|
||||
// The webviewMessageHandler will handle the conversation state
|
||||
} catch (error) {
|
||||
console.error("Error in edit message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error editing message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1665,12 +1826,17 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
case "deleteMessageConfirm":
|
||||
if (message.messageTs) {
|
||||
await handleDeleteMessageConfirm(message.messageTs)
|
||||
await handleDeleteMessageConfirm(message.messageTs, message.restoreCheckpoint)
|
||||
}
|
||||
break
|
||||
case "editMessageConfirm":
|
||||
if (message.messageTs && message.text) {
|
||||
await handleEditMessageConfirm(message.messageTs, message.text, message.images)
|
||||
await handleEditMessageConfirm(
|
||||
message.messageTs,
|
||||
message.text,
|
||||
message.restoreCheckpoint,
|
||||
message.images,
|
||||
)
|
||||
}
|
||||
break
|
||||
case "getListApiConfiguration":
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export interface ExtensionMessage {
|
|||
rulesFolderPath?: string
|
||||
settings?: any
|
||||
messageTs?: number
|
||||
hasCheckpoint?: boolean
|
||||
context?: string
|
||||
commands?: Command[]
|
||||
queuedMessages?: QueuedMessage[]
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ export interface WebviewMessage {
|
|||
hasSystemPromptOverride?: boolean
|
||||
terminalOperation?: "continue" | "abort"
|
||||
messageTs?: number
|
||||
restoreCheckpoint?: boolean
|
||||
historyPreviewCollapsed?: boolean
|
||||
filters?: { type?: string; search?: string; tags?: string[] }
|
||||
settings?: any
|
||||
|
|
|
|||
|
|
@ -19,6 +19,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 { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog"
|
||||
import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog"
|
||||
import ErrorBoundary from "./components/ErrorBoundary"
|
||||
import { CloudView } from "./components/cloud/CloudView"
|
||||
|
|
@ -37,18 +38,21 @@ interface HumanRelayDialogState {
|
|||
interface DeleteMessageDialogState {
|
||||
isOpen: boolean
|
||||
messageTs: number
|
||||
hasCheckpoint: boolean
|
||||
}
|
||||
|
||||
interface EditMessageDialogState {
|
||||
isOpen: boolean
|
||||
messageTs: number
|
||||
text: string
|
||||
hasCheckpoint: boolean
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
// Memoize dialog components to prevent unnecessary re-renders
|
||||
const MemoizedDeleteMessageDialog = React.memo(DeleteMessageDialog)
|
||||
const MemoizedEditMessageDialog = React.memo(EditMessageDialog)
|
||||
const MemoizedCheckpointRestoreDialog = React.memo(CheckpointRestoreDialog)
|
||||
const MemoizedHumanRelayDialog = React.memo(HumanRelayDialog)
|
||||
|
||||
const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
|
||||
|
|
@ -91,12 +95,14 @@ const App = () => {
|
|||
const [deleteMessageDialogState, setDeleteMessageDialogState] = useState<DeleteMessageDialogState>({
|
||||
isOpen: false,
|
||||
messageTs: 0,
|
||||
hasCheckpoint: false,
|
||||
})
|
||||
|
||||
const [editMessageDialogState, setEditMessageDialogState] = useState<EditMessageDialogState>({
|
||||
isOpen: false,
|
||||
messageTs: 0,
|
||||
text: "",
|
||||
hasCheckpoint: false,
|
||||
images: [],
|
||||
})
|
||||
|
||||
|
|
@ -159,7 +165,11 @@ const App = () => {
|
|||
}
|
||||
|
||||
if (message.type === "showDeleteMessageDialog" && message.messageTs) {
|
||||
setDeleteMessageDialogState({ isOpen: true, messageTs: message.messageTs })
|
||||
setDeleteMessageDialogState({
|
||||
isOpen: true,
|
||||
messageTs: message.messageTs,
|
||||
hasCheckpoint: message.hasCheckpoint || false,
|
||||
})
|
||||
}
|
||||
|
||||
if (message.type === "showEditMessageDialog" && message.messageTs && message.text) {
|
||||
|
|
@ -167,6 +177,7 @@ const App = () => {
|
|||
isOpen: true,
|
||||
messageTs: message.messageTs,
|
||||
text: message.text,
|
||||
hasCheckpoint: message.hasCheckpoint || false,
|
||||
images: message.images || [],
|
||||
})
|
||||
}
|
||||
|
|
@ -271,30 +282,65 @@ 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 }))
|
||||
}}
|
||||
/>
|
||||
{deleteMessageDialogState.hasCheckpoint ? (
|
||||
<MemoizedCheckpointRestoreDialog
|
||||
open={deleteMessageDialogState.isOpen}
|
||||
type="delete"
|
||||
hasCheckpoint={deleteMessageDialogState.hasCheckpoint}
|
||||
onOpenChange={(open: boolean) => setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: open }))}
|
||||
onConfirm={(restoreCheckpoint: boolean) => {
|
||||
vscode.postMessage({
|
||||
type: "deleteMessageConfirm",
|
||||
messageTs: deleteMessageDialogState.messageTs,
|
||||
restoreCheckpoint,
|
||||
})
|
||||
setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MemoizedDeleteMessageDialog
|
||||
open={deleteMessageDialogState.isOpen}
|
||||
onOpenChange={(open: boolean) => setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: open }))}
|
||||
onConfirm={() => {
|
||||
vscode.postMessage({
|
||||
type: "deleteMessageConfirm",
|
||||
messageTs: deleteMessageDialogState.messageTs,
|
||||
})
|
||||
setDeleteMessageDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editMessageDialogState.hasCheckpoint ? (
|
||||
<MemoizedCheckpointRestoreDialog
|
||||
open={editMessageDialogState.isOpen}
|
||||
type="edit"
|
||||
hasCheckpoint={editMessageDialogState.hasCheckpoint}
|
||||
onOpenChange={(open: boolean) => setEditMessageDialogState((prev) => ({ ...prev, isOpen: open }))}
|
||||
onConfirm={(restoreCheckpoint: boolean) => {
|
||||
vscode.postMessage({
|
||||
type: "editMessageConfirm",
|
||||
messageTs: editMessageDialogState.messageTs,
|
||||
text: editMessageDialogState.text,
|
||||
restoreCheckpoint,
|
||||
})
|
||||
setEditMessageDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MemoizedEditMessageDialog
|
||||
open={editMessageDialogState.isOpen}
|
||||
onOpenChange={(open: boolean) => setEditMessageDialogState((prev) => ({ ...prev, isOpen: open }))}
|
||||
onConfirm={() => {
|
||||
vscode.postMessage({
|
||||
type: "editMessageConfirm",
|
||||
messageTs: editMessageDialogState.messageTs,
|
||||
text: editMessageDialogState.text,
|
||||
images: editMessageDialogState.images,
|
||||
})
|
||||
setEditMessageDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import deepEqual from "fast-deep-equal"
|
|||
import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types"
|
||||
import { Mode } from "@roo/modes"
|
||||
|
||||
import { ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool } from "@roo/ExtensionMessage"
|
||||
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
|
||||
|
|
@ -41,7 +42,10 @@ import { CommandExecutionError } from "./CommandExecutionError"
|
|||
import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning"
|
||||
import { CondenseContextErrorRow, CondensingContextRow, ContextCondenseRow } from "./ContextCondenseRow"
|
||||
import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay"
|
||||
import { appendImages } from "@src/utils/imageUtils"
|
||||
import { McpExecution } from "./McpExecution"
|
||||
import { ChatTextArea } from "./ChatTextArea"
|
||||
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||
|
||||
interface ChatRowProps {
|
||||
message: ClineMessage
|
||||
|
|
@ -111,19 +115,70 @@ export const ChatRowContent = ({
|
|||
}: 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)
|
||||
}, [onToggleExpand, message.ts])
|
||||
|
||||
// Handle edit button click
|
||||
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.images, mode])
|
||||
|
||||
// Handle cancel edit
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
setEditedContent(message.text || "")
|
||||
setEditImages(message.images || [])
|
||||
setEditMode(mode || "code")
|
||||
}, [message.text, message.images, mode])
|
||||
|
||||
// Handle save edit
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
// Send edited message to backend
|
||||
vscode.postMessage({
|
||||
type: "submitEditedMessage",
|
||||
value: message.ts,
|
||||
editedMessageContent: editedContent,
|
||||
images: editImages,
|
||||
})
|
||||
}, [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") {
|
||||
const info = safeJsonParse<ClineApiReqInfo>(message.text)
|
||||
|
|
@ -1117,26 +1172,58 @@ export const ChatRowContent = ({
|
|||
case "user_feedback":
|
||||
return (
|
||||
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex-grow px-2 py-1 wrap-anywhere">
|
||||
<Mention text={message.text} withShadow />
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<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>
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={isStreaming}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
vscode.postMessage({ type: "deleteMessage", value: message.ts })
|
||||
}}>
|
||||
<span className="codicon codicon-trash" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<div className="flex-grow px-2 py-1 wrap-anywhere">
|
||||
<Mention text={message.text} withShadow />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={isStreaming}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditClick()
|
||||
}}>
|
||||
<span className="codicon codicon-edit" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={isStreaming}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
vscode.postMessage({ type: "deleteMessage", value: message.ts })
|
||||
}}>
|
||||
<span className="codicon codicon-trash" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.images && message.images.length > 0 && (
|
||||
)}
|
||||
{!isEditing && message.images && message.images.length > 0 && (
|
||||
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import ContextMenu from "./ContextMenu"
|
|||
import { IndexingStatusBadge } from "./IndexingStatusBadge"
|
||||
import { SlashCommandsPopover } from "./SlashCommandsPopover"
|
||||
import { usePromptHistory } from "./hooks/usePromptHistory"
|
||||
import { EditModeControls } from "./EditModeControls"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
|
|
@ -47,6 +48,9 @@ interface ChatTextAreaProps {
|
|||
mode: Mode
|
||||
setMode: (value: Mode) => void
|
||||
modeShortcutText: string
|
||||
// Edit mode props
|
||||
isEditMode?: boolean
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
|
|
@ -54,6 +58,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
{
|
||||
inputValue,
|
||||
setInputValue,
|
||||
sendingDisabled,
|
||||
selectApiConfigDisabled,
|
||||
placeholderText,
|
||||
selectedImages,
|
||||
|
|
@ -65,6 +70,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
mode,
|
||||
setMode,
|
||||
modeShortcutText,
|
||||
isEditMode = false,
|
||||
onCancel,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
|
|
@ -881,6 +888,7 @@ export 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)
|
||||
|
|
@ -889,10 +897,261 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
[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 handle API config change
|
||||
const handleApiConfigChange = useCallback((value: string) => {
|
||||
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
|
||||
}, [])
|
||||
|
||||
// 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")}>
|
||||
<ApiConfigSelector
|
||||
value={currentConfigId}
|
||||
displayName={displayName}
|
||||
disabled={selectApiConfigDisabled}
|
||||
title={t("chat:selectApiConfig")}
|
||||
onChange={handleApiConfigChange}
|
||||
triggerClassName="w-full text-ellipsis overflow-hidden"
|
||||
listApiConfigMeta={listApiConfigMeta || []}
|
||||
pinnedApiConfigs={pinnedApiConfigs}
|
||||
togglePinnedApiConfig={togglePinnedApiConfig}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<SlashCommandsPopover />
|
||||
<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}
|
||||
data-testid="highlight-layer"
|
||||
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",
|
||||
isFocused
|
||||
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
|
||||
: isDraggingOver
|
||||
? "border-2 border-dashed border-vscode-focusBorder"
|
||||
: "border border-transparent",
|
||||
isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2",
|
||||
"px-[8px]",
|
||||
"pr-9",
|
||||
"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={false}
|
||||
onClick={handleEnhancePrompt}
|
||||
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)]",
|
||||
"cursor-pointer",
|
||||
)}>
|
||||
<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={false}
|
||||
onClick={onSend}
|
||||
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)]",
|
||||
"cursor-pointer",
|
||||
)}>
|
||||
<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 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height"
|
||||
style={{
|
||||
bottom: "0.25rem",
|
||||
color: "color-mix(in oklab, var(--vscode-input-foreground) 50%, transparent)",
|
||||
userSelect: "none",
|
||||
pointerEvents: "none",
|
||||
}}>
|
||||
{placeholderBottomText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -901,12 +1160,12 @@ export 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",
|
||||
|
|
@ -969,168 +1228,23 @@ export 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}
|
||||
data-testid="highlight-layer"
|
||||
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",
|
||||
isFocused
|
||||
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
|
||||
: isDraggingOver
|
||||
? "border-2 border-dashed border-vscode-focusBorder"
|
||||
: "border border-transparent",
|
||||
"px-[8px]",
|
||||
"py-1.5",
|
||||
"pr-9",
|
||||
"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={false}
|
||||
onClick={handleEnhancePrompt}
|
||||
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)]",
|
||||
"cursor-pointer",
|
||||
)}>
|
||||
<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={false}
|
||||
onClick={onSend}
|
||||
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)]",
|
||||
"cursor-pointer",
|
||||
)}>
|
||||
<SendHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
|
||||
{!inputValue && (
|
||||
<div
|
||||
className="absolute left-2 z-30 pr-9 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height"
|
||||
style={{
|
||||
bottom: "0.25rem",
|
||||
color: "color-mix(in oklab, var(--vscode-input-foreground) 50%, transparent)",
|
||||
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 && (
|
||||
|
|
@ -1145,80 +1259,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="max-w-32">
|
||||
<ModeSelector
|
||||
value={mode}
|
||||
title={t("chat:selectMode")}
|
||||
onChange={handleModeChange}
|
||||
triggerClassName="w-full"
|
||||
modeShortcutText={modeShortcutText}
|
||||
customModes={customModes}
|
||||
customModePrompts={customModePrompts}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-32">
|
||||
<ApiConfigSelector
|
||||
value={currentConfigId}
|
||||
displayName={displayName}
|
||||
disabled={selectApiConfigDisabled}
|
||||
title={t("chat:selectApiConfig")}
|
||||
onChange={handleApiConfigChange}
|
||||
triggerClassName="w-full text-ellipsis overflow-hidden"
|
||||
listApiConfigMeta={listApiConfigMeta || []}
|
||||
pinnedApiConfigs={pinnedApiConfigs}
|
||||
togglePinnedApiConfig={togglePinnedApiConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{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>
|
||||
)}
|
||||
<SlashCommandsPopover />
|
||||
<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>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -843,9 +843,30 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
useMount(() => textAreaRef.current?.focus())
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
// Pre-compute checkpoint hashes that have associated user messages for O(1) lookup
|
||||
const userMessageCheckpointHashes = new Set<string>()
|
||||
modifiedMessages.forEach((msg) => {
|
||||
if (
|
||||
msg.say === "user_feedback" &&
|
||||
msg.checkpoint &&
|
||||
(msg.checkpoint as any).type === "user_message" &&
|
||||
(msg.checkpoint as any).hash
|
||||
) {
|
||||
userMessageCheckpointHashes.add((msg.checkpoint as any).hash)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove the 500-message limit to prevent array index shifting
|
||||
// Virtuoso is designed to efficiently handle large lists through virtualization
|
||||
const newVisibleMessages = modifiedMessages.filter((message: ClineMessage) => {
|
||||
const newVisibleMessages = modifiedMessages.filter((message) => {
|
||||
// Filter out checkpoint_saved messages that are associated with user messages
|
||||
if (message.say === "checkpoint_saved" && message.text) {
|
||||
// Use O(1) Set lookup instead of O(n) array search
|
||||
if (userMessageCheckpointHashes.has(message.text)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (everVisibleMessagesTsRef.current.has(message.ts)) {
|
||||
const alwaysHiddenOnceProcessedAsk: ClineAsk[] = [
|
||||
"api_req_failed",
|
||||
|
|
|
|||
83
webview-ui/src/components/chat/CheckpointRestoreDialog.tsx
Normal file
83
webview-ui/src/components/chat/CheckpointRestoreDialog.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React from "react"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@src/components/ui"
|
||||
|
||||
interface CheckpointRestoreDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (restoreCheckpoint: boolean) => void
|
||||
type: "edit" | "delete"
|
||||
hasCheckpoint: boolean
|
||||
}
|
||||
|
||||
export const CheckpointRestoreDialog: React.FC<CheckpointRestoreDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
type,
|
||||
hasCheckpoint,
|
||||
}) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const isEdit = type === "edit"
|
||||
const title = isEdit ? t("common:confirmation.editMessage") : t("common:confirmation.deleteMessage")
|
||||
const description = isEdit
|
||||
? t("common:confirmation.editQuestionWithCheckpoint")
|
||||
: t("common:confirmation.deleteQuestionWithCheckpoint")
|
||||
|
||||
const handleConfirmWithRestore = () => {
|
||||
onConfirm(true)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleConfirmWithoutRestore = () => {
|
||||
onConfirm(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
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={handleConfirmWithoutRestore}
|
||||
className="bg-vscode-button-background hover:bg-vscode-button-hoverBackground text-vscode-button-foreground border-vscode-button-border">
|
||||
{isEdit ? t("common:confirmation.editOnly") : t("common:confirmation.deleteOnly")}
|
||||
</AlertDialogAction>
|
||||
{hasCheckpoint && (
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmWithRestore}
|
||||
className="bg-vscode-button-background hover:bg-vscode-button-hoverBackground text-vscode-button-foreground border-vscode-button-border">
|
||||
{t("common:confirmation.restoreToCheckpoint")}
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Export convenience components for backward compatibility
|
||||
export const EditMessageWithCheckpointDialog: React.FC<Omit<CheckpointRestoreDialogProps, "type">> = (props) => (
|
||||
<CheckpointRestoreDialog {...props} type="edit" />
|
||||
)
|
||||
|
||||
export const DeleteMessageWithCheckpointDialog: React.FC<Omit<CheckpointRestoreDialogProps, "type">> = (props) => (
|
||||
<CheckpointRestoreDialog {...props} type="delete" />
|
||||
)
|
||||
115
webview-ui/src/components/chat/EditModeControls.tsx
Normal file
115
webview-ui/src/components/chat/EditModeControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
// npx vitest run src/components/chat/__tests__/CheckpointRestoreDialog.spec.tsx
|
||||
|
||||
import React from "react"
|
||||
import { render, screen, fireEvent } from "@/utils/test-utils"
|
||||
import { vi } from "vitest"
|
||||
|
||||
import { CheckpointRestoreDialog } from "../CheckpointRestoreDialog"
|
||||
|
||||
// Mock the translation context
|
||||
vi.mock("@src/i18n/TranslationContext", () => ({
|
||||
useAppTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common:confirmation.deleteMessage": "Delete Message",
|
||||
"common:confirmation.editMessage": "Edit Message",
|
||||
"common:confirmation.deleteQuestionWithCheckpoint":
|
||||
"Deleting this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
"common:confirmation.editQuestionWithCheckpoint":
|
||||
"Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
"common:confirmation.editOnly": "Edit Only",
|
||||
"common:confirmation.deleteOnly": "Delete Only",
|
||||
"common:confirmation.restoreToCheckpoint": "Restore to Checkpoint",
|
||||
"common:answers.cancel": "Cancel",
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("CheckpointRestoreDialog", () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
type: "edit" as const,
|
||||
hasCheckpoint: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("Basic Rendering", () => {
|
||||
it("renders edit dialog without checkpoint", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText("Edit Message")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("Edit Only")).toBeInTheDocument()
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Restore to Checkpoint")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders delete dialog without checkpoint", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} type="delete" />)
|
||||
|
||||
expect(screen.getByText("Delete Message")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Deleting this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("Delete Only")).toBeInTheDocument()
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Restore to Checkpoint")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders edit dialog with checkpoint option", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} hasCheckpoint={true} />)
|
||||
|
||||
expect(screen.getByText("Edit Message")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("Edit Only")).toBeInTheDocument()
|
||||
expect(screen.getByText("Restore to Checkpoint")).toBeInTheDocument()
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders delete dialog with checkpoint option", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} type="delete" hasCheckpoint={true} />)
|
||||
|
||||
expect(screen.getByText("Delete Message")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Deleting this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("Delete Only")).toBeInTheDocument()
|
||||
expect(screen.getByText("Restore to Checkpoint")).toBeInTheDocument()
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("calls onOpenChange when cancel is clicked", () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<CheckpointRestoreDialog {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Cancel"))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it("calls onConfirm with correct parameters when edit only is clicked", () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<CheckpointRestoreDialog {...defaultProps} onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Only"))
|
||||
expect(onConfirm).toHaveBeenCalledWith(false) // restoreCheckpoint
|
||||
})
|
||||
|
||||
it("calls onConfirm with restoreCheckpoint=false when edit only is clicked with checkpoint", () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<CheckpointRestoreDialog {...defaultProps} onConfirm={onConfirm} hasCheckpoint={true} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Only"))
|
||||
expect(onConfirm).toHaveBeenCalledWith(false) // restoreCheckpoint
|
||||
})
|
||||
|
||||
it("calls onConfirm with restoreCheckpoint=true when restore to checkpoint is clicked", () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<CheckpointRestoreDialog {...defaultProps} onConfirm={onConfirm} hasCheckpoint={true} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Restore to Checkpoint"))
|
||||
expect(onConfirm).toHaveBeenCalledWith(true) // restoreCheckpoint
|
||||
})
|
||||
|
||||
it("calls onOpenChange when dialog is closed", () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<CheckpointRestoreDialog {...defaultProps} onOpenChange={onOpenChange} hasCheckpoint={true} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Only"))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Dialog State Management", () => {
|
||||
it("does not render when open is false", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} open={false} />)
|
||||
|
||||
expect(screen.queryByText("Edit Message")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("Delete Message")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("maintains state when dialog stays open", async () => {
|
||||
const { rerender } = render(<CheckpointRestoreDialog {...defaultProps} hasCheckpoint={true} open={true} />)
|
||||
|
||||
// Verify initial state
|
||||
expect(screen.getByText("Edit Only")).toBeInTheDocument()
|
||||
expect(screen.getByText("Restore to Checkpoint")).toBeInTheDocument()
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<CheckpointRestoreDialog {...defaultProps} hasCheckpoint={true} open={true} />)
|
||||
|
||||
// Should still have same buttons
|
||||
expect(screen.getByText("Edit Only")).toBeInTheDocument()
|
||||
expect(screen.getByText("Restore to Checkpoint")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("has proper ARIA labels and roles", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} hasCheckpoint={true} />)
|
||||
|
||||
expect(screen.getByRole("alertdialog")).toBeInTheDocument() // AlertDialog uses alertdialog role
|
||||
expect(screen.getByRole("button", { name: "Edit Only" })).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "Restore to Checkpoint" })).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles missing translation keys gracefully", () => {
|
||||
// This test is simplified since we can't easily mock the translation function mid-test
|
||||
// The component should handle missing keys by returning the key itself
|
||||
render(<CheckpointRestoreDialog {...defaultProps} />)
|
||||
|
||||
// Should still render with proper text from our mock
|
||||
expect(screen.getByText("Edit Message")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("handles rapid button clicks", async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<CheckpointRestoreDialog
|
||||
{...defaultProps}
|
||||
onConfirm={onConfirm}
|
||||
onOpenChange={onOpenChange}
|
||||
hasCheckpoint={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editOnlyButton = screen.getByText("Edit Only")
|
||||
|
||||
// Click button once
|
||||
fireEvent.click(editOnlyButton)
|
||||
|
||||
// Should be called once with correct parameters
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).toHaveBeenCalledWith(false) // restoreCheckpoint
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false) // dialog should close
|
||||
})
|
||||
})
|
||||
|
||||
describe("Type-specific Behavior", () => {
|
||||
it("shows correct warning text for edit type", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} type="edit" />)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows correct warning text for delete type", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} type="delete" />)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Deleting this message will delete all subsequent messages in the conversation. Do you want to proceed?",
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows correct title for edit type", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} type="edit" />)
|
||||
|
||||
expect(screen.getByText("Edit Message")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows correct title for delete type", () => {
|
||||
render(<CheckpointRestoreDialog {...defaultProps} type="delete" />)
|
||||
|
||||
expect(screen.getByText("Delete Message")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
8
webview-ui/src/i18n/locales/ca/common.json
generated
8
webview-ui/src/i18n/locales/ca/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Editar aquest missatge eliminarà tots els missatges posteriors de la conversa. També vols desfer tots els canvis de codi fins a aquest punt de control?",
|
||||
"deleteQuestionWithCheckpoint": "Eliminar aquest missatge eliminarà tots els missatges posteriors de la conversa. També vols desfer tots els canvis de codi fins a aquest punt de control?",
|
||||
"editOnly": "No, només editar el missatge",
|
||||
"deleteOnly": "No, només eliminar el missatge",
|
||||
"restoreToCheckpoint": "Sí, restaurar el codi al punt de control",
|
||||
"proceed": "Continuar",
|
||||
"dontShowAgain": "No tornis a mostrar això"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "ara mateix",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/de/common.json
generated
8
webview-ui/src/i18n/locales/de/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Das Bearbeiten dieser Nachricht wird alle späteren Nachrichten in der Unterhaltung löschen. Möchtest du auch alle Code-Änderungen bis zu diesem Checkpoint rückgängig machen?",
|
||||
"deleteQuestionWithCheckpoint": "Das Löschen dieser Nachricht wird alle späteren Nachrichten in der Unterhaltung löschen. Möchtest du auch alle Code-Änderungen bis zu diesem Checkpoint rückgängig machen?",
|
||||
"editOnly": "Nein, nur Nachricht bearbeiten",
|
||||
"deleteOnly": "Nein, nur Nachricht löschen",
|
||||
"restoreToCheckpoint": "Ja, Code zum Checkpoint wiederherstellen",
|
||||
"proceed": "Fortfahren",
|
||||
"dontShowAgain": "Nicht mehr anzeigen"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "gerade eben",
|
||||
|
|
|
|||
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Editing this message will delete all later messages in the conversation. Do you also want to undo all code changes back to this checkpoint?",
|
||||
"deleteQuestionWithCheckpoint": "Deleting this message will delete all later messages in the conversation. Do you also want to undo all code changes back to this checkpoint?",
|
||||
"editOnly": "No, edit message only",
|
||||
"deleteOnly": "No, delete message only",
|
||||
"restoreToCheckpoint": "Yes, restore code to checkpoint",
|
||||
"proceed": "Proceed",
|
||||
"dontShowAgain": "Don't show this again"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "just now",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/es/common.json
generated
8
webview-ui/src/i18n/locales/es/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Editar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿También deseas deshacer todos los cambios de código hasta este punto de control?",
|
||||
"deleteQuestionWithCheckpoint": "Eliminar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿También deseas deshacer todos los cambios de código hasta este punto de control?",
|
||||
"editOnly": "No, solo editar el mensaje",
|
||||
"deleteOnly": "No, solo eliminar el mensaje",
|
||||
"restoreToCheckpoint": "Sí, restaurar código al punto de control",
|
||||
"proceed": "Continuar",
|
||||
"dontShowAgain": "No mostrar esto de nuevo"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "ahora mismo",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/fr/common.json
generated
8
webview-ui/src/i18n/locales/fr/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Modifier ce message supprimera tous les messages ultérieurs dans la conversation. Voulez-vous aussi annuler tous les changements de code jusqu'à ce point de contrôle ?",
|
||||
"deleteQuestionWithCheckpoint": "Supprimer ce message supprimera tous les messages ultérieurs dans la conversation. Voulez-vous aussi annuler tous les changements de code jusqu'à ce point de contrôle ?",
|
||||
"editOnly": "Non, modifier le message seulement",
|
||||
"deleteOnly": "Non, supprimer le message seulement",
|
||||
"restoreToCheckpoint": "Oui, restaurer le code au point de contrôle",
|
||||
"proceed": "Continuer",
|
||||
"dontShowAgain": "Ne plus afficher ceci"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "à l'instant",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/hi/common.json
generated
8
webview-ui/src/i18n/locales/hi/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"deleteWarning": "इस संदेश को हटाने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?",
|
||||
"editMessage": "संदेश संपादित करें",
|
||||
"editWarning": "इस संदेश को संपादित करने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?",
|
||||
"proceed": "जारी रखें"
|
||||
"editQuestionWithCheckpoint": "इस संदेश को संपादित करने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप इस चेकपॉइंट तक सभी कोड परिवर्तनों को भी पूर्ववत करना चाहते हैं?",
|
||||
"deleteQuestionWithCheckpoint": "इस संदेश को हटाने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप इस चेकपॉइंट तक सभी कोड परिवर्तनों को भी पूर्ववत करना चाहते हैं?",
|
||||
"editOnly": "नहीं, केवल संदेश संपादित करें",
|
||||
"deleteOnly": "नहीं, केवल संदेश हटाएं",
|
||||
"restoreToCheckpoint": "हां, कोड को चेकपॉइंट पर पुनर्स्थापित करें",
|
||||
"proceed": "जारी रखें",
|
||||
"dontShowAgain": "यह फिर से न दिखाएं"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "अभी",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/id/common.json
generated
8
webview-ui/src/i18n/locales/id/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Mengedit pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu juga ingin membatalkan semua perubahan kode kembali ke checkpoint ini?",
|
||||
"deleteQuestionWithCheckpoint": "Menghapus pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu juga ingin membatalkan semua perubahan kode kembali ke checkpoint ini?",
|
||||
"editOnly": "Tidak, edit pesan saja",
|
||||
"deleteOnly": "Tidak, hapus pesan saja",
|
||||
"restoreToCheckpoint": "Ya, pulihkan kode ke checkpoint",
|
||||
"proceed": "Lanjutkan",
|
||||
"dontShowAgain": "Jangan tampilkan lagi"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "baru saja",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/it/common.json
generated
8
webview-ui/src/i18n/locales/it/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Modificando questo messaggio verranno eliminati tutti i messaggi successivi nella conversazione. Vuoi anche annullare tutte le modifiche al codice fino a questo checkpoint?",
|
||||
"deleteQuestionWithCheckpoint": "Eliminando questo messaggio verranno eliminati tutti i messaggi successivi nella conversazione. Vuoi anche annullare tutte le modifiche al codice fino a questo checkpoint?",
|
||||
"editOnly": "No, modifica solo il messaggio",
|
||||
"deleteOnly": "No, elimina solo il messaggio",
|
||||
"restoreToCheckpoint": "Sì, ripristina il codice al checkpoint",
|
||||
"proceed": "Procedi",
|
||||
"dontShowAgain": "Non mostrare più"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "proprio ora",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ja/common.json
generated
8
webview-ui/src/i18n/locales/ja/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"deleteWarning": "このメッセージを削除すると、会話内の後続のメッセージもすべて削除されます。続行しますか?",
|
||||
"editMessage": "メッセージを編集",
|
||||
"editWarning": "このメッセージを編集すると、会話内の後続のメッセージもすべて削除されます。続行しますか?",
|
||||
"proceed": "続行"
|
||||
"editQuestionWithCheckpoint": "このメッセージを編集すると、会話内の後続のメッセージもすべて削除されます。このチェックポイントまでのすべてのコード変更も元に戻しますか?",
|
||||
"deleteQuestionWithCheckpoint": "このメッセージを削除すると、会話内の後続のメッセージもすべて削除されます。このチェックポイントまでのすべてのコード変更も元に戻しますか?",
|
||||
"editOnly": "いいえ、メッセージのみ編集",
|
||||
"deleteOnly": "いいえ、メッセージのみ削除",
|
||||
"restoreToCheckpoint": "はい、コードをチェックポイントに復元",
|
||||
"proceed": "続行",
|
||||
"dontShowAgain": "今後表示しない"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "たった今",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ko/common.json
generated
8
webview-ui/src/i18n/locales/ko/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"deleteWarning": "이 메시지를 삭제하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?",
|
||||
"editMessage": "메시지 편집",
|
||||
"editWarning": "이 메시지를 편집하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?",
|
||||
"proceed": "계속"
|
||||
"editQuestionWithCheckpoint": "이 메시지를 편집하면 대화의 모든 후속 메시지가 삭제됩니다. 이 체크포인트까지의 모든 코드 변경사항도 되돌리시겠습니까?",
|
||||
"deleteQuestionWithCheckpoint": "이 메시지를 삭제하면 대화의 모든 후속 메시지가 삭제됩니다. 이 체크포인트까지의 모든 코드 변경사항도 되돌리시겠습니까?",
|
||||
"editOnly": "아니요, 메시지만 편집",
|
||||
"deleteOnly": "아니요, 메시지만 삭제",
|
||||
"restoreToCheckpoint": "예, 코드를 체크포인트로 복원",
|
||||
"proceed": "계속",
|
||||
"dontShowAgain": "다시 표시하지 않음"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "방금",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/nl/common.json
generated
8
webview-ui/src/i18n/locales/nl/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Het bewerken van dit bericht zal alle latere berichten in het gesprek verwijderen. Wil je ook alle codewijzigingen ongedaan maken tot dit checkpoint?",
|
||||
"deleteQuestionWithCheckpoint": "Het verwijderen van dit bericht zal alle latere berichten in het gesprek verwijderen. Wil je ook alle codewijzigingen ongedaan maken tot dit checkpoint?",
|
||||
"editOnly": "Nee, alleen bericht bewerken",
|
||||
"deleteOnly": "Nee, alleen bericht verwijderen",
|
||||
"restoreToCheckpoint": "Ja, code herstellen naar checkpoint",
|
||||
"proceed": "Doorgaan",
|
||||
"dontShowAgain": "Niet meer tonen"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "zojuist",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/pl/common.json
generated
8
webview-ui/src/i18n/locales/pl/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Edycja tej wiadomości spowoduje usunięcie wszystkich późniejszych wiadomości w rozmowie. Czy chcesz również cofnąć wszystkie zmiany w kodzie do tego punktu kontrolnego?",
|
||||
"deleteQuestionWithCheckpoint": "Usunięcie tej wiadomości spowoduje usunięcie wszystkich późniejszych wiadomości w rozmowie. Czy chcesz również cofnąć wszystkie zmiany w kodzie do tego punktu kontrolnego?",
|
||||
"editOnly": "Nie, tylko edytuj wiadomość",
|
||||
"deleteOnly": "Nie, tylko usuń wiadomość",
|
||||
"restoreToCheckpoint": "Tak, przywróć kod do punktu kontrolnego",
|
||||
"proceed": "Kontynuuj",
|
||||
"dontShowAgain": "Nie pokazuj ponownie"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "przed chwilą",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/pt-BR/common.json
generated
8
webview-ui/src/i18n/locales/pt-BR/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Editar esta mensagem irá excluir todas as mensagens posteriores na conversa. Você também deseja desfazer todas as alterações de código até este checkpoint?",
|
||||
"deleteQuestionWithCheckpoint": "Excluir esta mensagem irá excluir todas as mensagens posteriores na conversa. Você também deseja desfazer todas as alterações de código até este checkpoint?",
|
||||
"editOnly": "Não, apenas editar a mensagem",
|
||||
"deleteOnly": "Não, apenas excluir a mensagem",
|
||||
"restoreToCheckpoint": "Sim, restaurar código para o checkpoint",
|
||||
"proceed": "Prosseguir",
|
||||
"dontShowAgain": "Não mostrar novamente"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "agora mesmo",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ru/common.json
generated
8
webview-ui/src/i18n/locales/ru/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"deleteWarning": "Удаление этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите продолжить?",
|
||||
"editMessage": "Редактировать Сообщение",
|
||||
"editWarning": "Редактирование этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите продолжить?",
|
||||
"proceed": "Продолжить"
|
||||
"editQuestionWithCheckpoint": "Редактирование этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите также отменить все изменения кода до этой контрольной точки?",
|
||||
"deleteQuestionWithCheckpoint": "Удаление этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите также отменить все изменения кода до этой контрольной точки?",
|
||||
"editOnly": "Нет, только редактировать сообщение",
|
||||
"deleteOnly": "Нет, только удалить сообщение",
|
||||
"restoreToCheckpoint": "Да, восстановить код до контрольной точки",
|
||||
"proceed": "Продолжить",
|
||||
"dontShowAgain": "Больше не показывать"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "только что",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/tr/common.json
generated
8
webview-ui/src/i18n/locales/tr/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Bu mesajı düzenlemek, konuşmadaki sonraki tüm mesajları da silecektir. Bu kontrol noktasına kadar olan tüm kod değişikliklerini de geri almak istiyor musun?",
|
||||
"deleteQuestionWithCheckpoint": "Bu mesajı silmek, konuşmadaki sonraki tüm mesajları da silecektir. Bu kontrol noktasına kadar olan tüm kod değişikliklerini de geri almak istiyor musun?",
|
||||
"editOnly": "Hayır, sadece mesajı düzenle",
|
||||
"deleteOnly": "Hayır, sadece mesajı sil",
|
||||
"restoreToCheckpoint": "Evet, kodu kontrol noktasına geri yükle",
|
||||
"proceed": "Devam Et",
|
||||
"dontShowAgain": "Tekrar gösterme"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "şimdi",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/vi/common.json
generated
8
webview-ui/src/i18n/locales/vi/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"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"
|
||||
"editQuestionWithCheckpoint": "Chỉnh sửa tin nhắn này sẽ xóa tất cả các tin nhắn sau đó trong cuộc trò chuyện. Bạn có muốn hoàn tác tất cả các thay đổi mã về checkpoint này không?",
|
||||
"deleteQuestionWithCheckpoint": "Xóa tin nhắn này sẽ xóa tất cả các tin nhắn sau đó trong cuộc trò chuyện. Bạn có muốn hoàn tác tất cả các thay đổi mã về checkpoint này không?",
|
||||
"editOnly": "Không, chỉ chỉnh sửa tin nhắn",
|
||||
"deleteOnly": "Không, chỉ xóa tin nhắn",
|
||||
"restoreToCheckpoint": "Có, khôi phục mã về checkpoint",
|
||||
"proceed": "Tiếp Tục",
|
||||
"dontShowAgain": "Không hiển thị lại"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "vừa xong",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/zh-CN/common.json
generated
8
webview-ui/src/i18n/locales/zh-CN/common.json
generated
|
|
@ -72,7 +72,13 @@
|
|||
"deleteWarning": "删除此消息将删除对话中的所有后续消息。是否继续?",
|
||||
"editMessage": "编辑消息",
|
||||
"editWarning": "编辑此消息将删除对话中的所有后续消息。是否继续?",
|
||||
"proceed": "继续"
|
||||
"editQuestionWithCheckpoint": "编辑此消息将删除对话中的所有后续消息。是否同时将代码变更撤销到此存档点?",
|
||||
"deleteQuestionWithCheckpoint": "删除此消息将删除对话中的所有后续消息。是否同时将代码变更撤销到此存档点?",
|
||||
"editOnly": "否,仅编辑消息",
|
||||
"deleteOnly": "否,仅删除消息",
|
||||
"restoreToCheckpoint": "是,恢复代码到存档点",
|
||||
"proceed": "继续",
|
||||
"dontShowAgain": "不再显示"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "刚刚",
|
||||
|
|
|
|||
10
webview-ui/src/i18n/locales/zh-TW/common.json
generated
10
webview-ui/src/i18n/locales/zh-TW/common.json
generated
|
|
@ -71,8 +71,14 @@
|
|||
"deleteMessage": "刪除訊息",
|
||||
"deleteWarning": "刪除此訊息將會刪除對話中所有後續的訊息。您要繼續嗎?",
|
||||
"editMessage": "編輯訊息",
|
||||
"editWarning": "編輯此訊息將會刪除對話中所有後續的訊息。您要繼續嗎?",
|
||||
"proceed": "繼續"
|
||||
"editWarning": "編輯此訊息將刪除對話中的所有後續訊息。是否繼續?",
|
||||
"editQuestionWithCheckpoint": "編輯此訊息將刪除對話中的所有後續訊息。是否同時將程式碼變更撤銷到此存檔點?",
|
||||
"deleteQuestionWithCheckpoint": "刪除此訊息將刪除對話中的所有後續訊息。是否同時將程式碼變更撤銷到此存檔點?",
|
||||
"editOnly": "否,僅編輯訊息",
|
||||
"deleteOnly": "否,僅刪除訊息",
|
||||
"restoreToCheckpoint": "是,恢復程式碼到存檔點",
|
||||
"proceed": "繼續",
|
||||
"dontShowAgain": "不再顯示"
|
||||
},
|
||||
"time_ago": {
|
||||
"just_now": "剛剛",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue