Browse Source

Merge branch 'create-logging-util' of https://github.com/nissa-seru/Roo-Code into create-logging-util

Nissa Seru 1 year ago
parent
commit
b194bcea4d

+ 3 - 1
.gitignore

@@ -23,6 +23,8 @@ docs/_site/
 # Dotenv
 # Dotenv
 .env.integration
 .env.integration
 
 
+#Local lint config
+.eslintrc.local.json
 
 
 #Logging
 #Logging
-logs
+logs

+ 4 - 1
package.json

@@ -272,6 +272,7 @@
 		"compile:integration": "tsc -p tsconfig.integration.json",
 		"compile:integration": "tsc -p tsconfig.integration.json",
 		"install:all": "npm install && cd webview-ui && npm install",
 		"install:all": "npm install && cd webview-ui && npm install",
 		"lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
 		"lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
+		"lint-fix": "eslint src --ext ts --fix && npm run lint-fix --prefix webview-ui",
 		"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
 		"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
 		"pretest": "npm run compile && npm run compile:integration",
 		"pretest": "npm run compile && npm run compile:integration",
 		"dev": "cd webview-ui && npm run dev",
 		"dev": "cd webview-ui && npm run dev",
@@ -283,7 +284,7 @@
 		"publish": "npm run build && changeset publish && npm install --package-lock-only",
 		"publish": "npm run build && changeset publish && npm install --package-lock-only",
 		"version-packages": "changeset version && npm install --package-lock-only",
 		"version-packages": "changeset version && npm install --package-lock-only",
 		"vscode:prepublish": "npm run package",
 		"vscode:prepublish": "npm run package",
-		"vsix": "mkdir -p bin && npx vsce package --out bin",
+		"vsix": "rimraf bin && mkdirp bin && npx vsce package --out bin",
 		"watch": "npm-run-all -p watch:*",
 		"watch": "npm-run-all -p watch:*",
 		"watch:esbuild": "node esbuild.js --watch",
 		"watch:esbuild": "node esbuild.js --watch",
 		"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
 		"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
@@ -350,6 +351,8 @@
 		"@vscode/test-cli": "^0.0.9",
 		"@vscode/test-cli": "^0.0.9",
 		"@vscode/test-electron": "^2.4.0",
 		"@vscode/test-electron": "^2.4.0",
 		"esbuild": "^0.24.0",
 		"esbuild": "^0.24.0",
+		"mkdirp": "^3.0.1",
+		"rimraf": "^6.0.1",
 		"eslint": "^8.57.0",
 		"eslint": "^8.57.0",
 		"husky": "^9.1.7",
 		"husky": "^9.1.7",
 		"jest": "^29.7.0",
 		"jest": "^29.7.0",

+ 218 - 11
src/core/Cline.ts

@@ -11,7 +11,8 @@ import { serializeError } from "serialize-error"
 import * as vscode from "vscode"
 import * as vscode from "vscode"
 import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
 import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
 import { ApiStream } from "../api/transform/stream"
 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 { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
 import {
 import {
 	extractTextFromFile,
 	extractTextFromFile,
@@ -93,12 +94,19 @@ export class Cline {
 	private consecutiveMistakeCountForApplyDiff: Map<string, number> = new Map()
 	private consecutiveMistakeCountForApplyDiff: Map<string, number> = new Map()
 	private providerRef: WeakRef<ClineProvider>
 	private providerRef: WeakRef<ClineProvider>
 	private abort: boolean = false
 	private abort: boolean = false
-	didFinishAborting = false
+	didFinishAbortingStream = false
 	abandoned = false
 	abandoned = false
 	private diffViewProvider: DiffViewProvider
 	private diffViewProvider: DiffViewProvider
 	private lastApiRequestTime?: number
 	private lastApiRequestTime?: number
+	isInitialized = false
+
+	// checkpoints
+	checkpointsEnabled: boolean = false
+	private checkpointService?: CheckpointService
 
 
 	// streaming
 	// streaming
+	isWaitingForFirstChunk = false
+	isStreaming = false
 	private currentStreamingContentIndex = 0
 	private currentStreamingContentIndex = 0
 	private assistantMessageContent: AssistantMessageContent[] = []
 	private assistantMessageContent: AssistantMessageContent[] = []
 	private presentAssistantMessageLocked = false
 	private presentAssistantMessageLocked = false
@@ -114,6 +122,7 @@ export class Cline {
 		apiConfiguration: ApiConfiguration,
 		apiConfiguration: ApiConfiguration,
 		customInstructions?: string,
 		customInstructions?: string,
 		enableDiff?: boolean,
 		enableDiff?: boolean,
+		enableCheckpoints?: boolean,
 		fuzzyMatchThreshold?: number,
 		fuzzyMatchThreshold?: number,
 		task?: string | undefined,
 		task?: string | undefined,
 		images?: string[] | undefined,
 		images?: string[] | undefined,
@@ -134,6 +143,7 @@ export class Cline {
 		this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
 		this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
 		this.providerRef = new WeakRef(provider)
 		this.providerRef = new WeakRef(provider)
 		this.diffViewProvider = new DiffViewProvider(cwd)
 		this.diffViewProvider = new DiffViewProvider(cwd)
+		this.checkpointsEnabled = enableCheckpoints ?? false
 
 
 		if (historyItem) {
 		if (historyItem) {
 			this.taskId = historyItem.id
 			this.taskId = historyItem.id
@@ -438,6 +448,7 @@ export class Cline {
 		await this.providerRef.deref()?.postStateToWebview()
 		await this.providerRef.deref()?.postStateToWebview()
 
 
 		await this.say("text", task, images)
 		await this.say("text", task, images)
+		this.isInitialized = true
 
 
 		let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
 		let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
 		await this.initiateTaskLoop([
 		await this.initiateTaskLoop([
@@ -477,12 +488,13 @@ export class Cline {
 		await this.overwriteClineMessages(modifiedClineMessages)
 		await this.overwriteClineMessages(modifiedClineMessages)
 		this.clineMessages = await this.getSavedClineMessages()
 		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
 		const lastClineMessage = this.clineMessages
 			.slice()
 			.slice()
@@ -506,6 +518,8 @@ export class Cline {
 			askType = "resume_task"
 			askType = "resume_task"
 		}
 		}
 
 
+		this.isInitialized = true
+
 		const { response, text, images } = await this.ask(askType) // calls poststatetowebview
 		const { response, text, images } = await this.ask(askType) // calls poststatetowebview
 		let responseText: string | undefined
 		let responseText: string | undefined
 		let responseImages: string[] | undefined
 		let responseImages: string[] | undefined
@@ -515,6 +529,11 @@ export class Cline {
 			responseImages = images
 			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
 		// 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) => {
 		const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
 			if (Array.isArray(message.content)) {
 			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.terminalManager.disposeAll()
 		this.urlContentFetcher.closeBrowser()
 		this.urlContentFetcher.closeBrowser()
 		this.browserSession.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
 	// Tools
@@ -927,8 +949,10 @@ export class Cline {
 
 
 		try {
 		try {
 			// awaiting first chunk to see if it will throw an error
 			// awaiting first chunk to see if it will throw an error
+			this.isWaitingForFirstChunk = true
 			const firstChunk = await iterator.next()
 			const firstChunk = await iterator.next()
 			yield firstChunk.value
 			yield firstChunk.value
+			this.isWaitingForFirstChunk = false
 		} catch (error) {
 		} 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.
 			// 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) {
 			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
 		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) {
 		switch (block.type) {
 			case "text": {
 			case "text": {
 				if (this.didRejectTool || this.didAlreadyUseTool) {
 				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
 					// 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
 					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) => {
 				const askApproval = async (type: ClineAsk, partialMessage?: string) => {
@@ -2655,6 +2686,10 @@ export class Cline {
 				break
 				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.
 		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.
 		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()
 				await this.saveClineMessages()
 
 
 				// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
 				// 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
 			// reset streaming state
@@ -3197,6 +3232,178 @@ export class Cline {
 
 
 		return `<environment_details>\n${details.trim()}\n</environment_details>`
 		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.providerRef
+					.deref()
+					?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
+
+				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)
+
+			await this.providerRef
+				.deref()
+				?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint })
+
+			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 {
 function escapeRegExp(string: string): string {

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

@@ -306,6 +306,7 @@ describe("Cline", () => {
 				mockApiConfig,
 				mockApiConfig,
 				"custom instructions",
 				"custom instructions",
 				false,
 				false,
+				false,
 				0.95, // 95% threshold
 				0.95, // 95% threshold
 				"test task",
 				"test task",
 			)
 			)
@@ -315,7 +316,15 @@ describe("Cline", () => {
 		})
 		})
 
 
 		it("should use default fuzzy match threshold when not provided", () => {
 		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)
 			expect(cline.diffEnabled).toBe(true)
 			// The diff strategy should be created with default threshold (1.0)
 			// The diff strategy should be created with default threshold (1.0)
@@ -330,6 +339,7 @@ describe("Cline", () => {
 				mockApiConfig,
 				mockApiConfig,
 				"custom instructions",
 				"custom instructions",
 				true,
 				true,
+				false,
 				0.9, // 90% threshold
 				0.9, // 90% threshold
 				"test task",
 				"test task",
 			)
 			)
@@ -344,7 +354,15 @@ describe("Cline", () => {
 		it("should pass default threshold to diff strategy when not provided", () => {
 		it("should pass default threshold to diff strategy when not provided", () => {
 			const getDiffStrategySpy = jest.spyOn(require("../diff/DiffStrategy"), "getDiffStrategy")
 			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.diffEnabled).toBe(true)
 			expect(cline.diffStrategy).toBeDefined()
 			expect(cline.diffStrategy).toBeDefined()
@@ -360,6 +378,7 @@ describe("Cline", () => {
 					mockApiConfig,
 					mockApiConfig,
 					undefined, // customInstructions
 					undefined, // customInstructions
 					false, // diffEnabled
 					false, // diffEnabled
+					false, // checkpointsEnabled
 					undefined, // fuzzyMatchThreshold
 					undefined, // fuzzyMatchThreshold
 					undefined, // task
 					undefined, // task
 				)
 				)
@@ -412,7 +431,7 @@ describe("Cline", () => {
 		})
 		})
 
 
 		it("should include timezone information in environment details", async () => {
 		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)
 			const details = await cline["getEnvironmentDetails"](false)
 
 
@@ -425,7 +444,7 @@ describe("Cline", () => {
 
 
 		describe("API conversation handling", () => {
 		describe("API conversation handling", () => {
 			it("should clean conversation history before sending to API", async () => {
 			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
 				// Mock the API's createMessage method to capture the conversation history
 				const createMessageSpy = jest.fn()
 				const createMessageSpy = jest.fn()
@@ -537,6 +556,7 @@ describe("Cline", () => {
 					configWithImages,
 					configWithImages,
 					undefined,
 					undefined,
 					false,
 					false,
+					false,
 					undefined,
 					undefined,
 					"test task",
 					"test task",
 				)
 				)
@@ -561,6 +581,7 @@ describe("Cline", () => {
 					configWithoutImages,
 					configWithoutImages,
 					undefined,
 					undefined,
 					false,
 					false,
+					false,
 					undefined,
 					undefined,
 					"test task",
 					"test task",
 				)
 				)
@@ -647,7 +668,7 @@ describe("Cline", () => {
 			})
 			})
 
 
 			it("should handle API retry with countdown", async () => {
 			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
 				// Mock delay to track countdown timing
 				const mockDelay = jest.fn().mockResolvedValue(undefined)
 				const mockDelay = jest.fn().mockResolvedValue(undefined)
@@ -767,7 +788,15 @@ describe("Cline", () => {
 
 
 			describe("loadContext", () => {
 			describe("loadContext", () => {
 				it("should process mentions in task and feedback tags", async () => {
 				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
 					// Mock parseMentions to track calls
 					const mockParseMentions = jest.fn().mockImplementation((text) => `processed: ${text}`)
 					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 pWaitFor from "p-wait-for"
 import * as path from "path"
 import * as path from "path"
 import * as vscode from "vscode"
 import * as vscode from "vscode"
+import simpleGit from "simple-git"
+
 import { buildApiHandler } from "../../api"
 import { buildApiHandler } from "../../api"
 import { downloadTask } from "../../integrations/misc/export-markdown"
 import { downloadTask } from "../../integrations/misc/export-markdown"
 import { openFile, openImage } from "../../integrations/misc/open-file"
 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 { findLast } from "../../shared/array"
 import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
 import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
 import { HistoryItem } from "../../shared/HistoryItem"
 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 { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
 import { SYSTEM_PROMPT } from "../prompts/system"
 import { SYSTEM_PROMPT } from "../prompts/system"
 import { fileExistsAtPath } from "../../utils/fs"
 import { fileExistsAtPath } from "../../utils/fs"
@@ -96,6 +98,7 @@ type GlobalStateKey =
 	| "soundEnabled"
 	| "soundEnabled"
 	| "soundVolume"
 	| "soundVolume"
 	| "diffEnabled"
 	| "diffEnabled"
+	| "checkpointsEnabled"
 	| "browserViewportSize"
 	| "browserViewportSize"
 	| "screenshotQuality"
 	| "screenshotQuality"
 	| "fuzzyMatchThreshold"
 	| "fuzzyMatchThreshold"
@@ -391,6 +394,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			apiConfiguration,
 			apiConfiguration,
 			customModePrompts,
 			customModePrompts,
 			diffEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			fuzzyMatchThreshold,
 			mode,
 			mode,
 			customInstructions: globalInstructions,
 			customInstructions: globalInstructions,
@@ -405,6 +409,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			apiConfiguration,
 			apiConfiguration,
 			effectiveInstructions,
 			effectiveInstructions,
 			diffEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			fuzzyMatchThreshold,
 			task,
 			task,
 			images,
 			images,
@@ -415,10 +420,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 
 	public async initClineWithHistoryItem(historyItem: HistoryItem) {
 	public async initClineWithHistoryItem(historyItem: HistoryItem) {
 		await this.clearTask()
 		await this.clearTask()
+
 		const {
 		const {
 			apiConfiguration,
 			apiConfiguration,
 			customModePrompts,
 			customModePrompts,
 			diffEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			fuzzyMatchThreshold,
 			mode,
 			mode,
 			customInstructions: globalInstructions,
 			customInstructions: globalInstructions,
@@ -433,6 +440,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			apiConfiguration,
 			apiConfiguration,
 			effectiveInstructions,
 			effectiveInstructions,
 			diffEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			fuzzyMatchThreshold,
 			fuzzyMatchThreshold,
 			undefined,
 			undefined,
 			undefined,
 			undefined,
@@ -825,25 +833,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 					case "openMention":
 					case "openMention":
 						openMention(message.text)
 						openMention(message.text)
 						break
 						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
 						break
 					case "allowedCommands":
 					case "allowedCommands":
 						await this.context.globalState.update("allowedCommands", message.commands)
 						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.updateGlobalState("diffEnabled", diffEnabled)
 						await this.postStateToWebview()
 						await this.postStateToWebview()
 						break
 						break
+					case "checkpointsEnabled":
+						const checkpointsEnabled = message.bool ?? false
+						await this.updateGlobalState("checkpointsEnabled", checkpointsEnabled)
+						await this.postStateToWebview()
+						break
 					case "browserViewportSize":
 					case "browserViewportSize":
 						const browserViewportSize = message.text ?? "900x600"
 						const browserViewportSize = message.text ?? "900x600"
 						await this.updateGlobalState("browserViewportSize", browserViewportSize)
 						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) {
 	async updateCustomInstructions(instructions?: string) {
 		// User may be clearing the field
 		// User may be clearing the field
 		await this.updateGlobalState("customInstructions", instructions || undefined)
 		await this.updateGlobalState("customInstructions", instructions || undefined)
@@ -2029,6 +2087,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			await fs.unlink(legacyMessagesFilePath)
 			await fs.unlink(legacyMessagesFilePath)
 		}
 		}
 		await fs.rmdir(taskDirPath) // succeeds if the dir is empty
 		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) {
 	async deleteTaskFromState(id: string) {
@@ -2059,6 +2132,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowModeSwitch,
 			alwaysAllowModeSwitch,
 			soundEnabled,
 			soundEnabled,
 			diffEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			taskHistory,
 			taskHistory,
 			soundVolume,
 			soundVolume,
 			browserViewportSize,
 			browserViewportSize,
@@ -2101,6 +2175,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 				.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
 				.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
 			soundEnabled: soundEnabled ?? false,
 			soundEnabled: soundEnabled ?? false,
 			diffEnabled: diffEnabled ?? true,
 			diffEnabled: diffEnabled ?? true,
+			checkpointsEnabled: checkpointsEnabled ?? false,
 			shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
 			shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
 			allowedCommands,
 			allowedCommands,
 			soundVolume: soundVolume ?? 0.5,
 			soundVolume: soundVolume ?? 0.5,
@@ -2229,6 +2304,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			allowedCommands,
 			allowedCommands,
 			soundEnabled,
 			soundEnabled,
 			diffEnabled,
 			diffEnabled,
+			checkpointsEnabled,
 			soundVolume,
 			soundVolume,
 			browserViewportSize,
 			browserViewportSize,
 			fuzzyMatchThreshold,
 			fuzzyMatchThreshold,
@@ -2303,6 +2379,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
 			this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
 			this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
 			this.getGlobalState("diffEnabled") 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("soundVolume") as Promise<number | undefined>,
 			this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
 			this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
 			this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
 			this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
@@ -2398,6 +2475,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			allowedCommands,
 			allowedCommands,
 			soundEnabled: soundEnabled ?? false,
 			soundEnabled: soundEnabled ?? false,
 			diffEnabled: diffEnabled ?? true,
 			diffEnabled: diffEnabled ?? true,
+			checkpointsEnabled: checkpointsEnabled ?? false,
 			soundVolume,
 			soundVolume,
 			browserViewportSize: browserViewportSize ?? "900x600",
 			browserViewportSize: browserViewportSize ?? "900x600",
 			screenshotQuality: screenshotQuality ?? 75,
 			screenshotQuality: screenshotQuality ?? 75,
@@ -2551,6 +2629,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
 		await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
 	}
 	}
 
 
+	// logging
+
+	public log(message: string) {
+		this.outputChannel.appendLine(message)
+	}
+
 	// integration tests
 	// integration tests
 
 
 	get viewLaunched() {
 	get viewLaunched() {

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

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

+ 14 - 7
src/services/checkpoints/CheckpointService.ts

@@ -9,12 +9,6 @@ if (process.env.NODE_ENV !== "test") {
 	debug.enable("simple-git")
 	debug.enable("simple-git")
 }
 }
 
 
-export interface Checkpoint {
-	hash: string
-	message: string
-	timestamp?: Date
-}
-
 export type CheckpointServiceOptions = {
 export type CheckpointServiceOptions = {
 	taskId: string
 	taskId: string
 	git?: SimpleGit
 	git?: SimpleGit
@@ -60,6 +54,16 @@ export type CheckpointServiceOptions = {
  */
  */
 
 
 export class CheckpointService {
 export class CheckpointService {
+	private _currentCheckpoint?: string
+
+	public get currentCheckpoint() {
+		return this._currentCheckpoint
+	}
+
+	private set currentCheckpoint(value: string | undefined) {
+		this._currentCheckpoint = value
+	}
+
 	constructor(
 	constructor(
 		public readonly taskId: string,
 		public readonly taskId: string,
 		private readonly git: SimpleGit,
 		private readonly git: SimpleGit,
@@ -217,6 +221,8 @@ export class CheckpointService {
 				await this.popStash()
 				await this.popStash()
 			}
 			}
 
 
+			this.currentCheckpoint = commit.commit
+
 			return commit
 			return commit
 		} catch (err) {
 		} catch (err) {
 			this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
 			this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
@@ -237,6 +243,7 @@ export class CheckpointService {
 		await this.ensureBranch(this.mainBranch)
 		await this.ensureBranch(this.mainBranch)
 		await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
 		await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
 		await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
 		await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
+		this.currentCheckpoint = commitHash
 	}
 	}
 
 
 	public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
 	public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
@@ -291,7 +298,7 @@ export class CheckpointService {
 			// the checkpoint (i.e. the `git restore` command doesn't work
 			// the checkpoint (i.e. the `git restore` command doesn't work
 			// for empty commits).
 			// for empty commits).
 			await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
 			await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
-			await git.add(".")
+			await git.add(".gitkeep")
 			const commit = await git.commit("Initial commit")
 			const commit = await git.commit("Initial commit")
 
 
 			if (!commit.commit) {
 			if (!commit.commit) {

+ 1 - 1
src/services/checkpoints/__tests__/CheckpointService.test.ts

@@ -291,6 +291,7 @@ describe("CheckpointService", () => {
 			const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`)
 			const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`)
 			await fs.mkdir(baseDir)
 			await fs.mkdir(baseDir)
 			const newTestFile = path.join(baseDir, "test.txt")
 			const newTestFile = path.join(baseDir, "test.txt")
+			await fs.writeFile(newTestFile, "Hello, world!")
 
 
 			const newGit = simpleGit(baseDir)
 			const newGit = simpleGit(baseDir)
 			const initSpy = jest.spyOn(newGit, "init")
 			const initSpy = jest.spyOn(newGit, "init")
@@ -300,7 +301,6 @@ describe("CheckpointService", () => {
 			expect(initSpy).toHaveBeenCalled()
 			expect(initSpy).toHaveBeenCalled()
 
 
 			// Save a checkpoint: Hello, world!
 			// Save a checkpoint: Hello, world!
-			await fs.writeFile(newTestFile, "Hello, world!")
 			const commit1 = await newService.saveCheckpoint("Hello, world!")
 			const commit1 = await newService.saveCheckpoint("Hello, world!")
 			expect(commit1?.commit).toBeTruthy()
 			expect(commit1?.commit).toBeTruthy()
 			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
 			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")

+ 7 - 2
src/shared/ExtensionMessage.ts

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

+ 24 - 1
src/shared/WebviewMessage.ts

@@ -1,6 +1,9 @@
+import { z } from "zod"
 import { ApiConfiguration, ApiProvider } from "./api"
 import { ApiConfiguration, ApiProvider } from "./api"
 import { Mode, PromptComponent, ModeConfig } from "./modes"
 import { Mode, PromptComponent, ModeConfig } from "./modes"
 
 
+export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+
 export type PromptMode = Mode | "enhance"
 export type PromptMode = Mode | "enhance"
 
 
 export type AudioType = "notification" | "celebration" | "progress_loop"
 export type AudioType = "notification" | "celebration" | "progress_loop"
@@ -46,6 +49,7 @@ export interface WebviewMessage {
 		| "soundEnabled"
 		| "soundEnabled"
 		| "soundVolume"
 		| "soundVolume"
 		| "diffEnabled"
 		| "diffEnabled"
+		| "checkpointsEnabled"
 		| "browserViewportSize"
 		| "browserViewportSize"
 		| "screenshotQuality"
 		| "screenshotQuality"
 		| "openMcpSettings"
 		| "openMcpSettings"
@@ -83,6 +87,8 @@ export interface WebviewMessage {
 		| "deleteCustomMode"
 		| "deleteCustomMode"
 		| "setopenAiCustomModelInfo"
 		| "setopenAiCustomModelInfo"
 		| "openCustomModesSettings"
 		| "openCustomModesSettings"
+		| "checkpointDiff"
+		| "checkpointRestore"
 	text?: string
 	text?: string
 	disabled?: boolean
 	disabled?: boolean
 	askResponse?: ClineAskResponse
 	askResponse?: ClineAskResponse
@@ -104,6 +110,23 @@ export interface WebviewMessage {
 	slug?: string
 	slug?: string
 	modeConfig?: ModeConfig
 	modeConfig?: ModeConfig
 	timeout?: number
 	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

+ 1 - 1
src/utils/logging/index.ts

@@ -6,7 +6,7 @@
 import { CompactLogger } from "./CompactLogger"
 import { CompactLogger } from "./CompactLogger"
 
 
 /**
 /**
- * No-operation logger implementation for test environments
+ * No-operation logger implementation for production environments
  */
  */
 const noopLogger = {
 const noopLogger = {
 	debug: () => {},
 	debug: () => {},

+ 1 - 0
webview-ui/package.json

@@ -5,6 +5,7 @@
 	"type": "module",
 	"type": "module",
 	"scripts": {
 	"scripts": {
 		"lint": "eslint src --ext ts,tsx",
 		"lint": "eslint src --ext ts,tsx",
+		"lint-fix": "eslint src --ext ts,tsx --fix",
 		"check-types": "tsc --noEmit",
 		"check-types": "tsc --noEmit",
 		"test": "jest",
 		"test": "jest",
 		"dev": "vite",
 		"dev": "vite",

+ 10 - 1
webview-ui/src/components/chat/ChatRow.tsx

@@ -21,6 +21,7 @@ import Thumbnails from "../common/Thumbnails"
 import McpResourceRow from "../mcp/McpResourceRow"
 import McpResourceRow from "../mcp/McpResourceRow"
 import McpToolRow from "../mcp/McpToolRow"
 import McpToolRow from "../mcp/McpToolRow"
 import { highlightMentions } from "./TaskHeader"
 import { highlightMentions } from "./TaskHeader"
+import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
 
 
 interface ChatRowProps {
 interface ChatRowProps {
 	message: ClineMessage
 	message: ClineMessage
@@ -80,7 +81,7 @@ export const ChatRowContent = ({
 	isLast,
 	isLast,
 	isStreaming,
 	isStreaming,
 }: ChatRowContentProps) => {
 }: ChatRowContentProps) => {
-	const { mcpServers, alwaysAllowMcp } = useExtensionState()
+	const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
 	const [reasoningCollapsed, setReasoningCollapsed] = useState(false)
 	const [reasoningCollapsed, setReasoningCollapsed] = useState(false)
 
 
 	// Auto-collapse reasoning when new messages arrive
 	// Auto-collapse reasoning when new messages arrive
@@ -755,6 +756,14 @@ export const ChatRowContent = ({
 							</div>
 							</div>
 						</>
 						</>
 					)
 					)
+				case "checkpoint_saved":
+					return (
+						<CheckpointSaved
+							ts={message.ts!}
+							commitHash={message.text!}
+							currentCheckpointHash={currentCheckpoint}
+						/>
+					)
 				default:
 				default:
 					return (
 					return (
 						<>
 						<>

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

@@ -223,9 +223,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 								setEnableButtons(false)
 								setEnableButtons(false)
 							}
 							}
 							break
 							break
+						case "api_req_finished":
 						case "task":
 						case "task":
 						case "error":
 						case "error":
-						case "api_req_finished":
 						case "text":
 						case "text":
 						case "browser_action":
 						case "browser_action":
 						case "browser_action_result":
 						case "browser_action_result":
@@ -547,6 +547,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 			switch (message.say) {
 			switch (message.say) {
 				case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
 				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_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
 					return false
 				case "api_req_retry_delayed":
 				case "api_req_retry_delayed":
 					// Only show the retry message if it's the last message
 					// Only show the retry message if it's the last message
@@ -1121,6 +1122,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 					)}
 					)}
 				</>
 				</>
 			)}
 			)}
+
 			<ChatTextArea
 			<ChatTextArea
 				ref={textAreaRef}
 				ref={textAreaRef}
 				inputValue={inputValue}
 				inputValue={inputValue}
@@ -1140,6 +1142,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				mode={mode}
 				mode={mode}
 				setMode={setMode}
 				setMode={setMode}
 			/>
 			/>
+
+			<div id="chat-view-portal" />
 		</div>
 		</div>
 	)
 	)
 }
 }

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

@@ -0,0 +1,109 @@
+import { useState, useEffect, useCallback } from "react"
+import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"
+
+import { vscode } from "../../../utils/vscode"
+
+import { Button, Popover, PopoverContent, PopoverTrigger } from "@/components/ui"
+
+type CheckpointMenuProps = {
+	ts: number
+	commitHash: string
+	currentCheckpointHash?: string
+}
+
+export const CheckpointMenu = ({ ts, commitHash, currentCheckpointHash }: CheckpointMenuProps) => {
+	const [portalContainer, setPortalContainer] = useState<HTMLElement>()
+	const [isOpen, setIsOpen] = useState(false)
+	const [isConfirming, setIsConfirming] = useState(false)
+
+	const isCurrent = currentCheckpointHash === 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" } })
+		setIsOpen(false)
+	}, [ts, commitHash])
+
+	const onRestore = useCallback(() => {
+		vscode.postMessage({ type: "checkpointRestore", payload: { ts, commitHash, mode: "restore" } })
+		setIsOpen(false)
+	}, [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 (
+		<div className="flex flex-row gap-1">
+			<Button variant="ghost" size="icon" onClick={onCheckpointDiff}>
+				<span className="codicon codicon-diff-single" />
+			</Button>
+			<Popover
+				open={isOpen}
+				onOpenChange={(open) => {
+					setIsOpen(open)
+					setIsConfirming(false)
+				}}>
+				<PopoverTrigger asChild>
+					<Button variant="ghost" size="icon">
+						<span className="codicon codicon-history" />
+					</Button>
+				</PopoverTrigger>
+				<PopoverContent align="end" container={portalContainer}>
+					<div className="flex flex-col gap-2">
+						{!isCurrent && (
+							<div className="flex flex-col gap-1 group hover:text-foreground">
+								<Button variant="secondary" onClick={onPreview}>
+									Restore Files
+								</Button>
+								<div className="text-muted transition-colors group-hover:text-foreground">
+									Restores your project's files back to a snapshot taken at this point.
+								</div>
+							</div>
+						)}
+						<div className="flex flex-col gap-1 group hover:text-foreground">
+							<div className="flex flex-col gap-1 group hover:text-foreground">
+								{!isConfirming ? (
+									<Button variant="secondary" onClick={() => setIsConfirming(true)}>
+										Restore Files & Task
+									</Button>
+								) : (
+									<>
+										<Button variant="default" onClick={onRestore} className="grow">
+											<div className="flex flex-row gap-1">
+												<CheckIcon />
+												<div>Confirm</div>
+											</div>
+										</Button>
+										<Button variant="secondary" onClick={() => setIsConfirming(false)}>
+											<div className="flex flex-row gap-1">
+												<Cross2Icon />
+												<div>Cancel</div>
+											</div>
+										</Button>
+									</>
+								)}
+								{isConfirming ? (
+									<div className="text-destructive font-bold">This action cannot be undone.</div>
+								) : (
+									<div className="text-muted transition-colors group-hover:text-foreground">
+										Restores your project's files back to a snapshot taken at this point and deletes
+										all messages after this point.
+									</div>
+								)}
+							</div>
+						</div>
+					</div>
+				</PopoverContent>
+			</Popover>
+		</div>
+	)
+}

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

@@ -0,0 +1,22 @@
+import { CheckpointMenu } from "./CheckpointMenu"
+
+type CheckpointSavedProps = {
+	ts: number
+	commitHash: string
+	currentCheckpointHash?: string
+}
+
+export const CheckpointSaved = (props: CheckpointSavedProps) => {
+	const isCurrent = props.currentCheckpointHash === props.commitHash
+
+	return (
+		<div className="flex items-center justify-between">
+			<div className="flex gap-2">
+				<span className="codicon codicon-git-commit text-blue-400" />
+				<span className="font-bold">Checkpoint</span>
+				{isCurrent && <span className="text-muted text-sm">Current</span>}
+			</div>
+			<CheckpointMenu {...props} />
+		</div>
+	)
+}

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

@@ -34,6 +34,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		setSoundVolume,
 		setSoundVolume,
 		diffEnabled,
 		diffEnabled,
 		setDiffEnabled,
 		setDiffEnabled,
+		checkpointsEnabled,
+		setCheckpointsEnabled,
 		browserViewportSize,
 		browserViewportSize,
 		setBrowserViewportSize,
 		setBrowserViewportSize,
 		openRouterModels,
 		openRouterModels,
@@ -86,6 +88,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 			vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
 			vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
 			vscode.postMessage({ type: "soundVolume", value: soundVolume })
 			vscode.postMessage({ type: "soundVolume", value: soundVolume })
 			vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
 			vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
+			vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
 			vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
 			vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
 			vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
 			vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
 			vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
@@ -699,6 +702,25 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 								/>
 								/>
 							))}
 							))}
 					</div>
 					</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>
 
 
 				<div
 				<div

+ 5 - 3
webview-ui/src/components/ui/popover.tsx

@@ -11,9 +11,11 @@ const PopoverAnchor = PopoverPrimitive.Anchor
 
 
 const PopoverContent = React.forwardRef<
 const PopoverContent = React.forwardRef<
 	React.ElementRef<typeof PopoverPrimitive.Content>,
 	React.ElementRef<typeof PopoverPrimitive.Content>,
-	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
-	<PopoverPrimitive.Portal>
+	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
+		container?: HTMLElement
+	}
+>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
+	<PopoverPrimitive.Portal container={container}>
 		<PopoverPrimitive.Content
 		<PopoverPrimitive.Content
 			ref={ref}
 			ref={ref}
 			align={align}
 			align={align}

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

@@ -26,6 +26,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	openRouterModels: Record<string, ModelInfo>
 	openRouterModels: Record<string, ModelInfo>
 	openAiModels: string[]
 	openAiModels: string[]
 	mcpServers: McpServer[]
 	mcpServers: McpServer[]
+	currentCheckpoint?: string
 	filePaths: string[]
 	filePaths: string[]
 	openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
 	openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
 	setApiConfiguration: (config: ApiConfiguration) => void
 	setApiConfiguration: (config: ApiConfiguration) => void
@@ -41,6 +42,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setSoundEnabled: (value: boolean) => void
 	setSoundEnabled: (value: boolean) => void
 	setSoundVolume: (value: number) => void
 	setSoundVolume: (value: number) => void
 	setDiffEnabled: (value: boolean) => void
 	setDiffEnabled: (value: boolean) => void
+	setCheckpointsEnabled: (value: boolean) => void
 	setBrowserViewportSize: (value: string) => void
 	setBrowserViewportSize: (value: string) => void
 	setFuzzyMatchThreshold: (value: number) => void
 	setFuzzyMatchThreshold: (value: number) => void
 	preferredLanguage: string
 	preferredLanguage: string
@@ -88,6 +90,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		soundEnabled: false,
 		soundEnabled: false,
 		soundVolume: 0.5,
 		soundVolume: 0.5,
 		diffEnabled: false,
 		diffEnabled: false,
+		checkpointsEnabled: false,
 		fuzzyMatchThreshold: 1.0,
 		fuzzyMatchThreshold: 1.0,
 		preferredLanguage: "English",
 		preferredLanguage: "English",
 		writeDelayMs: 1000,
 		writeDelayMs: 1000,
@@ -124,6 +127,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 
 
 	const [openAiModels, setOpenAiModels] = useState<string[]>([])
 	const [openAiModels, setOpenAiModels] = useState<string[]>([])
 	const [mcpServers, setMcpServers] = useState<McpServer[]>([])
 	const [mcpServers, setMcpServers] = useState<McpServer[]>([])
+	const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
 
 
 	const setListApiConfigMeta = useCallback(
 	const setListApiConfigMeta = useCallback(
 		(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
 		(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
@@ -239,6 +243,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 					setMcpServers(message.mcpServers ?? [])
 					setMcpServers(message.mcpServers ?? [])
 					break
 					break
 				}
 				}
+				case "currentCheckpointUpdated": {
+					setCurrentCheckpoint(message.text)
+					break
+				}
 				case "listApiConfig": {
 				case "listApiConfig": {
 					setListApiConfigMeta(message.listApiConfig ?? [])
 					setListApiConfigMeta(message.listApiConfig ?? [])
 					break
 					break
@@ -263,6 +271,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		openRouterModels,
 		openRouterModels,
 		openAiModels,
 		openAiModels,
 		mcpServers,
 		mcpServers,
+		currentCheckpoint,
 		filePaths,
 		filePaths,
 		openedTabs,
 		openedTabs,
 		soundVolume: state.soundVolume,
 		soundVolume: state.soundVolume,
@@ -288,6 +297,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
 		setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
 		setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
 		setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
 		setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
 		setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
+		setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })),
 		setBrowserViewportSize: (value: string) =>
 		setBrowserViewportSize: (value: string) =>
 			setState((prevState) => ({ ...prevState, browserViewportSize: value })),
 			setState((prevState) => ({ ...prevState, browserViewportSize: value })),
 		setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),
 		setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),