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."
}
}