diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 86d8b2ddbbe..184dfabdc5e 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 33fa12ca78c..09220417b8b 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 dbeb380d162..1ecae838e63 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 00000000000..85c00ab23b6 --- /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 00000000000..2046824ed33 --- /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 d8ee4315938..5d09de37392 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 a79467758cc..77cc3abfa74 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 00000000000..bad648b4039 --- /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 00000000000..c034997ff5a --- /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 608be93140c..51ef105aaec 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",