diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx
index d358c68f1c..d6d22f3b53 100644
--- a/webview-ui/src/components/chat/ChatView.tsx
+++ b/webview-ui/src/components/chat/ChatView.tsx
@@ -40,7 +40,6 @@ import RooTips from "@src/components/welcome/RooTips"
import { StandardTooltip } from "@src/components/ui"
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
-import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import TelemetryBanner from "../common/TelemetryBanner"
import VersionIndicator from "../common/VersionIndicator"
@@ -55,9 +54,12 @@ import SystemPromptWarning from "./SystemPromptWarning"
import ProfileViolationWarning from "./ProfileViolationWarning"
import { CheckpointWarning } from "./CheckpointWarning"
import { QueuedMessages } from "./QueuedMessages"
+import { Cloud } from "lucide-react"
+
+import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
-import { Cloud } from "lucide-react"
+import { useUpsellVisibility, UPSELL_IDS } from "@src/hooks/useUpsellVisibility"
export interface ChatViewProps {
isHidden: boolean
@@ -1772,6 +1774,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction
- {cloudIsAuthenticated || taskHistory.length < 4 ? (
+ {cloudIsAuthenticated || (tasks.length === 0 && !isCloudUpsellVisible) ? (
) : (
<>
}
onClick={() => openUpsell()}
dismissOnClick={false}
diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx
index 6164294722..b4b03ac1a8 100644
--- a/webview-ui/src/components/chat/TaskHeader.tsx
+++ b/webview-ui/src/components/chat/TaskHeader.tsx
@@ -2,7 +2,6 @@ import { memo, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
-import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react"
import prettyBytes from "pretty-bytes"
@@ -24,6 +23,9 @@ import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
+import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
+import { UPSELL_IDS } from "@/constants/upsellIds"
+
export interface TaskHeaderProps {
task: ClineMessage
tokensIn: number
@@ -103,7 +105,7 @@ const TaskHeader = ({
{showLongRunningTaskMessage && !isTaskComplete && (
openUpsell()}
dismissOnClick={false}
variant="banner">
diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx
index 2eefb89972..073b1d0c74 100644
--- a/webview-ui/src/components/common/DismissibleUpsell.tsx
+++ b/webview-ui/src/components/common/DismissibleUpsell.tsx
@@ -1,6 +1,6 @@
-import { memo, ReactNode, useEffect, useState, useRef } from "react"
-import { vscode } from "@src/utils/vscode"
+import { memo, ReactNode } from "react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
+import { DismissedUpsellsProvider, useDismissedUpsells } from "@src/context/DismissedUpsellsContext"
interface DismissibleUpsellProps {
/** Required unique identifier for this upsell */
@@ -32,7 +32,8 @@ const DismissIcon = () => (
)
-const DismissibleUpsell = memo(
+// Internal component that uses the context
+const DismissibleUpsellInternal = memo(
({
upsellId,
className,
@@ -44,55 +45,21 @@ const DismissibleUpsell = memo(
dismissOnClick = false,
}: DismissibleUpsellProps) => {
const { t } = useAppTranslation()
- const [isVisible, setIsVisible] = useState(false)
- const isMountedRef = useRef(true)
+ const { isUpsellVisible, dismissUpsell, isLoading } = useDismissedUpsells()
- useEffect(() => {
- // Track mounted state
- isMountedRef.current = true
+ // Check if this upsell is visible
+ const isVisible = isUpsellVisible(upsellId)
- // Request the current list of dismissed upsells from the extension
- vscode.postMessage({ type: "getDismissedUpsells" })
-
- // Listen for the response
- const handleMessage = (event: MessageEvent) => {
- // Only update state if component is still mounted
- if (!isMountedRef.current) return
-
- const message = event.data
- // Add null/undefined check for message
- if (message && message.type === "dismissedUpsells" && Array.isArray(message.list)) {
- // Check if this upsell has been dismissed
- if (!message.list.includes(upsellId)) {
- setIsVisible(true)
- }
- }
- }
-
- window.addEventListener("message", handleMessage)
- return () => {
- isMountedRef.current = false
- window.removeEventListener("message", handleMessage)
- }
- }, [upsellId])
-
- const handleDismiss = async () => {
- // First notify the extension to persist the dismissal
- // This ensures the message is sent even if the component unmounts quickly
- vscode.postMessage({
- type: "dismissUpsell",
- upsellId: upsellId,
- })
-
- // Then hide the upsell
- setIsVisible(false)
+ const handleDismiss = () => {
+ // Dismiss the upsell through the context
+ dismissUpsell(upsellId)
// Call the optional callback
onDismiss?.()
}
- // Don't render if not visible
- if (!isVisible) {
+ // Don't render if not visible or still loading
+ if (!isVisible || isLoading) {
return null
}
@@ -107,6 +74,7 @@ const DismissibleUpsell = memo(
button: "text-vscode-notifications-foreground",
},
}
+
// Build container classes based on variant and presence of click handler
const containerClasses = [
"relative flex items-start justify-between gap-2",
@@ -158,6 +126,17 @@ const DismissibleUpsell = memo(
},
)
+DismissibleUpsellInternal.displayName = "DismissibleUpsellInternal"
+
+// Wrapper component that provides the context
+const DismissibleUpsell = memo((props: DismissibleUpsellProps) => {
+ return (
+
+
+
+ )
+})
+
DismissibleUpsell.displayName = "DismissibleUpsell"
export default DismissibleUpsell
diff --git a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx
index 3af66dfdf1..393cce6f85 100644
--- a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx
+++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx
@@ -1,6 +1,7 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import DismissibleUpsell from "../DismissibleUpsell"
+import React from "react"
// Mock the vscode API
const mockPostMessage = vi.fn()
@@ -24,34 +25,59 @@ vi.mock("@src/i18n/TranslationContext", () => ({
}))
describe("DismissibleUpsell", () => {
+ let messageHandler: ((event: MessageEvent) => void) | null = null
+
beforeEach(() => {
mockPostMessage.mockClear()
vi.clearAllTimers()
+
+ // Capture the message event handler
+ window.addEventListener = vi.fn((event, handler) => {
+ if (event === "message") {
+ messageHandler = handler as (event: MessageEvent) => void
+ }
+ })
+
+ window.removeEventListener = vi.fn()
})
afterEach(() => {
vi.clearAllTimers()
+ messageHandler = null
})
// Helper function to make the component visible
const makeUpsellVisible = () => {
- const messageEvent = new MessageEvent("message", {
- data: {
- type: "dismissedUpsells",
- list: [], // Empty list means no upsells are dismissed
- },
+ act(() => {
+ messageHandler?.({
+ data: {
+ type: "dismissedUpsells",
+ list: [], // Empty list means no upsells are dismissed
+ },
+ } as MessageEvent)
})
- window.dispatchEvent(messageEvent)
}
- it("renders children content", async () => {
+ // Helper function to mark upsell as dismissed
+ const makeUpsellDismissed = (upsellIds: string[]) => {
+ act(() => {
+ messageHandler?.({
+ data: {
+ type: "dismissedUpsells",
+ list: upsellIds,
+ },
+ } as MessageEvent)
+ })
+ }
+
+ it("renders children content when visible", async () => {
render(
Test content
,
)
- // Component starts hidden, make it visible
+ // Component starts hidden (loading), make it visible
makeUpsellVisible()
// Wait for component to become visible
@@ -60,7 +86,7 @@ describe("DismissibleUpsell", () => {
})
})
- it("requests dismissed upsells list on mount", () => {
+ it("requests dismissed upsells list on mount via context", () => {
render(
Test content
@@ -92,7 +118,7 @@ describe("DismissibleUpsell", () => {
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
fireEvent.click(dismissButton)
- // Check that the dismiss message was sent BEFORE hiding
+ // Check that the dismiss message was sent
expect(mockPostMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "test-upsell",
@@ -114,17 +140,8 @@ describe("DismissibleUpsell", () => {
,
)
- // Component starts hidden by default
- expect(container.firstChild).toBeNull()
-
// Simulate receiving a message that this upsell is dismissed
- const messageEvent = new MessageEvent("message", {
- data: {
- type: "dismissedUpsells",
- list: ["test-upsell", "other-upsell"],
- },
- })
- window.dispatchEvent(messageEvent)
+ makeUpsellDismissed(["test-upsell", "other-upsell"])
// Check that the component remains hidden
await waitFor(() => {
@@ -140,15 +157,9 @@ describe("DismissibleUpsell", () => {
)
// Simulate receiving a message that doesn't include this upsell
- const messageEvent = new MessageEvent("message", {
- data: {
- type: "dismissedUpsells",
- list: ["other-upsell"],
- },
- })
- window.dispatchEvent(messageEvent)
+ makeUpsellDismissed(["other-upsell"])
- // Check that the component is still visible
+ // Check that the component is visible
await waitFor(() => {
expect(screen.getByText("Test content")).toBeInTheDocument()
})
@@ -192,7 +203,6 @@ describe("DismissibleUpsell", () => {
expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again")
})
- // New edge case tests
it("handles multiple rapid dismissals of the same component", async () => {
const onDismiss = vi.fn()
render(
@@ -216,141 +226,16 @@ describe("DismissibleUpsell", () => {
fireEvent.click(dismissButton)
fireEvent.click(dismissButton)
- // Should only send one message
- expect(mockPostMessage).toHaveBeenCalledTimes(2) // 1 for getDismissedUpsells, 1 for dismissUpsell
- expect(mockPostMessage).toHaveBeenCalledWith({
- type: "dismissUpsell",
- upsellId: "test-upsell",
- })
+ // Should only send one dismiss message (plus initial getDismissedUpsells)
+ const dismissCalls = mockPostMessage.mock.calls.filter(
+ (call) => call[0].type === "dismissUpsell" && call[0].upsellId === "test-upsell",
+ )
+ expect(dismissCalls.length).toBe(1)
// Callback should only be called once
expect(onDismiss).toHaveBeenCalledTimes(1)
})
- it("does not update state after component unmounts", async () => {
- const { unmount } = render(
-
-
Test content
- ,
- )
-
- // Unmount the component
- unmount()
-
- // Simulate receiving a message after unmount
- const messageEvent = new MessageEvent("message", {
- data: {
- type: "dismissedUpsells",
- list: ["test-upsell"],
- },
- })
-
- // This should not cause any errors
- act(() => {
- window.dispatchEvent(messageEvent)
- })
-
- // No errors should be thrown
- expect(true).toBe(true)
- })
-
- it("handles invalid/malformed messages gracefully", async () => {
- render(
-
-
Test content
- ,
- )
-
- // First make it visible
- makeUpsellVisible()
-
- // Wait for component to be visible
- await waitFor(() => {
- expect(screen.getByText("Test content")).toBeInTheDocument()
- })
-
- // Send various malformed messages
- const malformedMessages = [
- { type: "dismissedUpsells", list: null },
- { type: "dismissedUpsells", list: "not-an-array" },
- { type: "dismissedUpsells" }, // missing list
- { type: "wrongType", list: ["test-upsell"] },
- null,
- undefined,
- "string-message",
- ]
-
- malformedMessages.forEach((data) => {
- const messageEvent = new MessageEvent("message", { data })
- window.dispatchEvent(messageEvent)
- })
-
- // Component should still be visible
- expect(screen.getByText("Test content")).toBeInTheDocument()
- })
-
- it("ensures message is sent before component unmounts on dismiss", async () => {
- const { unmount } = render(
-
-
Test content
- ,
- )
-
- // Make component visible
- makeUpsellVisible()
-
- // Wait for component to be visible
- await waitFor(() => {
- expect(screen.getByText("Test content")).toBeInTheDocument()
- })
-
- const dismissButton = screen.getByRole("button", { name: /dismiss/i })
- fireEvent.click(dismissButton)
-
- // Message should be sent immediately
- expect(mockPostMessage).toHaveBeenCalledWith({
- type: "dismissUpsell",
- upsellId: "test-upsell",
- })
-
- // Unmount immediately after clicking
- unmount()
-
- // Message was already sent before unmount
- expect(mockPostMessage).toHaveBeenCalledWith({
- type: "dismissUpsell",
- upsellId: "test-upsell",
- })
- })
-
- it("uses separate id and className props correctly", async () => {
- const { container } = render(
-
-
Test content
- ,
- )
-
- // Make component visible
- makeUpsellVisible()
-
- // Wait for component to be visible
- await waitFor(() => {
- expect(container.firstChild).not.toBeNull()
- })
-
- // className should be applied to the container
- expect(container.firstChild).toHaveClass("styling-class")
-
- // When dismissed, should use the id, not className
- const dismissButton = screen.getByRole("button", { name: /dismiss/i })
- fireEvent.click(dismissButton)
-
- expect(mockPostMessage).toHaveBeenCalledWith({
- type: "dismissUpsell",
- upsellId: "unique-id",
- })
- })
-
it("calls onClick when the container is clicked", async () => {
const onClick = vi.fn()
render(
@@ -425,6 +310,14 @@ describe("DismissibleUpsell", () => {
,
)
+ // Make sure it's still visible after re-render
+ makeUpsellVisible()
+
+ // Wait for component to be visible again
+ await waitFor(() => {
+ expect(container.firstChild).not.toBeNull()
+ })
+
// Should not have cursor-pointer when onClick is not provided
expect(container.firstChild).not.toHaveClass("cursor-pointer")
})
@@ -526,7 +419,10 @@ describe("DismissibleUpsell", () => {
expect(onClick).toHaveBeenCalledTimes(1)
expect(onDismiss).not.toHaveBeenCalled()
- expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "dismissUpsell" }))
+ // Should not dismiss the upsell
+ const dismissCalls = mockPostMessage.mock.calls.filter((call) => call[0].type === "dismissUpsell")
+ expect(dismissCalls.length).toBe(0)
+
expect(screen.getByText("Test content")).toBeInTheDocument()
})
@@ -554,4 +450,77 @@ describe("DismissibleUpsell", () => {
expect(onDismiss).not.toHaveBeenCalled()
expect(screen.getByText("Test content")).toBeInTheDocument()
})
+
+ it("renders icon when provided", async () => {
+ const TestIcon = () => Icon
+ render(
+ }>
+