diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts
index f1c4b81c48..2ebb3ff634 100644
--- a/packages/types/src/global-settings.ts
+++ b/packages/types/src/global-settings.ts
@@ -41,6 +41,7 @@ export const globalSettingsSchema = z.object({
lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
taskHistory: z.array(historyItemSchema).optional(),
+ dismissedUpsells: z.array(z.string()).optional(),
// Image generation settings (experimental) - flattened for simplicity
openRouterImageApiKey: z.string().optional(),
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index d88d10d22a..0a1597a32e 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -2998,5 +2998,39 @@ export const webviewMessageHandler = async (
break
}
+ case "dismissUpsell": {
+ if (message.upsellId) {
+ try {
+ // Get current list of dismissed upsells
+ const dismissedUpsells = getGlobalState("dismissedUpsells") || []
+
+ // Add the new upsell ID if not already present
+ let updatedList = dismissedUpsells
+ if (!dismissedUpsells.includes(message.upsellId)) {
+ updatedList = [...dismissedUpsells, message.upsellId]
+ await updateGlobalState("dismissedUpsells", updatedList)
+ }
+
+ // Send updated list back to webview (use the already computed updatedList)
+ await provider.postMessageToWebview({
+ type: "dismissedUpsells",
+ list: updatedList,
+ })
+ } catch (error) {
+ // Fail silently as per Bruno's comment - it's OK to fail silently in this case
+ provider.log(`Failed to dismiss upsell: ${error instanceof Error ? error.message : String(error)}`)
+ }
+ }
+ break
+ }
+ case "getDismissedUpsells": {
+ // Send the current list of dismissed upsells to the webview
+ const dismissedUpsells = getGlobalState("dismissedUpsells") || []
+ await provider.postMessageToWebview({
+ type: "dismissedUpsells",
+ list: dismissedUpsells,
+ })
+ break
+ }
}
}
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 1565bb8c52..45bc978b83 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -123,6 +123,7 @@ export interface ExtensionMessage {
| "showEditMessageDialog"
| "commands"
| "insertTextIntoTextarea"
+ | "dismissedUpsells"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@@ -199,6 +200,7 @@ export interface ExtensionMessage {
context?: string
commands?: Command[]
queuedMessages?: QueuedMessage[]
+ list?: string[] // For dismissedUpsells
}
export type ExtensionState = Pick<
@@ -209,6 +211,7 @@ export type ExtensionState = Pick<
// | "lastShownAnnouncementId"
| "customInstructions"
// | "taskHistory" // Optional in GlobalSettings, required here.
+ | "dismissedUpsells"
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index ae8c72dd04..daa6a92eaa 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -222,6 +222,8 @@ export interface WebviewMessage {
| "queueMessage"
| "removeQueuedMessage"
| "editQueuedMessage"
+ | "dismissUpsell"
+ | "getDismissedUpsells"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -267,6 +269,8 @@ export interface WebviewMessage {
visibility?: ShareVisibility // For share visibility
hasContent?: boolean // For checkRulesDirectoryResult
checkOnly?: boolean // For deleteCustomMode check
+ upsellId?: string // For dismissUpsell
+ list?: string[] // For dismissedUpsells response
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean
diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx
new file mode 100644
index 0000000000..b1c0f82001
--- /dev/null
+++ b/webview-ui/src/components/common/DismissibleUpsell.tsx
@@ -0,0 +1,160 @@
+import { memo, ReactNode, useEffect, useState, useRef } from "react"
+import styled from "styled-components"
+
+import { vscode } from "@src/utils/vscode"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
+
+interface DismissibleUpsellProps {
+ /** Required unique identifier for this upsell */
+ id: string
+ /** Optional CSS class name for styling */
+ className?: string
+ /** Content to display inside the upsell */
+ children: ReactNode
+ /** Visual variant of the upsell */
+ variant?: "banner" | "default"
+ /** Optional callback when upsell is dismissed */
+ onDismiss?: () => void
+}
+
+const UpsellContainer = styled.div<{ $variant: "banner" | "default" }>`
+ position: relative;
+ padding: 12px 40px 12px 16px;
+ border-radius: 6px;
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+
+ ${(props) =>
+ props.$variant === "banner"
+ ? `
+ background-color: var(--vscode-button-background);
+ color: var(--vscode-button-foreground);
+ `
+ : `
+ background-color: var(--vscode-notifications-background);
+ color: var(--vscode-notifications-foreground);
+ border: 1px solid var(--vscode-notifications-border);
+ `}
+`
+
+const DismissButton = styled.button<{ $variant: "banner" | "default" }>`
+ position: absolute;
+ top: 50%;
+ right: 12px;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+
+ ${(props) =>
+ props.$variant === "banner"
+ ? `
+ color: var(--vscode-button-foreground);
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+ `
+ : `
+ color: var(--vscode-notifications-foreground);
+
+ &:hover {
+ background-color: var(--vscode-toolbar-hoverBackground);
+ }
+ `}
+
+ &:focus {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: 1px;
+ }
+`
+
+const DismissIcon = () => (
+
+)
+
+const DismissibleUpsell = memo(({ id, className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => {
+ const { t } = useAppTranslation()
+ const [isVisible, setIsVisible] = useState(true)
+ const isMountedRef = useRef(true)
+
+ useEffect(() => {
+ // Track mounted state
+ isMountedRef.current = true
+
+ // 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(id)) {
+ setIsVisible(false)
+ }
+ }
+ }
+
+ window.addEventListener("message", handleMessage)
+ return () => {
+ isMountedRef.current = false
+ window.removeEventListener("message", handleMessage)
+ }
+ }, [id])
+
+ 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: id,
+ })
+
+ // Then hide the upsell
+ setIsVisible(false)
+
+ // Call the optional callback
+ onDismiss?.()
+ }
+
+ // Don't render if not visible
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children}
+
+
+
+
+ )
+})
+
+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
new file mode 100644
index 0000000000..78b6c71e90
--- /dev/null
+++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx
@@ -0,0 +1,305 @@
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import DismissibleUpsell from "../DismissibleUpsell"
+
+// Mock the vscode API
+const mockPostMessage = vi.fn()
+vi.mock("@src/utils/vscode", () => ({
+ vscode: {
+ postMessage: (message: any) => mockPostMessage(message),
+ },
+}))
+
+// Mock the translation hook
+vi.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ "common:dismiss": "Dismiss",
+ "common:dismissAndDontShowAgain": "Dismiss and don't show again",
+ }
+ return translations[key] || key
+ },
+ }),
+}))
+
+describe("DismissibleUpsell", () => {
+ beforeEach(() => {
+ mockPostMessage.mockClear()
+ vi.clearAllTimers()
+ })
+
+ afterEach(() => {
+ vi.clearAllTimers()
+ })
+
+ it("renders children content", () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ it("applies the correct variant styles", () => {
+ const { container, rerender } = render(
+
+ Banner content
+ ,
+ )
+
+ // Check banner variant has correct background color style
+ const bannerContainer = container.firstChild
+ expect(bannerContainer).toHaveStyle({
+ backgroundColor: "var(--vscode-button-background)",
+ color: "var(--vscode-button-foreground)",
+ })
+
+ // Re-render with default variant
+ rerender(
+
+ Default content
+ ,
+ )
+
+ const defaultContainer = container.firstChild
+ expect(defaultContainer).toHaveStyle({
+ backgroundColor: "var(--vscode-notifications-background)",
+ color: "var(--vscode-notifications-foreground)",
+ })
+ })
+
+ it("requests dismissed upsells list on mount", () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ expect(mockPostMessage).toHaveBeenCalledWith({
+ type: "getDismissedUpsells",
+ })
+ })
+
+ it("hides the upsell when dismiss button is clicked", async () => {
+ const onDismiss = vi.fn()
+ const { container } = render(
+
+ Test content
+ ,
+ )
+
+ // Find and click the dismiss button
+ const dismissButton = screen.getByRole("button", { name: /dismiss/i })
+ fireEvent.click(dismissButton)
+
+ // Check that the dismiss message was sent BEFORE hiding
+ expect(mockPostMessage).toHaveBeenCalledWith({
+ type: "dismissUpsell",
+ upsellId: "test-upsell",
+ })
+
+ // Check that the component is no longer visible
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull()
+ })
+
+ // Check that the callback was called
+ expect(onDismiss).toHaveBeenCalled()
+ })
+
+ it("hides the upsell if it's in the dismissed list", async () => {
+ const { container } = render(
+
+ Test content
+ ,
+ )
+
+ // 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)
+
+ // Check that the component is no longer visible
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ it("remains visible if not in the dismissed list", async () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ // Simulate receiving a message that doesn't include this upsell
+ const messageEvent = new MessageEvent("message", {
+ data: {
+ type: "dismissedUpsells",
+ list: ["other-upsell"],
+ },
+ })
+ window.dispatchEvent(messageEvent)
+
+ // Check that the component is still visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+ })
+
+ it("applies the className prop to the container", () => {
+ const { container } = render(
+
+ Test content
+ ,
+ )
+
+ expect(container.firstChild).toHaveClass("custom-class")
+ })
+
+ it("dismiss button has proper accessibility attributes", () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ const dismissButton = screen.getByRole("button", { name: /dismiss/i })
+ expect(dismissButton).toHaveAttribute("aria-label", "Dismiss")
+ 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(
+
+ Test content
+ ,
+ )
+
+ const dismissButton = screen.getByRole("button", { name: /dismiss/i })
+
+ // Click multiple times rapidly
+ fireEvent.click(dismissButton)
+ 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",
+ })
+
+ // 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", () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ // 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
+ ,
+ )
+
+ 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", () => {
+ const { container } = render(
+
+ Test content
+ ,
+ )
+
+ // 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",
+ })
+ })
+})