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", + }) + }) +})