mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
873a763ea7
commit
82f86d07f0
4 changed files with 450 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
212
webview-ui/src/components/chat/TaskHistoryWarning.tsx
Normal file
212
webview-ui/src/components/chat/TaskHistoryWarning.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"]),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue