diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 7cd051c6bf..6e0ed6f7b0 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -7,7 +7,6 @@ import { providerSettingsEntrySchema, providerSettingsSchema, } from "./provider-settings.js" -import { historyItemSchema } from "./history.js" import { codebaseIndexModelsSchema, codebaseIndexConfigSchema } from "./codebase-index.js" import { experimentsSchema } from "./experiment.js" import { telemetrySettingsSchema } from "./telemetry.js" @@ -26,7 +25,6 @@ export const globalSettingsSchema = z.object({ lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), - taskHistory: z.array(historyItemSchema).optional(), condensingApiConfigId: z.string().optional(), customCondensingPrompt: z.string().optional(), diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6c6db9ccd5..c18ffc5314 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -20,3 +20,4 @@ export * from "./terminal.js" export * from "./tool.js" export * from "./type-fu.js" export * from "./vscode.js" +export * from "./workspace-settings.js" diff --git a/packages/types/src/workspace-settings.ts b/packages/types/src/workspace-settings.ts new file mode 100644 index 0000000000..68700af1c0 --- /dev/null +++ b/packages/types/src/workspace-settings.ts @@ -0,0 +1,15 @@ +import { z } from "zod" +import { historyItemSchema } from "./history.js" + +/** + * WorkspaceSettings - Settings that are specific to a workspace + */ +export const workspaceSettingsSchema = z.object({ + taskHistory: z.array(historyItemSchema).optional(), +}) + +export type WorkspaceSettings = z.infer + +export const WORKSPACE_SETTINGS_KEYS = workspaceSettingsSchema.keyof().options + +export type WorkspaceSettingsKey = keyof WorkspaceSettings diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index c4324fbb13..74b9011d3d 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -14,6 +14,10 @@ import { providerSettingsSchema, globalSettingsSchema, isSecretStateKey, + type WorkspaceSettings, + type WorkspaceSettingsKey, + WORKSPACE_SETTINGS_KEYS, + workspaceSettingsSchema, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -23,12 +27,11 @@ type GlobalStateKey = keyof GlobalState type SecretStateKey = keyof SecretState type RooCodeSettingsKey = keyof RooCodeSettings -const PASS_THROUGH_STATE_KEYS = ["taskHistory"] +const PASS_THROUGH_STATE_KEYS: string[] = [] export const isPassThroughStateKey = (key: string) => PASS_THROUGH_STATE_KEYS.includes(key) const globalSettingsExportSchema = globalSettingsSchema.omit({ - taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, }) @@ -38,12 +41,14 @@ export class ContextProxy { private stateCache: GlobalState private secretCache: SecretState + private workspaceStateCache: WorkspaceSettings private _isInitialized = false constructor(context: vscode.ExtensionContext) { this.originalContext = context this.stateCache = {} this.secretCache = {} + this.workspaceStateCache = {} this._isInitialized = false } @@ -71,6 +76,17 @@ export class ContextProxy { await Promise.all(promises) + // Initialize workspace state cache + for (const key of WORKSPACE_SETTINGS_KEYS) { + try { + this.workspaceStateCache[key] = this.originalContext.workspaceState.get(key) + } catch (error) { + logger.error( + `Error loading workspace ${key}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + this._isInitialized = true } @@ -151,6 +167,51 @@ export class ContextProxy { return Object.fromEntries(SECRET_STATE_KEYS.map((key) => [key, this.getSecret(key)])) } + /** + * ExtensionContext.workspaceState + * https://code.visualstudio.com/api/references/vscode-api#ExtensionContext.workspaceState + */ + + getWorkspaceState(key: K): WorkspaceSettings[K] + getWorkspaceState(key: K, defaultValue: WorkspaceSettings[K]): WorkspaceSettings[K] + getWorkspaceState( + key: K, + defaultValue?: WorkspaceSettings[K], + ): WorkspaceSettings[K] { + const value = this.workspaceStateCache[key] + return value !== undefined ? value : defaultValue + } + + updateWorkspaceState(key: K, value: WorkspaceSettings[K]) { + this.workspaceStateCache[key] = value + return this.originalContext.workspaceState.update(key, value) + } + + private getAllWorkspaceState(): WorkspaceSettings { + return Object.fromEntries(WORKSPACE_SETTINGS_KEYS.map((key) => [key, this.getWorkspaceState(key)])) + } + + /** + * WorkspaceSettings + */ + + public getWorkspaceSettings(): WorkspaceSettings { + const values = this.getAllWorkspaceState() + + try { + return workspaceSettingsSchema.parse(values) + } catch (error) { + if (error instanceof ZodError) { + TelemetryService.instance.captureSchemaValidationError({ schemaName: "WorkspaceSettings", error }) + } + + return WORKSPACE_SETTINGS_KEYS.reduce( + (acc, key) => ({ ...acc, [key]: values[key] }), + {} as WorkspaceSettings, + ) + } + } + /** * GlobalSettings */ @@ -264,10 +325,12 @@ export class ContextProxy { // Clear in-memory caches this.stateCache = {} this.secretCache = {} + this.workspaceStateCache = {} await Promise.all([ ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), + ...WORKSPACE_SETTINGS_KEYS.map((key) => this.originalContext.workspaceState.update(key, undefined)), ]) await this.initialize() diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 86b7bbef30..ef5e93667c 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode" -import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS } from "@roo-code/types" +import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS, WORKSPACE_SETTINGS_KEYS } from "@roo-code/types" import { ContextProxy } from "../ContextProxy" @@ -22,6 +22,7 @@ describe("ContextProxy", () => { let mockContext: any let mockGlobalState: any let mockSecrets: any + let mockWorkspaceState: any beforeEach(async () => { // Reset mocks @@ -40,10 +41,17 @@ describe("ContextProxy", () => { delete: vi.fn().mockResolvedValue(undefined), } + // Mock workspaceState + mockWorkspaceState = { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + } + // Mock the extension context mockContext = { globalState: mockGlobalState, secrets: mockSecrets, + workspaceState: mockWorkspaceState, extensionUri: { path: "/test/extension" }, extensionPath: "/test/extension", globalStorageUri: { path: "/test/storage" }, @@ -82,6 +90,13 @@ describe("ContextProxy", () => { expect(mockSecrets.get).toHaveBeenCalledWith(key) } }) + + it("should initialize workspace state cache with all workspace settings keys", () => { + expect(mockWorkspaceState.get).toHaveBeenCalledTimes(WORKSPACE_SETTINGS_KEYS.length) + for (const key of WORKSPACE_SETTINGS_KEYS) { + expect(mockWorkspaceState.get).toHaveBeenCalledWith(key) + } + }) }) describe("getGlobalState", () => { @@ -102,41 +117,6 @@ describe("ContextProxy", () => { const result = proxy.getGlobalState("apiProvider", "deepseek") expect(result).toBe("deepseek") }) - - it("should bypass cache for pass-through state keys", async () => { - // Setup mock return value - mockGlobalState.get.mockReturnValue("pass-through-value") - - // Use a pass-through key (taskHistory) - const result = proxy.getGlobalState("taskHistory") - - // Should get value directly from original context - expect(result).toBe("pass-through-value") - expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory") - }) - - it("should respect default values for pass-through state keys", async () => { - // Setup mock to return undefined - mockGlobalState.get.mockReturnValue(undefined) - - // Use a pass-through key with default value - const historyItems = [ - { - id: "1", - number: 1, - ts: 1, - task: "test", - tokensIn: 1, - tokensOut: 1, - totalCost: 1, - }, - ] - - const result = proxy.getGlobalState("taskHistory", historyItems) - - // Should return default value when original context returns undefined - expect(result).toBe(historyItems) - }) }) describe("updateGlobalState", () => { @@ -150,33 +130,6 @@ describe("ContextProxy", () => { const storedValue = await proxy.getGlobalState("apiProvider") expect(storedValue).toBe("deepseek") }) - - it("should bypass cache for pass-through state keys", async () => { - const historyItems = [ - { - id: "1", - number: 1, - ts: 1, - task: "test", - tokensIn: 1, - tokensOut: 1, - totalCost: 1, - }, - ] - - await proxy.updateGlobalState("taskHistory", historyItems) - - // Should update original context - expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", historyItems) - - // Setup mock for subsequent get - mockGlobalState.get.mockReturnValue(historyItems) - - // Should get fresh value from original context - const storedValue = proxy.getGlobalState("taskHistory") - expect(storedValue).toBe(historyItems) - expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory") - }) }) describe("getSecret", () => { @@ -391,6 +344,16 @@ describe("ContextProxy", () => { expect(mockGlobalState.update).toHaveBeenCalledTimes(expectedUpdateCalls) }) + it("should update all workspace state keys to undefined", async () => { + // Reset all state + await proxy.resetAllState() + + // Should have called update with undefined for each workspace key + for (const key of WORKSPACE_SETTINGS_KEYS) { + expect(mockWorkspaceState.update).toHaveBeenCalledWith(key, undefined) + } + }) + it("should delete all secrets", async () => { // Setup initial secrets await proxy.storeSecret("apiKey", "test-api-key") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9d99eea1d4..f1ecdf009d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1130,7 +1130,7 @@ export class ClineProvider uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { - const history = this.getGlobalState("taskHistory") ?? [] + const history = this.contextProxy.getWorkspaceState("taskHistory") ?? [] const historyItem = history.find((item) => item.id === id) if (historyItem) { @@ -1240,9 +1240,9 @@ export class ClineProvider } async deleteTaskFromState(id: string) { - const taskHistory = this.getGlobalState("taskHistory") ?? [] + const taskHistory = this.contextProxy.getWorkspaceState("taskHistory") ?? [] const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) - await this.updateGlobalState("taskHistory", updatedTaskHistory) + await this.contextProxy.updateWorkspaceState("taskHistory", updatedTaskHistory) await this.postStateToWebview() } @@ -1443,10 +1443,12 @@ export class ClineProvider autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, currentTaskItem: this.getCurrentCline()?.taskId - ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId) + ? (this.contextProxy.getWorkspaceState("taskHistory") || []).find( + (item: HistoryItem) => item.id === this.getCurrentCline()?.taskId, + ) : undefined, clineMessages: this.getCurrentCline()?.clineMessages || [], - taskHistory: (taskHistory || []) + taskHistory: (this.contextProxy.getWorkspaceState("taskHistory") || []) .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), soundEnabled: soundEnabled ?? false, @@ -1610,7 +1612,7 @@ export class ClineProvider allowedMaxRequests: stateValues.allowedMaxRequests, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, - taskHistory: stateValues.taskHistory, + taskHistory: this.contextProxy.getWorkspaceState("taskHistory"), allowedCommands: stateValues.allowedCommands, soundEnabled: stateValues.soundEnabled ?? false, ttsEnabled: stateValues.ttsEnabled ?? false, @@ -1681,7 +1683,7 @@ export class ClineProvider } async updateTaskHistory(item: HistoryItem): Promise { - const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] + const history = this.contextProxy.getWorkspaceState("taskHistory") || [] const existingItemIndex = history.findIndex((h) => h.id === item.id) if (existingItemIndex !== -1) { @@ -1690,7 +1692,7 @@ export class ClineProvider history.push(item) } - await this.updateGlobalState("taskHistory", history) + await this.contextProxy.updateWorkspaceState("taskHistory", history) return history } diff --git a/src/utils/__tests__/migrateSettings.spec.ts b/src/utils/__tests__/migrateSettings.spec.ts new file mode 100644 index 0000000000..d40efde118 --- /dev/null +++ b/src/utils/__tests__/migrateSettings.spec.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { migrateTaskHistoryWithContextProxy } from "../migrateSettings" +import type { ContextProxy } from "../../core/config/ContextProxy" +import type { HistoryItem } from "../../../packages/types/src" + +describe("migrateTaskHistoryWithContextProxy", () => { + let mockContextProxy: any + let mockWorkspaceFolder: any + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + + // Mock workspace folder + mockWorkspaceFolder = { + uri: { + fsPath: "/test/workspace", + }, + } + + // Mock VSCode context + const mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } + + // Mock context proxy + mockContextProxy = { + getGlobalState: vi.fn(), + updateGlobalState: vi.fn(), + updateWorkspaceState: vi.fn(), + getWorkspaceState: vi.fn(), + getWorkspaceSettings: vi.fn(), + context: mockContext, + } as any + }) + + it("should migrate task history from global state to workspace state", async () => { + // Arrange + const mockTaskHistory: HistoryItem[] = [ + { + id: "task1", + number: 1, + ts: Date.now(), + task: "Test task 1", + tokensIn: 100, + tokensOut: 50, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.01, + workspace: "/test/workspace", + }, + { + id: "task2", + number: 2, + ts: Date.now() - 1000, + task: "Test task 2", + tokensIn: 200, + tokensOut: 100, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.02, + workspace: "/test/workspace", + }, + { + id: "task3", + number: 3, + ts: Date.now() - 2000, + task: "Test task from different workspace", + tokensIn: 150, + tokensOut: 75, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.015, + workspace: "/different/workspace", + }, + ] + + // Mock context.globalState.get to return the raw state with taskHistory + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === "globalSettings") { + return { taskHistory: mockTaskHistory } + } + return defaultValue + }) + vi.mocked(mockContextProxy.getWorkspaceSettings).mockReturnValue({}) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, mockWorkspaceFolder) + + // Assert + // Should update workspace state with only tasks from current workspace + expect(mockContextProxy.updateWorkspaceState).toHaveBeenCalledWith("taskHistory", [ + mockTaskHistory[0], + mockTaskHistory[1], + ]) + + // Should update global state to keep only tasks from other workspaces + expect(mockContextProxy.context.globalState.update).toHaveBeenCalledWith("globalSettings", { + taskHistory: [mockTaskHistory[2]], + }) + }) + + it("should handle empty task history in global state", async () => { + // Arrange + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation((key: string) => { + if (key === "globalSettings") { + return { taskHistory: [] } + } + return undefined + }) + vi.mocked(mockContextProxy.getWorkspaceSettings).mockReturnValue({}) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, mockWorkspaceFolder) + + // Assert + expect(mockContextProxy.updateWorkspaceState).not.toHaveBeenCalled() + expect(mockContextProxy.context.globalState.update).not.toHaveBeenCalled() + }) + + it("should handle undefined task history in global state", async () => { + // Arrange + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation((key: string) => { + if (key === "globalSettings") { + return {} + } + return undefined + }) + vi.mocked(mockContextProxy.getWorkspaceSettings).mockReturnValue({}) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, mockWorkspaceFolder) + + // Assert + expect(mockContextProxy.updateWorkspaceState).not.toHaveBeenCalled() + expect(mockContextProxy.context.globalState.update).not.toHaveBeenCalled() + }) + + it("should merge with existing workspace task history", async () => { + // Arrange + const existingWorkspaceTask: HistoryItem = { + id: "existing1", + number: 1, + ts: Date.now() - 5000, + task: "Existing workspace task", + tokensIn: 50, + tokensOut: 25, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.005, + workspace: "/test/workspace", + } + + const globalTask: HistoryItem = { + id: "global1", + number: 2, + ts: Date.now(), + task: "Task from global state", + tokensIn: 100, + tokensOut: 50, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.01, + workspace: "/test/workspace", + } + + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation((key: string) => { + if (key === "globalSettings") { + return { taskHistory: [globalTask] } + } + return undefined + }) + vi.mocked(mockContextProxy.getWorkspaceSettings).mockReturnValue({ taskHistory: [existingWorkspaceTask] }) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, mockWorkspaceFolder) + + // Assert + // Should merge tasks, keeping both existing and migrated + expect(mockContextProxy.updateWorkspaceState).toHaveBeenCalledWith("taskHistory", [ + existingWorkspaceTask, + globalTask, + ]) + + // Should clear the migrated task from global state + expect(mockContextProxy.context.globalState.update).toHaveBeenCalledWith("globalSettings", {}) + }) + + it("should handle tasks without workspacePath", async () => { + // Arrange + const taskWithoutPath: HistoryItem = { + id: "task1", + number: 1, + ts: Date.now(), + task: "Task without workspace path", + tokensIn: 100, + tokensOut: 50, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.01, + // No workspace property + } as HistoryItem + + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation((key: string) => { + if (key === "globalSettings") { + return { taskHistory: [taskWithoutPath] } + } + return undefined + }) + vi.mocked(mockContextProxy.getWorkspaceSettings).mockReturnValue({}) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, mockWorkspaceFolder) + + // Assert + // Should not migrate tasks without workspace path + expect(mockContextProxy.updateWorkspaceState).not.toHaveBeenCalled() + // Should keep the task in global state + expect(mockContextProxy.context.globalState.update).toHaveBeenCalledWith("globalSettings", { + taskHistory: [taskWithoutPath], + }) + }) + + it("should handle no workspace folder", async () => { + // Arrange + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation((key: string) => { + if (key === "globalSettings") { + return { + taskHistory: [ + { + id: "task1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0.01, + workspace: "/test/workspace", + }, + ], + } + } + return undefined + }) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, undefined) + + // Assert + // Should not perform any migration without a workspace + expect(mockContextProxy.updateWorkspaceState).not.toHaveBeenCalled() + expect(mockContextProxy.context.globalState.update).not.toHaveBeenCalled() + }) + + it("should handle errors gracefully", async () => { + // Arrange + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(mockContextProxy.context.globalState.get).mockImplementation(() => { + throw new Error("Failed to get global state") + }) + + // Act + await migrateTaskHistoryWithContextProxy(mockContextProxy, mockWorkspaceFolder) + + // Assert + expect(consoleSpy).toHaveBeenCalledWith("Failed to migrate task history to workspace:", expect.any(Error)) + expect(mockContextProxy.updateWorkspaceState).not.toHaveBeenCalled() + expect(mockContextProxy.context.globalState.update).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) +}) diff --git a/src/utils/migrateSettings.ts b/src/utils/migrateSettings.ts index 43b1d7291f..7312439a46 100644 --- a/src/utils/migrateSettings.ts +++ b/src/utils/migrateSettings.ts @@ -4,6 +4,8 @@ import * as fs from "fs/promises" import { fileExistsAtPath } from "./fs" import { GlobalFileNames } from "../shared/globalFileNames" import * as yaml from "yaml" +import type { ContextProxy } from "../core/config/ContextProxy" +import type { HistoryItem } from "../../packages/types/src" const deprecatedCustomModesJSONFilename = "custom_modes.json" @@ -55,6 +57,9 @@ export async function migrateSettings( // Special migration for custom_modes.json to custom_modes.yaml with content transformation await migrateCustomModesToYaml(settingsDir, outputChannel) + + // Migrate task history from global state to workspace state + await migrateTaskHistoryToWorkspace(context, outputChannel) } catch (error) { outputChannel.appendLine(`Error in file migrations: ${error}`) } @@ -113,3 +118,137 @@ async function migrateCustomModesToYaml(settingsDir: string, outputChannel: vsco outputChannel.appendLine(`Error reading custom_modes.json: ${fileError}. Skipping migration.`) } } + +/** + * Migrates task history from global state to workspace state + * This ensures each workspace has its own isolated task history + * + * TODO: Remove this migration code in September 2025 (6 months after implementation) + */ +async function migrateTaskHistoryToWorkspace( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, +): Promise { + try { + // Check if we've already performed this migration + const migrationKey = "taskHistoryMigratedToWorkspace" + const alreadyMigrated = context.globalState.get(migrationKey, false) + + if (alreadyMigrated) { + outputChannel.appendLine("Task history migration already completed, skipping") + return + } + + // Get the current workspace folder + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + outputChannel.appendLine("No workspace folder found, skipping task history migration") + return + } + + // Get task history from global state + const globalSettings = context.globalState.get("globalSettings") + if (!globalSettings?.taskHistory || globalSettings.taskHistory.length === 0) { + outputChannel.appendLine("No task history found in global state, skipping migration") + // Mark as migrated even if there's no data to prevent future checks + await context.globalState.update(migrationKey, true) + return + } + + const taskHistory = globalSettings.taskHistory + const currentWorkspacePath = workspaceFolder.uri.fsPath + + // Filter tasks that belong to the current workspace + const workspaceTasks = taskHistory.filter((task: any) => task.workspace === currentWorkspacePath) + + if (workspaceTasks.length > 0) { + // Get current workspace settings + const workspaceSettings = context.workspaceState.get("workspaceSettings", {}) + + // Add the filtered task history to workspace settings + workspaceSettings.taskHistory = workspaceTasks + + // Save to workspace state + await context.workspaceState.update("workspaceSettings", workspaceSettings) + + outputChannel.appendLine(`Successfully migrated ${workspaceTasks.length} tasks to workspace state`) + } else { + outputChannel.appendLine("No tasks found for current workspace, nothing to migrate") + } + + // Remove taskHistory from global settings + delete globalSettings.taskHistory + await context.globalState.update("globalSettings", globalSettings) + + // Mark migration as complete + await context.globalState.update(migrationKey, true) + + outputChannel.appendLine("Task history migration completed successfully") + } catch (error) { + outputChannel.appendLine(`Error migrating task history: ${error}`) + } +} + +/** + * Migrates task history from global state to workspace state using ContextProxy + * This is used for the new architecture with ContextProxy + * @param contextProxy The context proxy instance + * @param workspaceFolder The current workspace folder + */ +export async function migrateTaskHistoryWithContextProxy( + contextProxy: ContextProxy, + workspaceFolder: vscode.WorkspaceFolder | undefined, +): Promise { + if (!workspaceFolder) { + return + } + + try { + // Access the raw context to get the global state directly + const context = (contextProxy as any).context as vscode.ExtensionContext + + // Get the raw global state + const rawGlobalState = context.globalState.get("globalSettings", {}) + const taskHistory = rawGlobalState.taskHistory as HistoryItem[] | undefined + + if (!taskHistory || taskHistory.length === 0) { + return + } + + const currentWorkspacePath = workspaceFolder.uri.fsPath + + // Filter tasks that belong to the current workspace + const workspaceTasks = taskHistory.filter((task) => task.workspace === currentWorkspacePath) + + // Get tasks that don't belong to current workspace + const otherWorkspaceTasks = taskHistory.filter((task) => task.workspace !== currentWorkspacePath) + + if (workspaceTasks.length > 0) { + // Get existing workspace settings + const workspaceSettings = contextProxy.getWorkspaceSettings() + const existingWorkspaceHistory = workspaceSettings.taskHistory || [] + + // Merge with existing workspace history (avoiding duplicates) + const existingIds = new Set(existingWorkspaceHistory.map((t) => t.id)) + const newTasks = workspaceTasks.filter((t) => !existingIds.has(t.id)) + const mergedHistory = [...existingWorkspaceHistory, ...newTasks] + + // Update workspace state with the merged history + await contextProxy.updateWorkspaceState("taskHistory", mergedHistory) + } + + // Update global state to remove task history (or keep only other workspace tasks) + if (otherWorkspaceTasks.length > 0) { + // Keep tasks from other workspaces + rawGlobalState.taskHistory = otherWorkspaceTasks + } else { + // Remove taskHistory completely + delete rawGlobalState.taskHistory + } + + // Update the raw global state directly + await context.globalState.update("globalSettings", rawGlobalState) + } catch (error) { + console.error("Failed to migrate task history to workspace:", error) + } +} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 2f156d0418..c1c1123454 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -28,16 +28,7 @@ type HistoryViewProps = { type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" const HistoryView = ({ onDone }: HistoryViewProps) => { - const { - tasks, - searchQuery, - setSearchQuery, - sortOption, - setSortOption, - setLastNonRelevantSort, - showAllWorkspaces, - setShowAllWorkspaces, - } = useTaskSearch() + const { tasks, searchQuery, setSearchQuery, sortOption, setSortOption, setLastNonRelevantSort } = useTaskSearch() const { t } = useAppTranslation() const [deleteTaskId, setDeleteTaskId] = useState(null) @@ -128,32 +119,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { )}
-