Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
34 changes: 34 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2998,5 +2998,39 @@ export const webviewMessageHandler = async (

break
}
case "dismissUpsell": {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling. What happens if updateGlobalState fails? Consider wrapping in try-catch and logging any errors.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's OK to fail silently in this case

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
}
}
}
3 changes: 3 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?:
Expand Down Expand Up @@ -199,6 +200,7 @@ export interface ExtensionMessage {
context?: string
commands?: Command[]
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
}

export type ExtensionState = Pick<
Expand All @@ -209,6 +211,7 @@ export type ExtensionState = Pick<
// | "lastShownAnnouncementId"
| "customInstructions"
// | "taskHistory" // Optional in GlobalSettings, required here.
| "dismissedUpsells"
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
Expand Down
4 changes: 4 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ export interface WebviewMessage {
| "queueMessage"
| "removeQueuedMessage"
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down Expand Up @@ -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
Expand Down
160 changes: 160 additions & 0 deletions webview-ui/src/components/common/DismissibleUpsell.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<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(({ 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?.()
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: If the component unmounts immediately after clicking dismiss but before the message is sent, the dismissal won't be persisted. Consider ensuring the message is sent before hiding the component.


// Don't render if not visible
if (!isVisible) {
return null
}

return (
<UpsellContainer $variant={variant} className={className}>
{children}
<DismissButton
$variant={variant}
onClick={handleDismiss}
aria-label={t("common:dismiss")}
title={t("common:dismissAndDontShowAgain")}>
<DismissIcon />
</DismissButton>
</UpsellContainer>
)
})

DismissibleUpsell.displayName = "DismissibleUpsell"

export default DismissibleUpsell
Loading
Loading