diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index ace134566e..7f5ee1a87d 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -16,6 +16,7 @@ export const historyItemSchema = z.object({ totalCost: z.number(), size: z.number().optional(), workspace: z.string().optional(), + projectId: z.string().optional(), mode: z.string().optional(), }) diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 00f6bbbcba..7ab41e68c9 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -53,6 +53,8 @@ export const commandIds = [ "focusInput", "acceptInput", "focusPanel", + + "generateProjectId", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index bd925b0e90..051d74761a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -218,6 +218,31 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "acceptInput" }) }, + generateProjectId: async () => { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + vscode.window.showErrorMessage(t("projectId.noWorkspace")) + return + } + + try { + const { generateProjectId, hasProjectId } = await import("../utils/projectId") + + // Check if project already has an ID + const hasId = await hasProjectId(workspaceFolder.uri.fsPath) + if (hasId) { + vscode.window.showInformationMessage(t("projectId.alreadyExists")) + return + } + + // Generate new project ID + await generateProjectId(workspaceFolder.uri.fsPath) + vscode.window.showInformationMessage(t("projectId.generated")) + } catch (error) { + outputChannel.appendLine(`Error generating project ID: ${error}`) + vscode.window.showErrorMessage(t("projectId.generateError", { error: String(error) })) + } + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 7b93b5c14a..8e92bca4d1 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -8,6 +8,7 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences" import { getApiMetrics } from "../../shared/getApiMetrics" import { findLastIndex } from "../../shared/array" import { getTaskDirectoryPath } from "../../utils/storage" +import { getProjectId } from "../../utils/projectId" import { t } from "../../i18n" const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 }) @@ -79,6 +80,9 @@ export async function taskMetadata({ } } + // Get project ID if available + const projectId = await getProjectId(workspace) + // Create historyItem once with pre-calculated values const historyItem: HistoryItem = { id: taskId, @@ -94,6 +98,7 @@ export async function taskMetadata({ totalCost: tokenUsage.totalCost, size: taskDirSize, workspace, + projectId: projectId || undefined, mode, } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 384de58be7..f8555a0976 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1367,12 +1367,33 @@ export class ClineProvider apiConversationHistory: Anthropic.MessageParam[] }> { const history = this.getGlobalState("taskHistory") ?? [] - const historyItem = history.find((item) => item.id === id) + + // First try to find by task ID + let historyItem = history.find((item) => item.id === id) + + // If not found by ID and the ID looks like a project ID (UUID format), + // try to find by project ID + if (!historyItem && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) { + // Get the current workspace root + const workspaceRoot = this.cwd + if (workspaceRoot) { + // Check if this workspace has the given project ID + const { getProjectId } = await import("../../utils/projectId") + const currentProjectId = await getProjectId(workspaceRoot) + + if (currentProjectId === id) { + // Find the most recent task for this project ID + historyItem = history + .filter((item) => item.projectId === id) + .sort((a, b) => (b.ts || 0) - (a.ts || 0))[0] + } + } + } if (historyItem) { const { getTaskDirectoryPath } = await import("../../utils/storage") const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) + const taskDirPath = await getTaskDirectoryPath(globalStoragePath, historyItem.id) const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) @@ -1392,7 +1413,9 @@ export class ClineProvider // if we tried to get a task that doesn't exist, remove it from state // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason - await this.deleteTaskFromState(id) + if (historyItem) { + await this.deleteTaskFromState(historyItem.id) + } throw new Error("Task not found") } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index c8deee5cf4..928869ba17 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -190,5 +190,11 @@ "preventCompletionWithOpenTodos": { "description": "Prevent task completion when there are incomplete todos in the todo list" } + }, + "projectId": { + "noWorkspace": "No workspace folder found", + "alreadyExists": "Project already has a unique ID", + "generated": "Project ID generated successfully", + "generateError": "Failed to generate project ID: {{error}}" } } diff --git a/src/package.json b/src/package.json index d35f6f34dd..52fffaf47c 100644 --- a/src/package.json +++ b/src/package.json @@ -174,6 +174,11 @@ "command": "roo-cline.acceptInput", "title": "%command.acceptInput.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.generateProjectId", + "title": "%command.generateProjectId.title%", + "category": "%configuration.title%" } ], "menus": { diff --git a/src/package.nls.json b/src/package.nls.json index 36ef72a823..d1988bb106 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -25,6 +25,7 @@ "command.terminal.fixCommand.title": "Fix This Command", "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", + "command.generateProjectId.title": "Generate Project ID", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/src/utils/__tests__/projectId.spec.ts b/src/utils/__tests__/projectId.spec.ts new file mode 100644 index 0000000000..b165c20454 --- /dev/null +++ b/src/utils/__tests__/projectId.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import fs from "fs/promises" +import path from "path" +import { v4 as uuidv4 } from "uuid" +import { getProjectId, generateProjectId, hasProjectId } from "../projectId" +import { fileExistsAtPath } from "../fs" + +// Mock dependencies +vi.mock("fs/promises") +vi.mock("../fs") +vi.mock("uuid") + +describe("projectId", () => { + const mockWorkspaceRoot = "/test/workspace" + const mockProjectId = "12345678-1234-1234-1234-123456789012" + const projectIdPath = path.join(mockWorkspaceRoot, ".rooprojectid") + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("getProjectId", () => { + it("should return project ID when file exists and contains valid ID", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue(mockProjectId) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBe(mockProjectId) + expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath) + expect(fs.readFile).toHaveBeenCalledWith(projectIdPath, "utf8") + }) + + it("should return null when file does not exist", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath) + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("should return null when file is empty", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("") + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + }) + + it("should trim whitespace from project ID", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue(` ${mockProjectId} \n`) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBe(mockProjectId) + }) + + it("should handle read errors gracefully", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error")) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to read project ID: Error: Read error") + }) + }) + + describe("generateProjectId", () => { + it("should generate and write a new project ID", async () => { + vi.mocked(uuidv4).mockReturnValue(mockProjectId as any) + vi.mocked(fs.writeFile).mockResolvedValue() + + const result = await generateProjectId(mockWorkspaceRoot) + + expect(result).toBe(mockProjectId) + expect(uuidv4).toHaveBeenCalled() + expect(fs.writeFile).toHaveBeenCalledWith(projectIdPath, mockProjectId, "utf8") + }) + + it("should handle write errors", async () => { + vi.mocked(uuidv4).mockReturnValue(mockProjectId as any) + vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write error")) + + await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write error") + }) + }) + + describe("hasProjectId", () => { + it("should return true when project ID file exists", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + + const result = await hasProjectId(mockWorkspaceRoot) + + expect(result).toBe(true) + expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath) + }) + + it("should return false when project ID file does not exist", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + const result = await hasProjectId(mockWorkspaceRoot) + + expect(result).toBe(false) + expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath) + }) + }) +}) diff --git a/src/utils/projectId.ts b/src/utils/projectId.ts new file mode 100644 index 0000000000..f7e00c5e1f --- /dev/null +++ b/src/utils/projectId.ts @@ -0,0 +1,64 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { v4 as uuidv4 } from "uuid" +import { fileExistsAtPath } from "./fs" + +const PROJECT_ID_FILENAME = ".rooprojectid" + +/** + * Gets the project ID from the .rooprojectid file in the workspace root. + * Returns null if the file doesn't exist or can't be read. + * + * @param workspaceRoot The root directory of the workspace + * @returns The project ID string or null + */ +export async function getProjectId(workspaceRoot: string): Promise { + try { + const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME) + const exists = await fileExistsAtPath(projectIdPath) + + if (!exists) { + return null + } + + const content = await fs.readFile(projectIdPath, "utf8") + const projectId = content.trim() + + // Validate that it's not empty + if (!projectId) { + return null + } + + return projectId + } catch (error) { + // If we can't read the file for any reason, return null + console.error(`Failed to read project ID: ${error}`) + return null + } +} + +/** + * Generates a new project ID and writes it to the .rooprojectid file. + * + * @param workspaceRoot The root directory of the workspace + * @returns The generated project ID + */ +export async function generateProjectId(workspaceRoot: string): Promise { + const projectId = uuidv4() + const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME) + + await fs.writeFile(projectIdPath, projectId, "utf8") + + return projectId +} + +/** + * Checks if a project has a project ID file. + * + * @param workspaceRoot The root directory of the workspace + * @returns True if the project has a .rooprojectid file + */ +export async function hasProjectId(workspaceRoot: string): Promise { + const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME) + return await fileExistsAtPath(projectIdPath) +}