From 121f3ab7fae84cb136b3c8098544f2077cb67a61 Mon Sep 17 00:00:00 2001 From: cte Date: Fri, 7 Feb 2025 22:27:27 -0800 Subject: [PATCH 1/8] Checkpoint service integration --- src/core/Cline.ts | 221 +++++++++++++++++- src/core/__tests__/Cline.test.ts | 41 +++- src/core/webview/ClineProvider.ts | 120 ++++++++-- .../webview/__tests__/ClineProvider.test.ts | 3 + src/shared/ExtensionMessage.ts | 8 +- src/shared/WebviewMessage.ts | 25 +- webview-ui/src/components/chat/ChatRow.tsx | 3 + webview-ui/src/components/chat/ChatView.tsx | 6 +- .../chat/checkpoints/CheckpointMenu.tsx | 80 +++++++ .../chat/checkpoints/CheckpointSaved.tsx | 16 ++ .../src/components/settings/SettingsView.tsx | 22 ++ .../src/context/ExtensionStateContext.tsx | 3 + 12 files changed, 509 insertions(+), 39 deletions(-) create mode 100644 webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx create mode 100644 webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 0f2e796132..6e7b7f72b8 100644 --- a/src/core/Cline.ts +++ b/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 = new Map() private providerRef: WeakRef 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 `\n${details.trim()}\n` } + + // 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 { diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 4c1f697333..87fa034dec 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/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}`) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 23346d945c..039a0f0554 100644 --- a/src/core/webview/ClineProvider.ts +++ b/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 - } - 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 + 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.") + } + } + + 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, this.getGlobalState("soundEnabled") as Promise, this.getGlobalState("diffEnabled") as Promise, + this.getGlobalState("checkpointsEnabled") as Promise, this.getGlobalState("soundVolume") as Promise, this.getGlobalState("browserViewportSize") as Promise, this.getGlobalState("fuzzyMatchThreshold") as Promise, @@ -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() { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 8ca919b27e..25a4f0bd16 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/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, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index bee51f6d09..d1ae925287 100644 --- a/src/shared/ExtensionMessage.ts +++ b/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: diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 5fde75c4bb..7d54aafb9a 100644 --- a/src/shared/WebviewMessage.ts +++ b/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 + +export const checkoutRestorePayloadSchema = z.object({ + ts: z.number(), + commitHash: z.string(), + mode: z.enum(["preview", "restore"]), +}) + +export type CheckpointRestorePayload = z.infer + +export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 8df6f596c5..d932dee5c9 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/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 = ({ ) + case "checkpoint_saved": + return default: return ( <> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a102ca1fc2..b9fc215a1c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/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 )} )} + + +
) } diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx new file mode 100644 index 0000000000..657c292613 --- /dev/null +++ b/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() + + 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 ( + + + + + + + + + + + + ) +} + +type CheckpointMenuItemProps = DropdownMenuItemProps & { + label: React.ReactNode + icon: "diff-single" | "diff-multiple" | "open-preview" | "history" +} + +const CheckpointMenuItem = ({ label, icon, ...props }: CheckpointMenuItemProps) => ( + +
+
{label}
+ + + +
+
+) diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx new file mode 100644 index 0000000000..103f1d172c --- /dev/null +++ b/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) => ( +
+
+ + Checkpoint +
+ +
+) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index c81c372ce1..462454aaee 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/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) => { /> ))} + +
+ { + setCheckpointsEnabled(e.target.checked) + }}> + Enable checkpoints + +

+ When enabled, Roo will be save a workspace checkpoint after each tool execution if a file in + the workspace is modified, added or deleted. +

+
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 })), From 1a527fca123db20c321a81e2daef90c13f5703a0 Mon Sep 17 00:00:00 2001 From: cte Date: Fri, 7 Feb 2025 23:54:21 -0800 Subject: [PATCH 2/8] Checkpoint UI tweaks --- .../chat/checkpoints/CheckpointMenu.tsx | 110 +++++++++++------- .../chat/checkpoints/CheckpointSaved.tsx | 2 +- webview-ui/src/components/ui/popover.tsx | 8 +- 3 files changed, 73 insertions(+), 47 deletions(-) diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx index 657c292613..39141a6fbf 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx @@ -1,17 +1,9 @@ import { useState, useEffect, useCallback } from "react" -import { DotsHorizontalIcon } from "@radix-ui/react-icons" -import { DropdownMenuItemProps } from "@radix-ui/react-dropdown-menu" +import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons" import { vscode } from "../../../utils/vscode" -import { - Button, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuShortcut, -} from "@/components/ui" +import { Button, Popover, PopoverContent, PopoverTrigger } from "@/components/ui" type CheckpointMenuProps = { ts: number @@ -20,10 +12,8 @@ type CheckpointMenuProps = { export const CheckpointMenu = ({ ts, commitHash }: CheckpointMenuProps) => { const [portalContainer, setPortalContainer] = useState() - - const onTaskDiff = useCallback(() => { - vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "full" } }) - }, [ts, commitHash]) + const [isOpen, setIsOpen] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) const onCheckpointDiff = useCallback(() => { vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "checkpoint" } }) @@ -31,10 +21,12 @@ export const CheckpointMenu = ({ ts, commitHash }: CheckpointMenuProps) => { 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(() => { @@ -47,34 +39,66 @@ export const CheckpointMenu = ({ ts, commitHash }: CheckpointMenuProps) => { }, []) return ( - - - - - - - - - - - +
+ + { + setIsOpen(open) + setIsConfirming(false) + }}> + + + + +
+
+ +
+ Restores your project's files back to a snapshot taken at this point. +
+
+
+
+ {!isConfirming ? ( + + ) : ( + <> + + + + )} + {isConfirming ? ( +
This action cannot be undone.
+ ) : ( +
+ Restores your project's files back to a snapshot taken at this point and deletes + all messages after this point. +
+ )} +
+
+
+
+
+
) } - -type CheckpointMenuItemProps = DropdownMenuItemProps & { - label: React.ReactNode - icon: "diff-single" | "diff-multiple" | "open-preview" | "history" -} - -const CheckpointMenuItem = ({ label, icon, ...props }: CheckpointMenuItemProps) => ( - -
-
{label}
- - - -
-
-) diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index 103f1d172c..5947b6a1fc 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -8,7 +8,7 @@ type CheckpointSavedProps = { export const CheckpointSaved = (props: CheckpointSavedProps) => (
- + Checkpoint
diff --git a/webview-ui/src/components/ui/popover.tsx b/webview-ui/src/components/ui/popover.tsx index 09c6ba659a..3ab3344689 100644 --- a/webview-ui/src/components/ui/popover.tsx +++ b/webview-ui/src/components/ui/popover.tsx @@ -11,9 +11,11 @@ const PopoverAnchor = PopoverPrimitive.Anchor const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + container?: HTMLElement + } +>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => ( + Date: Sat, 8 Feb 2025 01:07:31 -0800 Subject: [PATCH 3/8] Current checkpoint indicator --- src/core/Cline.ts | 8 ++ src/services/checkpoints/CheckpointService.ts | 21 ++-- .../__tests__/CheckpointService.test.ts | 2 +- src/shared/ExtensionMessage.ts | 1 + webview-ui/src/components/chat/ChatRow.tsx | 10 +- .../chat/checkpoints/CheckpointMenu.tsx | 111 +++++++++--------- .../chat/checkpoints/CheckpointSaved.tsx | 22 ++-- .../src/context/ExtensionStateContext.tsx | 7 ++ 8 files changed, 111 insertions(+), 71 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 6e7b7f72b8..83cdabd466 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -3313,6 +3313,10 @@ export class Cline { 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) { @@ -3349,6 +3353,10 @@ export class Cline { 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), diff --git a/src/services/checkpoints/CheckpointService.ts b/src/services/checkpoints/CheckpointService.ts index af1d438e54..6df220e115 100644 --- a/src/services/checkpoints/CheckpointService.ts +++ b/src/services/checkpoints/CheckpointService.ts @@ -9,12 +9,6 @@ if (process.env.NODE_ENV !== "test") { debug.enable("simple-git") } -export interface Checkpoint { - hash: string - message: string - timestamp?: Date -} - export type CheckpointServiceOptions = { taskId: string git?: SimpleGit @@ -60,6 +54,16 @@ export type CheckpointServiceOptions = { */ export class CheckpointService { + private _currentCheckpoint?: string + + public get currentCheckpoint() { + return this._currentCheckpoint + } + + private set currentCheckpoint(value: string | undefined) { + this._currentCheckpoint = value + } + constructor( public readonly taskId: string, private readonly git: SimpleGit, @@ -217,6 +221,8 @@ export class CheckpointService { await this.popStash() } + this.currentCheckpoint = commit.commit + return commit } catch (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.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE]) await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."]) + this.currentCheckpoint = commitHash } 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 // for empty commits). await fs.writeFile(path.join(baseDir, ".gitkeep"), "") - await git.add(".") + await git.add(".gitkeep") const commit = await git.commit("Initial commit") if (!commit.commit) { diff --git a/src/services/checkpoints/__tests__/CheckpointService.test.ts b/src/services/checkpoints/__tests__/CheckpointService.test.ts index cd33a5dc7c..faa4cd5cef 100644 --- a/src/services/checkpoints/__tests__/CheckpointService.test.ts +++ b/src/services/checkpoints/__tests__/CheckpointService.test.ts @@ -291,6 +291,7 @@ describe("CheckpointService", () => { const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`) await fs.mkdir(baseDir) const newTestFile = path.join(baseDir, "test.txt") + await fs.writeFile(newTestFile, "Hello, world!") const newGit = simpleGit(baseDir) const initSpy = jest.spyOn(newGit, "init") @@ -300,7 +301,6 @@ describe("CheckpointService", () => { expect(initSpy).toHaveBeenCalled() // Save a checkpoint: Hello, world! - await fs.writeFile(newTestFile, "Hello, world!") const commit1 = await newService.saveCheckpoint("Hello, world!") expect(commit1?.commit).toBeTruthy() expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!") diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index d1ae925287..18cee3ef95 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -42,6 +42,7 @@ export interface ExtensionMessage { | "autoApprovalEnabled" | "updateCustomMode" | "deleteCustomMode" + | "currentCheckpointUpdated" text?: string action?: | "chatButtonClicked" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index d932dee5c9..c18fa84889 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -81,7 +81,7 @@ export const ChatRowContent = ({ isLast, isStreaming, }: ChatRowContentProps) => { - const { mcpServers, alwaysAllowMcp } = useExtensionState() + const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState() const [reasoningCollapsed, setReasoningCollapsed] = useState(false) // Auto-collapse reasoning when new messages arrive @@ -757,7 +757,13 @@ export const ChatRowContent = ({ ) case "checkpoint_saved": - return + return ( + + ) default: return ( <> diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx index 39141a6fbf..49a76a505a 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx @@ -8,13 +8,16 @@ import { Button, Popover, PopoverContent, PopoverTrigger } from "@/components/ui type CheckpointMenuProps = { ts: number commitHash: string + currentCheckpointHash?: string } -export const CheckpointMenu = ({ ts, commitHash }: CheckpointMenuProps) => { +export const CheckpointMenu = ({ ts, commitHash, currentCheckpointHash }: CheckpointMenuProps) => { const [portalContainer, setPortalContainer] = useState() 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]) @@ -43,62 +46,64 @@ export const CheckpointMenu = ({ ts, commitHash }: CheckpointMenuProps) => { - { - setIsOpen(open) - setIsConfirming(false) - }}> - - - - -
-
- -
- Restores your project's files back to a snapshot taken at this point. -
-
-
+ {!isCurrent && ( + { + setIsOpen(open) + setIsConfirming(false) + }}> + + + + +
- {!isConfirming ? ( - - ) : ( - <> - +
+ Restores your project's files back to a snapshot taken at this point. +
+
+
+
+ {!isConfirming ? ( + - - - )} - {isConfirming ? ( -
This action cannot be undone.
- ) : ( -
- Restores your project's files back to a snapshot taken at this point and deletes - all messages after this point. -
- )} + ) : ( + <> + + + + )} + {isConfirming ? ( +
This action cannot be undone.
+ ) : ( +
+ Restores your project's files back to a snapshot taken at this point and + deletes all messages after this point. +
+ )} +
-
- - + + + )}
) } diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index 5947b6a1fc..b5e243db05 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -3,14 +3,20 @@ import { CheckpointMenu } from "./CheckpointMenu" type CheckpointSavedProps = { ts: number commitHash: string + currentCheckpointHash?: string } -export const CheckpointSaved = (props: CheckpointSavedProps) => ( -
-
- - Checkpoint +export const CheckpointSaved = (props: CheckpointSavedProps) => { + const isCurrent = props.currentCheckpointHash === props.commitHash + + return ( +
+
+ + Checkpoint + {isCurrent && Current} +
+
- -
-) + ) +} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 7e6a06894d..d518878aae 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -26,6 +26,7 @@ export interface ExtensionStateContextType extends ExtensionState { openRouterModels: Record openAiModels: string[] mcpServers: McpServer[] + currentCheckpoint?: string filePaths: string[] openedTabs: Array<{ label: string; isActive: boolean; path?: string }> setApiConfiguration: (config: ApiConfiguration) => void @@ -126,6 +127,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) + const [currentCheckpoint, setCurrentCheckpoint] = useState() const setListApiConfigMeta = useCallback( (value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -241,6 +243,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpServers(message.mcpServers ?? []) break } + case "currentCheckpointUpdated": { + setCurrentCheckpoint(message.text) + break + } case "listApiConfig": { setListApiConfigMeta(message.listApiConfig ?? []) break @@ -265,6 +271,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterModels, openAiModels, mcpServers, + currentCheckpoint, filePaths, openedTabs, soundVolume: state.soundVolume, From a0993a3ba079352c5911a9a9fc2024f560bf6108 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 8 Feb 2025 01:19:12 -0800 Subject: [PATCH 4/8] Remove "Restore" option on current checkpoint --- .../chat/checkpoints/CheckpointMenu.tsx | 92 +++++++++---------- .../chat/checkpoints/CheckpointSaved.tsx | 2 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx index 49a76a505a..954dc2e8f3 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx @@ -46,20 +46,20 @@ export const CheckpointMenu = ({ ts, commitHash, currentCheckpointHash }: Checkp - {!isCurrent && ( - { - setIsOpen(open) - setIsConfirming(false) - }}> - - - - -
+ { + setIsOpen(open) + setIsConfirming(false) + }}> + + + + +
+ {!isCurrent && (
+ )} +
-
- {!isConfirming ? ( - + ) : ( + <> + - ) : ( - <> - - - - )} - {isConfirming ? ( -
This action cannot be undone.
- ) : ( -
- Restores your project's files back to a snapshot taken at this point and - deletes all messages after this point. -
- )} -
+ + + )} + {isConfirming ? ( +
This action cannot be undone.
+ ) : ( +
+ Restores your project's files back to a snapshot taken at this point and deletes + all messages after this point. +
+ )}
-
-
- )} +
+
+
) } diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index b5e243db05..d5c0050e53 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -11,7 +11,7 @@ export const CheckpointSaved = (props: CheckpointSavedProps) => { return (
-
+
Checkpoint {isCurrent && Current} From 3c5dde2a03d5be6803ed95be31af9972f1c4ad2f Mon Sep 17 00:00:00 2001 From: Nissa Seru <119150866+nissa-seru@users.noreply.github.com> Date: Sat, 8 Feb 2025 07:23:54 -0500 Subject: [PATCH 5/8] Add local eslintrc config to .gitignore to enable local use of additional linting rules --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9deb750e65..9d123c2412 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ docs/_site/ # Dotenv .env.integration +.eslintrc.local.json From b6eb80e10d24b77bf67f4fe133ea0b3670fff577 Mon Sep 17 00:00:00 2001 From: Nissa Seru <119150866+nissa-seru@users.noreply.github.com> Date: Sat, 8 Feb 2025 07:26:13 -0500 Subject: [PATCH 6/8] Use `rimraf` and `mkdirp` in `vsix` command to avoid misparsing of `-p` arg and build failure if `bin` dir exists. --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9efe5b87dd..97cca7d8c0 100644 --- a/package.json +++ b/package.json @@ -283,7 +283,7 @@ "publish": "npm run build && changeset publish && npm install --package-lock-only", "version-packages": "changeset version && npm install --package-lock-only", "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:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", @@ -350,6 +350,8 @@ "@vscode/test-cli": "^0.0.9", "@vscode/test-electron": "^2.4.0", "esbuild": "^0.24.0", + "mkdirp": "^3.0.1", + "rimraf": "^6.0.1", "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", From c84551f1e6aa716edd6634eda43cbef2263de906 Mon Sep 17 00:00:00 2001 From: Nissa Seru <119150866+nissa-seru@users.noreply.github.com> Date: Sat, 8 Feb 2025 07:31:48 -0500 Subject: [PATCH 7/8] Add `lint-fix` command; existing `lint` command is unchanged --- package.json | 3 ++- webview-ui/package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 97cca7d8c0..e0301792b6 100644 --- a/package.json +++ b/package.json @@ -271,7 +271,8 @@ "compile": "tsc -p . --outDir out && node esbuild.js", "compile:integration": "tsc -p tsconfig.integration.json", "install:all": "npm install && cd webview-ui && npm install", - "lint": "eslint src --ext ts && npm run lint --prefix webview-ui", + "lint-fix": "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", "pretest": "npm run compile && npm run compile:integration", "dev": "cd webview-ui && npm run dev", diff --git a/webview-ui/package.json b/webview-ui/package.json index d7a5765690..3317551f20 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "lint": "eslint src --ext ts,tsx", + "lint-fix": "eslint src --ext ts,tsx --fix", "check-types": "tsc --noEmit", "test": "jest", "dev": "vite", From 987fabb2ca7cfa74f1d98c3d6ccc8a83ad39f71c Mon Sep 17 00:00:00 2001 From: Nissa Seru <119150866+nissa-seru@users.noreply.github.com> Date: Sat, 8 Feb 2025 07:44:16 -0500 Subject: [PATCH 8/8] Typo fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e0301792b6..b3a8afcb88 100644 --- a/package.json +++ b/package.json @@ -271,7 +271,7 @@ "compile": "tsc -p . --outDir out && node esbuild.js", "compile:integration": "tsc -p tsconfig.integration.json", "install:all": "npm install && cd webview-ui && npm install", - "lint-fix": "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", "pretest": "npm run compile && npm run compile:integration",