diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 86d8b2ddbb..184dfabdc5 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -385,6 +385,7 @@ export interface WebviewMessage { | "shareCurrentTask" | "showTaskWithId" | "deleteTaskWithId" + | "deleteTaskCheckpointsWithId" | "exportTaskWithId" | "importSettings" | "exportSettings" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78..09220417b8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1798,6 +1798,50 @@ export class ClineProvider } } + // This function deletes only the checkpoints for a task, preserving the task history and conversation + async deleteTaskCheckpointsWithId(id: string) { + try { + // Get the task directory full path + const { taskDirPath } = await this.getTaskWithId(id) + + // Path to the checkpoints directory + const checkpointsPath = path.join(taskDirPath, "checkpoints") + + // Delete associated shadow repository or branch + const globalStorageDir = this.contextProxy.globalStorageUri.fsPath + const workspaceDir = this.cwd + + try { + await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir }) + console.log(`[deleteTaskCheckpointsWithId:${id}] deleted associated shadow repository or branch`) + } catch (error) { + console.error( + `[deleteTaskCheckpointsWithId:${id}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // Delete the checkpoints directory only + try { + await fs.rm(checkpointsPath, { recursive: true, force: true }) + console.log(`[deleteTaskCheckpointsWithId:${id}] removed checkpoints directory`) + } catch (error) { + console.error( + `[deleteTaskCheckpointsWithId:${id}] failed to remove checkpoints directory: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // Notify the webview to refresh the state + await this.postStateToWebview() + } catch (error) { + // If task is not found, log the error + if (error instanceof Error && error.message === "Task not found") { + console.error(`[deleteTaskCheckpointsWithId:${id}] task not found`) + return + } + throw error + } + } + async deleteTaskFromState(id: string) { const taskHistory = this.getGlobalState("taskHistory") ?? [] const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dbeb380d16..1ecae838e6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -737,6 +737,9 @@ export const webviewMessageHandler = async ( case "deleteTaskWithId": provider.deleteTaskWithId(message.text!) break + case "deleteTaskCheckpointsWithId": + provider.deleteTaskCheckpointsWithId(message.text!) + break case "deleteMultipleTasksWithIds": { const ids = message.ids diff --git a/webview-ui/src/components/history/DeleteCheckpointsButton.tsx b/webview-ui/src/components/history/DeleteCheckpointsButton.tsx new file mode 100644 index 0000000000..85c00ab23b --- /dev/null +++ b/webview-ui/src/components/history/DeleteCheckpointsButton.tsx @@ -0,0 +1,39 @@ +import { useCallback } from "react" + +import { Button, StandardTooltip } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" + +type DeleteCheckpointsButtonProps = { + itemId: string + onDeleteCheckpoints?: (taskId: string) => void +} + +export const DeleteCheckpointsButton = ({ itemId, onDeleteCheckpoints }: DeleteCheckpointsButtonProps) => { + const { t } = useAppTranslation() + + const handleDeleteCheckpointsClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (e.shiftKey) { + vscode.postMessage({ type: "deleteTaskCheckpointsWithId", text: itemId }) + } else if (onDeleteCheckpoints) { + onDeleteCheckpoints(itemId) + } + }, + [itemId, onDeleteCheckpoints], + ) + + return ( + + + + ) +} diff --git a/webview-ui/src/components/history/DeleteCheckpointsDialog.tsx b/webview-ui/src/components/history/DeleteCheckpointsDialog.tsx new file mode 100644 index 0000000000..2046824ed3 --- /dev/null +++ b/webview-ui/src/components/history/DeleteCheckpointsDialog.tsx @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from "react" +import { useKeyPress } from "react-use" +import { AlertDialogProps } from "@radix-ui/react-alert-dialog" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" + +import { vscode } from "@/utils/vscode" + +interface DeleteCheckpointsDialogProps extends AlertDialogProps { + taskId: string +} + +export const DeleteCheckpointsDialog = ({ taskId, ...props }: DeleteCheckpointsDialogProps) => { + const { t } = useAppTranslation() + const [isEnterPressed] = useKeyPress("Enter") + + const { onOpenChange } = props + + const onDeleteCheckpoints = useCallback(() => { + if (taskId) { + vscode.postMessage({ type: "deleteTaskCheckpointsWithId", text: taskId }) + onOpenChange?.(false) + } + }, [taskId, onOpenChange]) + + useEffect(() => { + if (taskId && isEnterPressed) { + onDeleteCheckpoints() + } + }, [taskId, isEnterPressed, onDeleteCheckpoints]) + + return ( + + onOpenChange?.(false)}> + + {t("history:deleteCheckpoints")} + {t("history:deleteCheckpointsMessage")} + + + + + + + + + + + + ) +} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index d8ee431593..5d09de3739 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -1,6 +1,7 @@ import React, { memo, useState } from "react" import { ArrowLeft } from "lucide-react" import { DeleteTaskDialog } from "./DeleteTaskDialog" +import { DeleteCheckpointsDialog } from "./DeleteCheckpointsDialog" import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog" import { Virtuoso } from "react-virtuoso" @@ -42,6 +43,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const { t } = useAppTranslation() const [deleteTaskId, setDeleteTaskId] = useState(null) + const [deleteCheckpointsTaskId, setDeleteCheckpointsTaskId] = useState(null) const [isSelectionMode, setIsSelectionMode] = useState(false) const [selectedTaskIds, setSelectedTaskIds] = useState([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) @@ -250,6 +252,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { isSelected={selectedTaskIds.includes(item.id)} onToggleSelection={toggleTaskSelection} onDelete={setDeleteTaskId} + onDeleteCheckpoints={setDeleteCheckpointsTaskId} className="m-2" /> )} @@ -278,6 +281,15 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { !open && setDeleteTaskId(null)} open /> )} + {/* Delete checkpoints dialog */} + {deleteCheckpointsTaskId && ( + !open && setDeleteCheckpointsTaskId(null)} + open + /> + )} + {/* Batch delete dialog */} {showBatchDeleteDialog && ( void onDelete?: (taskId: string) => void + onDeleteCheckpoints?: (taskId: string) => void className?: string } @@ -30,6 +31,7 @@ const TaskItem = ({ isSelected = false, onToggleSelection, onDelete, + onDeleteCheckpoints, className, }: TaskItemProps) => { const handleClick = () => { @@ -87,6 +89,7 @@ const TaskItem = ({ variant={variant} isSelectionMode={isSelectionMode} onDelete={onDelete} + onDeleteCheckpoints={onDeleteCheckpoints} /> {showWorkspace && item.workspace && ( diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index a79467758c..77cc3abfa7 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -4,6 +4,7 @@ import { formatTimeAgo } from "@/utils/format" import { CopyButton } from "./CopyButton" import { ExportButton } from "./ExportButton" import { DeleteButton } from "./DeleteButton" +import { DeleteCheckpointsButton } from "./DeleteCheckpointsButton" import { StandardTooltip } from "../ui/standard-tooltip" export interface TaskItemFooterProps { @@ -11,9 +12,16 @@ export interface TaskItemFooterProps { variant: "compact" | "full" isSelectionMode?: boolean onDelete?: (taskId: string) => void + onDeleteCheckpoints?: (taskId: string) => void } -const TaskItemFooter: React.FC = ({ item, variant, isSelectionMode = false, onDelete }) => { +const TaskItemFooter: React.FC = ({ + item, + variant, + isSelectionMode = false, + onDelete, + onDeleteCheckpoints, +}) => { return (
@@ -35,6 +43,9 @@ const TaskItemFooter: React.FC = ({ item, variant, isSelect
{variant === "full" && } + {variant === "full" && onDeleteCheckpoints && ( + + )} {onDelete && }
)} diff --git a/webview-ui/src/components/history/__tests__/DeleteCheckpointsButton.spec.tsx b/webview-ui/src/components/history/__tests__/DeleteCheckpointsButton.spec.tsx new file mode 100644 index 0000000000..bad648b403 --- /dev/null +++ b/webview-ui/src/components/history/__tests__/DeleteCheckpointsButton.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { DeleteCheckpointsButton } from "../DeleteCheckpointsButton" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("DeleteCheckpointsButton", () => { + it("calls onDeleteCheckpoints when clicked", () => { + const onDeleteCheckpoints = vi.fn() + render() + + const deleteCheckpointsButton = screen.getByRole("button") + fireEvent.click(deleteCheckpointsButton) + + expect(onDeleteCheckpoints).toHaveBeenCalledWith("test-id") + }) +}) diff --git a/webview-ui/src/components/history/__tests__/DeleteCheckpointsDialog.spec.tsx b/webview-ui/src/components/history/__tests__/DeleteCheckpointsDialog.spec.tsx new file mode 100644 index 0000000000..c034997ff5 --- /dev/null +++ b/webview-ui/src/components/history/__tests__/DeleteCheckpointsDialog.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen, fireEvent, act } from "@/utils/test-utils" + +import { DeleteCheckpointsDialog } from "../DeleteCheckpointsDialog" +import { vscode } from "@/utils/vscode" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +describe("DeleteCheckpointsDialog", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders dialog with correct content", async () => { + await act(async () => { + render( {}} />) + }) + + expect(screen.getByText("history:deleteCheckpoints")).toBeInTheDocument() + expect(screen.getByText("history:deleteCheckpointsMessage")).toBeInTheDocument() + }) + + it("calls vscode.postMessage when delete is confirmed", async () => { + const onOpenChange = vi.fn() + await act(async () => { + render() + }) + + await act(async () => { + fireEvent.click(screen.getByText("history:delete")) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "deleteTaskCheckpointsWithId", + text: "test-id", + }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it("calls onOpenChange when cancel is clicked", async () => { + const onOpenChange = vi.fn() + await act(async () => { + render() + }) + + await act(async () => { + fireEvent.click(screen.getByText("history:cancel")) + }) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it("does not call vscode.postMessage when taskId is empty", async () => { + const onOpenChange = vi.fn() + await act(async () => { + render() + }) + + await act(async () => { + fireEvent.click(screen.getByText("history:delete")) + }) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 608be93140..51ef105aae 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -11,6 +11,9 @@ "mostTokens": "Most Tokens", "mostRelevant": "Most Relevant", "deleteTaskTitle": "Delete Task (Shift + Click to skip confirmation)", + "deleteCheckpointsTitle": "Delete Checkpoints Only (Shift + Click to skip confirmation)", + "deleteCheckpoints": "Delete Checkpoints", + "deleteCheckpointsMessage": "Are you sure you want to delete the checkpoints for this task? The task history will be preserved, but you will lose the ability to restore file changes. This action cannot be undone.", "copyPrompt": "Copy Prompt", "exportTask": "Export Task", "deleteTask": "Delete Task",