feat: add Cloud notification for long-running tasks

- Add Cloud icon button to TaskHeader that lights up blue when notifications should be shown
- Create CloudNotificationBanner component with speech bubble design
- Implement task duration tracking with 2+ minute threshold
- Add persistence for dismissal state per task using ExtensionStateContext
- Enable navigation to Account page on notification click
- Include smooth height animations for show/hide transitions
- Add comprehensive test coverage for new functionality
- Add localization support for notification message
This commit is contained in:
Roo Code 2025-08-05 18:01:57 +00:00 committed by Bruno Bergher
parent 24584f54ec
commit bf466b0e25
7 changed files with 609 additions and 4 deletions

View file

@ -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 (
<div
className={cn(
"relative mx-3 mb-3 transition-all duration-200 ease-in-out",
isAnimating ? "opacity-0 transform scale-95" : "opacity-100 transform scale-100",
className,
)}>
{/* Main notification container with speech bubble */}
<div
className="relative bg-vscode-charts-blue text-white px-4 py-3 rounded-md cursor-pointer hover:bg-opacity-90 transition-colors"
onClick={handleClick}>
{/* Speech bubble triangle */}
<div className="absolute top-1/2 right-0 transform translate-x-full -translate-y-1/2">
<div
className="w-0 h-0 border-l-[12px] border-r-0 border-t-[8px] border-b-[8px]"
style={{
borderLeftColor: "var(--vscode-charts-blue)",
borderTopColor: "transparent",
borderBottomColor: "transparent",
}}
/>
</div>
{/* Content */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium pr-8">{t("chat:cloudNotification.message")}</span>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation()
handleDismiss()
}}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 hover:bg-white hover:bg-opacity-20 rounded transition-colors"
aria-label="Close notification">
<X size={16} className="text-white" />
</button>
</div>
</div>
</div>
)
}

View file

@ -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<string | null>(null)
const { t } = useTranslation()
const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard()
return (
<div className="flex flex-row items-center">
{/* Cloud icon button */}
<StandardTooltip content="Cloud">
<button
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
showCloudNotification
? "text-vscode-charts-blue opacity-100"
: "text-vscode-foreground opacity-85",
)}
style={{ fontSize: 16.5 }}
aria-label="Cloud">
<Cloud size={16} />
</button>
</StandardTooltip>
<IconButton
iconClass="codicon-desktop-download"
title={t("chat:task.export")}

View file

@ -1,4 +1,4 @@
import { memo, useRef, useState } from "react"
import { memo, useRef, useState, useEffect } from "react"
import { useTranslation } from "react-i18next"
import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react"
import prettyBytes from "pretty-bytes"
@ -12,6 +12,7 @@ import { cn } from "@src/lib/utils"
import { StandardTooltip } from "@src/components/ui"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { vscode } from "@src/utils/vscode"
import Thumbnails from "../common/Thumbnails"
@ -19,6 +20,7 @@ import { TaskActions } from "./TaskActions"
import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
import { CloudNotificationBanner } from "./CloudNotificationBanner"
export interface TaskHeaderProps {
task: ClineMessage
@ -46,14 +48,44 @@ const TaskHeader = ({
todos,
}: TaskHeaderProps) => {
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<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(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 = (
<StandardTooltip content={t("chat:task.condenseContext")}>
<button
@ -292,11 +324,24 @@ const TaskHeader = ({
{/* Footer with task management buttons */}
<div onClick={(e) => e.stopPropagation()}>
<TaskActions item={currentTaskItem} buttonsDisabled={buttonsDisabled} />
<TaskActions
item={currentTaskItem}
buttonsDisabled={buttonsDisabled}
showCloudNotification={showCloudNotification}
/>
</div>
</>
)}
</div>
{/* Cloud notification banner */}
{showCloudNotification && (
<CloudNotificationBanner
onDismiss={handleDismissCloudNotification}
onNavigateToAccount={handleNavigateToAccount}
/>
)}
<TodoListDisplay todos={todos ?? (task as any)?.tool?.todos ?? []} />
</div>
)

View file

@ -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<string, string> = {
"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(<CloudNotificationBanner {...defaultProps} />)
expect(
screen.getByText("This might take a while. Grab a coffee and continue from anywhere with Cloud."),
).toBeInTheDocument()
})
it("renders the close button", () => {
render(<CloudNotificationBanner {...defaultProps} />)
const closeButton = screen.getByRole("button", { name: "Close notification" })
expect(closeButton).toBeInTheDocument()
})
it("calls onNavigateToAccount and onDismiss when banner is clicked", async () => {
render(<CloudNotificationBanner {...defaultProps} />)
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(<CloudNotificationBanner {...defaultProps} />)
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(<CloudNotificationBanner {...defaultProps} />)
const closeButton = screen.getByRole("button", { name: "Close notification" })
fireEvent.click(closeButton)
expect(mockOnNavigateToAccount).not.toHaveBeenCalled()
})
it("applies custom className when provided", () => {
const { container } = render(<CloudNotificationBanner {...defaultProps} className="custom-class" />)
const bannerContainer = container.firstChild as HTMLElement
expect(bannerContainer).toHaveClass("custom-class")
})
it("has proper speech bubble styling", () => {
render(<CloudNotificationBanner {...defaultProps} />)
// 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(<CloudNotificationBanner {...defaultProps} />)
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")
})
})
})

View file

@ -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<string, string> = {
"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<string>(),
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 }) => (
<div data-testid="task-actions" data-show-cloud-notification={showCloudNotification}>
Task Actions
</div>
),
}))
vi.mock("../CloudNotificationBanner", () => ({
CloudNotificationBanner: ({ onDismiss, onNavigateToAccount }: any) => (
<div data-testid="cloud-notification-banner">
<button onClick={onDismiss} data-testid="dismiss-button">
Dismiss
</button>
<button onClick={onNavigateToAccount} data-testid="navigate-button">
Navigate
</button>
</div>
),
}))
vi.mock("../TodoListDisplay", () => ({
TodoListDisplay: () => <div data-testid="todo-list">Todo List</div>,
}))
vi.mock("../ContextWindowProgress", () => ({
ContextWindowProgress: () => <div>Context Progress</div>,
}))
vi.mock("../Mention", () => ({
Mention: ({ text }: { text: string }) => <span>{text}</span>,
}))
vi.mock("../../common/Thumbnails", () => ({
default: () => <div>Thumbnails</div>,
}))
// 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<TaskHeaderProps> = {}) => {
return render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<TaskHeader {...defaultProps} {...props} />
</TooltipProvider>
</QueryClientProvider>,
)
}
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(
<QueryClientProvider client={queryClient}>
<TaskHeader
{...defaultProps}
task={{ type: "say", ts: Date.now() - 3 * 60 * 1000, text: "Test task", images: [] }}
/>
</QueryClientProvider>,
)
// Fast-forward timers
act(() => {
vi.advanceTimersByTime(1000)
})
await waitFor(() => {
expect(screen.getByTestId("cloud-notification-banner")).toBeInTheDocument()
})
})
})

View file

@ -147,6 +147,9 @@ export interface ExtensionStateContextType extends ExtensionState {
setMaxDiagnosticMessages: (value: number) => void
includeTaskHistoryInEnhance?: boolean
setIncludeTaskHistoryInEnhance: (value: boolean) => void
dismissedCloudNotifications: Set<string>
setDismissedCloudNotifications: (value: Set<string>) => void
addDismissedCloudNotification: (taskId: string) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -266,6 +269,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
global: {},
})
const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(false)
const [dismissedCloudNotifications, setDismissedCloudNotifications] = useState<Set<string>>(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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

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