feat: add DismissibleUpsell component for dismissible messages

- Created DismissibleUpsell component with variant support (banner/default)
- Added dismissedUpsells to GlobalState for persistence
- Implemented message handlers for dismissing and retrieving dismissed upsells
- Added comprehensive tests for the component
- Uses VSCode extension globalState for persistent storage
This commit is contained in:
Roo Code 2025-09-10 06:36:10 +00:00
parent 7cd6520302
commit ddd2b98f2c
6 changed files with 340 additions and 0 deletions

View file

@ -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(),

View file

@ -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
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -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 = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.647 3.646.708.707L8 8.707z"
fill="currentColor"
/>
</svg>
)
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 (
<UpsellContainer $variant={variant} className={className}>
{children}
<DismissButton
$variant={variant}
onClick={handleDismiss}
aria-label="Dismiss"
title="Dismiss and don't show again">
<DismissIcon />
</DismissButton>
</UpsellContainer>
)
})
DismissibleUpsell.displayName = "DismissibleUpsell"
export default DismissibleUpsell

View file

@ -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(
<DismissibleUpsell className="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
expect(screen.getByText("Test content")).toBeInTheDocument()
})
it("applies the correct variant styles", () => {
const { container, rerender } = render(
<DismissibleUpsell className="test-upsell" variant="banner">
<div>Banner content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell className="test-upsell" variant="default">
<div>Default content</div>
</DismissibleUpsell>,
)
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(
<DismissibleUpsell className="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
expect(mockPostMessage).toHaveBeenCalledWith({
type: "getDismissedUpsells",
})
})
it("hides the upsell when dismiss button is clicked", async () => {
const onDismiss = vi.fn()
const { container } = render(
<DismissibleUpsell className="test-upsell" onDismiss={onDismiss}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell className="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell className="test-upsell">
<div>Test content</div>
</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)
// 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(
<DismissibleUpsell className="custom-class">
<div>Test content</div>
</DismissibleUpsell>,
)
expect(container.firstChild).toHaveClass("custom-class")
})
it("dismiss button has proper accessibility attributes", () => {
render(
<DismissibleUpsell className="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
expect(dismissButton).toHaveAttribute("aria-label", "Dismiss")
expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again")
})
})