fix: migrate task history to global state for cross-workspace access (#5315)

This commit is contained in:
hannesrudolph 2025-07-01 15:12:25 -06:00
parent a1ae29cdcd
commit 9fe17a4be3
12 changed files with 549 additions and 166 deletions

View file

@ -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(),

View file

@ -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"

View 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

View file

@ -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()

View file

@ -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")

View file

@ -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
}

View 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()
})
})

View file

@ -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)
}
}

View file

@ -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}

View file

@ -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>

View file

@ -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")
})

View file

@ -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,
}
}