diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts index c1838440c24..d1694f2effa 100644 --- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts +++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts @@ -281,7 +281,7 @@ describe("CustomToolRegistry", () => { const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) expect(result.loaded).toContain("cached") - }, 30000) + }, 120_000) }) describe.sequential("loadFromDirectories", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 0f75e1d6107..5f4cbf0cd3f 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -44,6 +44,14 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Allowed values for the task history retention setting. + * Stored as strings in most UI/extension flows. + */ +export const TASK_HISTORY_RETENTION_OPTIONS = ["never", "90", "60", "30", "7", "3"] as const + +export type TaskHistoryRetentionSetting = (typeof TASK_HISTORY_RETENTION_OPTIONS)[number] + /** * GlobalSettings */ @@ -181,6 +189,16 @@ export const globalSettingsSchema = z.object({ customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), includeTaskHistoryInEnhance: z.boolean().optional(), + // Auto-delete task history on extension reload. + taskHistoryRetention: z.enum(TASK_HISTORY_RETENTION_OPTIONS).optional(), + // Calculated task history count for the Settings > About page + // Note: Size calculation was removed for performance reasons - with large numbers of + // tasks (e.g., 9000+), recursively stat'ing every file caused significant delays. + taskHistorySize: z + .object({ + taskCount: z.number(), + }) + .optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), /** diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 49f9687f009..77f6c5e34b0 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -335,6 +335,7 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "taskHistoryRetention" > & { version: string clineMessages: ClineMessage[] @@ -395,6 +396,14 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record hasOpenedModeSelector: boolean + /** Task history count for the Settings > About page + * Note: Size calculation was removed for performance reasons - with large numbers of + * tasks (e.g., 9000+), recursively stat'ing every file caused significant delays. + */ + taskHistorySize?: { + /** Number of task directories */ + taskCount: number + } openRouterImageApiKey?: string messageQueue?: QueuedMessage[] lastShownAnnouncementId?: string @@ -592,6 +601,7 @@ export interface WebviewMessage { | "requestModes" | "switchMode" | "debugSetting" + | "refreshTaskHistorySize" // Worktree messages | "listWorktrees" | "createWorktree" diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 5b072672699..31e72e66483 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -114,6 +114,8 @@ vi.mock("../core/config/ContextProxy", () => ({ setValue: vi.fn(), getValues: vi.fn().mockReturnValue({}), getProviderSettings: vi.fn().mockReturnValue({}), + // Needed by retention purge on activation + globalStorageUri: { fsPath: "/tmp/roo-retention-test" }, }), }, })) @@ -157,6 +159,17 @@ vi.mock("../utils/autoImportSettings", () => ({ autoImportSettings: vi.fn().mockResolvedValue(undefined), })) +// Avoid filesystem access during activation by stubbing background purge +vi.mock("../utils/task-history-retention", () => ({ + startBackgroundRetentionPurge: vi.fn(), + startBackgroundCheckpointPurge: vi.fn(), +})) + +// Ensure storage base path resolves to provided path to avoid touching VS Code config +vi.mock("../utils/storage", () => ({ + getStorageBasePath: (p: string) => Promise.resolve(p), +})) + vi.mock("../extension/api", () => ({ API: vi.fn().mockImplementation(() => ({})), })) diff --git a/src/__tests__/task-history-retention.spec.ts b/src/__tests__/task-history-retention.spec.ts new file mode 100644 index 00000000000..ced897697d6 --- /dev/null +++ b/src/__tests__/task-history-retention.spec.ts @@ -0,0 +1,434 @@ +// npx vitest run __tests__/task-history-retention.spec.ts +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" + +import { describe, it, expect } from "vitest" + +// Ensure purge uses the provided base path without touching VS Code config +vi.mock("../utils/storage", () => ({ + getStorageBasePath: (p: string) => Promise.resolve(p), +})) + +import { purgeOldTasks, purgeOldCheckpoints } from "../utils/task-history-retention" +import { GlobalFileNames } from "../shared/globalFileNames" + +// Helpers +async function exists(p: string): Promise { + try { + await fs.access(p) + return true + } catch { + return false + } +} + +async function mkTempBase(): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "roo-retention-")) + // Ensure /tasks exists + await fs.mkdir(path.join(base, "tasks"), { recursive: true }) + return base +} + +async function createTask(base: string, id: string, ts?: number | "invalid"): Promise { + const dir = path.join(base, "tasks", id) + await fs.mkdir(dir, { recursive: true }) + const metadataPath = path.join(dir, GlobalFileNames.taskMetadata) + const metadata = ts === "invalid" ? "{ invalid json" : JSON.stringify({ ts: ts ?? Date.now() }, null, 2) + await fs.writeFile(metadataPath, metadata, "utf8") + return dir +} + +describe("utils/task-history-retention.ts purgeOldTasks()", () => { + it("purges tasks older than 7 days when retention is '7'", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTask(base, "task-8d", now - days(8)) + const recent = await createTask(base, "task-6d", now - days(6)) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}, false) + expect(purgedCount).toBe(1) + expect(await exists(old)).toBe(false) + expect(await exists(recent)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("purges tasks older than 3 days when retention is '3'", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTask(base, "task-4d", now - days(4)) + const recent = await createTask(base, "task-2d", now - days(2)) + + const { purgedCount } = await purgeOldTasks("3", base, () => {}, false) + expect(purgedCount).toBe(1) + expect(await exists(old)).toBe(false) + expect(await exists(recent)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does not delete anything in dry run mode but still reports purgedCount", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTask(base, "task-8d", now - days(8)) + const recent = await createTask(base, "task-6d", now - days(6)) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}, true) + expect(purgedCount).toBe(1) + // In dry run, nothing is deleted + expect(await exists(old)).toBe(true) + expect(await exists(recent)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does nothing when retention is 'never'", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const oldTs = now - 45 * 24 * 60 * 60 * 1000 // 45 days ago + const t1 = await createTask(base, "task-old", oldTs) + const t2 = await createTask(base, "task-new", now) + + const { purgedCount, cutoff } = await purgeOldTasks("never", base, () => {}) + + expect(purgedCount).toBe(0) + expect(cutoff).toBeNull() + expect(await exists(t1)).toBe(true) + expect(await exists(t2)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("purges tasks older than 30 days and keeps newer or invalid-metadata ones", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // One older than 30 days => delete + const old = await createTask(base, "task-31d", now - days(31)) + // One newer than 30 days => keep + const recent = await createTask(base, "task-29d", now - days(29)) + // Invalid metadata => skipped (kept) + const invalid = await createTask(base, "task-invalid", "invalid") + + const { purgedCount, cutoff } = await purgeOldTasks("30", base, () => {}) + + expect(typeof cutoff).toBe("number") + expect(purgedCount).toBe(1) + expect(await exists(old)).toBe(false) + expect(await exists(recent)).toBe(true) + expect(await exists(invalid)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("deletes orphan checkpoint-only directories regardless of age", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a normal task that is recent (should be kept) + const normalTask = await createTask(base, "task-normal", now - days(1)) + + // Create an orphan checkpoint-only directory (only has checkpoints/ subdirectory, no metadata) + const orphanDir = path.join(base, "tasks", "task-orphan-checkpoints") + await fs.mkdir(orphanDir, { recursive: true }) + const checkpointsDir = path.join(orphanDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + // Add a dummy file inside checkpoints to make it realistic + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + + // Create another orphan with just checkpoints (no other files) + const orphanDir2 = path.join(base, "tasks", "task-orphan-empty") + await fs.mkdir(orphanDir2, { recursive: true }) + const checkpointsDir2 = path.join(orphanDir2, "checkpoints") + await fs.mkdir(checkpointsDir2, { recursive: true }) + + // Run purge with 7 day retention - orphans should be deleted regardless of age + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Orphan directories should be deleted even though they're "recent" + expect(await exists(orphanDir)).toBe(false) + expect(await exists(orphanDir2)).toBe(false) + // Normal task should still exist (it's recent) + expect(await exists(normalTask)).toBe(true) + // Should have deleted 2 orphan directories + expect(purgedCount).toBe(2) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does not delete directories with checkpoints AND other content", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a task directory with both checkpoints and other files (but recent, so should be kept) + const taskDir = path.join(base, "tasks", "task-with-content") + await fs.mkdir(taskDir, { recursive: true }) + const checkpointsDir = path.join(taskDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + // Add other files (not just checkpoints) + await fs.writeFile(path.join(taskDir, "some-file.txt"), "content", "utf8") + // Note: No metadata file, so it's technically invalid but has content + + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Should NOT be deleted because it has content besides checkpoints + expect(await exists(taskDir)).toBe(true) + expect(purgedCount).toBe(0) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("falls back to directory mtime for legacy tasks without metadata", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a legacy task directory without any metadata file + const oldLegacyDir = path.join(base, "tasks", "task-legacy-old") + await fs.mkdir(oldLegacyDir, { recursive: true }) + // Add some content file + await fs.writeFile(path.join(oldLegacyDir, "content.txt"), "old task", "utf8") + // Manually set mtime to 10 days ago by touching the directory + const oldTime = new Date(now - days(10)) + await fs.utimes(oldLegacyDir, oldTime, oldTime) + + // Create another legacy task that is recent + const recentLegacyDir = path.join(base, "tasks", "task-legacy-recent") + await fs.mkdir(recentLegacyDir, { recursive: true }) + await fs.writeFile(path.join(recentLegacyDir, "content.txt"), "recent task", "utf8") + // This one has recent mtime (now) + + // Run purge with 7 day retention + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Old legacy task should be deleted based on mtime + expect(await exists(oldLegacyDir)).toBe(false) + // Recent legacy task should be kept + expect(await exists(recentLegacyDir)).toBe(true) + expect(purgedCount).toBe(1) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("prioritizes metadata timestamp over mtime when both exist", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create task with old metadata ts but recent mtime + const taskDir = path.join(base, "tasks", "task-priority-test") + await fs.mkdir(taskDir, { recursive: true }) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + // Metadata says it's 10 days old (should be deleted with 7 day retention) + const metadata = JSON.stringify({ ts: now - days(10) }, null, 2) + await fs.writeFile(metadataPath, metadata, "utf8") + // But directory mtime is recent (could happen after editing) + // (Directory mtime is automatically recent from mkdir/writeFile) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Should be deleted based on metadata ts, not mtime + expect(await exists(taskDir)).toBe(false) + expect(purgedCount).toBe(1) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) +}) + +// Helper to create task with checkpoints +async function createTaskWithCheckpoints( + base: string, + id: string, + ts: number, +): Promise<{ taskDir: string; checkpointsDir: string }> { + const taskDir = path.join(base, "tasks", id) + await fs.mkdir(taskDir, { recursive: true }) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + const metadata = JSON.stringify({ ts }, null, 2) + await fs.writeFile(metadataPath, metadata, "utf8") + const checkpointsDir = path.join(taskDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + // Add some checkpoint content + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + return { taskDir, checkpointsDir } +} + +describe("utils/task-history-retention.ts purgeOldCheckpoints()", () => { + it("culls checkpoints from tasks older than 30 days", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Old task (31 days) - checkpoints should be culled + const old = await createTaskWithCheckpoints(base, "task-old", now - days(31)) + // Recent task (29 days) - checkpoints should be kept + const recent = await createTaskWithCheckpoints(base, "task-recent", now - days(29)) + + const { culledCount } = await purgeOldCheckpoints(base, () => {}, false) + + expect(culledCount).toBe(1) + // Old task checkpoints should be removed, but task dir should remain + expect(await exists(old.taskDir)).toBe(true) + expect(await exists(old.checkpointsDir)).toBe(false) + // Recent task should be completely intact + expect(await exists(recent.taskDir)).toBe(true) + expect(await exists(recent.checkpointsDir)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does not delete checkpoints in dry run mode but reports count", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTaskWithCheckpoints(base, "task-old", now - days(31)) + + const { culledCount } = await purgeOldCheckpoints(base, () => {}, true) + + expect(culledCount).toBe(1) + // In dry run, checkpoints should still exist + expect(await exists(old.checkpointsDir)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("skips tasks without checkpoints directory", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create an old task WITHOUT checkpoints + const taskDir = await createTask(base, "task-no-checkpoints", now - days(31)) + + const { culledCount } = await purgeOldCheckpoints(base, () => {}, false) + + expect(culledCount).toBe(0) + // Task should be completely intact + expect(await exists(taskDir)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("uses mtime fallback when no metadata timestamp", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a task without metadata but with checkpoints + const taskDir = path.join(base, "tasks", "task-no-metadata") + await fs.mkdir(taskDir, { recursive: true }) + const checkpointsDir = path.join(taskDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + await fs.writeFile(path.join(checkpointsDir, "checkpoint.json"), "{}", "utf8") + // Set old mtime + const oldTime = new Date(now - days(31)) + await fs.utimes(taskDir, oldTime, oldTime) + + const { culledCount } = await purgeOldCheckpoints(base, () => {}, false) + + expect(culledCount).toBe(1) + // Task dir should remain, checkpoints should be gone + expect(await exists(taskDir)).toBe(true) + expect(await exists(checkpointsDir)).toBe(false) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("always uses 30-day hardcoded cutoff", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Tasks at various ages around the 30-day boundary + const day29 = await createTaskWithCheckpoints(base, "task-29d", now - days(29)) + const day30 = await createTaskWithCheckpoints(base, "task-30d", now - days(30)) + const day31 = await createTaskWithCheckpoints(base, "task-31d", now - days(31)) + + const { culledCount, cutoff } = await purgeOldCheckpoints(base, () => {}, false) + + // Check cutoff is approximately 30 days ago + const expectedCutoff = now - days(30) + expect(cutoff).toBeGreaterThan(expectedCutoff - 1000) // Allow 1 second margin + expect(cutoff).toBeLessThan(expectedCutoff + 1000) + + // 29 day task should keep checkpoints (younger than 30 days) + expect(await exists(day29.checkpointsDir)).toBe(true) + // 30 and 31 day tasks should lose checkpoints (>= 30 days) + expect(await exists(day30.checkpointsDir)).toBe(false) + expect(await exists(day31.checkpointsDir)).toBe(false) + expect(culledCount).toBe(2) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("preserves task metadata and other files when culling checkpoints", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create task with checkpoints and other content + const taskDir = path.join(base, "tasks", "task-with-content") + await fs.mkdir(taskDir, { recursive: true }) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + await fs.writeFile(metadataPath, JSON.stringify({ ts: now - days(31) }), "utf8") + const checkpointsDir = path.join(taskDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + await fs.writeFile(path.join(checkpointsDir, "checkpoint.json"), "{}", "utf8") + // Add conversation history + await fs.writeFile(path.join(taskDir, "conversation.json"), "[]", "utf8") + + const { culledCount } = await purgeOldCheckpoints(base, () => {}, false) + + expect(culledCount).toBe(1) + // Task dir and metadata should remain + expect(await exists(taskDir)).toBe(true) + expect(await exists(metadataPath)).toBe(true) + expect(await exists(path.join(taskDir, "conversation.json"))).toBe(true) + // Only checkpoints should be removed + expect(await exists(checkpointsDir)).toBe(false) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) +}) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f02ee8309a3..18d7c512c03 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -105,7 +105,7 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt return openClineInNewTab({ context, outputChannel }) }, openInNewTab: () => openClineInNewTab({ context, outputChannel }), - settingsButtonClicked: () => { + settingsButtonClicked: (section?: string) => { const visibleProvider = getVisibleProviderOrLog(outputChannel) if (!visibleProvider) { @@ -114,7 +114,11 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt TelemetryService.instance.captureTitleButtonClicked("settings") - visibleProvider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) + visibleProvider.postMessageToWebview({ + type: "action", + action: "settingsButtonClicked", + values: section ? { section } : undefined, + }) // Also explicitly post the visibility message to trigger scroll reliably visibleProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) }, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fd70c41af09..1567d4864a3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1802,8 +1802,8 @@ export class ClineProvider try { await ShadowCheckpointService.deleteTask({ taskId, globalStorageDir, workspaceDir }) } catch (error) { - console.error( - `[deleteTaskWithId${taskId}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, + this.log( + `[deleteTaskWithId] failed to delete shadow repository for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, ) } @@ -1811,10 +1811,9 @@ export class ClineProvider try { const dirPath = await getTaskDirectoryPath(globalStoragePath, taskId) await fs.rm(dirPath, { recursive: true, force: true }) - console.log(`[deleteTaskWithId${taskId}] removed task directory`) } catch (error) { - console.error( - `[deleteTaskWithId${taskId}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, + this.log( + `[deleteTaskWithId] failed to remove task directory for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -2053,6 +2052,8 @@ export class ClineProvider reasoningBlockCollapsed, enterBehavior, cloudUserInfo, + taskHistoryRetention, + taskHistorySize, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, @@ -2236,6 +2237,10 @@ export class ClineProvider includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, + // Task history retention setting for About tab dropdown + taskHistoryRetention: taskHistoryRetention ?? "never", + // Task history storage size info for the Settings > About page + taskHistorySize, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -2451,6 +2456,10 @@ export class ClineProvider organizationAllowList, organizationSettingsVersion, customCondensingPrompt: stateValues.customCondensingPrompt, + // Task history retention selection + taskHistoryRetention: stateValues.taskHistoryRetention ?? "never", + // Task history storage size info + taskHistorySize: stateValues.taskHistorySize, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2dc77d05027..14a469f5a38 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -532,6 +532,9 @@ export const webviewMessageHandler = async ( TelemetryService.instance.updateTelemetryState(isOptedIn) }) + // Note: Task history storage size calculation is triggered on-demand when the About + // settings tab is opened (via "refreshTaskHistorySize" message), not on webview launch. + provider.isViewLaunched = true break case "newTask": @@ -593,6 +596,9 @@ export const webviewMessageHandler = async ( await vscode.workspace .getConfiguration(Package.name) .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) + } else if (key === "taskHistoryRetention") { + // taskHistoryRetention is stored in Roo application state (global state), not VS Code settings. + newValue = ((value ?? "never") as string).toString() } else if (key === "ttsEnabled") { newValue = value ?? true setTtsEnabled(newValue as boolean) @@ -3337,6 +3343,24 @@ export const webviewMessageHandler = async ( break } + case "refreshTaskHistorySize": { + // Refresh the task history storage size calculation + try { + const { calculateTaskStorageSize } = await import("../../utils/task-storage-size") + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const sizeInfo = await calculateTaskStorageSize(globalStoragePath) + + // Update state and notify webview + await provider.contextProxy.setValue("taskHistorySize", sizeInfo) + await provider.postStateToWebview() + } catch (error) { + provider.log( + `Error refreshing task history size: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + /** * Git Worktree Management */ diff --git a/src/extension.ts b/src/extension.ts index bcfbe339932..0d1af502836 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -44,6 +44,8 @@ import { } from "./activate" import { initializeI18n } from "./i18n" import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache" +import { startBackgroundRetentionPurge, startBackgroundCheckpointPurge } from "./utils/task-history-retention" +import { TASK_HISTORY_RETENTION_OPTIONS, type TaskHistoryRetentionSetting } from "@roo-code/types" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -163,6 +165,8 @@ export async function activate(context: vscode.ExtensionContext) { const contextProxy = await ContextProxy.getInstance(context) + // Initialize the provider *before* the Roo Code Cloud service so we can reuse its task deletion logic. + const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) // Initialize code index managers for all workspace folders. const codeIndexManagers: CodeIndexManager[] = [] @@ -186,9 +190,6 @@ export async function activate(context: vscode.ExtensionContext) { } } - // Initialize the provider *before* the Roo Code Cloud service. - const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) - // Initialize Roo Code Cloud service. const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebview() @@ -388,6 +389,34 @@ export async function activate(context: vscode.ExtensionContext) { // Allows other extensions to activate once Roo is ready. vscode.commands.executeCommand(`${Package.name}.activationCompleted`) + // Task history retention purge (runs in background after activation) + // By this point, provider is fully initialized and ready to handle deletions + { + const retentionValue = contextProxy.getValue("taskHistoryRetention") + const retention: TaskHistoryRetentionSetting = TASK_HISTORY_RETENTION_OPTIONS.includes( + retentionValue as TaskHistoryRetentionSetting, + ) + ? (retentionValue as TaskHistoryRetentionSetting) + : "never" + startBackgroundRetentionPurge({ + globalStoragePath: contextProxy.globalStorageUri.fsPath, + log: (m) => outputChannel.appendLine(m), + deleteTaskById: async (taskId: string) => { + // Reuse the same internal deletion logic as the History view so that + // checkpoints, shadow repositories, and task state are cleaned up consistently. + await provider.deleteTaskWithId(taskId) + }, + retention, + }) + } + + // Checkpoint culling (runs in background after activation) + // Automatically removes checkpoints from tasks not touched in 30 days (non-configurable) + startBackgroundCheckpointPurge({ + globalStoragePath: contextProxy.globalStorageUri.fsPath, + log: (m) => outputChannel.appendLine(m), + }) + // Implements the `RooCodeAPI` interface. const socketPath = process.env.ROO_CODE_IPC_SOCKET_PATH const enableLogging = typeof socketPath === "string" diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 9f8f961e73e..72ed59ff9f5 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code ha eliminat {{count}} tasca més antiga de {{days}} dies", + "purgeNotification_plural": "Roo Code ha eliminat {{count}} tasques més antigues de {{days}} dies", + "actions": { + "viewSettings": "Veure configuració", + "dismiss": "Descartar" + } } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 086372dda85..e814b30dc14 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -258,5 +258,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code hat {{count}} Aufgabe gelöscht, die älter als {{days}} Tage war", + "purgeNotification_plural": "Roo Code hat {{count}} Aufgaben gelöscht, die älter als {{days}} Tage waren", + "actions": { + "viewSettings": "Einstellungen anzeigen", + "dismiss": "Verwerfen" + } } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 636d26f76cb..89043321d66 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -252,5 +252,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code deleted {{count}} task older than {{days}} days", + "purgeNotification_plural": "Roo Code deleted {{count}} tasks older than {{days}} days", + "actions": { + "viewSettings": "View Settings", + "dismiss": "Dismiss" + } } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index bc22040c6a8..dda28291787 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -258,5 +258,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code eliminó {{count}} tarea más antigua de {{days}} días", + "purgeNotification_plural": "Roo Code eliminó {{count}} tareas más antiguas de {{days}} días", + "actions": { + "viewSettings": "Ver configuración", + "dismiss": "Descartar" + } } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index f7a76a53c12..e95b6313fc4 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code a supprimé {{count}} tâche datant de plus de {{days}} jours", + "purgeNotification_plural": "Roo Code a supprimé {{count}} tâches datant de plus de {{days}} jours", + "actions": { + "viewSettings": "Voir les paramètres", + "dismiss": "Ignorer" + } } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index e51d177d946..85811b8bf4c 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code ने {{days}} दिनों से अधिक पुराना {{count}} कार्य हटा दिया", + "purgeNotification_plural": "Roo Code ने {{days}} दिनों से अधिक पुराने {{count}} कार्य हटा दिए", + "actions": { + "viewSettings": "सेटिंग्स देखें", + "dismiss": "खारिज करें" + } } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index cfb165979d3..6fc394b952a 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code menghapus {{count}} tugas yang lebih lama dari {{days}} hari", + "purgeNotification_plural": "Roo Code menghapus {{count}} tugas yang lebih lama dari {{days}} hari", + "actions": { + "viewSettings": "Lihat Pengaturan", + "dismiss": "Tutup" + } } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index e5fa6d68db3..6c04e108f7d 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code ha eliminato {{count}} attività più vecchia di {{days}} giorni", + "purgeNotification_plural": "Roo Code ha eliminato {{count}} attività più vecchie di {{days}} giorni", + "actions": { + "viewSettings": "Visualizza Impostazioni", + "dismiss": "Ignora" + } } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7ebe0de597d..c572deb6a2e 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Codeは{{days}}日より古い{{count}}件のタスクを削除しました", + "purgeNotification_plural": "Roo Codeは{{days}}日より古い{{count}}件のタスクを削除しました", + "actions": { + "viewSettings": "設定を表示", + "dismiss": "閉じる" + } } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0c1ed5ba518..52b1194b475 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code가 {{days}}일보다 오래된 작업 {{count}}개를 삭제했습니다", + "purgeNotification_plural": "Roo Code가 {{days}}일보다 오래된 작업 {{count}}개를 삭제했습니다", + "actions": { + "viewSettings": "설정 보기", + "dismiss": "닫기" + } } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0bbf5695364..77ea9e718f2 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code heeft {{count}} taak verwijderd die ouder is dan {{days}} dagen", + "purgeNotification_plural": "Roo Code heeft {{count}} taken verwijderd die ouder zijn dan {{days}} dagen", + "actions": { + "viewSettings": "Instellingen Bekijken", + "dismiss": "Sluiten" + } } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 23bc09e4d78..3144aaff4a2 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code usunął {{count}} zadanie starsze niż {{days}} dni", + "purgeNotification_plural": "Roo Code usunął {{count}} zadań starszych niż {{days}} dni", + "actions": { + "viewSettings": "Zobacz Ustawienia", + "dismiss": "Zamknij" + } } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 737b322f78a..95f9b89b42e 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code excluiu {{count}} tarefa mais antiga que {{days}} dias", + "purgeNotification_plural": "Roo Code excluiu {{count}} tarefas mais antigas que {{days}} dias", + "actions": { + "viewSettings": "Ver Configurações", + "dismiss": "Dispensar" + } } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 7ac53199ba8..a7827a4506f 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code удалил {{count}} задачу старше {{days}} дней", + "purgeNotification_plural": "Roo Code удалил {{count}} задач старше {{days}} дней", + "actions": { + "viewSettings": "Посмотреть Настройки", + "dismiss": "Закрыть" + } } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index fca268c0ff6..7e00f6cf077 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code, {{days}} günden eski {{count}} görevi sildi", + "purgeNotification_plural": "Roo Code, {{days}} günden eski {{count}} görevi sildi", + "actions": { + "viewSettings": "Ayarları Görüntüle", + "dismiss": "Kapat" + } } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index bd9bb72b474..2e7b44fb474 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -270,5 +270,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code đã xóa {{count}} tác vụ cũ hơn {{days}} ngày", + "purgeNotification_plural": "Roo Code đã xóa {{count}} tác vụ cũ hơn {{days}} ngày", + "actions": { + "viewSettings": "Xem Cài Đặt", + "dismiss": "Đóng" + } } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 494c246d658..4fb885cd07a 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -268,5 +268,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code 已删除 {{count}} 个超过 {{days}} 天的任务", + "purgeNotification_plural": "Roo Code 已删除 {{count}} 个超过 {{days}} 天的任务", + "actions": { + "viewSettings": "查看设置", + "dismiss": "关闭" + } } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 572cdb46519..90c9ef05a37 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code 已刪除 {{count}} 個超過 {{days}} 天的工作", + "purgeNotification_plural": "Roo Code 已刪除 {{count}} 個超過 {{days}} 天的工作", + "actions": { + "viewSettings": "檢視設定", + "dismiss": "關閉" + } } } diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4b..417ba9d8870 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -449,22 +449,21 @@ export abstract class ShadowCheckpointService extends EventEmitter { workspaceDir: string }) { const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir }) - const branchName = `roo-${taskId}` - const git = createSanitizedGit(workspaceRepoDir) - const success = await this.deleteBranch(git, branchName) - if (success) { - console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`) - } else { - console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`) + // Check if the workspace repo directory exists before attempting git operations + if (!(await fileExistsAtPath(workspaceRepoDir))) { + return } + + const branchName = `roo-${taskId}` + const git = createSanitizedGit(workspaceRepoDir) + await this.deleteBranch(git, branchName) } public static async deleteBranch(git: SimpleGit, branchName: string) { const branches = await git.branchLocal() if (!branches.all.includes(branchName)) { - console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`) return false } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index ee8f7bbdc9c..7c531b6d3b6 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -12,7 +12,9 @@ import * as fileSearch from "../../../services/search/file-search" import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService" -const tmpDir = path.join(os.tmpdir(), "CheckpointService") +// Use a unique tmp directory per test run to avoid collisions with other +// Vitest workers/processes and to keep cleanup fast/reliable on Windows. +const tmpDir = path.join(os.tmpdir(), `CheckpointService-${process.pid}-${Date.now()}`) const initWorkspaceRepo = async ({ workspaceDir, @@ -55,10 +57,12 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( let workspaceGit: SimpleGit let testFile: string let service: RepoPerTaskCheckpointService + let shadowDir: string + let workspaceDir: string beforeEach(async () => { - const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`) - const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`) + shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`) + workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`) const repo = await initWorkspaceRepo({ workspaceDir }) workspaceGit = repo.git @@ -70,10 +74,21 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( afterEach(async () => { vitest.restoreAllMocks() + + // Clean up per-test directories to prevent a huge accumulated tmp tree. + // This makes Windows CI much less likely to hit slow/blocked recursive deletes. + await Promise.all([ + fs + .rm(shadowDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + .catch(() => undefined), + fs + .rm(workspaceDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + .catch(() => undefined), + ]) }) afterAll(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }) + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }, 60_000) // 60 second timeout for Windows cleanup describe(`${klass.name}#getDiff`, () => { @@ -913,5 +928,30 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( } }) }) + + describe(`${klass.name}#deleteTask`, () => { + it("handles non-existent workspace repo directory gracefully", async () => { + const nonExistentWorkspaceDir = path.join(tmpDir, `non-existent-workspace-${Date.now()}`) + const nonExistentGlobalStorageDir = path.join(tmpDir, `non-existent-storage-${Date.now()}`) + const taskIdToDelete = "non-existent-task" + + // Verify the workspace repo directory doesn't exist + const workspaceRepoDir = path.join( + nonExistentGlobalStorageDir, + "checkpoints", + klass.hashWorkspaceDir(nonExistentWorkspaceDir), + ) + expect(await fileExistsAtPath(workspaceRepoDir)).toBe(false) + + // Should not throw when the directory doesn't exist + await expect( + klass.deleteTask({ + taskId: taskIdToDelete, + globalStorageDir: nonExistentGlobalStorageDir, + workspaceDir: nonExistentWorkspaceDir, + }), + ).resolves.not.toThrow() + }) + }) }, ) diff --git a/src/utils/__tests__/task-storage-size.spec.ts b/src/utils/__tests__/task-storage-size.spec.ts new file mode 100644 index 00000000000..4ec7753be08 --- /dev/null +++ b/src/utils/__tests__/task-storage-size.spec.ts @@ -0,0 +1,125 @@ +import { calculateTaskStorageSize } from "../task-storage-size" + +// Mock storage to avoid VS Code config access during tests +vi.mock("../storage", () => ({ + getStorageBasePath: (p: string) => Promise.resolve(p), +})) + +// Mock fs/promises +const mockReaddir = vi.fn() + +vi.mock("fs/promises", () => ({ + readdir: (...args: unknown[]) => mockReaddir(...args), +})) + +describe("calculateTaskStorageSize", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return zero count when tasks directory does not exist", async () => { + mockReaddir.mockRejectedValue(new Error("ENOENT: no such file or directory")) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result).toEqual({ + taskCount: 0, + }) + }) + + it("should return zero count for empty tasks directory", async () => { + mockReaddir.mockResolvedValue([]) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result).toEqual({ + taskCount: 0, + }) + }) + + it("should count task directories correctly", async () => { + // Mock the tasks directory read + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + // Return task directories + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-2", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-3", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + return Promise.resolve([]) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result.taskCount).toBe(3) + }) + + it("should only count directories, not files in tasks folder", async () => { + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-2", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { + name: "some-file.txt", + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, // Should not count as task + { + name: "another-file.json", + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, // Should not count as task + ]) + } + return Promise.resolve([]) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + // Only directories count as tasks + expect(result.taskCount).toBe(2) + }) + + it("should handle large task counts efficiently (does not recurse into subdirectories)", async () => { + // Simulate 9000 task directories - this should be fast since we don't recurse + const manyTasks = Array.from({ length: 9000 }, (_, i) => ({ + name: `task-${i}`, + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + })) + + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + return Promise.resolve(manyTasks) + } + return Promise.resolve([]) + }) + + const startTime = Date.now() + const result = await calculateTaskStorageSize("/global/storage") + const elapsed = Date.now() - startTime + + expect(result.taskCount).toBe(9000) + // Should complete quickly since we're not recursing into directories + expect(elapsed).toBeLessThan(100) // Should be nearly instant + }) + + it("should handle readdir errors gracefully", async () => { + mockReaddir.mockRejectedValue(new Error("Permission denied")) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result).toEqual({ + taskCount: 0, + }) + }) +}) diff --git a/src/utils/task-history-retention.ts b/src/utils/task-history-retention.ts new file mode 100644 index 00000000000..824c2d28f06 --- /dev/null +++ b/src/utils/task-history-retention.ts @@ -0,0 +1,610 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import type { Dirent } from "fs" +import pLimit from "p-limit" + +import { TASK_HISTORY_RETENTION_OPTIONS, type TaskHistoryRetentionSetting } from "@roo-code/types" + +import { getStorageBasePath } from "./storage" +import { GlobalFileNames } from "../shared/globalFileNames" +import { t } from "../i18n" + +export type RetentionSetting = TaskHistoryRetentionSetting + +export type PurgeResult = { + purgedCount: number + cutoff: number | null +} + +export type CheckpointPurgeResult = { + culledCount: number + cutoff: number +} + +/** Concurrency limit for parallel metadata reads */ +const METADATA_READ_CONCURRENCY = 50 + +/** Concurrency limit for parallel task deletions */ +const DELETION_CONCURRENCY = 10 + +/** Hardcoded checkpoint retention: 30 days */ +const CHECKPOINT_RETENTION_DAYS = 30 + +/** + * Task metadata read result for batch processing + */ +interface TaskMetadata { + taskId: string + taskDir: string + ts: number | null + isOrphan: boolean + mtime: number | null +} + +/** + * Read metadata for a single task directory. + * Returns null if the task directory should be skipped. + */ +async function readTaskMetadata(taskId: string, tasksDir: string): Promise { + const taskDir = path.join(tasksDir, taskId) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + + let ts: number | null = null + let isOrphan = false + let mtime: number | null = null + + // Try to read metadata file + try { + const raw = await fs.readFile(metadataPath, "utf8") + const meta: unknown = JSON.parse(raw) + const maybeTs = Number( + typeof meta === "object" && meta !== null && "ts" in meta ? (meta as { ts: unknown }).ts : undefined, + ) + if (Number.isFinite(maybeTs)) { + ts = maybeTs + } + } catch { + // Missing or invalid metadata + } + + // Check for orphan directories (checkpoint-only) - only if no valid timestamp + if (ts === null) { + try { + const childEntries = await fs.readdir(taskDir, { withFileTypes: true }) + const visibleNames = childEntries.map((e) => e.name).filter((n) => !n.startsWith(".")) + const hasCheckpointsDir = childEntries.some((e) => e.isDirectory() && e.name === "checkpoints") + const nonCheckpointVisible = visibleNames.filter((n) => n !== "checkpoints") + const hasMetadataFile = visibleNames.includes(GlobalFileNames.taskMetadata) + if (hasCheckpointsDir && nonCheckpointVisible.length === 0 && !hasMetadataFile) { + isOrphan = true + } + } catch { + // Ignore errors + } + + // Get mtime as fallback for tasks without valid ts + if (!isOrphan) { + try { + const stat = await fs.stat(taskDir) + mtime = stat.mtime.getTime() + } catch { + // Can't stat - skip this task + return null + } + } + } + + return { taskId, taskDir, ts, isOrphan, mtime } +} + +/** + * Check if path exists + */ +async function pathExists(p: string): Promise { + try { + await fs.access(p) + return true + } catch { + return false + } +} + +/** + * Simplified directory removal - one attempt with fallback + * Removed aggressive retries and sleeps for performance + */ +async function removeDir(dir: string): Promise { + // First attempt: standard recursive remove + try { + await fs.rm(dir, { recursive: true, force: true }) + } catch { + // ignore + } + + if (!(await pathExists(dir))) return true + + // Fallback: try removing checkpoints first (common stubborn directory) + try { + await fs.rm(path.join(dir, "checkpoints"), { recursive: true, force: true }) + await fs.rm(dir, { recursive: true, force: true }) + } catch { + // ignore + } + + return !(await pathExists(dir)) +} + +/** + * Purge old task directories under /tasks based on task_metadata.json ts value. + * Optimized for performance with parallel metadata reads and parallel deletions. + * Executes best-effort deletes; errors are logged and skipped. + * + * @param retention Retention setting: "never" | "90" | "60" | "30" | "7" | "3" or number of days + * @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath) + * @param log Optional logger + * @param dryRun When true, logs which tasks would be deleted but does not delete anything + * @returns PurgeResult with count and cutoff used + */ +export async function purgeOldTasks( + retention: RetentionSetting, + globalStoragePath: string, + log?: (message: string) => void, + dryRun: boolean = false, + deleteTaskById?: (taskId: string, taskDirPath: string) => Promise, + verbose: boolean = false, +): Promise { + const days = normalizeDays(retention) + if (!days) { + log?.("[Retention] No purge (setting is 'never' or not a positive number)") + return { purgedCount: 0, cutoff: null } + } + + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000 + const logv = (msg: string) => { + if (verbose) log?.(msg) + } + logv( + `[Retention] Starting optimized purge with retention=${retention} (${days} day(s))${dryRun ? " (dry run)" : ""}`, + ) + + let basePath: string + + try { + basePath = await getStorageBasePath(globalStoragePath) + } catch (e) { + log?.( + `[Retention] Failed to resolve storage base path: ${ + e instanceof Error ? e.message : String(e) + }${dryRun ? " (dry run)" : ""}`, + ) + return { purgedCount: 0, cutoff } + } + + const tasksDir = path.join(basePath, "tasks") + + let entries: Dirent[] + try { + entries = await fs.readdir(tasksDir, { withFileTypes: true }) + } catch (e) { + // No tasks directory yet or unreadable; nothing to purge. + logv(`[Retention] Tasks directory not found or unreadable at ${tasksDir}${dryRun ? " (dry run)" : ""}`) + return { purgedCount: 0, cutoff } + } + + const taskDirs = entries.filter((d) => d.isDirectory()) + const totalTasks = taskDirs.length + + logv(`[Retention] Found ${totalTasks} task director${totalTasks === 1 ? "y" : "ies"} under ${tasksDir}`) + + if (totalTasks === 0) { + return { purgedCount: 0, cutoff } + } + + // Phase 1: Batch read all metadata in parallel + logv(`[Retention] Phase 1: Reading metadata for ${totalTasks} tasks (concurrency: ${METADATA_READ_CONCURRENCY})`) + const metadataLimit = pLimit(METADATA_READ_CONCURRENCY) + + const metadataResults = await Promise.all( + taskDirs.map((d) => metadataLimit(() => readTaskMetadata(d.name, tasksDir))), + ) + + // Phase 2: Filter tasks that need deletion + const tasksToDelete: Array<{ metadata: TaskMetadata; reason: string }> = [] + + for (const metadata of metadataResults) { + if (!metadata) continue + + let shouldDelete = false + let reason = "" + + // Check orphan directories (delete regardless of age) + if (metadata.isOrphan) { + shouldDelete = true + reason = "orphan checkpoints_only" + } + // Check by timestamp + else if (metadata.ts !== null && metadata.ts < cutoff) { + shouldDelete = true + reason = `ts=${metadata.ts}` + } + // Check by mtime fallback + else if (metadata.ts === null && metadata.mtime !== null && metadata.mtime < cutoff) { + shouldDelete = true + reason = `no valid ts, mtime=${new Date(metadata.mtime).toISOString()}` + } + + if (shouldDelete) { + tasksToDelete.push({ metadata, reason }) + } + } + + logv(`[Retention] Phase 2: ${tasksToDelete.length} of ${totalTasks} tasks marked for deletion`) + + if (tasksToDelete.length === 0) { + log?.(`[Retention] No tasks met purge criteria${dryRun ? " (dry run)" : ""}`) + return { purgedCount: 0, cutoff } + } + + // Phase 3: Delete tasks in parallel + if (dryRun) { + for (const { metadata, reason } of tasksToDelete) { + logv(`[Retention][DRY RUN] Would delete task ${metadata.taskId} (${reason}) @ ${metadata.taskDir}`) + } + log?.( + `[Retention] Would purge ${tasksToDelete.length} task(s) (dry run); cutoff=${new Date(cutoff).toISOString()}`, + ) + return { purgedCount: tasksToDelete.length, cutoff } + } + + logv(`[Retention] Phase 3: Deleting ${tasksToDelete.length} tasks (concurrency: ${DELETION_CONCURRENCY})`) + const deleteLimit = pLimit(DELETION_CONCURRENCY) + + const deleteResults = await Promise.all( + tasksToDelete.map(({ metadata, reason }) => + deleteLimit(async (): Promise => { + let deleted = false + + try { + if (deleteTaskById) { + logv( + `[Retention] Deleting task ${metadata.taskId} via provider @ ${metadata.taskDir} (${reason})`, + ) + await deleteTaskById(metadata.taskId, metadata.taskDir) + deleted = !(await pathExists(metadata.taskDir)) + } else { + logv(`[Retention] Deleting task ${metadata.taskId} via fs.rm @ ${metadata.taskDir} (${reason})`) + await fs.rm(metadata.taskDir, { recursive: true, force: true }) + deleted = !(await pathExists(metadata.taskDir)) + } + } catch (e) { + // Primary deletion failed, try fallback + logv( + `[Retention] Primary deletion failed for ${metadata.taskId}: ${ + e instanceof Error ? e.message : String(e) + }`, + ) + } + + // Fallback: simplified removal + if (!deleted) { + deleted = await removeDir(metadata.taskDir) + } + + if (!deleted) { + log?.( + `[Retention] Failed to delete task ${metadata.taskId} @ ${metadata.taskDir}: directory still present`, + ) + } else { + logv(`[Retention] Deleted task ${metadata.taskId} (${reason}) @ ${metadata.taskDir}`) + } + + return deleted + }), + ), + ) + + const purged = deleteResults.filter(Boolean).length + + if (purged > 0) { + log?.( + `[Retention] Purged ${purged} task(s)${dryRun ? " (dry run)" : ""}; cutoff=${new Date(cutoff).toISOString()}`, + ) + } else { + log?.(`[Retention] No tasks met purge criteria${dryRun ? " (dry run)" : ""}`) + } + + return { purgedCount: purged, cutoff } +} + +/** + * Normalize retention into a positive integer day count or 0 (no-op). + */ +function normalizeDays(value: RetentionSetting): number { + if (value === "never") return 0 + const n = parseInt(value, 10) + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0 +} + +/** + * Options for starting the background retention purge. + */ +export interface BackgroundPurgeOptions { + /** VS Code global storage fsPath */ + globalStoragePath: string + /** Logger function */ + log: (message: string) => void + /** Function to delete a task by ID (should use ClineProvider.deleteTaskWithId for full cleanup) */ + deleteTaskById: (taskId: string, taskDirPath: string) => Promise + /** Retention setting value from Roo application state */ + retention: RetentionSetting +} + +/** + * Starts the task history retention purge in the background. + * This function is designed to be called after extension activation completes, + * using a fire-and-forget pattern (void) to avoid blocking activation. + * + * It reads the retention setting from Roo application state, executes the purge, + * and shows a notification if tasks were deleted. + * + * @param options Configuration options for the background purge + */ +export function startBackgroundRetentionPurge(options: BackgroundPurgeOptions): void { + const { globalStoragePath, log, deleteTaskById, retention } = options + + void (async () => { + try { + // Skip if retention is disabled + if (retention === "never") { + log("[Retention] Background purge skipped: retention is set to 'never'") + return + } + + if (!TASK_HISTORY_RETENTION_OPTIONS.includes(retention)) { + log(`[Retention] Background purge skipped: invalid retention value '${retention}'`) + return + } + + log(`[Retention] Starting background purge: setting=${retention}`) + + const result = await purgeOldTasks(retention, globalStoragePath, log, false, deleteTaskById) + + log( + `[Retention] Background purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`, + ) + + // Show user notification if tasks were deleted + if (result.purgedCount > 0) { + const message = t("common:taskHistoryRetention.purgeNotification", { + count: result.purgedCount, + days: retention, + }) + + vscode.window.showInformationMessage(message) + } + } catch (error) { + log(`[Retention] Failed during background purge: ${error instanceof Error ? error.message : String(error)}`) + } + })() +} + +/** + * Metadata result for checkpoint culling - simplified from TaskMetadata + */ +interface CheckpointTaskMetadata { + taskId: string + taskDir: string + checkpointsDir: string + lastActivity: number | null // ts or mtime +} + +/** + * Read metadata for checkpoint culling - only needs task age, not orphan detection + */ +async function readCheckpointTaskMetadata(taskId: string, tasksDir: string): Promise { + const taskDir = path.join(tasksDir, taskId) + const checkpointsDir = path.join(taskDir, "checkpoints") + + // First check if checkpoints directory exists + if (!(await pathExists(checkpointsDir))) { + return null // No checkpoints to cull + } + + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + let lastActivity: number | null = null + + // Try to read timestamp from metadata file + try { + const raw = await fs.readFile(metadataPath, "utf8") + const meta: unknown = JSON.parse(raw) + const maybeTs = Number( + typeof meta === "object" && meta !== null && "ts" in meta ? (meta as { ts: unknown }).ts : undefined, + ) + if (Number.isFinite(maybeTs)) { + lastActivity = maybeTs + } + } catch { + // Missing or invalid metadata + } + + // Fallback to mtime if no valid ts + if (lastActivity === null) { + try { + const stat = await fs.stat(taskDir) + lastActivity = stat.mtime.getTime() + } catch { + // Can't determine age - skip this task + return null + } + } + + return { taskId, taskDir, checkpointsDir, lastActivity } +} + +/** + * Cull checkpoints from tasks that haven't been touched in CHECKPOINT_RETENTION_DAYS. + * This is a non-configurable, always-on feature that removes only the checkpoints/ + * subdirectory while preserving the task itself (conversation history, metadata). + * + * @param globalStoragePath VS Code global storage fsPath + * @param log Optional logger + * @param dryRun When true, logs which checkpoints would be deleted but does not delete + * @returns CheckpointPurgeResult with count and cutoff used + */ +export async function purgeOldCheckpoints( + globalStoragePath: string, + log?: (message: string) => void, + dryRun: boolean = false, +): Promise { + const cutoff = Date.now() - CHECKPOINT_RETENTION_DAYS * 24 * 60 * 60 * 1000 + + log?.(`[Checkpoints] Starting checkpoint cull (${CHECKPOINT_RETENTION_DAYS} days)${dryRun ? " (dry run)" : ""}`) + + let basePath: string + + try { + basePath = await getStorageBasePath(globalStoragePath) + } catch (e) { + log?.(`[Checkpoints] Failed to resolve storage base path: ${e instanceof Error ? e.message : String(e)}`) + return { culledCount: 0, cutoff } + } + + const tasksDir = path.join(basePath, "tasks") + + let entries: Dirent[] + try { + entries = await fs.readdir(tasksDir, { withFileTypes: true }) + } catch { + // No tasks directory yet or unreadable + log?.(`[Checkpoints] Tasks directory not found or unreadable`) + return { culledCount: 0, cutoff } + } + + const taskDirs = entries.filter((d) => d.isDirectory()) + const totalTasks = taskDirs.length + + if (totalTasks === 0) { + log?.(`[Checkpoints] No tasks found`) + return { culledCount: 0, cutoff } + } + + log?.(`[Checkpoints] Scanning ${totalTasks} tasks for old checkpoints...`) + + // Phase 1: Read metadata for all tasks with checkpoints + const metadataLimit = pLimit(METADATA_READ_CONCURRENCY) + const metadataResults = await Promise.all( + taskDirs.map((d) => metadataLimit(() => readCheckpointTaskMetadata(d.name, tasksDir))), + ) + + // Count tasks that have checkpoints + const tasksWithCheckpoints = metadataResults.filter((m) => m !== null).length + log?.(`[Checkpoints] Found ${tasksWithCheckpoints} tasks with checkpoints`) + + // Phase 2: Filter tasks with checkpoints that need culling + const tasksToCull: CheckpointTaskMetadata[] = [] + + for (const metadata of metadataResults) { + if (!metadata) continue + + // Check if task is older than cutoff + if (metadata.lastActivity !== null && metadata.lastActivity < cutoff) { + tasksToCull.push(metadata) + } + } + + if (tasksToCull.length === 0) { + log?.(`[Checkpoints] No checkpoints older than 30 days`) + return { culledCount: 0, cutoff } + } + + log?.(`[Checkpoints] ${tasksToCull.length} tasks have checkpoints older than 30 days`) + + // Dry run mode + if (dryRun) { + for (const metadata of tasksToCull) { + log?.( + `[Checkpoints][DRY RUN] Would cull checkpoints for task ${metadata.taskId} (last activity: ${new Date(metadata.lastActivity!).toISOString()})`, + ) + } + log?.(`[Checkpoints] Would cull checkpoints from ${tasksToCull.length} task(s) (dry run)`) + return { culledCount: tasksToCull.length, cutoff } + } + + // Phase 3: Delete checkpoints directories in parallel + log?.(`[Checkpoints] Deleting checkpoints from ${tasksToCull.length} tasks...`) + const deleteLimit = pLimit(DELETION_CONCURRENCY) + let deletedCount = 0 + + const deleteResults = await Promise.all( + tasksToCull.map((metadata) => + deleteLimit(async (): Promise => { + try { + await fs.rm(metadata.checkpointsDir, { recursive: true, force: true }) + const stillExists = await pathExists(metadata.checkpointsDir) + if (!stillExists) { + deletedCount++ + // Log progress every 100 deletions + if (deletedCount % 100 === 0) { + log?.(`[Checkpoints] Progress: ${deletedCount}/${tasksToCull.length} deleted`) + } + return true + } + } catch { + // Ignore errors + } + return false + }), + ), + ) + + const culled = deleteResults.filter(Boolean).length + + if (culled > 0) { + log?.(`[Checkpoints] Culled checkpoints from ${culled} task(s)`) + } + + return { culledCount: culled, cutoff } +} + +/** + * Options for starting the background checkpoint purge. + */ +export interface BackgroundCheckpointPurgeOptions { + /** VS Code global storage fsPath */ + globalStoragePath: string + /** Logger function */ + log: (message: string) => void +} + +/** + * Starts the checkpoint culling in the background. + * This function is designed to be called after extension activation completes, + * using a fire-and-forget pattern (void) to avoid blocking activation. + * + * Checkpoints are culled from tasks that haven't been touched in 30 days. + * This is non-configurable and always runs. + * + * @param options Configuration options for the background checkpoint purge + */ +export function startBackgroundCheckpointPurge(options: BackgroundCheckpointPurgeOptions): void { + const { globalStoragePath, log } = options + + void (async () => { + try { + log(`[Checkpoints] Starting background checkpoint cull (${CHECKPOINT_RETENTION_DAYS} days)`) + + const result = await purgeOldCheckpoints(globalStoragePath, log, false) + + log( + `[Checkpoints] Background checkpoint cull complete: culled=${result.culledCount}, cutoff=${new Date(result.cutoff).toISOString()}`, + ) + + // No user notification - silent operation as requested + } catch (error) { + log( + `[Checkpoints] Failed during background checkpoint cull: ${error instanceof Error ? error.message : String(error)}`, + ) + } + })() +} diff --git a/src/utils/task-storage-size.ts b/src/utils/task-storage-size.ts new file mode 100644 index 00000000000..46423c234f0 --- /dev/null +++ b/src/utils/task-storage-size.ts @@ -0,0 +1,63 @@ +import * as path from "path" +import * as fs from "fs/promises" + +import { getStorageBasePath } from "./storage" + +/** + * Result of counting task history items. + * Note: Size calculation was removed for performance reasons - with large numbers of + * tasks (e.g., 9000+), recursively stat'ing every file caused significant delays. + */ +export interface TaskStorageSizeResult { + /** Number of task directories found */ + taskCount: number +} + +/** + * Counts the number of task directories in task history storage. + * + * This function is designed to be fast and non-blocking - it only counts + * top-level directories without recursively walking the file tree. + * + * Note: Size calculation was intentionally removed because with large task counts + * (e.g., 9000+), the recursive stat calls caused significant performance issues + * and blocked the extension UI. + * + * @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath) + * @param log Optional logger function for debugging + * @returns TaskStorageSizeResult with task count + */ +export async function calculateTaskStorageSize( + globalStoragePath: string, + log?: (message: string) => void, +): Promise { + const defaultResult: TaskStorageSizeResult = { + taskCount: 0, + } + + let basePath: string + + try { + basePath = await getStorageBasePath(globalStoragePath) + } catch (e) { + log?.(`[TaskStorageSize] Failed to resolve storage base path: ${e instanceof Error ? e.message : String(e)}`) + return defaultResult + } + + const tasksDir = path.join(basePath, "tasks") + + // Count task directories - this is a fast O(1) readdir operation + let taskCount = 0 + try { + const entries = await fs.readdir(tasksDir, { withFileTypes: true }) + taskCount = entries.filter((d) => d.isDirectory()).length + } catch { + // Tasks directory doesn't exist yet + log?.(`[TaskStorageSize] Tasks directory not found at ${tasksDir}`) + return defaultResult + } + + return { + taskCount, + } +} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 88b65518812..eba11ce581c 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -1,14 +1,29 @@ -import React, { memo, useState, useMemo } from "react" -import { ArrowLeft } from "lucide-react" +import React, { memo, useState, useMemo, useCallback, useEffect } from "react" +import { ArrowLeft, Settings, FolderOpen, RefreshCw, Loader2 } from "lucide-react" import { DeleteTaskDialog } from "./DeleteTaskDialog" import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog" import { Virtuoso } from "react-virtuoso" +import { TASK_HISTORY_RETENTION_OPTIONS, type TaskHistoryRetentionSetting } from "@roo-code/types" + import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, Checkbox, + Popover, + PopoverContent, + PopoverTrigger, Select, SelectContent, SelectItem, @@ -41,6 +56,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { showAllWorkspaces, setShowAllWorkspaces, } = useTaskSearch() + const { taskHistoryRetention, taskHistorySize } = useExtensionState() const { t } = useAppTranslation() // Use grouped tasks hook @@ -51,6 +67,74 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [isSelectionMode, setIsSelectionMode] = useState(false) const [selectedTaskIds, setSelectedTaskIds] = useState([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) + const [isRetentionPopoverOpen, setIsRetentionPopoverOpen] = useState(false) + const [pendingRetention, setPendingRetention] = useState(null) + const [showRetentionConfirmDialog, setShowRetentionConfirmDialog] = useState(false) + const [isRefreshingTaskCount, setIsRefreshingTaskCount] = useState(false) + const [cachedTaskCount, setCachedTaskCount] = useState(taskHistorySize?.taskCount) + + // Update cached task count when taskHistorySize changes + useEffect(() => { + if (taskHistorySize) { + setCachedTaskCount(taskHistorySize.taskCount) + setIsRefreshingTaskCount(false) + } + }, [taskHistorySize]) + + // Handle refresh task count + const handleRefreshTaskCount = useCallback(() => { + setIsRefreshingTaskCount(true) + vscode.postMessage({ type: "refreshTaskHistorySize" }) + }, []) + + // Get task count display text + const getTaskCountDisplayText = (): string => { + const count = taskHistorySize?.taskCount ?? cachedTaskCount + if (count === undefined) { + return t("settings:taskHistoryStorage.clickToCount") + } + if (count === 0) { + return t("settings:taskHistoryStorage.empty") + } + if (count === 1) { + return t("settings:taskHistoryStorage.countSingular") + } + return t("settings:taskHistoryStorage.count", { count }) + } + + // Normalize retention setting to ensure it's valid + const normalizedRetention: TaskHistoryRetentionSetting = TASK_HISTORY_RETENTION_OPTIONS.includes( + taskHistoryRetention as TaskHistoryRetentionSetting, + ) + ? (taskHistoryRetention as TaskHistoryRetentionSetting) + : "never" + + // Handle retention setting change - show confirmation dialog first + const handleRetentionChange = (value: TaskHistoryRetentionSetting) => { + // If selecting the same value, do nothing + if (value === normalizedRetention) { + return + } + // Show confirmation dialog for any change + setPendingRetention(value) + setShowRetentionConfirmDialog(true) + } + + // Confirm retention change + const confirmRetentionChange = () => { + if (pendingRetention !== null) { + vscode.postMessage({ type: "updateSettings", updatedSettings: { taskHistoryRetention: pendingRetention } }) + } + setShowRetentionConfirmDialog(false) + setPendingRetention(null) + setIsRetentionPopoverOpen(false) + } + + // Cancel retention change + const cancelRetentionChange = () => { + setShowRetentionConfirmDialog(false) + setPendingRetention(null) + } // Get subtask count for a task const getSubtaskCount = useMemo(() => { @@ -116,20 +200,87 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {

{t("history:history")}

- - - +
+ + + + + + + +
+ {/* Task count display */} +
+ + {getTaskCountDisplayText()} + +
+ +
+

{t("settings:aboutRetention.label")}

+
+ +

+ {t("settings:aboutRetention.warning")} +

+
+
+
+ + + +
{ }} /> )} + + {/* Retention change confirmation dialog */} + + + + {t("settings:aboutRetention.confirmDialog.title")} + + {pendingRetention === "never" + ? t("settings:aboutRetention.confirmDialog.descriptionNever") + : t("settings:aboutRetention.confirmDialog.description", { + period: pendingRetention, + })} + + + + + {t("settings:aboutRetention.confirmDialog.cancel")} + + + {pendingRetention === "never" + ? t("settings:aboutRetention.confirmDialog.confirmNever") + : t("settings:aboutRetention.confirmDialog.confirm")} + + + + ) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index bd58db79e6d..60bbf355141 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -37,6 +37,8 @@ import { type TelemetrySetting, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, ImageGenerationProvider, + TASK_HISTORY_RETENTION_OPTIONS, + type TaskHistoryRetentionSetting, } from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -213,6 +215,7 @@ const SettingsView = forwardRef(({ onDone, t includeCurrentTime, includeCurrentCost, maxGitStatusFiles, + taskHistoryRetention, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -237,6 +240,20 @@ const SettingsView = forwardRef(({ onDone, t } }, [settingsImportedAt, extensionState]) + // Sync taskHistoryRetention from extensionState when it changes and the user + // hasn't made local changes yet. This handles the race condition where + // cachedState is initialized before the initial state message arrives. + useEffect(() => { + if (!isChangeDetected && extensionState.taskHistoryRetention !== undefined) { + setCachedState((prev) => { + if (prev.taskHistoryRetention === extensionState.taskHistoryRetention) { + return prev // No change needed + } + return { ...prev, taskHistoryRetention: extensionState.taskHistoryRetention } + }) + } + }, [extensionState.taskHistoryRetention, isChangeDetected]) + const setCachedStateField: SetCachedStateField = useCallback((field, value) => { setCachedState((prevState) => { if (prevState[field] === value) { @@ -423,6 +440,7 @@ const SettingsView = forwardRef(({ onDone, t includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, + taskHistoryRetention: normalizedTaskHistoryRetention, profileThresholds, imageGenerationProvider, openRouterImageApiKey, @@ -615,6 +633,12 @@ const SettingsView = forwardRef(({ onDone, t // Determine which tab content to render (for indexing or active display) const renderTab = isIndexing ? sectionNames[indexingTabIndex] : activeTab + const normalizedTaskHistoryRetention: TaskHistoryRetentionSetting = TASK_HISTORY_RETENTION_OPTIONS.includes( + taskHistoryRetention as TaskHistoryRetentionSetting, + ) + ? (taskHistoryRetention as TaskHistoryRetentionSetting) + : "never" + // Handle search navigation - switch to the correct tab and scroll to the element const handleSearchNavigate = useCallback( (section: SectionName, settingId: string) => { diff --git a/webview-ui/src/components/settings/__tests__/About.spec.tsx b/webview-ui/src/components/settings/__tests__/About.spec.tsx index 78c4981a80c..b62b7c13581 100644 --- a/webview-ui/src/components/settings/__tests__/About.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/About.spec.tsx @@ -1,3 +1,5 @@ +import type { ComponentProps } from "react" + import { render, screen } from "@/utils/test-utils" import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext" @@ -26,7 +28,7 @@ vi.mock("@roo/package", () => ({ })) describe("About", () => { - const defaultProps = { + const defaultProps: ComponentProps = { telemetrySetting: "enabled" as const, setTelemetrySetting: vi.fn(), } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 26b3851c1be..72ce7677ca4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -277,6 +277,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterImageGenerationSelectedModel: "", includeCurrentTime: true, includeCurrentCost: true, + taskHistoryRetention: "never", // Default to never auto-delete }) const [didHydrateState, setDidHydrateState] = useState(false) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 1509137a902..4b103b5996b 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -896,6 +896,34 @@ "useCustomModel": "Utilitzar personalitzat: {{modelId}}", "simplifiedExplanation": "Pots ajustar la configuració detallada del model més tard." }, + "aboutRetention": { + "label": "Eliminar automàticament l'historial de tasques", + "warning": "Quan estigui activat, les tasques anteriors al període seleccionat s'eliminen permanentment en reiniciar VS Code.", + "confirmDialog": { + "title": "Activar l'eliminació automàtica?", + "description": "Això eliminarà permanentment les tasques de més de {{period}} dies cada vegada que es reiniciï VS Code. Aquesta acció no es pot desfer.", + "descriptionNever": "L'eliminació automàtica es desactivarà. El teu historial de tasques es conservarà.", + "confirm": "Activar eliminació automàtica", + "confirmNever": "Desactivar eliminació automàtica", + "cancel": "Cancel·lar" + }, + "options": { + "never": "Mai (per defecte)", + "90": "90 dies", + "60": "60 dies", + "30": "30 dies", + "7": "7 dies", + "3": "3 dies" + } + }, + "taskHistoryStorage": { + "label": "Historial de tasques", + "clickToCount": "Clica per comptar", + "count": "{{count}} tasques a l'historial", + "countSingular": "1 tasca a l'historial", + "empty": "Encara no hi ha tasques", + "refresh": "Actualitzar" + }, "footer": { "feedback": "Si teniu qualsevol pregunta o comentari, no dubteu a obrir un issue a github.com/RooCodeInc/Roo-Code o unir-vos a reddit.com/r/RooCode o discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index bc275a64e50..8eef56ac82b 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -896,6 +896,34 @@ "useCustomModel": "Benutzerdefiniert verwenden: {{modelId}}", "simplifiedExplanation": "Du kannst detaillierte Modelleinstellungen später anpassen." }, + "aboutRetention": { + "label": "Aufgabenverlauf automatisch löschen", + "warning": "Wenn aktiviert, werden Aufgaben, die älter als der ausgewählte Zeitraum sind, beim Neustart von VS Code dauerhaft gelöscht.", + "confirmDialog": { + "title": "Automatisches Löschen aktivieren?", + "description": "Dies löscht dauerhaft Aufgaben, die älter als {{period}} Tage sind, bei jedem Neustart von VS Code. Diese Aktion kann nicht rückgängig gemacht werden.", + "descriptionNever": "Das automatische Löschen wird deaktiviert. Dein Aufgabenverlauf bleibt erhalten.", + "confirm": "Automatisches Löschen aktivieren", + "confirmNever": "Automatisches Löschen deaktivieren", + "cancel": "Abbrechen" + }, + "options": { + "never": "Nie (Standard)", + "90": "90 Tage", + "60": "60 Tage", + "30": "30 Tage", + "7": "7 Tage", + "3": "3 Tage" + } + }, + "taskHistoryStorage": { + "label": "Aufgabenverlauf", + "clickToCount": "Klicken zum Zählen", + "count": "{{count}} Aufgaben im Verlauf", + "countSingular": "1 Aufgabe im Verlauf", + "empty": "Noch keine Aufgaben", + "refresh": "Aktualisieren" + }, "footer": { "feedback": "Wenn du Fragen oder Feedback hast, kannst du gerne ein Issue auf github.com/RooCodeInc/Roo-Code eröffnen oder reddit.com/r/RooCode oder discord.gg/roocode beitreten", "telemetry": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7045ef07d19..106f7b631fe 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -905,6 +905,34 @@ "useCustomModel": "Use custom: {{modelId}}", "simplifiedExplanation": "You can adjust detailed model settings later." }, + "aboutRetention": { + "label": "Auto-delete task history", + "warning": "When enabled, tasks older than the selected period are permanently deleted when VS Code restarts.", + "confirmDialog": { + "title": "Enable Auto-Delete?", + "description": "This will permanently delete tasks older than {{period}} days whenever VS Code restarts. This action cannot be undone.", + "descriptionNever": "Auto-delete will be disabled. Your task history will be preserved.", + "confirm": "Enable Auto-Delete", + "confirmNever": "Disable Auto-Delete", + "cancel": "Cancel" + }, + "options": { + "never": "Never (default)", + "90": "90 days", + "60": "60 days", + "30": "30 days", + "7": "7 days", + "3": "3 days" + } + }, + "taskHistoryStorage": { + "label": "Task history", + "clickToCount": "Click to count tasks", + "count": "{{count}} tasks in history", + "countSingular": "1 task in history", + "empty": "No tasks yet", + "refresh": "Refresh count" + }, "footer": { "telemetry": { "label": "Allow anonymous error and usage reporting", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 36243b99be0..39f2d3f657a 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -896,6 +896,34 @@ "useCustomModel": "Usar personalizado: {{modelId}}", "simplifiedExplanation": "Puedes ajustar la configuración detallada del modelo más tarde." }, + "aboutRetention": { + "label": "Eliminar automáticamente el historial de tareas", + "warning": "Cuando está habilitado, las tareas anteriores al período seleccionado se eliminan permanentemente al reiniciar VS Code.", + "confirmDialog": { + "title": "¿Activar eliminación automática?", + "description": "Esto eliminará permanentemente las tareas de más de {{period}} días cada vez que VS Code se reinicie. Esta acción no se puede deshacer.", + "descriptionNever": "La eliminación automática se desactivará. Tu historial de tareas se conservará.", + "confirm": "Activar eliminación automática", + "confirmNever": "Desactivar eliminación automática", + "cancel": "Cancelar" + }, + "options": { + "never": "Nunca (predeterminado)", + "90": "90 días", + "60": "60 días", + "30": "30 días", + "7": "7 días", + "3": "3 días" + } + }, + "taskHistoryStorage": { + "label": "Historial de tareas", + "clickToCount": "Clic para contar", + "count": "{{count}} tareas en el historial", + "countSingular": "1 tarea en el historial", + "empty": "Sin tareas aún", + "refresh": "Actualizar" + }, "footer": { "feedback": "Si tiene alguna pregunta o comentario, no dude en abrir un issue en github.com/RooCodeInc/Roo-Code o unirse a reddit.com/r/RooCode o discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 1c28b763077..365f73534cb 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -896,6 +896,34 @@ "useCustomModel": "Utiliser personnalisé : {{modelId}}", "simplifiedExplanation": "Tu peux ajuster les paramètres détaillés du modèle ultérieurement." }, + "aboutRetention": { + "label": "Supprimer automatiquement l'historique des tâches", + "warning": "Si activé, les tâches antérieures à la période sélectionnée sont définitivement supprimées au redémarrage de VS Code.", + "confirmDialog": { + "title": "Activer la suppression automatique ?", + "description": "Cela supprimera définitivement les tâches de plus de {{period}} jours à chaque redémarrage de VS Code. Cette action ne peut pas être annulée.", + "descriptionNever": "La suppression automatique sera désactivée. Votre historique des tâches sera conservé.", + "confirm": "Activer la suppression automatique", + "confirmNever": "Désactiver la suppression automatique", + "cancel": "Annuler" + }, + "options": { + "never": "Jamais (par défaut)", + "90": "90 jours", + "60": "60 jours", + "30": "30 jours", + "7": "7 jours", + "3": "3 jours" + } + }, + "taskHistoryStorage": { + "label": "Historique des tâches", + "clickToCount": "Cliquer pour compter", + "count": "{{count}} tâches dans l'historique", + "countSingular": "1 tâche dans l'historique", + "empty": "Aucune tâche pour l'instant", + "refresh": "Actualiser" + }, "footer": { "feedback": "Si vous avez des questions ou des commentaires, n'hésitez pas à ouvrir un problème sur github.com/RooCodeInc/Roo-Code ou à rejoindre reddit.com/r/RooCode ou discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 4974ff706b4..24d910e3c11 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "कस्टम उपयोग करें: {{modelId}}", "simplifiedExplanation": "आप बाद में विस्तृत मॉडल सेटिंग्स समायोजित कर सकते हैं।" }, + "aboutRetention": { + "label": "कार्य इतिहास स्वचालित रूप से हटाएं", + "warning": "सक्षम होने पर, चयनित अवधि से पुराने कार्य VS Code के पुनः आरंभ होने पर स्थायी रूप से हटा दिए जाते हैं।", + "confirmDialog": { + "title": "स्वचालित हटाना सक्षम करें?", + "description": "यह VS Code के हर पुनः आरंभ पर {{period}} दिनों से पुराने कार्यों को स्थायी रूप से हटा देगा। इस क्रिया को पूर्ववत नहीं किया जा सकता।", + "descriptionNever": "स्वचालित हटाना अक्षम किया जाएगा। आपका कार्य इतिहास संरक्षित रहेगा।", + "confirm": "स्वचालित हटाना सक्षम करें", + "confirmNever": "स्वचालित हटाना अक्षम करें", + "cancel": "रद्द करें" + }, + "options": { + "never": "कभी नहीं (डिफ़ॉल्ट)", + "90": "90 दिन", + "60": "60 दिन", + "30": "30 दिन", + "7": "7 दिन", + "3": "3 दिन" + } + }, + "taskHistoryStorage": { + "label": "कार्य इतिहास", + "clickToCount": "गिनने के लिए क्लिक करें", + "count": "इतिहास में {{count}} कार्य", + "countSingular": "इतिहास में 1 कार्य", + "empty": "अभी तक कोई कार्य नहीं", + "refresh": "रिफ्रेश करें" + }, "footer": { "feedback": "यदि आपके कोई प्रश्न या प्रतिक्रिया है, तो github.com/RooCodeInc/Roo-Code पर एक मुद्दा खोलने या reddit.com/r/RooCode या discord.gg/roocode में शामिल होने में संकोच न करें", "telemetry": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 908c975a5b8..0fe138cee7b 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -926,6 +926,34 @@ "useCustomModel": "Gunakan kustom: {{modelId}}", "simplifiedExplanation": "Anda dapat menyesuaikan pengaturan model terperinci nanti." }, + "aboutRetention": { + "label": "Hapus otomatis riwayat tugas", + "warning": "Saat diaktifkan, tugas yang lebih lama dari periode yang dipilih akan dihapus secara permanen saat VS Code dimulai ulang.", + "confirmDialog": { + "title": "Aktifkan hapus otomatis?", + "description": "Ini akan menghapus tugas yang lebih lama dari {{period}} hari secara permanen setiap kali VS Code dimulai ulang. Tindakan ini tidak dapat dibatalkan.", + "descriptionNever": "Hapus otomatis akan dinonaktifkan. Riwayat tugas Anda akan dipertahankan.", + "confirm": "Aktifkan hapus otomatis", + "confirmNever": "Nonaktifkan hapus otomatis", + "cancel": "Batal" + }, + "options": { + "never": "Tidak pernah (default)", + "90": "90 hari", + "60": "60 hari", + "30": "30 hari", + "7": "7 hari", + "3": "3 hari" + } + }, + "taskHistoryStorage": { + "label": "Riwayat tugas", + "clickToCount": "Klik untuk menghitung", + "count": "{{count}} tugas dalam riwayat", + "countSingular": "1 tugas dalam riwayat", + "empty": "Belum ada tugas", + "refresh": "Segarkan" + }, "footer": { "feedback": "Jika kamu punya pertanyaan atau feedback, jangan ragu untuk membuka issue di github.com/RooCodeInc/Roo-Code atau bergabung reddit.com/r/RooCode atau discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 1c3c7e494d7..a912a59d3a9 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Usa personalizzato: {{modelId}}", "simplifiedExplanation": "Puoi modificare le impostazioni dettagliate del modello in seguito." }, + "aboutRetention": { + "label": "Elimina automaticamente la cronologia delle attività", + "warning": "Se abilitato, le attività più vecchie del periodo selezionato vengono eliminate permanentemente al riavvio di VS Code.", + "confirmDialog": { + "title": "Attivare l'eliminazione automatica?", + "description": "Questo eliminerà permanentemente le attività più vecchie di {{period}} giorni ogni volta che VS Code viene riavviato. Questa azione non può essere annullata.", + "descriptionNever": "L'eliminazione automatica sarà disattivata. La cronologia delle attività sarà conservata.", + "confirm": "Attiva eliminazione automatica", + "confirmNever": "Disattiva eliminazione automatica", + "cancel": "Annulla" + }, + "options": { + "never": "Mai (predefinito)", + "90": "90 giorni", + "60": "60 giorni", + "30": "30 giorni", + "7": "7 giorni", + "3": "3 giorni" + } + }, + "taskHistoryStorage": { + "label": "Cronologia attività", + "clickToCount": "Clicca per contare", + "count": "{{count}} attività nella cronologia", + "countSingular": "1 attività nella cronologia", + "empty": "Nessuna attività ancora", + "refresh": "Aggiorna" + }, "footer": { "feedback": "Se hai domande o feedback, sentiti libero di aprire un issue su github.com/RooCodeInc/Roo-Code o unirti a reddit.com/r/RooCode o discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b4af9d4033e..7f4ebf8e70d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "カスタムを使用: {{modelId}}", "simplifiedExplanation": "詳細なモデル設定は後で調整できます。" }, + "aboutRetention": { + "label": "タスク履歴を自動削除", + "warning": "有効にすると、選択した期間より古いタスクはVS Code再起動時に完全に削除されます。", + "confirmDialog": { + "title": "自動削除を有効にしますか?", + "description": "VS Codeを再起動するたびに、{{period}}日以上経過したタスクが完全に削除されます。この操作は取り消せません。", + "descriptionNever": "自動削除が無効になります。タスク履歴は保持されます。", + "confirm": "自動削除を有効にする", + "confirmNever": "自動削除を無効にする", + "cancel": "キャンセル" + }, + "options": { + "never": "削除しない(デフォルト)", + "90": "90日", + "60": "60日", + "30": "30日", + "7": "7日", + "3": "3日" + } + }, + "taskHistoryStorage": { + "label": "タスク履歴", + "clickToCount": "クリックしてカウント", + "count": "履歴に{{count}}件のタスク", + "countSingular": "履歴に1件のタスク", + "empty": "タスクはまだありません", + "refresh": "更新" + }, "footer": { "feedback": "質問やフィードバックがある場合は、github.com/RooCodeInc/Roo-Codeで問題を開くか、reddit.com/r/RooCodediscord.gg/roocodeに参加してください", "telemetry": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 2888d75bb0b..692e0c00861 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "사용자 정의 사용: {{modelId}}", "simplifiedExplanation": "나중에 자세한 모델 설정을 조정할 수 있습니다." }, + "aboutRetention": { + "label": "작업 기록 자동 삭제", + "warning": "활성화하면 선택한 기간보다 오래된 작업이 VS Code 재시작 시 영구적으로 삭제됩니다.", + "confirmDialog": { + "title": "자동 삭제를 활성화하시겠습니까?", + "description": "VS Code가 재시작될 때마다 {{period}}일 이상 된 작업이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "descriptionNever": "자동 삭제가 비활성화됩니다. 작업 기록이 보존됩니다.", + "confirm": "자동 삭제 활성화", + "confirmNever": "자동 삭제 비활성화", + "cancel": "취소" + }, + "options": { + "never": "삭제 안 함 (기본값)", + "90": "90일", + "60": "60일", + "30": "30일", + "7": "7일", + "3": "3일" + } + }, + "taskHistoryStorage": { + "label": "작업 기록", + "clickToCount": "클릭하여 계산", + "count": "기록에 {{count}}개 작업", + "countSingular": "기록에 1개 작업", + "empty": "아직 작업 없음", + "refresh": "새로고침" + }, "footer": { "feedback": "질문이나 피드백이 있으시면 github.com/RooCodeInc/Roo-Code에서 이슈를 열거나 reddit.com/r/RooCode 또는 discord.gg/roocode에 가입하세요", "telemetry": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 83e1f4b7ab3..3463f6ee049 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Aangepast gebruiken: {{modelId}}", "simplifiedExplanation": "Je kunt later gedetailleerde modelinstellingen aanpassen." }, + "aboutRetention": { + "label": "Taakgeschiedenis automatisch verwijderen", + "warning": "Wanneer ingeschakeld, worden taken ouder dan de geselecteerde periode permanent verwijderd bij het herstarten van VS Code.", + "confirmDialog": { + "title": "Automatisch verwijderen activeren?", + "description": "Dit verwijdert permanent taken ouder dan {{period}} dagen telkens wanneer VS Code opnieuw wordt gestart. Deze actie kan niet ongedaan worden gemaakt.", + "descriptionNever": "Automatisch verwijderen wordt uitgeschakeld. Je taakgeschiedenis wordt bewaard.", + "confirm": "Automatisch verwijderen activeren", + "confirmNever": "Automatisch verwijderen uitschakelen", + "cancel": "Annuleren" + }, + "options": { + "never": "Nooit (standaard)", + "90": "90 dagen", + "60": "60 dagen", + "30": "30 dagen", + "7": "7 dagen", + "3": "3 dagen" + } + }, + "taskHistoryStorage": { + "label": "Taakgeschiedenis", + "clickToCount": "Klik om te tellen", + "count": "{{count}} taken in geschiedenis", + "countSingular": "1 taak in geschiedenis", + "empty": "Nog geen taken", + "refresh": "Vernieuwen" + }, "footer": { "feedback": "Heb je vragen of feedback? Open gerust een issue op github.com/RooCodeInc/Roo-Code of sluit je aan bij reddit.com/r/RooCode of discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 7c339f96a5b..3f2628ee643 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Użyj niestandardowy: {{modelId}}", "simplifiedExplanation": "Można dostosować szczegółowe ustawienia modelu później." }, + "aboutRetention": { + "label": "Automatyczne usuwanie historii zadań", + "warning": "Po włączeniu, zadania starsze niż wybrany okres są trwale usuwane przy ponownym uruchomieniu VS Code.", + "confirmDialog": { + "title": "Włączyć automatyczne usuwanie?", + "description": "To trwale usunie zadania starsze niż {{period}} dni przy każdym ponownym uruchomieniu VS Code. Tej akcji nie można cofnąć.", + "descriptionNever": "Automatyczne usuwanie zostanie wyłączone. Historia zadań zostanie zachowana.", + "confirm": "Włącz automatyczne usuwanie", + "confirmNever": "Wyłącz automatyczne usuwanie", + "cancel": "Anuluj" + }, + "options": { + "never": "Nigdy (domyślnie)", + "90": "90 dni", + "60": "60 dni", + "30": "30 dni", + "7": "7 dni", + "3": "3 dni" + } + }, + "taskHistoryStorage": { + "label": "Historia zadań", + "clickToCount": "Kliknij, aby policzyć", + "count": "{{count}} zadań w historii", + "countSingular": "1 zadanie w historii", + "empty": "Brak zadań", + "refresh": "Odśwież" + }, "footer": { "feedback": "Jeśli masz jakiekolwiek pytania lub opinie, śmiało otwórz zgłoszenie na github.com/RooCodeInc/Roo-Code lub dołącz do reddit.com/r/RooCode lub discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 04a786ab11e..fe631b50918 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Usar personalizado: {{modelId}}", "simplifiedExplanation": "Você pode ajustar as configurações detalhadas do modelo mais tarde." }, + "aboutRetention": { + "label": "Excluir automaticamente o histórico de tarefas", + "warning": "Quando ativado, tarefas mais antigas que o período selecionado são excluídas permanentemente ao reiniciar o VS Code.", + "confirmDialog": { + "title": "Ativar exclusão automática?", + "description": "Isso excluirá permanentemente tarefas com mais de {{period}} dias sempre que o VS Code for reiniciado. Esta ação não pode ser desfeita.", + "descriptionNever": "A exclusão automática será desativada. Seu histórico de tarefas será preservado.", + "confirm": "Ativar exclusão automática", + "confirmNever": "Desativar exclusão automática", + "cancel": "Cancelar" + }, + "options": { + "never": "Nunca (padrão)", + "90": "90 dias", + "60": "60 dias", + "30": "30 dias", + "7": "7 dias", + "3": "3 dias" + } + }, + "taskHistoryStorage": { + "label": "Histórico de tarefas", + "clickToCount": "Clique para contar", + "count": "{{count}} tarefas no histórico", + "countSingular": "1 tarefa no histórico", + "empty": "Nenhuma tarefa ainda", + "refresh": "Atualizar" + }, "footer": { "feedback": "Se tiver alguma dúvida ou feedback, sinta-se à vontade para abrir um problema em github.com/RooCodeInc/Roo-Code ou juntar-se a reddit.com/r/RooCode ou discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index ebdbc01b931..a9fb4dcc55a 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Использовать пользовательскую: {{modelId}}", "simplifiedExplanation": "Ты сможешь настроить подробные параметры модели позже." }, + "aboutRetention": { + "label": "Автоматически удалять историю задач", + "warning": "При включении задачи старше выбранного периода безвозвратно удаляются при перезапуске VS Code.", + "confirmDialog": { + "title": "Включить автоудаление?", + "description": "Это безвозвратно удалит задачи старше {{period}} дней при каждом перезапуске VS Code. Это действие нельзя отменить.", + "descriptionNever": "Автоудаление будет отключено. История задач будет сохранена.", + "confirm": "Включить автоудаление", + "confirmNever": "Отключить автоудаление", + "cancel": "Отмена" + }, + "options": { + "never": "Никогда (по умолчанию)", + "90": "90 дней", + "60": "60 дней", + "30": "30 дней", + "7": "7 дней", + "3": "3 дня" + } + }, + "taskHistoryStorage": { + "label": "История задач", + "clickToCount": "Нажмите для подсчета", + "count": "{{count}} задач в истории", + "countSingular": "1 задача в истории", + "empty": "Задач пока нет", + "refresh": "Обновить" + }, "footer": { "feedback": "Если у вас есть вопросы или предложения, откройте issue на github.com/RooCodeInc/Roo-Code или присоединяйтесь к reddit.com/r/RooCode или discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5a7daeec1d7..2c224cf7cef 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Özel kullan: {{modelId}}", "simplifiedExplanation": "Ayrıntılı model ayarlarını daha sonra ayarlayabilirsiniz." }, + "aboutRetention": { + "label": "Görev geçmişini otomatik sil", + "warning": "Etkinleştirildiğinde, seçilen döneme göre daha eski görevler VS Code yeniden başlatıldığında kalıcı olarak silinir.", + "confirmDialog": { + "title": "Otomatik silmeyi etkinleştir?", + "description": "Bu, VS Code her yeniden başlatıldığında {{period}} günden eski görevleri kalıcı olarak silecek. Bu işlem geri alınamaz.", + "descriptionNever": "Otomatik silme devre dışı bırakılacak. Görev geçmişiniz korunacak.", + "confirm": "Otomatik silmeyi etkinleştir", + "confirmNever": "Otomatik silmeyi devre dışı bırak", + "cancel": "İptal" + }, + "options": { + "never": "Asla (varsayılan)", + "90": "90 gün", + "60": "60 gün", + "30": "30 gün", + "7": "7 gün", + "3": "3 gün" + } + }, + "taskHistoryStorage": { + "label": "Görev geçmişi", + "clickToCount": "Saymak için tıklayın", + "count": "Geçmişte {{count}} görev", + "countSingular": "Geçmişte 1 görev", + "empty": "Henüz görev yok", + "refresh": "Yenile" + }, "footer": { "feedback": "Herhangi bir sorunuz veya geri bildiriminiz varsa, github.com/RooCodeInc/Roo-Code adresinde bir konu açmaktan veya reddit.com/r/RooCode ya da discord.gg/roocode'a katılmaktan çekinmeyin", "telemetry": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 3e8e22d8f0c..70a5156275d 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "Sử dụng tùy chỉnh: {{modelId}}", "simplifiedExplanation": "Bạn có thể điều chỉnh cài đặt mô hình chi tiết sau." }, + "aboutRetention": { + "label": "Tự động xóa lịch sử tác vụ", + "warning": "Khi được bật, các tác vụ cũ hơn khoảng thời gian đã chọn sẽ bị xóa vĩnh viễn khi VS Code khởi động lại.", + "confirmDialog": { + "title": "Bật tự động xóa?", + "description": "Điều này sẽ xóa vĩnh viễn các tác vụ cũ hơn {{period}} ngày mỗi khi VS Code khởi động lại. Hành động này không thể hoàn tác.", + "descriptionNever": "Tự động xóa sẽ bị tắt. Lịch sử tác vụ của bạn sẽ được bảo tồn.", + "confirm": "Bật tự động xóa", + "confirmNever": "Tắt tự động xóa", + "cancel": "Hủy" + }, + "options": { + "never": "Không bao giờ (mặc định)", + "90": "90 ngày", + "60": "60 ngày", + "30": "30 ngày", + "7": "7 ngày", + "3": "3 ngày" + } + }, + "taskHistoryStorage": { + "label": "Lịch sử tác vụ", + "clickToCount": "Nhấp để đếm", + "count": "{{count}} tác vụ trong lịch sử", + "countSingular": "1 tác vụ trong lịch sử", + "empty": "Chưa có tác vụ nào", + "refresh": "Làm mới" + }, "footer": { "feedback": "Nếu bạn có bất kỳ câu hỏi hoặc phản hồi nào, vui lòng mở một vấn đề tại github.com/RooCodeInc/Roo-Code hoặc tham gia reddit.com/r/RooCode hoặc discord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 85bc9bd431b..278724a99c0 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -897,6 +897,34 @@ "useCustomModel": "使用自定义:{{modelId}}", "simplifiedExplanation": "你可以稍后调整详细的模型设置。" }, + "aboutRetention": { + "label": "自动删除任务历史", + "warning": "启用后,早于所选时段的任务将在VS Code重启时被永久删除。", + "confirmDialog": { + "title": "启用自动删除?", + "description": "每次VS Code重启时,将永久删除超过{{period}}天的任务。此操作无法撤销。", + "descriptionNever": "自动删除将被禁用。您的任务历史将被保留。", + "confirm": "启用自动删除", + "confirmNever": "禁用自动删除", + "cancel": "取消" + }, + "options": { + "never": "永不(默认)", + "90": "90天", + "60": "60天", + "30": "30天", + "7": "7天", + "3": "3天" + } + }, + "taskHistoryStorage": { + "label": "任务历史", + "clickToCount": "点击计数", + "count": "历史中有 {{count}} 个任务", + "countSingular": "历史中有 1 个任务", + "empty": "暂无任务", + "refresh": "刷新" + }, "footer": { "feedback": "如果您有任何问题或反馈,请随时在 github.com/RooCodeInc/Roo-Code 上提出问题或加入 reddit.com/r/RooCodediscord.gg/roocode", "telemetry": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 47f614932b9..b0ffab82e7e 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -905,6 +905,34 @@ "useCustomModel": "使用自訂模型:{{modelId}}", "simplifiedExplanation": "你可以稍後調整詳細的模型設定。" }, + "aboutRetention": { + "label": "自動刪除工作歷程記錄", + "warning": "啟用後,早於所選時段的工作將在VS Code重新啟動時被永久刪除。", + "confirmDialog": { + "title": "啟用自動刪除?", + "description": "每次VS Code重新啟動時,將永久刪除超過{{period}}天的工作。此操作無法復原。", + "descriptionNever": "自動刪除將被停用。您的工作歷程記錄將被保留。", + "confirm": "啟用自動刪除", + "confirmNever": "停用自動刪除", + "cancel": "取消" + }, + "options": { + "never": "永不(預設)", + "90": "90天", + "60": "60天", + "30": "30天", + "7": "7天", + "3": "3天" + } + }, + "taskHistoryStorage": { + "label": "工作歷程", + "clickToCount": "點擊計數", + "count": "歷程中有 {{count}} 個工作", + "countSingular": "歷程中有 1 個工作", + "empty": "尚無工作", + "refresh": "重新整理" + }, "footer": { "telemetry": { "label": "允許匿名錯誤與使用情況回報",