Browse Source

add multiple workspaces support (#1725)

feat: add multiple workspaces support

- Add getWorkspacePath function to centralize workspace directory path retrieval
- Use the new workspace directory retrieval logic in Cline, Mentions, ClineProvider, and WorkspaceTracker
- Update WorkspaceFile on tab switch and prevent redundant updates by checking prevWorkSpacePath
- Fix the bug that loads the contents of the previous tab when quickly switching tabs
- Optimize getWorkspacePath return value for better reliability

Co-authored-by: xiong <[email protected]>
teddyOOXX 9 months ago
parent
commit
23af4c2609

+ 5 - 0
.changeset/fast-taxis-speak.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+add multiple workspaces support

+ 47 - 43
src/core/Cline.ts

@@ -78,9 +78,7 @@ import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy"
 import { insertGroups } from "./diff/insert-groups"
 import { telemetryService } from "../services/telemetry/TelemetryService"
 import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
-
-const cwd =
-	vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
+import { getWorkspacePath } from "../utils/path"
 
 type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
 type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@@ -118,7 +116,9 @@ export type ClineOptions = {
 export class Cline extends EventEmitter<ClineEvents> {
 	readonly taskId: string
 	readonly instanceId: string
-
+	get cwd() {
+		return getWorkspacePath(path.join(os.homedir(), "Desktop"))
+	}
 	// Subtasks
 	readonly rootTask: Cline | undefined = undefined
 	readonly parentTask: Cline | undefined = undefined
@@ -195,7 +195,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			throw new Error("Either historyItem or task/images must be provided")
 		}
 
-		this.rooIgnoreController = new RooIgnoreController(cwd)
+		this.rooIgnoreController = new RooIgnoreController(this.cwd)
 		this.rooIgnoreController.initialize().catch((error) => {
 			console.error("Failed to initialize RooIgnoreController:", error)
 		})
@@ -211,7 +211,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		this.diffEnabled = enableDiff ?? false
 		this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
 		this.providerRef = new WeakRef(provider)
-		this.diffViewProvider = new DiffViewProvider(cwd)
+		this.diffViewProvider = new DiffViewProvider(this.cwd)
 		this.enableCheckpoints = enableCheckpoints
 		this.checkpointStorage = checkpointStorage
 
@@ -844,7 +844,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		newUserContent.push({
 			type: "text",
 			text:
-				`[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${
+				`[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${this.cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${
 					wasRecent
 						? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
 						: ""
@@ -941,11 +941,11 @@ export class Cline extends EventEmitter<ClineEvents> {
 	async executeCommandTool(command: string, customCwd?: string): Promise<[boolean, ToolResponse]> {
 		let workingDir: string
 		if (!customCwd) {
-			workingDir = cwd
+			workingDir = this.cwd
 		} else if (path.isAbsolute(customCwd)) {
 			workingDir = customCwd
 		} else {
-			workingDir = path.resolve(cwd, customCwd)
+			workingDir = path.resolve(this.cwd, customCwd)
 		}
 
 		// Check if directory exists
@@ -1125,7 +1125,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			}
 			return SYSTEM_PROMPT(
 				provider.context,
-				cwd,
+				this.cwd,
 				(this.api.getModel().info.supportsComputerUse ?? false) && (browserToolEnabled ?? true),
 				mcpHub,
 				this.diffStrategy,
@@ -1549,7 +1549,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 						if (this.diffViewProvider.editType !== undefined) {
 							fileExists = this.diffViewProvider.editType === "modify"
 						} else {
-							const absolutePath = path.resolve(cwd, relPath)
+							const absolutePath = path.resolve(this.cwd, relPath)
 							fileExists = await fileExistsAtPath(absolutePath)
 							this.diffViewProvider.editType = fileExists ? "modify" : "create"
 						}
@@ -1579,7 +1579,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 						const sharedMessageProps: ClineSayTool = {
 							tool: fileExists ? "editedExistingFile" : "newFileCreated",
-							path: getReadablePath(cwd, removeClosingTag("path", relPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
 						}
 						try {
 							if (block.partial) {
@@ -1696,7 +1696,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 										"user_feedback_diff",
 										JSON.stringify({
 											tool: fileExists ? "editedExistingFile" : "newFileCreated",
-											path: getReadablePath(cwd, relPath),
+											path: getReadablePath(this.cwd, relPath),
 											diff: userEdits,
 										} satisfies ClineSayTool),
 									)
@@ -1732,7 +1732,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 						const sharedMessageProps: ClineSayTool = {
 							tool: "appliedDiff",
-							path: getReadablePath(cwd, removeClosingTag("path", relPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
 						}
 
 						try {
@@ -1769,7 +1769,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 									break
 								}
 
-								const absolutePath = path.resolve(cwd, relPath)
+								const absolutePath = path.resolve(this.cwd, relPath)
 								const fileExists = await fileExistsAtPath(absolutePath)
 
 								if (!fileExists) {
@@ -1865,7 +1865,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 										"user_feedback_diff",
 										JSON.stringify({
 											tool: fileExists ? "editedExistingFile" : "newFileCreated",
-											path: getReadablePath(cwd, relPath),
+											path: getReadablePath(this.cwd, relPath),
 											diff: userEdits,
 										} satisfies ClineSayTool),
 									)
@@ -1904,7 +1904,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 						const sharedMessageProps: ClineSayTool = {
 							tool: "appliedDiff",
-							path: getReadablePath(cwd, removeClosingTag("path", relPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
 						}
 
 						try {
@@ -1927,7 +1927,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 								break
 							}
 
-							const absolutePath = path.resolve(cwd, relPath)
+							const absolutePath = path.resolve(this.cwd, relPath)
 							const fileExists = await fileExistsAtPath(absolutePath)
 
 							if (!fileExists) {
@@ -2021,7 +2021,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 							const userFeedbackDiff = JSON.stringify({
 								tool: "appliedDiff",
-								path: getReadablePath(cwd, relPath),
+								path: getReadablePath(this.cwd, relPath),
 								diff: userEdits,
 							} satisfies ClineSayTool)
 
@@ -2051,7 +2051,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 
 						const sharedMessageProps: ClineSayTool = {
 							tool: "appliedDiff",
-							path: getReadablePath(cwd, removeClosingTag("path", relPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
 						}
 
 						try {
@@ -2078,7 +2078,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 									break
 								}
 
-								const absolutePath = path.resolve(cwd, relPath)
+								const absolutePath = path.resolve(this.cwd, relPath)
 								const fileExists = await fileExistsAtPath(absolutePath)
 
 								if (!fileExists) {
@@ -2183,7 +2183,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 										"user_feedback_diff",
 										JSON.stringify({
 											tool: fileExists ? "editedExistingFile" : "newFileCreated",
-											path: getReadablePath(cwd, relPath),
+											path: getReadablePath(this.cwd, relPath),
 											diff: userEdits,
 										} satisfies ClineSayTool),
 									)
@@ -2216,7 +2216,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 						const relPath: string | undefined = block.params.path
 						const sharedMessageProps: ClineSayTool = {
 							tool: "readFile",
-							path: getReadablePath(cwd, removeClosingTag("path", relPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
 						}
 						try {
 							if (block.partial) {
@@ -2242,7 +2242,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 								}
 
 								this.consecutiveMistakeCount = 0
-								const absolutePath = path.resolve(cwd, relPath)
+								const absolutePath = path.resolve(this.cwd, relPath)
 								const completeMessage = JSON.stringify({
 									...sharedMessageProps,
 									content: absolutePath,
@@ -2267,7 +2267,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 						const recursive = recursiveRaw?.toLowerCase() === "true"
 						const sharedMessageProps: ClineSayTool = {
 							tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive",
-							path: getReadablePath(cwd, removeClosingTag("path", relDirPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)),
 						}
 						try {
 							if (block.partial) {
@@ -2284,7 +2284,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 									break
 								}
 								this.consecutiveMistakeCount = 0
-								const absolutePath = path.resolve(cwd, relDirPath)
+								const absolutePath = path.resolve(this.cwd, relDirPath)
 								const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
 								const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {}
 								const result = formatResponse.formatFilesList(
@@ -2314,7 +2314,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 						const relDirPath: string | undefined = block.params.path
 						const sharedMessageProps: ClineSayTool = {
 							tool: "listCodeDefinitionNames",
-							path: getReadablePath(cwd, removeClosingTag("path", relDirPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)),
 						}
 						try {
 							if (block.partial) {
@@ -2333,7 +2333,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 									break
 								}
 								this.consecutiveMistakeCount = 0
-								const absolutePath = path.resolve(cwd, relDirPath)
+								const absolutePath = path.resolve(this.cwd, relDirPath)
 								const result = await parseSourceCodeForDefinitionsTopLevel(
 									absolutePath,
 									this.rooIgnoreController,
@@ -2360,7 +2360,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 						const filePattern: string | undefined = block.params.file_pattern
 						const sharedMessageProps: ClineSayTool = {
 							tool: "searchFiles",
-							path: getReadablePath(cwd, removeClosingTag("path", relDirPath)),
+							path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)),
 							regex: removeClosingTag("regex", regex),
 							filePattern: removeClosingTag("file_pattern", filePattern),
 						}
@@ -2384,9 +2384,9 @@ export class Cline extends EventEmitter<ClineEvents> {
 									break
 								}
 								this.consecutiveMistakeCount = 0
-								const absolutePath = path.resolve(cwd, relDirPath)
+								const absolutePath = path.resolve(this.cwd, relDirPath)
 								const results = await regexSearchFiles(
-									cwd,
+									this.cwd,
 									absolutePath,
 									regex,
 									filePattern,
@@ -3469,7 +3469,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 						if (shouldProcessMentions(block.text)) {
 							return {
 								...block,
-								text: await parseMentions(block.text, cwd, this.urlContentFetcher),
+								text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
 							}
 						}
 						return block
@@ -3478,7 +3478,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 							if (shouldProcessMentions(block.content)) {
 								return {
 									...block,
-									content: await parseMentions(block.content, cwd, this.urlContentFetcher),
+									content: await parseMentions(block.content, this.cwd, this.urlContentFetcher),
 								}
 							}
 							return block
@@ -3488,7 +3488,11 @@ export class Cline extends EventEmitter<ClineEvents> {
 									if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
 										return {
 											...contentBlock,
-											text: await parseMentions(contentBlock.text, cwd, this.urlContentFetcher),
+											text: await parseMentions(
+												contentBlock.text,
+												this.cwd,
+												this.urlContentFetcher,
+											),
 										}
 									}
 									return contentBlock
@@ -3518,7 +3522,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		const visibleFilePaths = vscode.window.visibleTextEditors
 			?.map((editor) => editor.document?.uri?.fsPath)
 			.filter(Boolean)
-			.map((absolutePath) => path.relative(cwd, absolutePath))
+			.map((absolutePath) => path.relative(this.cwd, absolutePath))
 			.slice(0, maxWorkspaceFiles ?? 200)
 
 		// Filter paths through rooIgnoreController
@@ -3539,7 +3543,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 			.flatMap((group) => group.tabs)
 			.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
 			.filter(Boolean)
-			.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
+			.map((absolutePath) => path.relative(this.cwd, absolutePath).toPosix())
 			.slice(0, maxTabs)
 
 		// Filter paths through rooIgnoreController
@@ -3582,7 +3586,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		for (const [uri, fileDiagnostics] of diagnostics) {
 			const problems = fileDiagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error)
 			if (problems.length > 0) {
-				diagnosticsDetails += `\n## ${path.relative(cwd, uri.fsPath)}`
+				diagnosticsDetails += `\n## ${path.relative(this.cwd, uri.fsPath)}`
 				for (const diagnostic of problems) {
 					// let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning"
 					const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
@@ -3696,7 +3700,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		} = (await this.providerRef.deref()?.getState()) ?? {}
 		const currentMode = mode ?? defaultModeSlug
 		const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
-			cwd,
+			cwd: this.cwd,
 			globalCustomInstructions,
 			language: language ?? formatLanguage(vscode.env.language),
 		})
@@ -3723,17 +3727,17 @@ export class Cline extends EventEmitter<ClineEvents> {
 		}
 
 		if (includeFileDetails) {
-			details += `\n\n# Current Working Directory (${cwd.toPosix()}) Files\n`
-			const isDesktop = arePathsEqual(cwd, path.join(os.homedir(), "Desktop"))
+			details += `\n\n# Current Working Directory (${this.cwd.toPosix()}) Files\n`
+			const isDesktop = arePathsEqual(this.cwd, path.join(os.homedir(), "Desktop"))
 			if (isDesktop) {
 				// don't want to immediately access desktop since it would show permission popup
 				details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
 			} else {
 				const maxFiles = maxWorkspaceFiles ?? 200
-				const [files, didHitLimit] = await listFiles(cwd, true, maxFiles)
+				const [files, didHitLimit] = await listFiles(this.cwd, true, maxFiles)
 				const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {}
 				const result = formatResponse.formatFilesList(
-					cwd,
+					this.cwd,
 					files,
 					didHitLimit,
 					this.rooIgnoreController,
@@ -3768,7 +3772,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 		}
 
 		try {
-			const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
+			const workspaceDir = getWorkspacePath()
 
 			if (!workspaceDir) {
 				log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")

+ 3 - 3
src/core/config/CustomModesManager.ts

@@ -4,7 +4,7 @@ import * as fs from "fs/promises"
 import { CustomModesSettingsSchema } from "./CustomModesSchema"
 import { ModeConfig } from "../../shared/modes"
 import { fileExistsAtPath } from "../../utils/fs"
-import { arePathsEqual } from "../../utils/path"
+import { arePathsEqual, getWorkspacePath } from "../../utils/path"
 import { logger } from "../../utils/logging"
 
 const ROOMODES_FILENAME = ".roomodes"
@@ -51,7 +51,7 @@ export class CustomModesManager {
 		if (!workspaceFolders || workspaceFolders.length === 0) {
 			return undefined
 		}
-		const workspaceRoot = workspaceFolders[0].uri.fsPath
+		const workspaceRoot = getWorkspacePath()
 		const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
 		const exists = await fileExistsAtPath(roomodesPath)
 		return exists ? roomodesPath : undefined
@@ -226,7 +226,7 @@ export class CustomModesManager {
 					logger.error("Failed to update project mode: No workspace folder found", { slug })
 					throw new Error("No workspace folder found for project-specific mode")
 				}
-				const workspaceRoot = workspaceFolders[0].uri.fsPath
+				const workspaceRoot = getWorkspacePath()
 				targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
 				const exists = await fileExistsAtPath(targetPath)
 				logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {

+ 7 - 1
src/core/config/__tests__/CustomModesManager.test.ts

@@ -6,10 +6,12 @@ import * as fs from "fs/promises"
 import { CustomModesManager } from "../CustomModesManager"
 import { ModeConfig } from "../../../shared/modes"
 import { fileExistsAtPath } from "../../../utils/fs"
+import { getWorkspacePath, arePathsEqual } from "../../../utils/path"
 
 jest.mock("vscode")
 jest.mock("fs/promises")
 jest.mock("../../../utils/fs")
+jest.mock("../../../utils/path")
 
 describe("CustomModesManager", () => {
 	let manager: CustomModesManager
@@ -37,6 +39,7 @@ describe("CustomModesManager", () => {
 		mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }]
 		;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders
 		;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() })
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/mock/workspace")
 		;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
 			return path === mockSettingsPath || path === mockRoomodes
 		})
@@ -362,8 +365,11 @@ describe("CustomModesManager", () => {
 
 		it("watches file for changes", async () => {
 			const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
-			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
 
+			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
+			;(arePathsEqual as jest.Mock).mockImplementation((path1: string, path2: string) => {
+				return path.normalize(path1) === path.normalize(path2)
+			})
 			// Get the registered callback
 			const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
 			expect(registerCall).toBeDefined()

+ 41 - 5
src/core/mentions/__tests__/index.test.ts

@@ -27,7 +27,13 @@ const mockVscode = {
 			{
 				uri: { fsPath: "/test/workspace" },
 			},
-		],
+		] as { uri: { fsPath: string } }[] | undefined,
+		getWorkspaceFolder: jest.fn().mockReturnValue("/test/workspace"),
+		fs: {
+			stat: jest.fn(),
+			writeFile: jest.fn(),
+		},
+		openTextDocument: jest.fn().mockResolvedValue({}),
 	},
 	window: {
 		showErrorMessage: mockShowErrorMessage,
@@ -36,7 +42,14 @@ const mockVscode = {
 		createTextEditorDecorationType: jest.fn(),
 		createOutputChannel: jest.fn(),
 		createWebviewPanel: jest.fn(),
-		activeTextEditor: undefined,
+		showTextDocument: jest.fn().mockResolvedValue({}),
+		activeTextEditor: undefined as
+			| undefined
+			| {
+					document: {
+						uri: { fsPath: string }
+					}
+			  },
 	},
 	commands: {
 		executeCommand: mockExecuteCommand,
@@ -64,12 +77,16 @@ const mockVscode = {
 jest.mock("vscode", () => mockVscode)
 jest.mock("../../../services/browser/UrlContentFetcher")
 jest.mock("../../../utils/git")
+jest.mock("../../../utils/path")
 
 // Now import the modules that use the mocks
 import { parseMentions, openMention } from "../index"
 import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
 import * as git from "../../../utils/git"
 
+import { getWorkspacePath } from "../../../utils/path"
+;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
+
 describe("mentions", () => {
 	const mockCwd = "/test/workspace"
 	let mockUrlContentFetcher: UrlContentFetcher
@@ -83,6 +100,15 @@ describe("mentions", () => {
 			closeBrowser: jest.fn().mockResolvedValue(undefined),
 			urlToMarkdown: jest.fn().mockResolvedValue(""),
 		} as unknown as UrlContentFetcher
+
+		// Reset all vscode mocks
+		mockVscode.workspace.fs.stat.mockReset()
+		mockVscode.workspace.fs.writeFile.mockReset()
+		mockVscode.workspace.openTextDocument.mockReset().mockResolvedValue({})
+		mockVscode.window.showTextDocument.mockReset().mockResolvedValue({})
+		mockVscode.window.showErrorMessage.mockReset()
+		mockExecuteCommand.mockReset()
+		mockOpenExternal.mockReset()
 	})
 
 	describe("parseMentions", () => {
@@ -122,11 +148,21 @@ Detailed commit message with multiple lines
 
 	describe("openMention", () => {
 		it("should handle file paths and problems", async () => {
+			// Mock stat to simulate file not existing
+			mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist"))
+
+			// Call openMention and wait for it to complete
 			await openMention("/path/to/file")
+
+			// Verify error handling
 			expect(mockExecuteCommand).not.toHaveBeenCalled()
 			expect(mockOpenExternal).not.toHaveBeenCalled()
-			expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
+			expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
+
+			// Reset mocks for next test
+			jest.clearAllMocks()
 
+			// Test problems command
 			await openMention("problems")
 			expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
 		})
@@ -135,8 +171,8 @@ Detailed commit message with multiple lines
 			const url = "https://example.com"
 			await openMention(url)
 			const mockUri = mockVscode.Uri.parse(url)
-			expect(mockOpenExternal).toHaveBeenCalled()
-			const calledArg = mockOpenExternal.mock.calls[0][0]
+			expect(mockVscode.env.openExternal).toHaveBeenCalled()
+			const calledArg = mockVscode.env.openExternal.mock.calls[0][0]
 			expect(calledArg).toEqual(
 				expect.objectContaining({
 					scheme: mockUri.scheme,

+ 2 - 1
src/core/mentions/index.ts

@@ -9,13 +9,14 @@ import { isBinaryFile } from "isbinaryfile"
 import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
 import { getCommitInfo, getWorkingState } from "../../utils/git"
 import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
+import { getWorkspacePath } from "../../utils/path"
 
 export async function openMention(mention?: string): Promise<void> {
 	if (!mention) {
 		return
 	}
 
-	const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
+	const cwd = getWorkspacePath()
 	if (!cwd) {
 		return
 	}

+ 9 - 9
src/core/webview/ClineProvider.ts

@@ -63,6 +63,7 @@ import { getNonce } from "./getNonce"
 import { getUri } from "./getUri"
 import { telemetryService } from "../../services/telemetry/TelemetryService"
 import { TelemetrySetting } from "../../shared/TelemetrySetting"
+import { getWorkspacePath } from "../../utils/path"
 
 /**
  * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -87,7 +88,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 	private contextProxy: ContextProxy
 	configManager: ConfigManager
 	customModesManager: CustomModesManager
-
+	get cwd() {
+		return getWorkspacePath()
+	}
 	constructor(
 		readonly context: vscode.ExtensionContext,
 		private readonly outputChannel: vscode.OutputChannel,
@@ -501,7 +504,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 
 		const taskId = historyItem.id
 		const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
-		const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
+		const workspaceDir = this.cwd
 
 		const checkpoints: Pick<ClineOptions, "enableCheckpoints" | "checkpointStorage"> = {
 			enableCheckpoints,
@@ -1685,7 +1688,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 						}
 						break
 					case "searchCommits": {
-						const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
+						const cwd = this.cwd
 						if (cwd) {
 							try {
 								const commits = await searchCommits(message.query || "", cwd)
@@ -1953,7 +1956,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 				fuzzyMatchThreshold,
 				Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY),
 			)
-			const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
+			const cwd = this.cwd
 
 			const mode = message.mode ?? defaultModeSlug
 			const customModes = await this.customModesManager.getCustomModes()
@@ -2301,13 +2304,10 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		// delete task from the task history state
 		await this.deleteTaskFromState(id)
 
-		// get the base directory of the project
-		const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
-
 		// Delete associated shadow repository or branch.
 		// TODO: Store `workspaceDir` in the `HistoryItem` object.
 		const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
-		const workspaceDir = baseDir ?? ""
+		const workspaceDir = this.cwd
 
 		try {
 			await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
@@ -2395,7 +2395,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 
 		const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
 
-		const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
+		const cwd = this.cwd
 
 		return {
 			version: this.context.extension?.packageJSON?.version ?? "",

+ 2 - 2
src/integrations/misc/open-file.ts

@@ -1,7 +1,7 @@
 import * as path from "path"
 import * as os from "os"
 import * as vscode from "vscode"
-import { arePathsEqual } from "../../utils/path"
+import { arePathsEqual, getWorkspacePath } from "../../utils/path"
 
 export async function openImage(dataUri: string) {
 	const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
@@ -28,7 +28,7 @@ interface OpenFileOptions {
 export async function openFile(filePath: string, options: OpenFileOptions = {}) {
 	try {
 		// Get workspace root
-		const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
+		const workspaceRoot = getWorkspacePath()
 		if (!workspaceRoot) {
 			throw new Error("No workspace root found")
 		}

+ 41 - 10
src/integrations/workspace/WorkspaceTracker.ts

@@ -3,8 +3,9 @@ import * as path from "path"
 import { listFiles } from "../../services/glob/list-files"
 import { ClineProvider } from "../../core/webview/ClineProvider"
 import { toRelativePath } from "../../utils/path"
+import { getWorkspacePath } from "../../utils/path"
+import { logger } from "../../utils/logging"
 
-const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
 const MAX_INITIAL_FILES = 1_000
 
 // Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
@@ -13,7 +14,12 @@ class WorkspaceTracker {
 	private disposables: vscode.Disposable[] = []
 	private filePaths: Set<string> = new Set()
 	private updateTimer: NodeJS.Timeout | null = null
+	private prevWorkSpacePath: string | undefined
+	private resetTimer: NodeJS.Timeout | null = null
 
+	get cwd() {
+		return getWorkspacePath()
+	}
 	constructor(provider: ClineProvider) {
 		this.providerRef = new WeakRef(provider)
 		this.registerListeners()
@@ -21,17 +27,21 @@ class WorkspaceTracker {
 
 	async initializeFilePaths() {
 		// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
-		if (!cwd) {
+		if (!this.cwd) {
+			return
+		}
+		const tempCwd = this.cwd
+		const [files, _] = await listFiles(tempCwd, true, MAX_INITIAL_FILES)
+		if (this.prevWorkSpacePath !== tempCwd) {
 			return
 		}
-		const [files, _] = await listFiles(cwd, true, MAX_INITIAL_FILES)
 		files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
 		this.workspaceDidUpdate()
 	}
 
 	private registerListeners() {
 		const watcher = vscode.workspace.createFileSystemWatcher("**")
-
+		this.prevWorkSpacePath = this.cwd
 		this.disposables.push(
 			watcher.onDidCreate(async (uri) => {
 				await this.addFilePath(uri.fsPath)
@@ -50,7 +60,7 @@ class WorkspaceTracker {
 
 		this.disposables.push(watcher)
 
-		this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
+		this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidReset()))
 	}
 
 	private getOpenedTabsInfo() {
@@ -62,23 +72,40 @@ class WorkspaceTracker {
 					return {
 						label: tab.label,
 						isActive: tab.isActive,
-						path: toRelativePath(path, cwd || ""),
+						path: toRelativePath(path, this.cwd || ""),
 					}
 				}),
 		)
 	}
 
+	private async workspaceDidReset() {
+		if (this.resetTimer) {
+			clearTimeout(this.resetTimer)
+		}
+		this.resetTimer = setTimeout(async () => {
+			if (this.prevWorkSpacePath !== this.cwd) {
+				await this.providerRef.deref()?.postMessageToWebview({
+					type: "workspaceUpdated",
+					filePaths: [],
+					openedTabs: this.getOpenedTabsInfo(),
+				})
+				this.filePaths.clear()
+				this.prevWorkSpacePath = this.cwd
+				this.initializeFilePaths()
+			}
+		}, 300) // Debounce for 300ms
+	}
+
 	private workspaceDidUpdate() {
 		if (this.updateTimer) {
 			clearTimeout(this.updateTimer)
 		}
-
 		this.updateTimer = setTimeout(() => {
-			if (!cwd) {
+			if (!this.cwd) {
 				return
 			}
 
-			const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
+			const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, this.cwd))
 			this.providerRef.deref()?.postMessageToWebview({
 				type: "workspaceUpdated",
 				filePaths: relativeFilePaths,
@@ -89,7 +116,7 @@ class WorkspaceTracker {
 	}
 
 	private normalizeFilePath(filePath: string): string {
-		const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
+		const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
 		return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
 	}
 
@@ -123,6 +150,10 @@ class WorkspaceTracker {
 			clearTimeout(this.updateTimer)
 			this.updateTimer = null
 		}
+		if (this.resetTimer) {
+			clearTimeout(this.resetTimer)
+			this.resetTimer = null
+		}
 		this.disposables.forEach((d) => d.dispose())
 	}
 }

+ 167 - 3
src/integrations/workspace/__tests__/WorkspaceTracker.test.ts

@@ -2,25 +2,40 @@ import * as vscode from "vscode"
 import WorkspaceTracker from "../WorkspaceTracker"
 import { ClineProvider } from "../../../core/webview/ClineProvider"
 import { listFiles } from "../../../services/glob/list-files"
+import { getWorkspacePath } from "../../../utils/path"
 
-// Mock modules
+// Mock functions - must be defined before jest.mock calls
 const mockOnDidCreate = jest.fn()
 const mockOnDidDelete = jest.fn()
-const mockOnDidChange = jest.fn()
 const mockDispose = jest.fn()
 
+// Store registered tab change callback
+let registeredTabChangeCallback: (() => Promise<void>) | null = null
+
+// Mock workspace path
+jest.mock("../../../utils/path", () => ({
+	getWorkspacePath: jest.fn().mockReturnValue("/test/workspace"),
+	toRelativePath: jest.fn((path, cwd) => path.replace(`${cwd}/`, "")),
+}))
+
+// Mock watcher - must be defined after mockDispose but before jest.mock("vscode")
 const mockWatcher = {
 	onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }),
 	onDidDelete: mockOnDidDelete.mockReturnValue({ dispose: mockDispose }),
 	dispose: mockDispose,
 }
 
+// Mock vscode
 jest.mock("vscode", () => ({
 	window: {
 		tabGroups: {
-			onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })),
+			onDidChangeTabs: jest.fn((callback) => {
+				registeredTabChangeCallback = callback
+				return { dispose: mockDispose }
+			}),
 			all: [],
 		},
+		onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })),
 	},
 	workspace: {
 		workspaceFolders: [
@@ -48,6 +63,12 @@ describe("WorkspaceTracker", () => {
 		jest.clearAllMocks()
 		jest.useFakeTimers()
 
+		// Reset all mock implementations
+		registeredTabChangeCallback = null
+
+		// Reset workspace path mock
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
+
 		// Create provider mock
 		mockProvider = {
 			postMessageToWebview: jest.fn().mockResolvedValue(undefined),
@@ -55,6 +76,9 @@ describe("WorkspaceTracker", () => {
 
 		// Create tracker instance
 		workspaceTracker = new WorkspaceTracker(mockProvider)
+
+		// Ensure the tab change callback was registered
+		expect(registeredTabChangeCallback).not.toBeNull()
 	})
 
 	it("should initialize with workspace files", async () => {
@@ -159,8 +183,148 @@ describe("WorkspaceTracker", () => {
 	})
 
 	it("should clean up watchers and timers on dispose", () => {
+		// Set up updateTimer
+		const [[callback]] = mockOnDidCreate.mock.calls
+		callback({ fsPath: "/test/workspace/file.ts" })
+
 		workspaceTracker.dispose()
 		expect(mockDispose).toHaveBeenCalled()
 		jest.runAllTimers() // Ensure any pending timers are cleared
+
+		// No more updates should happen after dispose
+		expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
+	})
+
+	it("should handle workspace path changes when tabs change", async () => {
+		expect(registeredTabChangeCallback).not.toBeNull()
+
+		// Set initial workspace path and create tracker
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
+		workspaceTracker = new WorkspaceTracker(mockProvider)
+
+		// Clear any initialization calls
+		jest.clearAllMocks()
+
+		// Mock listFiles to return some files
+		const mockFiles = [["/test/new-workspace/file1.ts"], false]
+		;(listFiles as jest.Mock).mockResolvedValue(mockFiles)
+
+		// Change workspace path
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/test/new-workspace")
+
+		// Simulate tab change event
+		await registeredTabChangeCallback!()
+
+		// Run the debounce timer for workspaceDidReset
+		jest.advanceTimersByTime(300)
+
+		// Should clear file paths and reset workspace
+		expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
+			type: "workspaceUpdated",
+			filePaths: [],
+			openedTabs: [],
+		})
+
+		// Run all remaining timers to complete initialization
+		await Promise.resolve() // Wait for initializeFilePaths to complete
+		jest.runAllTimers()
+
+		// Should initialize file paths for new workspace
+		expect(listFiles).toHaveBeenCalledWith("/test/new-workspace", true, 1000)
+		jest.runAllTimers()
+	})
+
+	it("should not update file paths if workspace changes during initialization", async () => {
+		// Setup initial workspace path
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
+		workspaceTracker = new WorkspaceTracker(mockProvider)
+
+		// Clear any initialization calls
+		jest.clearAllMocks()
+		;(mockProvider.postMessageToWebview as jest.Mock).mockClear()
+
+		// Create a promise to control listFiles timing
+		let resolveListFiles: (value: [string[], boolean]) => void
+		const listFilesPromise = new Promise<[string[], boolean]>((resolve) => {
+			resolveListFiles = resolve
+		})
+
+		// Setup listFiles to use our controlled promise
+		;(listFiles as jest.Mock).mockImplementation(() => {
+			// Change workspace path before listFiles resolves
+			;(getWorkspacePath as jest.Mock).mockReturnValue("/test/changed-workspace")
+			return listFilesPromise
+		})
+
+		// Start initialization
+		const initPromise = workspaceTracker.initializeFilePaths()
+
+		// Resolve listFiles after workspace path change
+		resolveListFiles!([["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false])
+
+		// Wait for initialization to complete
+		await initPromise
+		jest.runAllTimers()
+
+		// Should not update file paths because workspace changed during initialization
+		expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
+			filePaths: ["/test/workspace/file1.ts", "/test/workspace/file2.ts"],
+			openedTabs: [],
+			type: "workspaceUpdated",
+		})
+	})
+
+	it("should clear resetTimer when calling workspaceDidReset multiple times", async () => {
+		expect(registeredTabChangeCallback).not.toBeNull()
+
+		// Set initial workspace path
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
+
+		// Create tracker instance to set initial prevWorkSpacePath
+		workspaceTracker = new WorkspaceTracker(mockProvider)
+
+		// Change workspace path to trigger update
+		;(getWorkspacePath as jest.Mock).mockReturnValue("/test/new-workspace")
+
+		// Call workspaceDidReset through tab change event
+		await registeredTabChangeCallback!()
+
+		// Call again before timer completes
+		await registeredTabChangeCallback!()
+
+		// Advance timer
+		jest.advanceTimersByTime(300)
+
+		// Should only have one call to postMessageToWebview
+		expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
+			type: "workspaceUpdated",
+			filePaths: [],
+			openedTabs: [],
+		})
+		expect(mockProvider.postMessageToWebview).toHaveBeenCalledTimes(1)
+	})
+
+	it("should handle dispose with active resetTimer", async () => {
+		expect(registeredTabChangeCallback).not.toBeNull()
+
+		// Mock workspace path change to trigger resetTimer
+		;(getWorkspacePath as jest.Mock)
+			.mockReturnValueOnce("/test/workspace")
+			.mockReturnValueOnce("/test/new-workspace")
+
+		// Trigger resetTimer
+		await registeredTabChangeCallback!()
+
+		// Dispose before timer completes
+		workspaceTracker.dispose()
+
+		// Advance timer
+		jest.advanceTimersByTime(300)
+
+		// Should have called dispose on all disposables
+		expect(mockDispose).toHaveBeenCalled()
+
+		// No postMessage should be called after dispose
+		expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
 	})
 })

+ 35 - 3
src/utils/__tests__/path.test.ts

@@ -1,12 +1,37 @@
 // npx jest src/utils/__tests__/path.test.ts
-
 import os from "os"
 import * as path from "path"
 
-import { arePathsEqual, getReadablePath } from "../path"
-
+import { arePathsEqual, getReadablePath, getWorkspacePath } from "../path"
+
+// Mock modules
+
+jest.mock("vscode", () => ({
+	window: {
+		activeTextEditor: {
+			document: {
+				uri: { fsPath: "/test/workspaceFolder/file.ts" },
+			},
+		},
+	},
+	workspace: {
+		workspaceFolders: [
+			{
+				uri: { fsPath: "/test/workspace" },
+				name: "test",
+				index: 0,
+			},
+		],
+		getWorkspaceFolder: jest.fn().mockReturnValue({
+			uri: {
+				fsPath: "/test/workspaceFolder",
+			},
+		}),
+	},
+}))
 describe("Path Utilities", () => {
 	const originalPlatform = process.platform
+	// Helper to mock VS Code configuration
 
 	afterEach(() => {
 		Object.defineProperty(process, "platform", {
@@ -30,7 +55,14 @@ describe("Path Utilities", () => {
 			expect(extendedPath.toPosix()).toBe("\\\\?\\C:\\Very\\Long\\Path")
 		})
 	})
+	describe("getWorkspacePath", () => {
+		it("should return the current workspace path", () => {
+			const workspacePath = "/Users/test/project"
+			expect(getWorkspacePath(workspacePath)).toBe("/test/workspaceFolder")
+		})
 
+		it("should return undefined when outside a workspace", () => {})
+	})
 	describe("arePathsEqual", () => {
 		describe("on Windows", () => {
 			beforeEach(() => {

+ 11 - 0
src/utils/path.ts

@@ -1,5 +1,6 @@
 import * as path from "path"
 import os from "os"
+import * as vscode from "vscode"
 
 /*
 The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
@@ -104,3 +105,13 @@ export const toRelativePath = (filePath: string, cwd: string) => {
 	const relativePath = path.relative(cwd, filePath).toPosix()
 	return filePath.endsWith("/") ? relativePath + "/" : relativePath
 }
+
+export const getWorkspacePath = (defaultCwdPath = "") => {
+	const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath
+	const currentFileUri = vscode.window.activeTextEditor?.document.uri
+	if (currentFileUri) {
+		const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
+		return workspaceFolder?.uri.fsPath || cwdPath
+	}
+	return cwdPath
+}