diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 58f42a367b..45b786b9cf 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( )} {!isEditMode ? : null} + {!isEditMode ? : null} {!isEditMode && cloudUserInfo && } {/* keep props referenced after moving browser button */}
= ({ className }) => { + const { t } = useAppTranslation() + const { taskHistory } = useExtensionState() + + const [dismissedAtTaskCount, setDismissedAtTaskCount] = useState(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 ( + <> + + + + + + + + +
+
+
+ +

{t("chat:taskHistoryWarning.title")}

+
+ +
+ +
+

{t("chat:taskHistoryWarning.message", { count: taskCount })}

+

{t("chat:taskHistoryWarning.performance")}

+
+ +
+ + + {tasksToDelete === 0 && taskCount > TASK_WARNING_THRESHOLD && ( +

+ {t("chat:taskHistoryWarning.allRecent")} +

+ )} +
+
+
+
+ + + + + {t("chat:taskHistoryWarning.confirmTitle")} + +
+ {t("chat:taskHistoryWarning.confirmMessage", { + count: tasksToDelete, + days: CLEANUP_DAYS_THRESHOLD, + })} +
+
+ {t("chat:taskHistoryWarning.confirmWarning")} +
+
+
+ + + + + + + + +
+
+ + ) +} diff --git a/webview-ui/src/components/chat/__tests__/TaskHistoryWarning.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHistoryWarning.spec.tsx new file mode 100644 index 0000000000..40075aee0c --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHistoryWarning.spec.tsx @@ -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 = { + "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() + expect(container.firstChild).toBeNull() + }) + + it("should render warning when task history exceeds threshold", () => { + mockTaskHistory.push(...Array(1001).fill({ id: "task" })) + + render() + + 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() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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"]), + }) + }) +}) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 6a0f7f8e15..ee7e530037 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -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}}" } }