Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export interface WebviewMessage {
| "shareCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "deleteTaskCheckpointsWithId"
| "exportTaskWithId"
| "importSettings"
| "exportSettings"
Expand Down
44 changes: 44 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions webview-ui/src/components/history/DeleteCheckpointsButton.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
63 changes: 63 additions & 0 deletions webview-ui/src/components/history/DeleteCheckpointsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
12 changes: 12 additions & 0 deletions webview-ui/src/components/history/HistoryView.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -250,6 +252,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
isSelected={selectedTaskIds.includes(item.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
onDeleteCheckpoints={setDeleteCheckpointsTaskId}
className="m-2"
/>
)}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/components/history/TaskItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface TaskItemProps {
isSelected?: boolean
onToggleSelection?: (taskId: string, isSelected: boolean) => void
onDelete?: (taskId: string) => void
onDeleteCheckpoints?: (taskId: string) => void
className?: string
}

Expand All @@ -30,6 +31,7 @@ const TaskItem = ({
isSelected = false,
onToggleSelection,
onDelete,
onDeleteCheckpoints,
className,
}: TaskItemProps) => {
const handleClick = () => {
Expand Down Expand Up @@ -87,6 +89,7 @@ const TaskItem = ({
variant={variant}
isSelectionMode={isSelectionMode}
onDelete={onDelete}
onDeleteCheckpoints={onDeleteCheckpoints}
/>

{showWorkspace && item.workspace && (
Expand Down
13 changes: 12 additions & 1 deletion webview-ui/src/components/history/TaskItemFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,24 @@ 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 {
item: HistoryItem
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">
Expand All @@ -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>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
})
})
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is named "calls onOpenChange when cancel is clicked" but it doesn't actually assert that onOpenChange is called. Compare with the existing DeleteTaskDialog.spec.tsx which properly includes expect(mockOnOpenChange).toHaveBeenCalledWith(false). The missing assertion should be added to verify the behavior claimed by the test name.

Suggested change
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(onOpenChange).toHaveBeenCalledWith(false)

Fix it with Roo Code or mention @roomote and request a fix.

})

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()
})
})
3 changes: 3 additions & 0 deletions webview-ui/src/i18n/locales/en/history.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading