feat: add button to delete checkpoints only

This PR adds a "Delete Checkpoints Only" button to the task history panel, allowing users to free up disk space by removing checkpoint data while preserving task history and conversation.

Changes:
- Add deleteTaskCheckpointsWithId message type to WebviewMessage
- Add deleteTaskCheckpointsWithId method in ClineProvider.ts
- Handle new message in webviewMessageHandler.ts
- Create DeleteCheckpointsButton component
- Create DeleteCheckpointsDialog component
- Update TaskItemFooter to include the new button
- Wire up the dialog in HistoryView
- Add translations for the new feature
- Add tests for the new components

Closes #10801
This commit is contained in:
Roo Code 2026-01-17 14:50:42 +00:00
parent 6608ed618a
commit f235430a75
11 changed files with 275 additions and 1 deletions

View file

@ -385,6 +385,7 @@ export interface WebviewMessage {
| "shareCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "deleteTaskCheckpointsWithId"
| "exportTaskWithId"
| "importSettings"
| "exportSettings"

View file

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

View file

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

View file

@ -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 (
<StandardTooltip content={t("history:deleteCheckpointsTitle")}>
<Button
variant="ghost"
size="icon"
data-testid="delete-checkpoints-button"
onClick={handleDeleteCheckpointsClick}
className="opacity-70">
<span className="codicon codicon-history size-4 align-middle text-vscode-descriptionForeground" />
</Button>
</StandardTooltip>
)
}

View file

@ -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 (
<AlertDialog {...props}>
<AlertDialogContent onEscapeKeyDown={() => onOpenChange?.(false)}>
<AlertDialogHeader>
<AlertDialogTitle>{t("history:deleteCheckpoints")}</AlertDialogTitle>
<AlertDialogDescription>{t("history:deleteCheckpointsMessage")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="secondary">{t("history:cancel")}</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onClick={onDeleteCheckpoints}>
{t("history:delete")}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -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<string | null>(null)
const [deleteCheckpointsTaskId, setDeleteCheckpointsTaskId] = useState<string | null>(null)
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([])
const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState<boolean>(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) => {
<DeleteTaskDialog taskId={deleteTaskId} onOpenChange={(open) => !open && setDeleteTaskId(null)} open />
)}
{/* Delete checkpoints dialog */}
{deleteCheckpointsTaskId && (
<DeleteCheckpointsDialog
taskId={deleteCheckpointsTaskId}
onOpenChange={(open) => !open && setDeleteCheckpointsTaskId(null)}
open
/>
)}
{/* Batch delete dialog */}
{showBatchDeleteDialog && (
<BatchDeleteTaskDialog

View file

@ -19,6 +19,7 @@ interface TaskItemProps {
isSelected?: boolean
onToggleSelection?: (taskId: string, isSelected: boolean) => 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 && (

View file

@ -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<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false, onDelete }) => {
const TaskItemFooter: React.FC<TaskItemFooterProps> = ({
item,
variant,
isSelectionMode = false,
onDelete,
onDeleteCheckpoints,
}) => {
return (
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
<div className="flex gap-1 items-center text-vscode-descriptionForeground/60">
@ -35,6 +43,9 @@ const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelect
<div className="flex flex-row gap-0 -mx-2 items-center text-vscode-descriptionForeground/60 hover:text-vscode-descriptionForeground">
<CopyButton itemTask={item.task} />
{variant === "full" && <ExportButton itemId={item.id} />}
{variant === "full" && onDeleteCheckpoints && (
<DeleteCheckpointsButton itemId={item.id} onDeleteCheckpoints={onDeleteCheckpoints} />
)}
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
</div>
)}

View file

@ -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(<DeleteCheckpointsButton itemId="test-id" onDeleteCheckpoints={onDeleteCheckpoints} />)
const deleteCheckpointsButton = screen.getByRole("button")
fireEvent.click(deleteCheckpointsButton)
expect(onDeleteCheckpoints).toHaveBeenCalledWith("test-id")
})
})

View file

@ -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(<DeleteCheckpointsDialog taskId="test-id" open={true} onOpenChange={() => {}} />)
})
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(<DeleteCheckpointsDialog taskId="test-id" open={true} onOpenChange={onOpenChange} />)
})
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(<DeleteCheckpointsDialog taskId="test-id" open={true} onOpenChange={onOpenChange} />)
})
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(<DeleteCheckpointsDialog taskId="" open={true} onOpenChange={onOpenChange} />)
})
await act(async () => {
fireEvent.click(screen.getByText("history:delete"))
})
expect(vscode.postMessage).not.toHaveBeenCalled()
})
})

View file

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