diff --git a/webview-ui/src/components/chat/CloudNotificationBanner.tsx b/webview-ui/src/components/chat/CloudNotificationBanner.tsx new file mode 100644 index 0000000000..bfb3ac17e9 --- /dev/null +++ b/webview-ui/src/components/chat/CloudNotificationBanner.tsx @@ -0,0 +1,77 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { X } from "lucide-react" +import { cn } from "@src/lib/utils" + +interface CloudNotificationBannerProps { + onDismiss: () => void + onNavigateToAccount: () => void + className?: string +} + +export const CloudNotificationBanner = ({ + onDismiss, + onNavigateToAccount, + className, +}: CloudNotificationBannerProps) => { + const { t } = useTranslation() + const [isVisible, setIsVisible] = useState(true) + const [isAnimating, setIsAnimating] = useState(false) + + const handleDismiss = () => { + setIsAnimating(true) + setTimeout(() => { + setIsVisible(false) + onDismiss() + }, 200) // Match animation duration + } + + const handleClick = () => { + onNavigateToAccount() + handleDismiss() + } + + if (!isVisible) return null + + return ( +
+ {/* Main notification container with speech bubble */} +
+ {/* Speech bubble triangle */} +
+
+
+ + {/* Content */} +
+ {t("chat:cloudNotification.message")} + + {/* Close button */} + +
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 1b192219ad..1cdfc8e5f1 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -1,10 +1,13 @@ import { useState } from "react" import { useTranslation } from "react-i18next" +import { Cloud } from "lucide-react" import type { HistoryItem } from "@roo-code/types" import { vscode } from "@/utils/vscode" import { useCopyToClipboard } from "@/utils/clipboard" +import { cn } from "@/lib/utils" +import { StandardTooltip } from "@/components/ui" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { IconButton } from "./IconButton" @@ -13,15 +16,37 @@ import { ShareButton } from "./ShareButton" interface TaskActionsProps { item?: HistoryItem buttonsDisabled: boolean + showCloudNotification?: boolean } -export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { +export const TaskActions = ({ item, buttonsDisabled, showCloudNotification }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) const { t } = useTranslation() const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() return (
+ {/* Cloud icon button */} + + + { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { apiConfiguration, currentTaskItem, dismissedCloudNotifications, addDismissedCloudNotification } = + useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) + const [showCloudNotification, setShowCloudNotification] = useState(false) const textContainerRef = useRef(null) const textRef = useRef(null) const contextWindow = model?.contextWindow || 1 + // Task duration tracking + useEffect(() => { + const taskId = currentTaskItem?.id + if (!taskId) return + + const interval = setInterval(() => { + const duration = Date.now() - task.ts + + // Show notification if task has been running for more than 2 minutes + // and hasn't been dismissed for this task + const shouldShow = duration > 2 * 60 * 1000 && !dismissedCloudNotifications.has(taskId) + setShowCloudNotification(shouldShow) + }, 1000) + + return () => clearInterval(interval) + }, [task.ts, currentTaskItem?.id, dismissedCloudNotifications]) + + const handleDismissCloudNotification = () => { + if (currentTaskItem?.id) { + addDismissedCloudNotification(currentTaskItem.id) + } + setShowCloudNotification(false) + } + + const handleNavigateToAccount = () => { + vscode.postMessage({ type: "switchTab", tab: "account" }) + } + const condenseButton = (
)}
+ + {/* Cloud notification banner */} + {showCloudNotification && ( + + )} + ) diff --git a/webview-ui/src/components/chat/__tests__/CloudNotificationBanner.spec.tsx b/webview-ui/src/components/chat/__tests__/CloudNotificationBanner.spec.tsx new file mode 100644 index 0000000000..c0275d0d8a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CloudNotificationBanner.spec.tsx @@ -0,0 +1,135 @@ +// npx vitest src/components/chat/__tests__/CloudNotificationBanner.spec.tsx + +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { vi } from "vitest" + +import { CloudNotificationBanner } from "../CloudNotificationBanner" + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "chat:cloudNotification.message": + "This might take a while. Grab a coffee and continue from anywhere with Cloud.", + } + return translations[key] || key + }, + }), +})) + +// Mock vscode +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock utils +vi.mock("@src/lib/utils", () => ({ + cn: (...classes: any[]) => classes.filter(Boolean).join(" "), +})) + +describe("CloudNotificationBanner", () => { + const mockOnDismiss = vi.fn() + const mockOnNavigateToAccount = vi.fn() + + const defaultProps = { + onDismiss: mockOnDismiss, + onNavigateToAccount: mockOnNavigateToAccount, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the notification banner with correct message", () => { + render() + + expect( + screen.getByText("This might take a while. Grab a coffee and continue from anywhere with Cloud."), + ).toBeInTheDocument() + }) + + it("renders the close button", () => { + render() + + const closeButton = screen.getByRole("button", { name: "Close notification" }) + expect(closeButton).toBeInTheDocument() + }) + + it("calls onNavigateToAccount and onDismiss when banner is clicked", async () => { + render() + + const banner = screen.getByText("This might take a while. Grab a coffee and continue from anywhere with Cloud.") + fireEvent.click(banner) + + expect(mockOnNavigateToAccount).toHaveBeenCalledTimes(1) + + // Wait for the animation timeout + await waitFor( + () => { + expect(mockOnDismiss).toHaveBeenCalledTimes(1) + }, + { timeout: 300 }, + ) + }) + + it("calls onDismiss when close button is clicked", async () => { + render() + + const closeButton = screen.getByRole("button", { name: "Close notification" }) + fireEvent.click(closeButton) + + // Wait for the animation timeout + await waitFor( + () => { + expect(mockOnDismiss).toHaveBeenCalledTimes(1) + }, + { timeout: 300 }, + ) + }) + + it("does not call onNavigateToAccount when close button is clicked", () => { + render() + + const closeButton = screen.getByRole("button", { name: "Close notification" }) + fireEvent.click(closeButton) + + expect(mockOnNavigateToAccount).not.toHaveBeenCalled() + }) + + it("applies custom className when provided", () => { + const { container } = render() + + const bannerContainer = container.firstChild as HTMLElement + expect(bannerContainer).toHaveClass("custom-class") + }) + + it("has proper speech bubble styling", () => { + render() + + // Check for the speech bubble triangle element + const triangleElement = screen + .getByText("This might take a while. Grab a coffee and continue from anywhere with Cloud.") + .closest("div") + ?.parentElement?.querySelector("div") + + expect(triangleElement).toBeInTheDocument() + }) + + it("handles animation states correctly", async () => { + const { container } = render() + + const bannerContainer = container.firstChild as HTMLElement + expect(bannerContainer).toHaveClass("opacity-100") + + const closeButton = screen.getByRole("button", { name: "Close notification" }) + fireEvent.click(closeButton) + + // Should start animation + await waitFor(() => { + expect(bannerContainer).toHaveClass("opacity-0") + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.cloud-notification.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.cloud-notification.spec.tsx new file mode 100644 index 0000000000..cb56449452 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.cloud-notification.spec.tsx @@ -0,0 +1,311 @@ +// npx vitest src/components/chat/__tests__/TaskHeader.cloud-notification.spec.tsx + +import { render, screen, waitFor, act } from "@testing-library/react" +import { vi } from "vitest" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import TaskHeader, { TaskHeaderProps } from "../TaskHeader" +import { TooltipProvider } from "@src/components/ui/tooltip" + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "chat:task.title": "Task", + "chat:task.expand": "Expand task", + "chat:task.collapse": "Collapse task", + "chat:cloudNotification.message": + "This might take a while. Grab a coffee and continue from anywhere with Cloud.", + } + return translations[key] || key + }, + }), +})) + +// Mock ExtensionStateContext +const mockExtensionState = { + apiConfiguration: {}, + currentTaskItem: { id: "test-task-id", size: 100 }, + dismissedCloudNotifications: new Set(), + addDismissedCloudNotification: vi.fn(), +} + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockExtensionState, +})) + +// Mock useSelectedModel +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ + id: "test-model", + info: { contextWindow: 4000 }, + }), +})) + +// Mock vscode +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock other components +vi.mock("../TaskActions", () => ({ + TaskActions: ({ showCloudNotification }: { showCloudNotification?: boolean }) => ( +
+ Task Actions +
+ ), +})) + +vi.mock("../CloudNotificationBanner", () => ({ + CloudNotificationBanner: ({ onDismiss, onNavigateToAccount }: any) => ( +
+ + +
+ ), +})) + +vi.mock("../TodoListDisplay", () => ({ + TodoListDisplay: () =>
Todo List
, +})) + +vi.mock("../ContextWindowProgress", () => ({ + ContextWindowProgress: () =>
Context Progress
, +})) + +vi.mock("../Mention", () => ({ + Mention: ({ text }: { text: string }) => {text}, +})) + +vi.mock("../../common/Thumbnails", () => ({ + default: () =>
Thumbnails
, +})) + +// Mock utils +vi.mock("@src/utils/format", () => ({ + formatLargeNumber: (num: number) => num.toString(), +})) + +vi.mock("@src/lib/utils", () => ({ + cn: (...classes: any[]) => classes.filter(Boolean).join(" "), +})) + +vi.mock("@roo/api", () => ({ + getModelMaxOutputTokens: () => 1000, +})) + +describe("TaskHeader Cloud Notification", () => { + let queryClient: QueryClient + + const defaultProps: TaskHeaderProps = { + task: { type: "say", ts: Date.now() - 3 * 60 * 1000, text: "Test task", images: [] }, // 3 minutes ago + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + contextTokens: 1000, + buttonsDisabled: false, + handleCondenseContext: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + // Reset mock state + mockExtensionState.dismissedCloudNotifications.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const renderTaskHeader = (props: Partial = {}) => { + return render( + + + + + , + ) + } + + it("shows cloud notification for tasks running longer than 2 minutes", async () => { + const taskStartTime = Date.now() - 3 * 60 * 1000 // 3 minutes ago + renderTaskHeader({ + task: { type: "say", ts: taskStartTime, text: "Test task", images: [] }, + }) + + // Fast-forward timers to trigger the interval + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.getByTestId("cloud-notification-banner")).toBeInTheDocument() + }) + }) + + it("does not show cloud notification for tasks running less than 2 minutes", async () => { + const taskStartTime = Date.now() - 1 * 60 * 1000 // 1 minute ago + renderTaskHeader({ + task: { type: "say", ts: taskStartTime, text: "Test task", images: [] }, + }) + + // Fast-forward timers to trigger the interval + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.queryByTestId("cloud-notification-banner")).not.toBeInTheDocument() + }) + }) + + it("does not show cloud notification if already dismissed for this task", async () => { + const taskStartTime = Date.now() - 3 * 60 * 1000 // 3 minutes ago + mockExtensionState.dismissedCloudNotifications.add("test-task-id") + + renderTaskHeader({ + task: { type: "say", ts: taskStartTime, text: "Test task", images: [] }, + }) + + // Fast-forward timers to trigger the interval + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.queryByTestId("cloud-notification-banner")).not.toBeInTheDocument() + }) + }) + + it("passes showCloudNotification prop to TaskActions", async () => { + const taskStartTime = Date.now() - 3 * 60 * 1000 // 3 minutes ago + renderTaskHeader({ + task: { type: "say", ts: taskStartTime, text: "Test task", images: [] }, + }) + + // Expand the task to see TaskActions + const expandButton = screen.getByRole("button", { name: "Expand task" }) + act(() => { + expandButton.click() + }) + + // Fast-forward timers to trigger the interval + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + const taskActions = screen.getByTestId("task-actions") + expect(taskActions).toHaveAttribute("data-show-cloud-notification", "true") + }) + }) + + it("handles cloud notification dismissal", async () => { + const taskStartTime = Date.now() - 3 * 60 * 1000 // 3 minutes ago + renderTaskHeader({ + task: { type: "say", ts: taskStartTime, text: "Test task", images: [] }, + }) + + // Fast-forward timers to trigger the interval + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.getByTestId("cloud-notification-banner")).toBeInTheDocument() + }) + + // Dismiss the notification + const dismissButton = screen.getByTestId("dismiss-button") + act(() => { + dismissButton.click() + }) + + expect(mockExtensionState.addDismissedCloudNotification).toHaveBeenCalledWith("test-task-id") + }) + + it("handles navigation to account page", async () => { + const { vscode } = await import("@src/utils/vscode") + const taskStartTime = Date.now() - 3 * 60 * 1000 // 3 minutes ago + renderTaskHeader({ + task: { type: "say", ts: taskStartTime, text: "Test task", images: [] }, + }) + + // Fast-forward timers to trigger the interval + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.getByTestId("cloud-notification-banner")).toBeInTheDocument() + }) + + // Click navigate button + const navigateButton = screen.getByTestId("navigate-button") + act(() => { + navigateButton.click() + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "switchTab", + tab: "account", + }) + }) + + it("cleans up interval on unmount", () => { + const clearIntervalSpy = vi.spyOn(global, "clearInterval") + const { unmount } = renderTaskHeader() + + unmount() + + expect(clearIntervalSpy).toHaveBeenCalled() + }) + + it("updates duration tracking when task changes", async () => { + const { rerender } = renderTaskHeader({ + task: { type: "say", ts: Date.now() - 1 * 60 * 1000, text: "Test task", images: [] }, + }) + + // Fast-forward timers + act(() => { + vi.advanceTimersByTime(1000) + }) + + // Should not show notification yet + expect(screen.queryByTestId("cloud-notification-banner")).not.toBeInTheDocument() + + // Update task to be older + rerender( + + + , + ) + + // Fast-forward timers + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.getByTestId("cloud-notification-banner")).toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index da7ab63358..a03ee9a83d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -147,6 +147,9 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxDiagnosticMessages: (value: number) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance: (value: boolean) => void + dismissedCloudNotifications: Set + setDismissedCloudNotifications: (value: Set) => void + addDismissedCloudNotification: (taskId: string) => void } export const ExtensionStateContext = createContext(undefined) @@ -266,6 +269,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode global: {}, }) const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(false) + const [dismissedCloudNotifications, setDismissedCloudNotifications] = useState>(new Set()) const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -517,6 +521,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance, + dismissedCloudNotifications, + setDismissedCloudNotifications, + addDismissedCloudNotification: (taskId: string) => { + setDismissedCloudNotifications((prev) => new Set([...Array.from(prev), taskId])) + }, } return {children} diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 3b1847a10f..7c76cde560 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -378,5 +378,8 @@ "queuedMessages": { "title": "Queued Messages:", "clickToEdit": "Click to edit message" + }, + "cloudNotification": { + "message": "This might take a while. Grab a coffee and continue from anywhere with Cloud." } }