Просмотр исходного кода

Checkpoint service integration

cte 10 месяцев назад
Родитель
Сommit
121f3ab7fa

+ 210 - 11
src/core/Cline.ts

@@ -11,7 +11,8 @@ import { serializeError } from "serialize-error"
 import * as vscode from "vscode"
 import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
 import { ApiStream } from "../api/transform/stream"
-import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
+import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
+import { CheckpointService } from "../services/checkpoints/CheckpointService"
 import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
 import {
 	extractTextFromFile,
@@ -93,12 +94,19 @@ export class Cline {
 	private consecutiveMistakeCountForApplyDiff: Map<string, number> = new Map()
 	private providerRef: WeakRef<ClineProvider>
 	private abort: boolean = false
-	didFinishAborting = false
+	didFinishAbortingStream = false
 	abandoned = false
 	private diffViewProvider: DiffViewProvider
 	private lastApiRequestTime?: number
+	isInitialized = false
+
+	// checkpoints
+	checkpointsEnabled: boolean = false
+	private checkpointService?: CheckpointService
 
 	// streaming
+	isWaitingForFirstChunk = false
+	isStreaming = false
 	private currentStreamingContentIndex = 0
 	private assistantMessageContent: AssistantMessageContent[] = []
 	private presentAssistantMessageLocked = false
@@ -114,6 +122,7 @@ export class Cline {
 		apiConfiguration: ApiConfiguration,
 		customInstructions?: string,
 		enableDiff?: boolean,
+		enableCheckpoints?: boolean,
 		fuzzyMatchThreshold?: number,
 		task?: string | undefined,
 		images?: string[] | undefined,
@@ -134,6 +143,7 @@ export class Cline {
 		this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
 		this.providerRef = new WeakRef(provider)
 		this.diffViewProvider = new DiffViewProvider(cwd)
+		this.checkpointsEnabled = enableCheckpoints ?? false
 
 		if (historyItem) {
 			this.taskId = historyItem.id
@@ -438,6 +448,7 @@ export class Cline {
 		await this.providerRef.deref()?.postStateToWebview()
 
 		await this.say("text", task, images)
+		this.isInitialized = true
 
 		let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
 		await this.initiateTaskLoop([
@@ -477,12 +488,13 @@ export class Cline {
 		await this.overwriteClineMessages(modifiedClineMessages)
 		this.clineMessages = await this.getSavedClineMessages()
 
-		// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages
-
-		let existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
-			await this.getSavedApiConversationHistory()
-
-		// Now present the cline messages to the user and ask if they want to resume
+		// Now present the cline messages to the user and ask if they want to
+		// resume (NOTE: we ran into a bug before where the
+		// apiConversationHistory wouldn't be initialized when opening a old
+		// task, and it was because we were waiting for resume).
+		// This is important in case the user deletes messages without resuming
+		// the task first.
+		this.apiConversationHistory = await this.getSavedApiConversationHistory()
 
 		const lastClineMessage = this.clineMessages
 			.slice()
@@ -506,6 +518,8 @@ export class Cline {
 			askType = "resume_task"
 		}
 
+		this.isInitialized = true
+
 		const { response, text, images } = await this.ask(askType) // calls poststatetowebview
 		let responseText: string | undefined
 		let responseImages: string[] | undefined
@@ -515,6 +529,11 @@ export class Cline {
 			responseImages = images
 		}
 
+		// Make sure that the api conversation history can be resumed by the API,
+		// even if it goes out of sync with cline messages.
+		let existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
+			await this.getSavedApiConversationHistory()
+
 		// v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema
 		const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
 			if (Array.isArray(message.content)) {
@@ -706,11 +725,14 @@ export class Cline {
 		}
 	}
 
-	abortTask() {
-		this.abort = true // will stop any autonomously running promises
+	async abortTask() {
+		this.abort = true // Will stop any autonomously running promises.
 		this.terminalManager.disposeAll()
 		this.urlContentFetcher.closeBrowser()
 		this.browserSession.closeBrowser()
+		// Need to await for when we want to make sure directories/files are
+		// reverted before re-starting the task from a checkpoint.
+		await this.diffViewProvider.revertChanges()
 	}
 
 	// Tools
@@ -927,8 +949,10 @@ export class Cline {
 
 		try {
 			// awaiting first chunk to see if it will throw an error
+			this.isWaitingForFirstChunk = true
 			const firstChunk = await iterator.next()
 			yield firstChunk.value
+			this.isWaitingForFirstChunk = false
 		} catch (error) {
 			// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
 			if (alwaysApproveResubmit) {
@@ -1003,6 +1027,9 @@ export class Cline {
 		}
 
 		const block = cloneDeep(this.assistantMessageContent[this.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
+
+		let isCheckpointPossible = false
+
 		switch (block.type) {
 			case "text": {
 				if (this.didRejectTool || this.didAlreadyUseTool) {
@@ -1134,6 +1161,10 @@ export class Cline {
 					}
 					// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
 					this.didAlreadyUseTool = true
+
+					// Flag a checkpoint as possible since we've used a tool
+					// which may have changed the file system.
+					isCheckpointPossible = true
 				}
 
 				const askApproval = async (type: ClineAsk, partialMessage?: string) => {
@@ -2655,6 +2686,10 @@ export class Cline {
 				break
 		}
 
+		if (isCheckpointPossible) {
+			await this.checkpointSave()
+		}
+
 		/*
 		Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
 		When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
@@ -2811,7 +2846,7 @@ export class Cline {
 				await this.saveClineMessages()
 
 				// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
-				this.didFinishAborting = true
+				this.didFinishAbortingStream = true
 			}
 
 			// reset streaming state
@@ -3197,6 +3232,170 @@ export class Cline {
 
 		return `<environment_details>\n${details.trim()}\n</environment_details>`
 	}
+
+	// Checkpoints
+
+	private async getCheckpointService() {
+		if (!this.checkpointService) {
+			this.checkpointService = await CheckpointService.create({
+				taskId: this.taskId,
+				baseDir: vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? "",
+			})
+		}
+
+		return this.checkpointService
+	}
+
+	public async checkpointDiff({
+		ts,
+		commitHash,
+		mode,
+	}: {
+		ts: number
+		commitHash: string
+		mode: "full" | "checkpoint"
+	}) {
+		if (!this.checkpointsEnabled) {
+			return
+		}
+
+		let previousCommitHash = undefined
+
+		if (mode === "checkpoint") {
+			const previousCheckpoint = this.clineMessages
+				.filter(({ say }) => say === "checkpoint_saved")
+				.sort((a, b) => b.ts - a.ts)
+				.find((message) => message.ts < ts)
+
+			previousCommitHash = previousCheckpoint?.text
+		}
+
+		try {
+			const service = await this.getCheckpointService()
+			const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
+
+			if (!changes?.length) {
+				vscode.window.showInformationMessage("No changes found.")
+				return
+			}
+
+			await vscode.commands.executeCommand(
+				"vscode.changes",
+				mode === "full" ? "Changes since task started" : "Changes since previous checkpoint",
+				changes.map((change) => [
+					vscode.Uri.file(change.paths.absolute),
+					vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
+						query: Buffer.from(change.content.before ?? "").toString("base64"),
+					}),
+					vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
+						query: Buffer.from(change.content.after ?? "").toString("base64"),
+					}),
+				]),
+			)
+		} catch (err) {
+			this.providerRef
+				.deref()
+				?.log(
+					`[checkpointDiff] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
+				)
+
+			this.checkpointsEnabled = false
+		}
+	}
+
+	public async checkpointSave() {
+		if (!this.checkpointsEnabled) {
+			return
+		}
+
+		try {
+			const service = await this.getCheckpointService()
+			const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
+
+			if (commit?.commit) {
+				await this.say("checkpoint_saved", commit.commit)
+			}
+		} catch (err) {
+			this.providerRef
+				.deref()
+				?.log(
+					`[checkpointSave] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
+				)
+
+			this.checkpointsEnabled = false
+		}
+	}
+
+	public async checkpointRestore({
+		ts,
+		commitHash,
+		mode,
+	}: {
+		ts: number
+		commitHash: string
+		mode: "preview" | "restore"
+	}) {
+		if (!this.checkpointsEnabled) {
+			return
+		}
+
+		const index = this.clineMessages.findIndex((m) => m.ts === ts)
+
+		if (index === -1) {
+			return
+		}
+
+		try {
+			const service = await this.getCheckpointService()
+			await service.restoreCheckpoint(commitHash)
+
+			if (mode === "restore") {
+				await this.overwriteApiConversationHistory(
+					this.apiConversationHistory.filter((m) => !m.ts || m.ts < ts),
+				)
+
+				const deletedMessages = this.clineMessages.slice(index + 1)
+
+				const { totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost } = getApiMetrics(
+					combineApiRequests(combineCommandSequences(deletedMessages)),
+				)
+
+				await this.overwriteClineMessages(this.clineMessages.slice(0, index + 1))
+
+				// TODO: Verify that this is working as expected.
+				await this.say(
+					"api_req_deleted",
+					JSON.stringify({
+						tokensIn: totalTokensIn,
+						tokensOut: totalTokensOut,
+						cacheWrites: totalCacheWrites,
+						cacheReads: totalCacheReads,
+						cost: totalCost,
+					} satisfies ClineApiReqInfo),
+				)
+			}
+
+			// The task is already cancelled by the provider beforehand, but we
+			// need to re-init to get the updated messages.
+			//
+			// This was take from Cline's implementation of the checkpoints
+			// feature. The cline instance will hang if we don't cancel twice,
+			// so this is currently necessary, but it seems like a complicated
+			// and hacky solution to a problem that I don't fully understand.
+			// I'd like to revisit this in the future and try to improve the
+			// task flow and the communication between the webview and the
+			// Cline instance.
+			this.providerRef.deref()?.cancelTask()
+		} catch (err) {
+			this.providerRef
+				.deref()
+				?.log(
+					`[restoreCheckpoint] Encountered unexpected error: $${err instanceof Error ? err.message : String(err)}`,
+				)
+
+			this.checkpointsEnabled = false
+		}
+	}
 }
 
 function escapeRegExp(string: string): string {

+ 35 - 6
src/core/__tests__/Cline.test.ts

@@ -306,6 +306,7 @@ describe("Cline", () => {
 				mockApiConfig,
 				"custom instructions",
 				false,
+				false,
 				0.95, // 95% threshold
 				"test task",
 			)
@@ -315,7 +316,15 @@ describe("Cline", () => {
 		})
 
 		it("should use default fuzzy match threshold when not provided", () => {
-			const cline = new Cline(mockProvider, mockApiConfig, "custom instructions", true, undefined, "test task")
+			const cline = new Cline(
+				mockProvider,
+				mockApiConfig,
+				"custom instructions",
+				true,
+				false,
+				undefined,
+				"test task",
+			)
 
 			expect(cline.diffEnabled).toBe(true)
 			// The diff strategy should be created with default threshold (1.0)
@@ -330,6 +339,7 @@ describe("Cline", () => {
 				mockApiConfig,
 				"custom instructions",
 				true,
+				false,
 				0.9, // 90% threshold
 				"test task",
 			)
@@ -344,7 +354,15 @@ describe("Cline", () => {
 		it("should pass default threshold to diff strategy when not provided", () => {
 			const getDiffStrategySpy = jest.spyOn(require("../diff/DiffStrategy"), "getDiffStrategy")
 
-			const cline = new Cline(mockProvider, mockApiConfig, "custom instructions", true, undefined, "test task")
+			const cline = new Cline(
+				mockProvider,
+				mockApiConfig,
+				"custom instructions",
+				true,
+				false,
+				undefined,
+				"test task",
+			)
 
 			expect(cline.diffEnabled).toBe(true)
 			expect(cline.diffStrategy).toBeDefined()
@@ -360,6 +378,7 @@ describe("Cline", () => {
 					mockApiConfig,
 					undefined, // customInstructions
 					false, // diffEnabled
+					false, // checkpointsEnabled
 					undefined, // fuzzyMatchThreshold
 					undefined, // task
 				)
@@ -412,7 +431,7 @@ describe("Cline", () => {
 		})
 
 		it("should include timezone information in environment details", async () => {
-			const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+			const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
 
 			const details = await cline["getEnvironmentDetails"](false)
 
@@ -425,7 +444,7 @@ describe("Cline", () => {
 
 		describe("API conversation handling", () => {
 			it("should clean conversation history before sending to API", async () => {
-				const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+				const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
 
 				// Mock the API's createMessage method to capture the conversation history
 				const createMessageSpy = jest.fn()
@@ -537,6 +556,7 @@ describe("Cline", () => {
 					configWithImages,
 					undefined,
 					false,
+					false,
 					undefined,
 					"test task",
 				)
@@ -561,6 +581,7 @@ describe("Cline", () => {
 					configWithoutImages,
 					undefined,
 					false,
+					false,
 					undefined,
 					"test task",
 				)
@@ -647,7 +668,7 @@ describe("Cline", () => {
 			})
 
 			it("should handle API retry with countdown", async () => {
-				const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+				const cline = new Cline(mockProvider, mockApiConfig, undefined, false, false, undefined, "test task")
 
 				// Mock delay to track countdown timing
 				const mockDelay = jest.fn().mockResolvedValue(undefined)
@@ -767,7 +788,15 @@ describe("Cline", () => {
 
 			describe("loadContext", () => {
 				it("should process mentions in task and feedback tags", async () => {
-					const cline = new Cline(mockProvider, mockApiConfig, undefined, false, undefined, "test task")
+					const cline = new Cline(
+						mockProvider,
+						mockApiConfig,
+						undefined,
+						false,
+						false,
+						undefined,
+						"test task",
+					)
 
 					// Mock parseMentions to track calls
 					const mockParseMentions = jest.fn().mockImplementation((text) => `processed: ${text}`)

+ 101 - 17
src/core/webview/ClineProvider.ts

@@ -6,6 +6,8 @@ import os from "os"
 import pWaitFor from "p-wait-for"
 import * as path from "path"
 import * as vscode from "vscode"
+import simpleGit from "simple-git"
+
 import { buildApiHandler } from "../../api"
 import { downloadTask } from "../../integrations/misc/export-markdown"
 import { openFile, openImage } from "../../integrations/misc/open-file"
@@ -18,7 +20,7 @@ import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
 import { findLast } from "../../shared/array"
 import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
 import { HistoryItem } from "../../shared/HistoryItem"
-import { WebviewMessage } from "../../shared/WebviewMessage"
+import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
 import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
 import { SYSTEM_PROMPT } from "../prompts/system"
 import { fileExistsAtPath } from "../../utils/fs"
@@ -96,6 +98,7 @@ type GlobalStateKey =
 	| "soundEnabled"
 	| "soundVolume"
 	| "diffEnabled"
+	| "checkpointsEnabled"
 	| "browserViewportSize"
 	| "screenshotQuality"
 	| "fuzzyMatchThreshold"
@@ -391,6 +394,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			apiConfiguration,
 			customModePrompts,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			mode,
 			customInstructions: globalInstructions,
@@ -405,6 +409,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			apiConfiguration,
 			effectiveInstructions,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			task,
 			images,
@@ -415,10 +420,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 	public async initClineWithHistoryItem(historyItem: HistoryItem) {
 		await this.clearTask()
+
 		const {
 			apiConfiguration,
 			customModePrompts,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			mode,
 			customInstructions: globalInstructions,
@@ -433,6 +440,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			apiConfiguration,
 			effectiveInstructions,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			undefined,
 			undefined,
@@ -825,25 +833,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 					case "openMention":
 						openMention(message.text)
 						break
-					case "cancelTask":
-						if (this.cline) {
-							const { historyItem } = await this.getTaskWithId(this.cline.taskId)
-							this.cline.abortTask()
-							await pWaitFor(() => this.cline === undefined || this.cline.didFinishAborting, {
-								timeout: 3_000,
-							}).catch((error) => {
-								this.outputChannel.appendLine(
-									`Failed to abort task ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
-								)
-							})
-							if (this.cline) {
-								// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
-								this.cline.abandoned = true
+					case "checkpointDiff":
+						const result = checkoutDiffPayloadSchema.safeParse(message.payload)
+
+						if (result.success) {
+							await this.cline?.checkpointDiff(result.data)
+						}
+
+						break
+					case "checkpointRestore": {
+						const result = checkoutRestorePayloadSchema.safeParse(message.payload)
+
+						if (result.success) {
+							await this.cancelTask()
+
+							try {
+								await pWaitFor(() => this.cline?.isInitialized === true, { timeout: 3_000 })
+							} catch (error) {
+								vscode.window.showErrorMessage("Timed out when attempting to restore checkpoint.")
+							}
+
+							try {
+								await this.cline?.checkpointRestore(result.data)
+							} catch (error) {
+								vscode.window.showErrorMessage("Failed to restore checkpoint.")
 							}
-							await this.initClineWithHistoryItem(historyItem) // clears task again, so we need to abortTask manually above
-							// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
 						}
 
+						break
+					}
+					case "cancelTask":
+						await this.cancelTask()
 						break
 					case "allowedCommands":
 						await this.context.globalState.update("allowedCommands", message.commands)
@@ -932,6 +952,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("diffEnabled", diffEnabled)
 						await this.postStateToWebview()
 						break
+					case "checkpointsEnabled":
+						const checkpointsEnabled = message.bool ?? false
+						await this.updateGlobalState("checkpointsEnabled", checkpointsEnabled)
+						await this.postStateToWebview()
+						break
 					case "browserViewportSize":
 						const browserViewportSize = message.text ?? "900x600"
 						await this.updateGlobalState("browserViewportSize", browserViewportSize)
@@ -1583,6 +1608,39 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		}
 	}
 
+	async cancelTask() {
+		if (this.cline) {
+			const { historyItem } = await this.getTaskWithId(this.cline.taskId)
+			this.cline.abortTask()
+
+			await pWaitFor(
+				() =>
+					this.cline === undefined ||
+					this.cline.isStreaming === false ||
+					this.cline.didFinishAbortingStream ||
+					// If only the first chunk is processed, then there's no
+					// need to wait for graceful abort (closes edits, browser,
+					// etc).
+					this.cline.isWaitingForFirstChunk,
+				{
+					timeout: 3_000,
+				},
+			).catch(() => {
+				console.error("Failed to abort task")
+			})
+
+			if (this.cline) {
+				// 'abandoned' will prevent this Cline instance from affecting
+				// future Cline instances. This may happen if its hanging on a
+				// streaming request.
+				this.cline.abandoned = true
+			}
+
+			// Clears task again, so we need to abortTask manually above.
+			await this.initClineWithHistoryItem(historyItem)
+		}
+	}
+
 	async updateCustomInstructions(instructions?: string) {
 		// User may be clearing the field
 		await this.updateGlobalState("customInstructions", instructions || undefined)
@@ -2029,6 +2087,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			await fs.unlink(legacyMessagesFilePath)
 		}
 		await fs.rmdir(taskDirPath) // succeeds if the dir is empty
+
+		const { checkpointsEnabled } = await this.getState()
+		const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
+		const branch = `roo-code-checkpoints-${id}`
+
+		if (checkpointsEnabled && baseDir) {
+			try {
+				await simpleGit(baseDir).branch(["-D", branch])
+				console.log(`[deleteTaskWithId] Deleted branch ${branch}`)
+			} catch (err) {
+				console.error(
+					`[deleteTaskWithId] Error deleting branch ${branch}: ${err instanceof Error ? err.message : String(err)}`,
+				)
+			}
+		}
 	}
 
 	async deleteTaskFromState(id: string) {
@@ -2059,6 +2132,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowModeSwitch,
 			soundEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			taskHistory,
 			soundVolume,
 			browserViewportSize,
@@ -2101,6 +2175,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
 			soundEnabled: soundEnabled ?? false,
 			diffEnabled: diffEnabled ?? true,
+			checkpointsEnabled: checkpointsEnabled ?? false,
 			shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
 			allowedCommands,
 			soundVolume: soundVolume ?? 0.5,
@@ -2229,6 +2304,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			allowedCommands,
 			soundEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			soundVolume,
 			browserViewportSize,
 			fuzzyMatchThreshold,
@@ -2303,6 +2379,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
 			this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
+			this.getGlobalState("checkpointsEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("soundVolume") as Promise<number | undefined>,
 			this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
 			this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
@@ -2398,6 +2475,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			allowedCommands,
 			soundEnabled: soundEnabled ?? false,
 			diffEnabled: diffEnabled ?? true,
+			checkpointsEnabled: checkpointsEnabled ?? false,
 			soundVolume,
 			browserViewportSize: browserViewportSize ?? "900x600",
 			screenshotQuality: screenshotQuality ?? 75,
@@ -2551,6 +2629,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
 	}
 
+	// logging
+
+	public log(message: string) {
+		this.outputChannel.appendLine(message)
+	}
+
 	// integration tests
 
 	get viewLaunched() {

+ 3 - 0
src/core/webview/__tests__/ClineProvider.test.ts

@@ -340,6 +340,7 @@ describe("ClineProvider", () => {
 			uriScheme: "vscode",
 			soundEnabled: false,
 			diffEnabled: false,
+			checkpointsEnabled: false,
 			writeDelayMs: 1000,
 			browserViewportSize: "900x600",
 			fuzzyMatchThreshold: 1.0,
@@ -646,6 +647,7 @@ describe("ClineProvider", () => {
 			},
 			mode: "code",
 			diffEnabled: true,
+			checkpointsEnabled: false,
 			fuzzyMatchThreshold: 1.0,
 			experiments: experimentDefault,
 		} as any)
@@ -663,6 +665,7 @@ describe("ClineProvider", () => {
 			mockApiConfig,
 			modeCustomInstructions,
 			true,
+			false,
 			1.0,
 			"Test task",
 			undefined,

+ 6 - 2
src/shared/ExtensionMessage.ts

@@ -105,6 +105,7 @@ export interface ExtensionState {
 	soundEnabled?: boolean
 	soundVolume?: number
 	diffEnabled?: boolean
+	checkpointsEnabled: boolean
 	browserViewportSize?: string
 	screenshotQuality?: number
 	fuzzyMatchThreshold?: number
@@ -131,6 +132,7 @@ export interface ClineMessage {
 	images?: string[]
 	partial?: boolean
 	reasoning?: string
+	conversationHistoryIndex?: number
 }
 
 export type ClineAsk =
@@ -151,13 +153,14 @@ export type ClineSay =
 	| "error"
 	| "api_req_started"
 	| "api_req_finished"
+	| "api_req_retried"
+	| "api_req_retry_delayed"
+	| "api_req_deleted"
 	| "text"
 	| "reasoning"
 	| "completion_result"
 	| "user_feedback"
 	| "user_feedback_diff"
-	| "api_req_retried"
-	| "api_req_retry_delayed"
 	| "command_output"
 	| "tool"
 	| "shell_integration_warning"
@@ -168,6 +171,7 @@ export type ClineSay =
 	| "mcp_server_response"
 	| "new_task_started"
 	| "new_task"
+	| "checkpoint_saved"
 
 export interface ClineSayTool {
 	tool:

+ 24 - 1
src/shared/WebviewMessage.ts

@@ -1,6 +1,9 @@
+import { z } from "zod"
 import { ApiConfiguration, ApiProvider } from "./api"
 import { Mode, PromptComponent, ModeConfig } from "./modes"
 
+export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+
 export type PromptMode = Mode | "enhance"
 
 export type AudioType = "notification" | "celebration" | "progress_loop"
@@ -46,6 +49,7 @@ export interface WebviewMessage {
 		| "soundEnabled"
 		| "soundVolume"
 		| "diffEnabled"
+		| "checkpointsEnabled"
 		| "browserViewportSize"
 		| "screenshotQuality"
 		| "openMcpSettings"
@@ -83,6 +87,8 @@ export interface WebviewMessage {
 		| "deleteCustomMode"
 		| "setopenAiCustomModelInfo"
 		| "openCustomModesSettings"
+		| "checkpointDiff"
+		| "checkpointRestore"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse
@@ -104,6 +110,23 @@ export interface WebviewMessage {
 	slug?: string
 	modeConfig?: ModeConfig
 	timeout?: number
+	payload?: WebViewMessagePayload
 }
 
-export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+export const checkoutDiffPayloadSchema = z.object({
+	ts: z.number(),
+	commitHash: z.string(),
+	mode: z.enum(["full", "checkpoint"]),
+})
+
+export type CheckpointDiffPayload = z.infer<typeof checkoutDiffPayloadSchema>
+
+export const checkoutRestorePayloadSchema = z.object({
+	ts: z.number(),
+	commitHash: z.string(),
+	mode: z.enum(["preview", "restore"]),
+})
+
+export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
+
+export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload

+ 3 - 0
webview-ui/src/components/chat/ChatRow.tsx

@@ -21,6 +21,7 @@ import Thumbnails from "../common/Thumbnails"
 import McpResourceRow from "../mcp/McpResourceRow"
 import McpToolRow from "../mcp/McpToolRow"
 import { highlightMentions } from "./TaskHeader"
+import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
 
 interface ChatRowProps {
 	message: ClineMessage
@@ -755,6 +756,8 @@ export const ChatRowContent = ({
 							</div>
 						</>
 					)
+				case "checkpoint_saved":
+					return <CheckpointSaved ts={message.ts!} commitHash={message.text!} />
 				default:
 					return (
 						<>

+ 5 - 1
webview-ui/src/components/chat/ChatView.tsx

@@ -223,9 +223,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 								setEnableButtons(false)
 							}
 							break
+						case "api_req_finished":
 						case "task":
 						case "error":
-						case "api_req_finished":
 						case "text":
 						case "browser_action":
 						case "browser_action_result":
@@ -547,6 +547,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 			switch (message.say) {
 				case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
 				case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
+				case "api_req_deleted": // aggregated api_req metrics from deleted messages
 					return false
 				case "api_req_retry_delayed":
 					// Only show the retry message if it's the last message
@@ -1121,6 +1122,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 					)}
 				</>
 			)}
+
 			<ChatTextArea
 				ref={textAreaRef}
 				inputValue={inputValue}
@@ -1140,6 +1142,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				mode={mode}
 				setMode={setMode}
 			/>
+
+			<div id="chat-view-portal" />
 		</div>
 	)
 }

+ 80 - 0
webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx

@@ -0,0 +1,80 @@
+import { useState, useEffect, useCallback } from "react"
+import { DotsHorizontalIcon } from "@radix-ui/react-icons"
+import { DropdownMenuItemProps } from "@radix-ui/react-dropdown-menu"
+
+import { vscode } from "../../../utils/vscode"
+
+import {
+	Button,
+	DropdownMenu,
+	DropdownMenuTrigger,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuShortcut,
+} from "@/components/ui"
+
+type CheckpointMenuProps = {
+	ts: number
+	commitHash: string
+}
+
+export const CheckpointMenu = ({ ts, commitHash }: CheckpointMenuProps) => {
+	const [portalContainer, setPortalContainer] = useState<HTMLElement>()
+
+	const onTaskDiff = useCallback(() => {
+		vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "full" } })
+	}, [ts, commitHash])
+
+	const onCheckpointDiff = useCallback(() => {
+		vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "checkpoint" } })
+	}, [ts, commitHash])
+
+	const onPreview = useCallback(() => {
+		vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "preview" } })
+	}, [ts, commitHash])
+
+	const onRestore = useCallback(() => {
+		vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "restore" } })
+	}, [ts, commitHash])
+
+	useEffect(() => {
+		// The dropdown menu uses a portal from @shadcn/ui which by default renders
+		// at the document root. This causes the menu to remain visible even when
+		// the parent ChatView component is hidden (during settings/history view).
+		// By moving the portal inside ChatView, the menu will properly hide when
+		// its parent is hidden.
+		setPortalContainer(document.getElementById("chat-view-portal") || undefined)
+	}, [])
+
+	return (
+		<DropdownMenu>
+			<DropdownMenuTrigger asChild>
+				<Button variant="ghost" size="icon">
+					<DotsHorizontalIcon />
+				</Button>
+			</DropdownMenuTrigger>
+			<DropdownMenuContent container={portalContainer} align="end">
+				<CheckpointMenuItem label="Checkpoint Diff" icon="diff-single" onClick={onCheckpointDiff} />
+				<CheckpointMenuItem label="Task Diff" icon="diff-multiple" onClick={onTaskDiff} />
+				<CheckpointMenuItem label="Preview" icon="open-preview" onClick={onPreview} />
+				<CheckpointMenuItem label="Restore" icon="history" onClick={onRestore} />
+			</DropdownMenuContent>
+		</DropdownMenu>
+	)
+}
+
+type CheckpointMenuItemProps = DropdownMenuItemProps & {
+	label: React.ReactNode
+	icon: "diff-single" | "diff-multiple" | "open-preview" | "history"
+}
+
+const CheckpointMenuItem = ({ label, icon, ...props }: CheckpointMenuItemProps) => (
+	<DropdownMenuItem {...props}>
+		<div className="flex flex-row-reverse gap-1">
+			<div>{label}</div>
+			<DropdownMenuShortcut>
+				<span className={`codicon codicon-${icon}`} />
+			</DropdownMenuShortcut>
+		</div>
+	</DropdownMenuItem>
+)

+ 16 - 0
webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx

@@ -0,0 +1,16 @@
+import { CheckpointMenu } from "./CheckpointMenu"
+
+type CheckpointSavedProps = {
+	ts: number
+	commitHash: string
+}
+
+export const CheckpointSaved = (props: CheckpointSavedProps) => (
+	<div className="flex items-center justify-between">
+		<div className="flex items-center gap-2">
+			<span className="codicon codicon-git-commit" />
+			<span className="font-bold">Checkpoint</span>
+		</div>
+		<CheckpointMenu {...props} />
+	</div>
+)

+ 22 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -34,6 +34,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		setSoundVolume,
 		diffEnabled,
 		setDiffEnabled,
+		checkpointsEnabled,
+		setCheckpointsEnabled,
 		browserViewportSize,
 		setBrowserViewportSize,
 		openRouterModels,
@@ -86,6 +88,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 			vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
 			vscode.postMessage({ type: "soundVolume", value: soundVolume })
 			vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
+			vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
 			vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
 			vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
@@ -699,6 +702,25 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 								/>
 							))}
 					</div>
+
+					<div style={{ marginBottom: 15 }}>
+						<VSCodeCheckbox
+							checked={checkpointsEnabled}
+							onChange={(e: any) => {
+								setCheckpointsEnabled(e.target.checked)
+							}}>
+							<span style={{ fontWeight: "500" }}>Enable checkpoints</span>
+						</VSCodeCheckbox>
+						<p
+							style={{
+								fontSize: "12px",
+								marginTop: "5px",
+								color: "var(--vscode-descriptionForeground)",
+							}}>
+							When enabled, Roo will be save a workspace checkpoint after each tool execution if a file in
+							the workspace is modified, added or deleted.
+						</p>
+					</div>
 				</div>
 
 				<div

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -41,6 +41,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setSoundEnabled: (value: boolean) => void
 	setSoundVolume: (value: number) => void
 	setDiffEnabled: (value: boolean) => void
+	setCheckpointsEnabled: (value: boolean) => void
 	setBrowserViewportSize: (value: string) => void
 	setFuzzyMatchThreshold: (value: number) => void
 	preferredLanguage: string
@@ -88,6 +89,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		soundEnabled: false,
 		soundVolume: 0.5,
 		diffEnabled: false,
+		checkpointsEnabled: false,
 		fuzzyMatchThreshold: 1.0,
 		preferredLanguage: "English",
 		writeDelayMs: 1000,
@@ -288,6 +290,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
 		setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
 		setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
+		setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })),
 		setBrowserViewportSize: (value: string) =>
 			setState((prevState) => ({ ...prevState, browserViewportSize: value })),
 		setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),