feat: add task history warning and cleanup feature

- Add TaskHistoryWarning component with non-blocking warning UI
- Show warning when task history exceeds 1000 tasks
- Provide "Clean up tasks older than 30 days" button
- Allow dismissal with reappearance at next 1000-task threshold
- Integrate component next to IndexingStatusBadge in ChatTextArea
- Add comprehensive test coverage for the new component
- Add translation strings for all UI text

Addresses issue #9773
This commit is contained in:
Roo Code 2025-12-03 10:01:30 +00:00
parent 873a763ea7
commit 82f86d07f0
4 changed files with 450 additions and 0 deletions

View file

@ -30,6 +30,7 @@ import { AutoApproveDropdown } from "./AutoApproveDropdown"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { TaskHistoryWarning } from "./TaskHistoryWarning"
import { usePromptHistory } from "./hooks/usePromptHistory"
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
@ -1265,6 +1266,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</StandardTooltip>
)}
{!isEditMode ? <IndexingStatusBadge /> : null}
{!isEditMode ? <TaskHistoryWarning /> : null}
{!isEditMode && cloudUserInfo && <CloudAccountSwitcher />}
{/* keep props referenced after moving browser button */}
<div

View file

@ -0,0 +1,212 @@
import React, { useState, useEffect, useMemo } from "react"
import { AlertTriangle, X, Trash2 } from "lucide-react"
import { cn } from "@src/lib/utils"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import {
Popover,
PopoverContent,
PopoverTrigger,
StandardTooltip,
Button,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@src/components/ui"
interface TaskHistoryWarningProps {
className?: string
}
const TASK_WARNING_THRESHOLD = 1000
const TASK_WARNING_INCREMENT = 1000
const CLEANUP_DAYS_THRESHOLD = 30
export const TaskHistoryWarning: React.FC<TaskHistoryWarningProps> = ({ className }) => {
const { t } = useAppTranslation()
const { taskHistory } = useExtensionState()
const [dismissedAtTaskCount, setDismissedAtTaskCount] = useState<number>(0)
const [showCleanupDialog, setShowCleanupDialog] = useState(false)
const [isCleaningUp, setIsCleaningUp] = useState(false)
// Load dismissed state from localStorage
useEffect(() => {
const stored = localStorage.getItem("taskHistoryWarningDismissed")
if (stored) {
setDismissedAtTaskCount(parseInt(stored, 10))
}
}, [])
// Calculate task count and whether to show warning
const taskCount = useMemo(() => {
return taskHistory?.length || 0
}, [taskHistory])
const shouldShowWarning = useMemo(() => {
if (taskCount < TASK_WARNING_THRESHOLD) {
return false
}
// Show warning if we've crossed a new threshold since dismissal
const currentThreshold = Math.floor(taskCount / TASK_WARNING_INCREMENT) * TASK_WARNING_INCREMENT
const dismissedThreshold = Math.floor(dismissedAtTaskCount / TASK_WARNING_INCREMENT) * TASK_WARNING_INCREMENT
return currentThreshold > dismissedThreshold
}, [taskCount, dismissedAtTaskCount])
// Calculate how many tasks would be deleted
const tasksToDelete = useMemo(() => {
if (!taskHistory) return 0
const cutoffDate = Date.now() - CLEANUP_DAYS_THRESHOLD * 24 * 60 * 60 * 1000
return taskHistory.filter((task) => task.ts < cutoffDate).length
}, [taskHistory])
const handleDismiss = () => {
setDismissedAtTaskCount(taskCount)
localStorage.setItem("taskHistoryWarningDismissed", taskCount.toString())
}
const handleCleanup = async () => {
if (isCleaningUp) return
setIsCleaningUp(true)
setShowCleanupDialog(false)
try {
// Get tasks older than 30 days
const cutoffDate = Date.now() - CLEANUP_DAYS_THRESHOLD * 24 * 60 * 60 * 1000
const tasksToDelete = taskHistory?.filter((task) => task.ts < cutoffDate).map((task) => task.id) || []
if (tasksToDelete.length > 0) {
// Send message to delete tasks
vscode.postMessage({
type: "deleteMultipleTasksWithIds",
ids: tasksToDelete,
})
// Reset dismissed state since we've cleaned up
setDismissedAtTaskCount(0)
localStorage.removeItem("taskHistoryWarningDismissed")
}
} finally {
setIsCleaningUp(false)
}
}
if (!shouldShowWarning) {
return null
}
return (
<>
<Popover>
<StandardTooltip content={t("chat:taskHistoryWarning.tooltip", { count: taskCount })}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label={t("chat:taskHistoryWarning.ariaLabel")}
className={cn(
"relative h-5 w-5 p-0",
"text-yellow-500 opacity-85",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"animate-pulse",
className,
)}>
<AlertTriangle className="w-4 h-4" />
{taskCount >= 2000 && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</Button>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent className="w-80 p-4" align="end">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<h3 className="font-semibold text-sm">{t("chat:taskHistoryWarning.title")}</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="h-5 w-5 p-0 opacity-60 hover:opacity-100"
aria-label={t("chat:taskHistoryWarning.dismiss")}>
<X className="w-3 h-3" />
</Button>
</div>
<div className="text-sm text-vscode-descriptionForeground">
<p className="mb-2">{t("chat:taskHistoryWarning.message", { count: taskCount })}</p>
<p className="text-xs opacity-80">{t("chat:taskHistoryWarning.performance")}</p>
</div>
<div className="flex flex-col gap-2">
<Button
variant="primary"
size="sm"
onClick={() => setShowCleanupDialog(true)}
disabled={isCleaningUp || tasksToDelete === 0}
className="w-full">
<Trash2 className="w-3 h-3 mr-2" />
{isCleaningUp
? t("chat:taskHistoryWarning.cleaning")
: tasksToDelete > 0
? t("chat:taskHistoryWarning.cleanupButton", { count: tasksToDelete })
: t("chat:taskHistoryWarning.noOldTasks")}
</Button>
{tasksToDelete === 0 && taskCount > TASK_WARNING_THRESHOLD && (
<p className="text-xs text-center text-vscode-descriptionForeground opacity-70">
{t("chat:taskHistoryWarning.allRecent")}
</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
<AlertDialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>{t("chat:taskHistoryWarning.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription className="text-vscode-foreground">
<div className="mb-2">
{t("chat:taskHistoryWarning.confirmMessage", {
count: tasksToDelete,
days: CLEANUP_DAYS_THRESHOLD,
})}
</div>
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
{t("chat:taskHistoryWarning.confirmWarning")}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="secondary">{t("common:cancel")}</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onClick={handleCleanup}>
<Trash2 className="w-3 h-3 mr-2" />
{t("chat:taskHistoryWarning.confirmButton")}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -0,0 +1,222 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { TaskHistoryWarning } from "../TaskHistoryWarning"
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, params?: any) => {
const translations: Record<string, string> = {
"chat:taskHistoryWarning.title": "High Task History",
"chat:taskHistoryWarning.description": `You have ${params?.count || 0} tasks in your history. Consider cleaning up old tasks to improve performance.`,
"chat:taskHistoryWarning.cleanupButton": "Clean up tasks older than 30 days",
"chat:taskHistoryWarning.dismiss": "Dismiss",
"chat:taskHistoryWarning.cleanupDialog.title": "Clean Up Old Tasks",
"chat:taskHistoryWarning.cleanupDialog.description": `This will permanently delete all tasks older than 30 days (${params?.count || 0} tasks). This action cannot be undone.`,
"chat:taskHistoryWarning.cleanupDialog.cancel": "Cancel",
"chat:taskHistoryWarning.cleanupDialog.confirm": "Clean Up",
"chat:taskHistoryWarning.cleanupSuccess": `Successfully deleted ${params?.count || 0} old tasks`,
"chat:taskHistoryWarning.cleanupError": `Failed to clean up tasks: ${params?.error || "Unknown error"}`,
}
return translations[key] || key
},
}),
}))
const mockTaskHistory: any[] = []
const mockSetTaskHistory = vi.fn()
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
taskHistory: mockTaskHistory,
setTaskHistory: mockSetTaskHistory,
}),
}))
const mockPostMessage = vi.fn()
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: mockPostMessage,
},
}))
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
})
describe("TaskHistoryWarning", () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.getItem.mockReturnValue(null)
mockTaskHistory.length = 0
})
it("should not render when task history is below threshold", () => {
mockTaskHistory.push(...Array(999).fill({ id: "task" }))
const { container } = render(<TaskHistoryWarning />)
expect(container.firstChild).toBeNull()
})
it("should render warning when task history exceeds threshold", () => {
mockTaskHistory.push(...Array(1001).fill({ id: "task" }))
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
expect(warningButton).toBeInTheDocument()
})
it("should not render if dismissed at current threshold", () => {
mockTaskHistory.push(...Array(1500).fill({ id: "task" }))
localStorageMock.getItem.mockReturnValue("1000")
const { container } = render(<TaskHistoryWarning />)
expect(container.firstChild).toBeNull()
})
it("should render if task count exceeds next threshold after dismissal", () => {
mockTaskHistory.push(...Array(2001).fill({ id: "task" }))
localStorageMock.getItem.mockReturnValue("1000")
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
expect(warningButton).toBeInTheDocument()
})
it("should show popover content when warning button is clicked", () => {
mockTaskHistory.push(...Array(1001).fill({ id: "task" }))
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
fireEvent.click(warningButton)
expect(screen.getByText(/You have 1001 tasks in your history/)).toBeInTheDocument()
expect(screen.getByText("Clean up tasks older than 30 days")).toBeInTheDocument()
expect(screen.getByText("Dismiss")).toBeInTheDocument()
})
it("should dismiss warning and save to localStorage", () => {
mockTaskHistory.push(...Array(1001).fill({ id: "task" }))
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
fireEvent.click(warningButton)
const dismissButton = screen.getByText("Dismiss")
fireEvent.click(dismissButton)
expect(localStorageMock.setItem).toHaveBeenCalledWith("taskHistoryWarningDismissedThreshold", "1000")
})
it("should show cleanup confirmation dialog when cleanup button is clicked", async () => {
const now = Date.now()
const oldDate = now - 31 * 24 * 60 * 60 * 1000
const newDate = now - 10 * 24 * 60 * 60 * 1000
mockTaskHistory.push(
...Array(500).fill({ id: "old", ts: oldDate }),
...Array(501).fill({ id: "new", ts: newDate }),
)
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
fireEvent.click(warningButton)
const cleanupButton = screen.getByText("Clean up tasks older than 30 days")
fireEvent.click(cleanupButton)
await waitFor(() => {
expect(screen.getByText("Clean Up Old Tasks")).toBeInTheDocument()
expect(screen.getByText(/This will permanently delete all tasks older than 30 days/)).toBeInTheDocument()
})
})
it("should call deleteMultipleTasksWithIds when cleanup is confirmed", async () => {
const now = Date.now()
const oldDate = now - 31 * 24 * 60 * 60 * 1000
const newDate = now - 10 * 24 * 60 * 60 * 1000
mockTaskHistory.push({ id: "old1", ts: oldDate }, { id: "old2", ts: oldDate }, { id: "new1", ts: newDate })
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
fireEvent.click(warningButton)
const cleanupButton = screen.getByText("Clean up tasks older than 30 days")
fireEvent.click(cleanupButton)
await waitFor(() => {
const confirmButton = screen.getByRole("button", { name: "Clean Up" })
fireEvent.click(confirmButton)
})
expect(mockPostMessage).toHaveBeenCalledWith({
type: "deleteMultipleTasksWithIds",
ids: ["old1", "old2"],
})
})
it("should handle cleanup with no old tasks gracefully", async () => {
const now = Date.now()
const newDate = now - 10 * 24 * 60 * 60 * 1000
mockTaskHistory.push(...Array(1001).fill({ id: "new", ts: newDate }))
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
fireEvent.click(warningButton)
const cleanupButton = screen.getByText("Clean up tasks older than 30 days")
fireEvent.click(cleanupButton)
await waitFor(() => {
expect(screen.getByText(/\(0 tasks\)/)).toBeInTheDocument()
})
})
it("should apply custom className when provided", () => {
mockTaskHistory.push(...Array(1001).fill({ id: "task" }))
const { container } = render(<TaskHistoryWarning className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass("custom-class")
})
it("should handle tasks without timestamps", async () => {
mockTaskHistory.push({ id: "no-ts-1" }, { id: "no-ts-2" }, ...Array(999).fill({ id: "task", ts: Date.now() }))
render(<TaskHistoryWarning />)
const warningButton = screen.getByRole("button", { name: /High Task History/i })
fireEvent.click(warningButton)
const cleanupButton = screen.getByText("Clean up tasks older than 30 days")
fireEvent.click(cleanupButton)
await waitFor(() => {
const confirmButton = screen.getByRole("button", { name: "Clean Up" })
fireEvent.click(confirmButton)
})
expect(mockPostMessage).toHaveBeenCalledWith({
type: "deleteMultipleTasksWithIds",
ids: expect.arrayContaining(["no-ts-1", "no-ts-2"]),
})
})
})

View file

@ -425,5 +425,19 @@
"updated": "Updated the to-do list",
"completed": "Completed",
"started": "Started"
},
"taskHistoryWarning": {
"title": "High Task History",
"description": "You have {{count}} tasks in your history. Consider cleaning up old tasks to improve performance.",
"cleanupButton": "Clean up tasks older than 30 days",
"dismiss": "Dismiss",
"cleanupDialog": {
"title": "Clean Up Old Tasks",
"description": "This will permanently delete all tasks older than 30 days ({{count}} tasks). This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Clean Up"
},
"cleanupSuccess": "Successfully deleted {{count}} old tasks",
"cleanupError": "Failed to clean up tasks: {{error}}"
}
}