mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
24584f54ec
commit
bf466b0e25
7 changed files with 609 additions and 4 deletions
77
webview-ui/src/components/chat/CloudNotificationBanner.tsx
Normal file
77
webview-ui/src/components/chat/CloudNotificationBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue