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..601c4e9b65 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -2998,5 +2998,33 @@ export const webviewMessageHandler = async (
break
}
+ case "dismissUpsell": {
+ if (message.upsellId) {
+ // Get current list of dismissed upsells
+ const dismissedUpsells = getGlobalState("dismissedUpsells") || []
+
+ // Add the new upsell ID if not already present
+ if (!dismissedUpsells.includes(message.upsellId)) {
+ const updatedList = [...dismissedUpsells, message.upsellId]
+ await updateGlobalState("dismissedUpsells", updatedList)
+ }
+
+ // Send updated list back to webview
+ await provider.postMessageToWebview({
+ type: "dismissedUpsells",
+ list: [...dismissedUpsells, message.upsellId],
+ })
+ }
+ 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..0721629c22
--- /dev/null
+++ b/webview-ui/src/components/common/DismissibleUpsell.tsx
@@ -0,0 +1,144 @@
+import { memo, ReactNode, useEffect, useState } from "react"
+import styled from "styled-components"
+
+import { vscode } from "@src/utils/vscode"
+
+interface DismissibleUpsellProps {
+ /** Required unique identifier for this upsell */
+ 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(({ className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => {
+ const [isVisible, setIsVisible] = useState(true)
+
+ useEffect(() => {
+ // Request the current list of dismissed upsells from the extension
+ vscode.postMessage({ type: "getDismissedUpsells" })
+
+ // Listen for the response
+ const handleMessage = (event: MessageEvent) => {
+ const message = event.data
+ if (message.type === "dismissedUpsells" && Array.isArray(message.list)) {
+ // Check if this upsell has been dismissed
+ if (message.list.includes(className)) {
+ setIsVisible(false)
+ }
+ }
+ }
+
+ window.addEventListener("message", handleMessage)
+ return () => window.removeEventListener("message", handleMessage)
+ }, [className])
+
+ const handleDismiss = () => {
+ // Hide the upsell immediately
+ setIsVisible(false)
+
+ // Notify the extension to persist the dismissal
+ vscode.postMessage({
+ type: "dismissUpsell",
+ upsellId: className,
+ })
+
+ // 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..3d81984bb9
--- /dev/null
+++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx
@@ -0,0 +1,160 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import { describe, it, expect, vi, beforeEach } 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),
+ },
+}))
+
+describe("DismissibleUpsell", () => {
+ beforeEach(() => {
+ mockPostMessage.mockClear()
+ })
+
+ 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 component is no longer visible
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull()
+ })
+
+ // Check that the dismiss message was sent
+ expect(mockPostMessage).toHaveBeenCalledWith({
+ type: "dismissUpsell",
+ upsellId: "test-upsell",
+ })
+
+ // 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")
+ })
+})