feat: task history retention purge uses provider delete path; checkpoint-only cleanup; quieter logging

This commit is contained in:
Hannes Rudolph 2025-11-13 22:49:49 -07:00
parent 953c7773c0
commit 8ffeaed6f2
13 changed files with 559 additions and 7 deletions

View file

@ -181,6 +181,8 @@ export const globalSettingsSchema = z.object({
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
includeTaskHistoryInEnhance: z.boolean().optional(),
// Auto-delete task history on extension reload. "never" | "90" | "60" | "30" | "7" | "3"
taskHistoryRetention: z.union([z.enum(["never", "90", "60", "30", "7", "3"]), z.number()]).optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**

View file

@ -335,6 +335,7 @@ export type ExtensionState = Pick<
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
| "taskHistoryRetention"
> & {
version: string
clineMessages: ClineMessage[]

View file

@ -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,16 @@ vi.mock("../utils/autoImportSettings", () => ({
autoImportSettings: vi.fn().mockResolvedValue(undefined),
}))
// Avoid filesystem access during activation by stubbing purge
vi.mock("../utils/task-history-retention", () => ({
purgeOldTasks: vi.fn().mockResolvedValue({ purgedCount: 0, cutoff: null }),
}))
// 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(() => ({})),
}))

View file

@ -0,0 +1,141 @@
// 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 } from "../utils/task-history-retention"
import { GlobalFileNames } from "../shared/globalFileNames"
// Helpers
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p)
return true
} catch {
return false
}
}
async function mkTempBase(): Promise<string> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "roo-retention-"))
// Ensure <base>/tasks exists
await fs.mkdir(path.join(base, "tasks"), { recursive: true })
return base
}
async function createTask(base: string, id: string, ts?: number | "invalid"): Promise<string> {
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 })
}
})
})

View file

@ -2053,6 +2053,7 @@ export class ClineProvider
reasoningBlockCollapsed,
enterBehavior,
cloudUserInfo,
taskHistoryRetention,
cloudIsAuthenticated,
sharingEnabled,
publicSharingEnabled,
@ -2236,6 +2237,8 @@ export class ClineProvider
includeDiagnosticMessages: includeDiagnosticMessages ?? true,
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
// Task history retention setting for About tab dropdown
taskHistoryRetention: taskHistoryRetention ?? "never",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
@ -2451,6 +2454,8 @@ export class ClineProvider
organizationAllowList,
organizationSettingsVersion,
customCondensingPrompt: stateValues.customCondensingPrompt,
// Task history retention selection
taskHistoryRetention: stateValues.taskHistoryRetention ?? "never",
codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
codebaseIndexConfig: {
codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false,

View file

@ -593,6 +593,12 @@ export const webviewMessageHandler = async (
await vscode.workspace
.getConfiguration(Package.name)
.update("deniedCommands", newValue, vscode.ConfigurationTarget.Global)
} else if (key === "taskHistoryRetention") {
const val = ((value ?? "never") as string).toString()
newValue = val
await vscode.workspace
.getConfiguration(Package.name)
.update("taskHistoryRetention", val, vscode.ConfigurationTarget.Global)
} else if (key === "ttsEnabled") {
newValue = value ?? true
setTtsEnabled(newValue as boolean)

View file

@ -1,6 +1,7 @@
import * as vscode from "vscode"
import * as dotenvx from "@dotenvx/dotenvx"
import * as path from "path"
import * as fs from "fs/promises"
// Load environment variables from .env file
try {
@ -44,6 +45,7 @@ import {
} from "./activate"
import { initializeI18n } from "./i18n"
import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache"
import { purgeOldTasks } from "./utils/task-history-retention"
/**
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@ -163,6 +165,40 @@ 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)
// Task history retention purge (runs only on activation)
try {
const config = vscode.workspace.getConfiguration(Package.name)
const retention = config.get<string>("taskHistoryRetention", "never") ?? "never"
outputChannel.appendLine(`[Retention] Startup purge: setting=${retention}`)
const result = await purgeOldTasks(
retention as any,
contextProxy.globalStorageUri.fsPath,
(m) => {
outputChannel.appendLine(m)
console.log(m)
},
false,
async (taskId: string, _taskDirPath: 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)
},
)
outputChannel.appendLine(
`[Retention] Startup purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`,
)
} catch (error) {
outputChannel.appendLine(
`[Retention] Failed during startup purge: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Initialize code index managers for all workspace folders.
const codeIndexManagers: CodeIndexManager[] = []
@ -186,9 +222,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()

View file

@ -429,6 +429,19 @@
"default": false,
"description": "%settings.debugProxy.tlsInsecure.description%",
"markdownDescription": "%settings.debugProxy.tlsInsecure.description%"
},
"roo-cline.taskHistoryRetention": {
"type": "string",
"enum": [
"never",
"90",
"60",
"30",
"7",
"3"
],
"default": "never",
"description": "%settings.taskHistoryRetention.description%"
}
}
}

View file

@ -45,5 +45,6 @@
"settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.",
"settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).",
"settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.",
"settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging."
"settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.",
"settings.taskHistoryRetention.description": "Auto-delete task history on extension reload. Deletes tasks older than the selected period. Options: Never (default), 90 days, 60 days, 30 days, 7 days, or 3 days. Warning: This cannot be undone and only runs on plugin reload."
}

View file

@ -0,0 +1,267 @@
import * as path from "path"
import * as fs from "fs/promises"
import type { Dirent } from "fs"
import { getStorageBasePath } from "./storage"
import { GlobalFileNames } from "../shared/globalFileNames"
/**
* Supported values for the retention setting.
* - "never" or 0 disables purging
* - "90" | "60" | "30" | "7" | "3" (string) or 90 | 60 | 30 | 7 | 3 (number) specify days
*/
export type RetentionSetting = "never" | "90" | "60" | "30" | "7" | "3" | 90 | 60 | 30 | 7 | 3 | 0 | "0" | number
export type PurgeResult = {
purgedCount: number
cutoff: number | null
}
/**
* Purge old task directories under <base>/tasks based on task_metadata.json ts value.
* 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<void>,
verbose: boolean = false,
): Promise<PurgeResult> {
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 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())
logv(`[Retention] Found ${taskDirs.length} task director${taskDirs.length === 1 ? "y" : "ies"} under ${tasksDir}`)
// Small helpers
const pathExists = async (p: string): Promise<boolean> => {
try {
await fs.access(p)
return true
} catch {
return false
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
// Aggressive recursive remove with retries; also directly clears checkpoints if needed
const removeDirAggressive = async (dir: string): Promise<boolean> => {
// Try up to 3 passes with short delays
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await fs.rm(dir, { recursive: true, force: true })
} catch {
// ignore and try more targeted cleanup below
}
// Verify
if (!(await pathExists(dir))) return true
// Targeted cleanup for stubborn checkpoint-only directories
try {
await fs.rm(path.join(dir, "checkpoints"), { recursive: true, force: true })
} catch {
// ignore
}
// Remove children one by one in case some FS impls struggle with rm -r
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(dir, entry.name)
try {
if (entry.isDirectory()) {
await fs.rm(entryPath, { recursive: true, force: true })
} else {
await fs.unlink(entryPath)
}
} catch {
// ignore individual failures; we'll retry the parent
}
}
} catch {
// ignore
}
// Final attempt this pass
try {
await fs.rm(dir, { recursive: true, force: true })
} catch {
// ignore
}
if (!(await pathExists(dir))) return true
// Backoff a bit before next attempt
await sleep(50 * attempt)
}
return !(await pathExists(dir))
}
const results = await Promise.all(
taskDirs.map(async (d) => {
const taskDir = path.join(tasksDir, d.name)
const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata)
let ts: number | null = null
// First try to get a timestamp from task_metadata.json (if present)
try {
const raw = await fs.readFile(metadataPath, "utf8")
const meta = JSON.parse(raw)
const maybeTs = Number((meta as any)?.ts)
if (Number.isFinite(maybeTs)) {
ts = maybeTs
}
} catch {
// Missing or invalid metadata; we'll fall back to directory mtime.
}
let shouldDelete = false
let reason = ""
// Check for checkpoint-only orphan directories (delete regardless of age)
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) {
shouldDelete = true
reason = "orphan checkpoints_only"
}
} catch {
// Ignore errors while scanning children; proceed with normal logic
}
if (!shouldDelete && ts !== null && ts < cutoff) {
// Normal case: metadata has a valid ts older than cutoff
shouldDelete = true
reason = `ts=${ts}`
} else if (!shouldDelete) {
// Orphan/legacy case: no valid ts; fall back to directory mtime
try {
const stat = await fs.stat(taskDir)
const mtimeMs = stat.mtime.getTime()
if (mtimeMs < cutoff) {
shouldDelete = true
reason = `no valid ts, mtime=${stat.mtime.toISOString()}`
}
} catch {
// If we can't stat the directory, skip it.
}
}
if (!shouldDelete) {
return 0
}
if (dryRun) {
logv(`[Retention][DRY RUN] Would delete task ${d.name} (${reason}) @ ${taskDir}`)
return 1
}
// Attempt deletion using provider callback (for full cleanup) or direct rm
let deletionError: unknown | null = null
try {
if (deleteTaskById) {
logv(`[Retention] Deleting task ${d.name} via provider @ ${taskDir} (${reason})`)
await deleteTaskById(d.name, taskDir)
} else {
logv(`[Retention] Deleting task ${d.name} via fs.rm @ ${taskDir} (${reason})`)
await fs.rm(taskDir, { recursive: true, force: true })
}
} catch (e) {
deletionError = e
}
// Verify deletion; if still exists, attempt aggressive cleanup with retries
let deleted = await removeDirAggressive(taskDir)
if (!deleted) {
// Did not actually remove; report the most relevant error
if (deletionError) {
log?.(
`[Retention] Failed to delete task ${d.name} @ ${taskDir}: ${
deletionError instanceof Error ? deletionError.message : String(deletionError)
} (directory still present)`,
)
} else {
log?.(
`[Retention] Failed to delete task ${d.name} @ ${taskDir}: directory still present after cleanup attempts`,
)
}
return 0
}
logv(`[Retention] Deleted task ${d.name} (${reason}) @ ${taskDir}`)
return 1
}),
)
const purged = results.reduce<number>((sum, n) => sum + n, 0)
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 = typeof value === "number" ? value : parseInt(String(value), 10)
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0
}

View file

@ -10,7 +10,7 @@ import { Package } from "@roo/package"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui"
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
@ -21,9 +21,20 @@ type AboutProps = HTMLAttributes<HTMLDivElement> & {
setTelemetrySetting: (setting: TelemetrySetting) => void
debug?: boolean
setDebug?: (debug: boolean) => void
taskHistoryRetention: "never" | "90" | "60" | "30" | "7" | "3"
setTaskHistoryRetention: (value: "never" | "90" | "60" | "30" | "7" | "3") => void
}
export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, className, ...props }: AboutProps) => {
export const About = ({
telemetrySetting,
setTelemetrySetting,
debug,
setDebug,
taskHistoryRetention,
setTaskHistoryRetention,
className,
...props
}: AboutProps) => {
const { t } = useAppTranslation()
return (
@ -131,10 +142,42 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
</Section>
<Section className="space-y-0">
<SearchableSetting
settingId="about-task-history-retention"
section="about"
label={t("settings:aboutRetention.label")}
className="mt-4">
<h3>{t("settings:aboutRetention.label")}</h3>
<div className="mt-2">
<Select
value={taskHistoryRetention}
onValueChange={(value: "never" | "90" | "60" | "30" | "7" | "3") => {
setTaskHistoryRetention(value)
}}>
<SelectTrigger className="w-64">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{t("settings:aboutRetention.options.never")}</SelectItem>
<SelectItem value="90">{t("settings:aboutRetention.options.90")}</SelectItem>
<SelectItem value="60">{t("settings:aboutRetention.options.60")}</SelectItem>
<SelectItem value="30">{t("settings:aboutRetention.options.30")}</SelectItem>
<SelectItem value="7">{t("settings:aboutRetention.options.7")}</SelectItem>
<SelectItem value="3">{t("settings:aboutRetention.options.3")}</SelectItem>
</SelectContent>
</Select>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:aboutRetention.description")}
</div>
<div className="text-red-500 text-sm mt-1">{t("settings:aboutRetention.warning")}</div>
</div>
</SearchableSetting>
<SearchableSetting
settingId="about-manage-settings"
section="about"
label={t("settings:about.manageSettings")}>
label={t("settings:about.manageSettings")}
className="mt-4 pt-4 border-t border-vscode-settings-headerBorder">
<h3>{t("settings:about.manageSettings")}</h3>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => vscode.postMessage({ type: "exportSettings" })} className="w-28">

View file

@ -213,6 +213,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
taskHistoryRetention,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -423,6 +424,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
taskHistoryRetention: taskHistoryRetention ?? "never",
profileThresholds,
imageGenerationProvider,
openRouterImageApiKey,
@ -615,6 +617,17 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
// Determine which tab content to render (for indexing or active display)
const renderTab = isIndexing ? sectionNames[indexingTabIndex] : activeTab
type TaskHistoryRetentionSetting = React.ComponentProps<typeof About>["taskHistoryRetention"]
const normalizedTaskHistoryRetention: TaskHistoryRetentionSetting =
taskHistoryRetention === "never" ||
taskHistoryRetention === "90" ||
taskHistoryRetention === "60" ||
taskHistoryRetention === "30" ||
taskHistoryRetention === "7" ||
taskHistoryRetention === "3"
? taskHistoryRetention
: "never"
// Handle search navigation - switch to the correct tab and scroll to the element
const handleSearchNavigate = useCallback(
(section: SectionName, settingId: string) => {
@ -947,6 +960,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
setTelemetrySetting={setTelemetrySetting}
debug={cachedState.debug}
setDebug={setDebug}
taskHistoryRetention={normalizedTaskHistoryRetention}
setTaskHistoryRetention={(value) => setCachedStateField("taskHistoryRetention", value)}
/>
)}
</SearchIndexProvider>

View file

@ -905,6 +905,19 @@
"useCustomModel": "Use custom: {{modelId}}",
"simplifiedExplanation": "You can adjust detailed model settings later."
},
"aboutRetention": {
"label": "Auto-delete task history",
"description": "Deletes tasks older than the selected period on extension reload.",
"warning": "Warning: This action permanently deletes tasks and only runs on plugin reload.",
"options": {
"never": "Never",
"90": "90 days",
"60": "60 days",
"30": "30 days",
"7": "7 days",
"3": "3 days"
}
},
"footer": {
"telemetry": {
"label": "Allow anonymous error and usage reporting",