Skip to content

Commit ddd2b98

Browse files
committed
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
1 parent 7cd6520 commit ddd2b98

File tree

6 files changed

+340
-0
lines changed

6 files changed

+340
-0
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const globalSettingsSchema = z.object({
4141
lastShownAnnouncementId: z.string().optional(),
4242
customInstructions: z.string().optional(),
4343
taskHistory: z.array(historyItemSchema).optional(),
44+
dismissedUpsells: z.array(z.string()).optional(),
4445

4546
// Image generation settings (experimental) - flattened for simplicity
4647
openRouterImageApiKey: z.string().optional(),

src/core/webview/webviewMessageHandler.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2998,5 +2998,33 @@ export const webviewMessageHandler = async (
29982998

29992999
break
30003000
}
3001+
case "dismissUpsell": {
3002+
if (message.upsellId) {
3003+
// Get current list of dismissed upsells
3004+
const dismissedUpsells = getGlobalState("dismissedUpsells") || []
3005+
3006+
// Add the new upsell ID if not already present
3007+
if (!dismissedUpsells.includes(message.upsellId)) {
3008+
const updatedList = [...dismissedUpsells, message.upsellId]
3009+
await updateGlobalState("dismissedUpsells", updatedList)
3010+
}
3011+
3012+
// Send updated list back to webview
3013+
await provider.postMessageToWebview({
3014+
type: "dismissedUpsells",
3015+
list: [...dismissedUpsells, message.upsellId],
3016+
})
3017+
}
3018+
break
3019+
}
3020+
case "getDismissedUpsells": {
3021+
// Send the current list of dismissed upsells to the webview
3022+
const dismissedUpsells = getGlobalState("dismissedUpsells") || []
3023+
await provider.postMessageToWebview({
3024+
type: "dismissedUpsells",
3025+
list: dismissedUpsells,
3026+
})
3027+
break
3028+
}
30013029
}
30023030
}

src/shared/ExtensionMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export interface ExtensionMessage {
123123
| "showEditMessageDialog"
124124
| "commands"
125125
| "insertTextIntoTextarea"
126+
| "dismissedUpsells"
126127
text?: string
127128
payload?: any // Add a generic payload for now, can refine later
128129
action?:
@@ -199,6 +200,7 @@ export interface ExtensionMessage {
199200
context?: string
200201
commands?: Command[]
201202
queuedMessages?: QueuedMessage[]
203+
list?: string[] // For dismissedUpsells
202204
}
203205

204206
export type ExtensionState = Pick<
@@ -209,6 +211,7 @@ export type ExtensionState = Pick<
209211
// | "lastShownAnnouncementId"
210212
| "customInstructions"
211213
// | "taskHistory" // Optional in GlobalSettings, required here.
214+
| "dismissedUpsells"
212215
| "autoApprovalEnabled"
213216
| "alwaysAllowReadOnly"
214217
| "alwaysAllowReadOnlyOutsideWorkspace"

src/shared/WebviewMessage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export interface WebviewMessage {
222222
| "queueMessage"
223223
| "removeQueuedMessage"
224224
| "editQueuedMessage"
225+
| "dismissUpsell"
226+
| "getDismissedUpsells"
225227
text?: string
226228
editedMessageContent?: string
227229
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@@ -267,6 +269,8 @@ export interface WebviewMessage {
267269
visibility?: ShareVisibility // For share visibility
268270
hasContent?: boolean // For checkRulesDirectoryResult
269271
checkOnly?: boolean // For deleteCustomMode check
272+
upsellId?: string // For dismissUpsell
273+
list?: string[] // For dismissedUpsells response
270274
codeIndexSettings?: {
271275
// Global state settings
272276
codebaseIndexEnabled: boolean
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { memo, ReactNode, useEffect, useState } from "react"
2+
import styled from "styled-components"
3+
4+
import { vscode } from "@src/utils/vscode"
5+
6+
interface DismissibleUpsellProps {
7+
/** Required unique identifier for this upsell */
8+
className: string
9+
/** Content to display inside the upsell */
10+
children: ReactNode
11+
/** Visual variant of the upsell */
12+
variant?: "banner" | "default"
13+
/** Optional callback when upsell is dismissed */
14+
onDismiss?: () => void
15+
}
16+
17+
const UpsellContainer = styled.div<{ $variant: "banner" | "default" }>`
18+
position: relative;
19+
padding: 12px 40px 12px 16px;
20+
border-radius: 6px;
21+
margin-bottom: 8px;
22+
display: flex;
23+
align-items: center;
24+
25+
${(props) =>
26+
props.$variant === "banner"
27+
? `
28+
background-color: var(--vscode-button-background);
29+
color: var(--vscode-button-foreground);
30+
`
31+
: `
32+
background-color: var(--vscode-notifications-background);
33+
color: var(--vscode-notifications-foreground);
34+
border: 1px solid var(--vscode-notifications-border);
35+
`}
36+
`
37+
38+
const DismissButton = styled.button<{ $variant: "banner" | "default" }>`
39+
position: absolute;
40+
top: 50%;
41+
right: 12px;
42+
transform: translateY(-50%);
43+
background: none;
44+
border: none;
45+
cursor: pointer;
46+
padding: 4px;
47+
display: flex;
48+
align-items: center;
49+
justify-content: center;
50+
border-radius: 4px;
51+
transition: background-color 0.2s;
52+
53+
${(props) =>
54+
props.$variant === "banner"
55+
? `
56+
color: var(--vscode-button-foreground);
57+
58+
&:hover {
59+
background-color: rgba(255, 255, 255, 0.1);
60+
}
61+
`
62+
: `
63+
color: var(--vscode-notifications-foreground);
64+
65+
&:hover {
66+
background-color: var(--vscode-toolbar-hoverBackground);
67+
}
68+
`}
69+
70+
&:focus {
71+
outline: 1px solid var(--vscode-focusBorder);
72+
outline-offset: 1px;
73+
}
74+
`
75+
76+
const DismissIcon = () => (
77+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
78+
<path
79+
fillRule="evenodd"
80+
clipRule="evenodd"
81+
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"
82+
fill="currentColor"
83+
/>
84+
</svg>
85+
)
86+
87+
const DismissibleUpsell = memo(({ className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => {
88+
const [isVisible, setIsVisible] = useState(true)
89+
90+
useEffect(() => {
91+
// Request the current list of dismissed upsells from the extension
92+
vscode.postMessage({ type: "getDismissedUpsells" })
93+
94+
// Listen for the response
95+
const handleMessage = (event: MessageEvent) => {
96+
const message = event.data
97+
if (message.type === "dismissedUpsells" && Array.isArray(message.list)) {
98+
// Check if this upsell has been dismissed
99+
if (message.list.includes(className)) {
100+
setIsVisible(false)
101+
}
102+
}
103+
}
104+
105+
window.addEventListener("message", handleMessage)
106+
return () => window.removeEventListener("message", handleMessage)
107+
}, [className])
108+
109+
const handleDismiss = () => {
110+
// Hide the upsell immediately
111+
setIsVisible(false)
112+
113+
// Notify the extension to persist the dismissal
114+
vscode.postMessage({
115+
type: "dismissUpsell",
116+
upsellId: className,
117+
})
118+
119+
// Call the optional callback
120+
onDismiss?.()
121+
}
122+
123+
// Don't render if not visible
124+
if (!isVisible) {
125+
return null
126+
}
127+
128+
return (
129+
<UpsellContainer $variant={variant} className={className}>
130+
{children}
131+
<DismissButton
132+
$variant={variant}
133+
onClick={handleDismiss}
134+
aria-label="Dismiss"
135+
title="Dismiss and don't show again">
136+
<DismissIcon />
137+
</DismissButton>
138+
</UpsellContainer>
139+
)
140+
})
141+
142+
DismissibleUpsell.displayName = "DismissibleUpsell"
143+
144+
export default DismissibleUpsell
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
2+
import { describe, it, expect, vi, beforeEach } from "vitest"
3+
import DismissibleUpsell from "../DismissibleUpsell"
4+
5+
// Mock the vscode API
6+
const mockPostMessage = vi.fn()
7+
vi.mock("@src/utils/vscode", () => ({
8+
vscode: {
9+
postMessage: (message: any) => mockPostMessage(message),
10+
},
11+
}))
12+
13+
describe("DismissibleUpsell", () => {
14+
beforeEach(() => {
15+
mockPostMessage.mockClear()
16+
})
17+
18+
it("renders children content", () => {
19+
render(
20+
<DismissibleUpsell className="test-upsell">
21+
<div>Test content</div>
22+
</DismissibleUpsell>,
23+
)
24+
25+
expect(screen.getByText("Test content")).toBeInTheDocument()
26+
})
27+
28+
it("applies the correct variant styles", () => {
29+
const { container, rerender } = render(
30+
<DismissibleUpsell className="test-upsell" variant="banner">
31+
<div>Banner content</div>
32+
</DismissibleUpsell>,
33+
)
34+
35+
// Check banner variant has correct background color style
36+
const bannerContainer = container.firstChild
37+
expect(bannerContainer).toHaveStyle({
38+
backgroundColor: "var(--vscode-button-background)",
39+
color: "var(--vscode-button-foreground)",
40+
})
41+
42+
// Re-render with default variant
43+
rerender(
44+
<DismissibleUpsell className="test-upsell" variant="default">
45+
<div>Default content</div>
46+
</DismissibleUpsell>,
47+
)
48+
49+
const defaultContainer = container.firstChild
50+
expect(defaultContainer).toHaveStyle({
51+
backgroundColor: "var(--vscode-notifications-background)",
52+
color: "var(--vscode-notifications-foreground)",
53+
})
54+
})
55+
56+
it("requests dismissed upsells list on mount", () => {
57+
render(
58+
<DismissibleUpsell className="test-upsell">
59+
<div>Test content</div>
60+
</DismissibleUpsell>,
61+
)
62+
63+
expect(mockPostMessage).toHaveBeenCalledWith({
64+
type: "getDismissedUpsells",
65+
})
66+
})
67+
68+
it("hides the upsell when dismiss button is clicked", async () => {
69+
const onDismiss = vi.fn()
70+
const { container } = render(
71+
<DismissibleUpsell className="test-upsell" onDismiss={onDismiss}>
72+
<div>Test content</div>
73+
</DismissibleUpsell>,
74+
)
75+
76+
// Find and click the dismiss button
77+
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
78+
fireEvent.click(dismissButton)
79+
80+
// Check that the component is no longer visible
81+
await waitFor(() => {
82+
expect(container.firstChild).toBeNull()
83+
})
84+
85+
// Check that the dismiss message was sent
86+
expect(mockPostMessage).toHaveBeenCalledWith({
87+
type: "dismissUpsell",
88+
upsellId: "test-upsell",
89+
})
90+
91+
// Check that the callback was called
92+
expect(onDismiss).toHaveBeenCalled()
93+
})
94+
95+
it("hides the upsell if it's in the dismissed list", async () => {
96+
const { container } = render(
97+
<DismissibleUpsell className="test-upsell">
98+
<div>Test content</div>
99+
</DismissibleUpsell>,
100+
)
101+
102+
// Simulate receiving a message that this upsell is dismissed
103+
const messageEvent = new MessageEvent("message", {
104+
data: {
105+
type: "dismissedUpsells",
106+
list: ["test-upsell", "other-upsell"],
107+
},
108+
})
109+
window.dispatchEvent(messageEvent)
110+
111+
// Check that the component is no longer visible
112+
await waitFor(() => {
113+
expect(container.firstChild).toBeNull()
114+
})
115+
})
116+
117+
it("remains visible if not in the dismissed list", async () => {
118+
render(
119+
<DismissibleUpsell className="test-upsell">
120+
<div>Test content</div>
121+
</DismissibleUpsell>,
122+
)
123+
124+
// Simulate receiving a message that doesn't include this upsell
125+
const messageEvent = new MessageEvent("message", {
126+
data: {
127+
type: "dismissedUpsells",
128+
list: ["other-upsell"],
129+
},
130+
})
131+
window.dispatchEvent(messageEvent)
132+
133+
// Check that the component is still visible
134+
await waitFor(() => {
135+
expect(screen.getByText("Test content")).toBeInTheDocument()
136+
})
137+
})
138+
139+
it("applies the className prop to the container", () => {
140+
const { container } = render(
141+
<DismissibleUpsell className="custom-class">
142+
<div>Test content</div>
143+
</DismissibleUpsell>,
144+
)
145+
146+
expect(container.firstChild).toHaveClass("custom-class")
147+
})
148+
149+
it("dismiss button has proper accessibility attributes", () => {
150+
render(
151+
<DismissibleUpsell className="test-upsell">
152+
<div>Test content</div>
153+
</DismissibleUpsell>,
154+
)
155+
156+
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
157+
expect(dismissButton).toHaveAttribute("aria-label", "Dismiss")
158+
expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again")
159+
})
160+
})

0 commit comments

Comments
 (0)