mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: migrate task history to global state for cross-workspace access (#5315)
This commit is contained in:
parent
a1ae29cdcd
commit
9fe17a4be3
12 changed files with 549 additions and 166 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
15
packages/types/src/workspace-settings.ts
Normal file
15
packages/types/src/workspace-settings.ts
Normal file
|
|
@ -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<typeof workspaceSettingsSchema>
|
||||
|
||||
export const WORKSPACE_SETTINGS_KEYS = workspaceSettingsSchema.keyof().options
|
||||
|
||||
export type WorkspaceSettingsKey = keyof WorkspaceSettings
|
||||
|
|
@ -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<K extends WorkspaceSettingsKey>(key: K): WorkspaceSettings[K]
|
||||
getWorkspaceState<K extends WorkspaceSettingsKey>(key: K, defaultValue: WorkspaceSettings[K]): WorkspaceSettings[K]
|
||||
getWorkspaceState<K extends WorkspaceSettingsKey>(
|
||||
key: K,
|
||||
defaultValue?: WorkspaceSettings[K],
|
||||
): WorkspaceSettings[K] {
|
||||
const value = this.workspaceStateCache[key]
|
||||
return value !== undefined ? value : defaultValue
|
||||
}
|
||||
|
||||
updateWorkspaceState<K extends WorkspaceSettingsKey>(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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<HistoryItem[]> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
281
src/utils/__tests__/migrateSettings.spec.ts
Normal file
281
src/utils/__tests__/migrateSettings.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void> {
|
||||
try {
|
||||
// Check if we've already performed this migration
|
||||
const migrationKey = "taskHistoryMigratedToWorkspace"
|
||||
const alreadyMigrated = context.globalState.get<boolean>(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<any>("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<any>("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<void> {
|
||||
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<any>("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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
|
|
@ -128,32 +119,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||
)}
|
||||
</VSCodeTextField>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={showAllWorkspaces ? "all" : "current"}
|
||||
onValueChange={(value) => setShowAllWorkspaces(value === "all")}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue>
|
||||
{t("history:workspace.prefix")}{" "}
|
||||
{t(`history:workspace.${showAllWorkspaces ? "all" : "current"}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="codicon codicon-folder" />
|
||||
{t("history:workspace.current")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="codicon codicon-folder-opened" />
|
||||
{t("history:workspace.all")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{t("history:sort.prefix")} {t(`history:sort.${sortOption}`)}
|
||||
</SelectValue>
|
||||
|
|
@ -238,7 +205,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||
key={item.id}
|
||||
item={item}
|
||||
variant="full"
|
||||
showWorkspace={showAllWorkspaces}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedTaskIds.includes(item.id)}
|
||||
onToggleSelection={toggleTaskSelection}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ interface DisplayHistoryItem extends HistoryItem {
|
|||
interface TaskItemProps {
|
||||
item: DisplayHistoryItem
|
||||
variant: "compact" | "full"
|
||||
showWorkspace?: boolean
|
||||
isSelectionMode?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelection?: (taskId: string, isSelected: boolean) => void
|
||||
|
|
@ -26,7 +25,6 @@ interface TaskItemProps {
|
|||
const TaskItem = ({
|
||||
item,
|
||||
variant,
|
||||
showWorkspace = false,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
|
|
@ -85,14 +83,6 @@ const TaskItem = ({
|
|||
|
||||
{/* Task Item Footer */}
|
||||
<TaskItemFooter item={item} variant={variant} isSelectionMode={isSelectionMode} />
|
||||
|
||||
{/* Workspace info */}
|
||||
{showWorkspace && item.workspace && (
|
||||
<div className="flex flex-row gap-1 text-vscode-descriptionForeground text-xs mt-1">
|
||||
<span className="codicon codicon-folder scale-80" />
|
||||
<span>{item.workspace}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,77 +75,53 @@ describe("useTaskSearch", () => {
|
|||
expect(result.current.tasks.every((task) => task.workspace === "/workspace/project1")).toBe(true)
|
||||
})
|
||||
|
||||
it("shows all workspaces when showAllWorkspaces is true", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toHaveLength(3)
|
||||
expect(result.current.showAllWorkspaces).toBe(true)
|
||||
})
|
||||
|
||||
it("sorts by newest by default", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
})
|
||||
|
||||
expect(result.current.sortOption).toBe("newest")
|
||||
expect(result.current.tasks[0].id).toBe("task-2") // Feb 17
|
||||
expect(result.current.tasks[1].id).toBe("task-1") // Feb 16
|
||||
expect(result.current.tasks[2].id).toBe("task-3") // Feb 15
|
||||
})
|
||||
|
||||
it("sorts by oldest", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSortOption("oldest")
|
||||
})
|
||||
|
||||
expect(result.current.tasks[0].id).toBe("task-3") // Feb 15
|
||||
expect(result.current.tasks[1].id).toBe("task-1") // Feb 16
|
||||
expect(result.current.tasks[2].id).toBe("task-2") // Feb 17
|
||||
expect(result.current.tasks[0].id).toBe("task-1") // Feb 16
|
||||
expect(result.current.tasks[1].id).toBe("task-2") // Feb 17
|
||||
})
|
||||
|
||||
it("sorts by most expensive", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSortOption("mostExpensive")
|
||||
})
|
||||
|
||||
expect(result.current.tasks[0].id).toBe("task-3") // $0.05
|
||||
expect(result.current.tasks[1].id).toBe("task-2") // $0.02
|
||||
expect(result.current.tasks[2].id).toBe("task-1") // $0.01
|
||||
expect(result.current.tasks[0].id).toBe("task-2") // $0.02
|
||||
expect(result.current.tasks[1].id).toBe("task-1") // $0.01
|
||||
})
|
||||
|
||||
it("sorts by most tokens", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSortOption("mostTokens")
|
||||
})
|
||||
|
||||
// task-2: 200 + 100 + 25 + 10 = 335 tokens
|
||||
// task-3: 150 + 75 = 225 tokens
|
||||
// task-1: 100 + 50 = 150 tokens
|
||||
expect(result.current.tasks[0].id).toBe("task-2")
|
||||
expect(result.current.tasks[1].id).toBe("task-3")
|
||||
expect(result.current.tasks[2].id).toBe("task-1")
|
||||
expect(result.current.tasks[1].id).toBe("task-1")
|
||||
})
|
||||
|
||||
it("filters tasks by search query", () => {
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSearchQuery("React")
|
||||
})
|
||||
|
||||
|
|
@ -251,12 +227,8 @@ describe("useTaskSearch", () => {
|
|||
|
||||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
})
|
||||
|
||||
// Should only include tasks with both ts and task content
|
||||
expect(result.current.tasks).toHaveLength(3)
|
||||
// Should only include tasks with both ts and task content from current workspace
|
||||
expect(result.current.tasks).toHaveLength(2)
|
||||
expect(result.current.tasks.every((task) => task.ts && task.task)).toBe(true)
|
||||
})
|
||||
|
||||
|
|
@ -264,7 +236,6 @@ describe("useTaskSearch", () => {
|
|||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSearchQuery("nonexistent")
|
||||
})
|
||||
|
||||
|
|
@ -275,7 +246,6 @@ describe("useTaskSearch", () => {
|
|||
const { result } = renderHook(() => useTaskSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowAllWorkspaces(true)
|
||||
result.current.setSearchQuery("test")
|
||||
result.current.setSortOption("mostRelevant")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export const useTaskSearch = () => {
|
|||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [sortOption, setSortOption] = useState<SortOption>("newest")
|
||||
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
|
||||
const [showAllWorkspaces, setShowAllWorkspaces] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
|
||||
|
|
@ -24,12 +23,10 @@ export const useTaskSearch = () => {
|
|||
}, [searchQuery, sortOption, lastNonRelevantSort])
|
||||
|
||||
const presentableTasks = useMemo(() => {
|
||||
let tasks = taskHistory.filter((item) => item.ts && item.task)
|
||||
if (!showAllWorkspaces) {
|
||||
tasks = tasks.filter((item) => item.workspace === cwd)
|
||||
}
|
||||
// Filter tasks by current workspace and ensure they have required fields
|
||||
const tasks = taskHistory.filter((item) => item.ts && item.task && item.workspace === cwd)
|
||||
return tasks
|
||||
}, [taskHistory, showAllWorkspaces, cwd])
|
||||
}, [taskHistory, cwd])
|
||||
|
||||
const fzf = useMemo(() => {
|
||||
return new Fzf(presentableTasks, {
|
||||
|
|
@ -86,7 +83,5 @@ export const useTaskSearch = () => {
|
|||
setSortOption,
|
||||
lastNonRelevantSort,
|
||||
setLastNonRelevantSort,
|
||||
showAllWorkspaces,
|
||||
setShowAllWorkspaces,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue