Compare commits

...
Author SHA1 Message Date
Sarah Fortune 8c91fc19b3 temp
# Conflicts:
#	src/core/controller/index.ts
#	webview-ui/src/components/common/CliInstallBanner.tsx
2026-01-09 19:42:46 -08:00
14 changed files with 225 additions and 435 deletions
-3
View File
@@ -25,9 +25,6 @@ service StateService {
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
rpc captureOnboardingProgress(OnboardingProgressRequest) returns (Empty);
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
rpc updateModelBannerVersion(Int64Request) returns (Empty);
rpc updateCliBannerVersion(Int64Request) returns (Empty);
rpc dismissBanner(StringRequest) returns (Empty);
rpc trackBannerEvent(TrackBannerEventRequest) returns (Empty);
rpc installClineCli(EmptyRequest) returns (Empty);
-11
View File
@@ -78,17 +78,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// Initialize banner service
BannerService.initialize(webview.controller)
BannerService.get()
.fetchActiveBanners()
.then((banners) => {
if (banners.length > 0) {
Logger.log(`BannerService: ${banners.length} active banner(s) fetched.`)
// Banners are now cached and can be accessed by the frontend when needed
}
})
.catch((error) => {
Logger.error("BannerService: Failed to fetch banners on startup", error)
})
telemetryService.captureExtensionActivated()
+4 -36
View File
@@ -6,6 +6,7 @@ import { setupWorkspaceManager } from "@core/workspace/setup"
import type { WorkspaceRootManager } from "@core/workspace/WorkspaceRootManager"
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
import { ClineAccountService } from "@services/account/ClineAccountService"
import { BannerService } from "@services/banner/BannerService"
import { McpHub } from "@services/mcp/McpHub"
import type { ApiProvider, ModelInfo } from "@shared/api"
import type { ChatContent } from "@shared/ChatContent"
@@ -30,7 +31,6 @@ import { ExtensionRegistryInfo } from "@/registry"
import { AuthService } from "@/services/auth/AuthService"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import { LogoutReason } from "@/services/auth/types"
import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
@@ -849,9 +849,6 @@ export class Controller {
const maxConsecutiveMistakes = this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
const subagentTerminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("subagentTerminalOutputLineLimit")
const favoritedModelIds = this.stateManager.getGlobalStateKey("favoritedModelIds")
const lastDismissedInfoBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
const lastDismissedModelBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedModelBannerVersion") || 0
const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
const subagentsEnabled = this.stateManager.getGlobalSettingsKey("subagentsEnabled")
const skillsEnabled = this.stateManager.getGlobalSettingsKey("skillsEnabled")
@@ -878,6 +875,8 @@ export class Controller {
const version = ExtensionRegistryInfo.version
const environment = ClineEnv.config().environment
const activeBanners = await BannerService.getActiveBanners()
// Set feature flag in dictation settings based on platform
const updatedDictationSettings = {
...dictationSettings,
@@ -952,15 +951,13 @@ export class Controller {
featureFlag: featureFlagsService.getWebtoolsEnabled(),
},
hooksEnabled: this.stateManager.getGlobalSettingsKey("hooksEnabled"),
lastDismissedInfoBannerVersion,
lastDismissedModelBannerVersion,
remoteConfigSettings: this.stateManager.getRemoteConfigSettings(),
lastDismissedCliBannerVersion,
subagentsEnabled,
nativeToolCallSetting: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
enableParallelToolCalling: this.stateManager.getGlobalSettingsKey("enableParallelToolCalling"),
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
skillsEnabled,
activeBanners,
}
}
@@ -1002,33 +999,4 @@ export class Controller {
this.stateManager.setGlobalState("taskHistory", history)
return history
}
/**
* Initializes the BannerService if not already initialized
*/
private async ensureBannerService() {
if (!BannerService.isInitialized()) {
try {
BannerService.initialize(this)
} catch (error) {
console.error("Failed to initialize BannerService:", error)
}
}
}
/**
* Fetches non-dismissed banners for display
* @returns Array of banners that haven't been dismissed
*/
async fetchBannersForDisplay(): Promise<any[]> {
try {
await this.ensureBannerService()
if (BannerService.isInitialized()) {
return await BannerService.get().getNonDismissedBanners()
}
} catch (error) {
console.error("Failed to fetch banners:", error)
}
return []
}
}
@@ -1,18 +0,0 @@
import { Empty, Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Updates the CLI banner version to hide it
* @param controller The controller instance
* @param request The request containing the version number
* @returns Empty response
*/
export async function updateCliBannerVersion(controller: Controller, request: Int64Request): Promise<Empty> {
// Save the banner version to global state to hide it
controller.stateManager.setGlobalState("lastDismissedCliBannerVersion", request.value ?? 1)
// Update webview
await controller.postStateToWebview()
return Empty.create()
}
@@ -1,17 +0,0 @@
import { Empty, Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Updates the info banner version to track which version the user has dismissed
* @param controller The controller instance
* @param request The request containing the version number
* @returns Empty response
*/
export async function updateInfoBannerVersion(controller: Controller, request: Int64Request): Promise<Empty> {
const version = Number(request.value)
controller.stateManager.setGlobalState("lastDismissedInfoBannerVersion", version)
await controller.postStateToWebview()
return Empty.create()
}
@@ -1,17 +0,0 @@
import { Empty, Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Updates the model banner version to track which version the user has dismissed
* @param controller The controller instance
* @param request The request containing the version number
* @returns Empty response
*/
export async function updateModelBannerVersion(controller: Controller, request: Int64Request): Promise<Empty> {
const version = Number(request.value)
controller.stateManager.setGlobalState("lastDismissedModelBannerVersion", version)
await controller.postStateToWebview()
return Empty.create()
}
-10
View File
@@ -274,13 +274,6 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
const dictationSettings = context.globalState.get<GlobalStateAndSettings["dictationSettings"]>("dictationSettings") as
| DictationSettings
| undefined
const lastDismissedInfoBannerVersion =
context.globalState.get<GlobalStateAndSettings["lastDismissedInfoBannerVersion"]>("lastDismissedInfoBannerVersion")
const lastDismissedModelBannerVersion = context.globalState.get<
GlobalStateAndSettings["lastDismissedModelBannerVersion"]
>("lastDismissedModelBannerVersion")
const lastDismissedCliBannerVersion =
context.globalState.get<GlobalStateAndSettings["lastDismissedCliBannerVersion"]>("lastDismissedCliBannerVersion")
const dismissedBanners = context.globalState.get<GlobalStateAndSettings["dismissedBanners"]>("dismissedBanners")
const qwenCodeOauthPath = context.globalState.get<GlobalStateAndSettings["qwenCodeOauthPath"]>("qwenCodeOauthPath")
const customPrompt = context.globalState.get<GlobalStateAndSettings["customPrompt"]>("customPrompt")
@@ -715,9 +708,6 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
subagentsEnabled: subagentsEnabled ?? false,
skillsEnabled: skillsEnabled ?? false,
enableParallelToolCalling: enableParallelToolCalling ?? false,
lastDismissedInfoBannerVersion: lastDismissedInfoBannerVersion ?? 0,
lastDismissedModelBannerVersion: lastDismissedModelBannerVersion ?? 0,
lastDismissedCliBannerVersion: lastDismissedCliBannerVersion ?? 0,
dismissedBanners: dismissedBanners || [],
nativeToolCallEnabled: nativeToolCallEnabled ?? true,
// Multi-root workspace support
+205 -167
View File
@@ -3,6 +3,7 @@ import axios from "axios"
import { ClineEnv } from "@/config"
import type { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { BannerActionType, BannerCardData } from "@/shared/cline/banner"
import { getAxiosSettings } from "@/shared/net"
import { AuthService } from "../auth/AuthService"
import { getDistinctId } from "../logging/distinctId"
@@ -18,7 +19,80 @@ export class BannerService {
private _lastFetchTime: number = 0
private readonly CACHE_DURATION_MS = 5 * 60 * 1000 // 5 minutes
private _controller: Controller
private _authService?: AuthService
/**
* The list of predefined banner configs.
*/
private BANNER_DATA: BannerCardData[] = [
// Info banner with inline link
{
id: "info-banner-v1",
icon: "lightbulb",
title: "Use Cline in Right Sidebar",
description:
"For the best experience, drag the Cline icon to your right sidebar. This keeps your file explorer and editor visible while you chat with Cline, making it easier to navigate your codebase and see changes in real-time. [See how →](https://docs.cline.bot/features/customization/opening-cline-in-sidebar)",
},
// Announcement with conditional actions based on user auth state
{
id: "new-model-opus-4-5-cline-users",
icon: "megaphone",
title: "Claude Opus 4.5 Now Available",
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
actions: [
{
title: "Try Now",
action: BannerActionType.SetModel,
arg: "anthropic/claude-opus-4.5",
},
],
isClineUserOnly: true, // Only Cline users see this
},
{
id: "new-model-opus-4-5-non-cline-users",
icon: "megaphone",
title: "Claude Opus 4.5 Now Available",
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
actions: [
{
title: "Get Started",
action: BannerActionType.ShowAccount,
},
],
isClineUserOnly: false, // Only non-Cline users see this
},
// Platform-specific banner (macOS/Linux)
{
id: "cli-install-unix-v1",
icon: "terminal",
title: "CLI & Subagents Available",
platforms: ["mac", "linux"] satisfies BannerCardData["platforms"],
description:
"Use Cline in your terminal and enable subagent capabilities. [Learn more](https://docs.cline.bot/cline-cli/overview)",
actions: [
{
title: "Install",
action: BannerActionType.InstallCli,
},
{
title: "Enable Subagents",
action: BannerActionType.ShowFeatureSettings,
},
],
},
// Platform-specific banner (Windows)
{
id: "cli-info-windows-v1",
icon: "terminal",
title: "Cline CLI Info",
platforms: ["windows"] satisfies BannerCardData["platforms"],
description:
"Available for macOS and Linux. Coming soon to other platforms. [Learn more](https://docs.cline.bot/cline-cli/overview)",
},
]
private constructor(controller: Controller) {
this._controller = controller
@@ -49,13 +123,6 @@ export class BannerService {
return BannerService.instance
}
/**
* Checks if BannerService has been initialized
*/
public static isInitialized(): boolean {
return !!BannerService.instance
}
/**
* Resets the BannerService instance (primarily for testing)
*/
@@ -63,12 +130,125 @@ export class BannerService {
BannerService.instance = null
}
public static async getActiveBanners(): Promise<BannerCardData[]> {
try {
return BannerService.get().getActiveBanners()
} catch (error) {
Logger.error("Couldnt get banners", error)
return []
}
}
private async getActiveBanners(): Promise<BannerCardData[]> {
return this.BANNER_DATA.filter(this.shouldShow)
// TODO: include banners from the API
}
/**
* Sets the AuthService instance for testing purposes
* In production, AuthService is loaded dynamically when needed
* Clears the banner cache
*/
public setAuthService(authService: AuthService): void {
this._authService = authService
public clearCache(): void {
this._cachedBanners = []
this._lastFetchTime = 0
Logger.log("BannerService: Cache cleared")
}
/**
* Checks if a banner should be shown on this IDE and it has not been dismissed by the user
* @param bannerId The ID of the banner to check
* @returns true if the banner has been dismissed
*/
public shouldShow(banner: BannerCardData): boolean {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
const isDismissed = !dismissedBanners.some((b) => b.bannerId === banner.id)
if (isDismissed) {
return false
}
const os = this.getOsType()
// This filtering is only required for hard-coded banners.
if (banner.platforms && !banner.platforms.some((p) => p === os)) {
// Banner is not used on this OS
return false
}
// TODO: Add isClineUser check
return true
} catch (error) {
Logger.error(`BannerService: Error checking if banner is dismissed`, error)
return true
}
}
/**
* Sends a banner event to the telemetry endpoint
* @param bannerId The ID of the banner
* @param eventType The type of event (now we only support dismiss, in the future we might want to support seen, click...)
*/
public async sendBannerEvent(bannerId: string, eventType: "dismiss"): Promise<void> {
try {
const url = new URL("/banners/v1/events", this._baseUrl).toString()
// Get IDE type for surface
const ideType = await this.getIdeType()
let surface: string
if (ideType === "cli") {
surface = "cli"
} else if (ideType === "jetbrains") {
surface = "jetbrains"
} else {
surface = "vscode"
}
const instanceId = getDistinctId()
const payload = {
banner_id: bannerId,
instance_id: instanceId,
surface,
event_type: eventType,
}
await axios.post(url, payload, {
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
...getAxiosSettings(),
})
Logger.log(`BannerService: Sent ${eventType} event for banner ${bannerId}`)
} catch (error) {
Logger.error(`BannerService: Error sending banner event`, error)
}
}
/**
* Marks a banner as dismissed and stores it in state
* @param bannerId The ID of the banner to dismiss
*/
public async dismissBanner(bannerId: string): Promise<void> {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
if (dismissedBanners.some((b) => b.bannerId === bannerId)) {
Logger.log(`BannerService: Banner ${bannerId} already dismissed`)
return
}
const newDismissal = {
bannerId,
dismissedAt: Date.now(),
}
this._controller.stateManager.setGlobalState("dismissedBanners", [...dismissedBanners, newDismissal])
await this.sendBannerEvent(bannerId, "dismiss")
this.clearCache()
Logger.log(`BannerService: Banner ${bannerId} dismissed`)
} catch (error) {
Logger.error(`BannerService: Error dismissing banner`, error)
}
}
/**
@@ -78,7 +258,7 @@ export class BannerService {
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of banners that match current environment
*/
public async fetchActiveBanners(forceRefresh = false): Promise<Banner[]> {
async fetchActiveBanners(forceRefresh = false): Promise<Banner[]> {
try {
// Return cached banners if still valid
const now = Date.now()
@@ -89,7 +269,7 @@ export class BannerService {
const ideType = await this.getIdeType()
const extensionVersion = await this.getExtensionVersion()
const osType = await this.getOSType()
const osType = this.getOsType()
const urlObj = new URL("/banners/v1/messages", this._baseUrl)
urlObj.searchParams.set("ide", ideType)
@@ -101,7 +281,7 @@ export class BannerService {
const url = urlObj.toString()
Logger.log(`BannerService: Fetching banners from ${url}`)
const authService = this.getAuthServiceInstance()
const authService = AuthService.getInstance(this._controller)
let token: string | null = null
if (authService) {
token = await authService.getAuthToken()
@@ -219,21 +399,16 @@ export class BannerService {
* Gets the current Operating System
* @returns OS type (windows, linux, macos or unknown)
*/
private async getOSType(): Promise<string> {
try {
switch (process.platform) {
case "win32":
return "windows"
case "linux":
return "linux"
case "darwin":
return "macos"
default:
return "unknown"
}
} catch (error) {
Logger.error("BannerService: Error getting OS type", error)
return "unknown"
private getOsType(): string {
switch (process.platform) {
case "win32":
return "windows"
case "linux":
return "linux"
case "darwin":
return "macos"
default:
return "unknown"
}
}
@@ -264,141 +439,4 @@ export class BannerService {
return "unknown"
}
}
/**
* Gets the AuthService instance
* @returns AuthService instance or undefined if not available
*/
private getAuthServiceInstance(): AuthService | undefined {
// Use injected instance if available (for testing)
if (this._authService) {
return this._authService
}
// Otherwise, get singleton instance
try {
return AuthService.getInstance(this._controller)
} catch {
return undefined
}
}
/**
* Clears the banner cache
*/
public clearCache(): void {
this._cachedBanners = []
this._lastFetchTime = 0
Logger.log("BannerService: Cache cleared")
}
/**
* Sends a banner event to the telemetry endpoint
* @param bannerId The ID of the banner
* @param eventType The type of event (now we only support dismiss, in the future we might want to support seen, click...)
*/
public async sendBannerEvent(bannerId: string, eventType: "dismiss"): Promise<void> {
try {
const url = new URL("/banners/v1/events", this._baseUrl).toString()
// Get IDE type for surface
const ideType = await this.getIdeType()
let surface: string
if (ideType === "cli") {
surface = "cli"
} else if (ideType === "jetbrains") {
surface = "jetbrains"
} else {
surface = "vscode"
}
const instanceId = this.getInstanceDistinctId()
const payload = {
banner_id: bannerId,
instance_id: instanceId,
surface,
event_type: eventType,
}
await axios.post(url, payload, {
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
...getAxiosSettings(),
})
Logger.log(`BannerService: Sent ${eventType} event for banner ${bannerId}`)
} catch (error) {
Logger.error(`BannerService: Error sending banner event`, error)
}
}
/**
* Marks a banner as dismissed and stores it in state
* @param bannerId The ID of the banner to dismiss
*/
public async dismissBanner(bannerId: string): Promise<void> {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
if (dismissedBanners.some((b) => b.bannerId === bannerId)) {
Logger.log(`BannerService: Banner ${bannerId} already dismissed`)
return
}
const newDismissal = {
bannerId,
dismissedAt: Date.now(),
}
this._controller.stateManager.setGlobalState("dismissedBanners", [...dismissedBanners, newDismissal])
await this.sendBannerEvent(bannerId, "dismiss")
this.clearCache()
Logger.log(`BannerService: Banner ${bannerId} dismissed`)
} catch (error) {
Logger.error(`BannerService: Error dismissing banner`, error)
}
}
/**
* Checks if a banner has been dismissed by the user
* @param bannerId The ID of the banner to check
* @returns true if the banner has been dismissed
*/
public isBannerDismissed(bannerId: string): boolean {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
return dismissedBanners.some((b) => b.bannerId === bannerId)
} catch (error) {
Logger.error(`BannerService: Error checking if banner is dismissed`, error)
return false
}
}
/**
* Gets banners that haven't been dismissed by the user
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of non-dismissed banners
*/
public async getNonDismissedBanners(forceRefresh = false): Promise<Banner[]> {
const allBanners = await this.fetchActiveBanners(forceRefresh)
return allBanners.filter((banner) => !this.isBannerDismissed(banner.id))
}
/**
* Gets the distinct ID for the current user
* @returns distinct ID string
*/
private getInstanceDistinctId(): string {
try {
return getDistinctId()
} catch (error) {
Logger.error("BannerService: Error getting distinct ID", error)
return "unknown"
}
}
}
+2 -3
View File
@@ -7,6 +7,7 @@ import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { ApiConfiguration } from "./api"
import { BrowserSettings } from "./BrowserSettings"
import { ClineFeatureSetting } from "./ClineFeatureSetting"
import { BannerCardData } from "./cline/banner"
import { ClineRulesToggles } from "./cline-rules"
import { DictationSettings } from "./DictationSettings"
import { FocusChainSettings } from "./FocusChainSettings"
@@ -98,9 +99,6 @@ export interface ExtensionState {
primaryRootIndex: number
isMultiRootWorkspace: boolean
multiRootSetting: ClineFeatureSetting
lastDismissedInfoBannerVersion: number
lastDismissedModelBannerVersion: number
lastDismissedCliBannerVersion: number
hooksEnabled?: boolean
remoteConfigSettings?: Partial<RemoteConfigFields>
subagentsEnabled?: boolean
@@ -110,6 +108,7 @@ export interface ExtensionState {
nativeToolCallSetting?: boolean
enableParallelToolCalling?: boolean
backgroundEditEnabled?: boolean
activeBanners?: BannerCardData[]
}
export interface ClineMessage {
-75
View File
@@ -85,78 +85,3 @@ export interface BannerAction {
*/
arg?: string
}
/**
* The list of predefined banner config rendered by the Welcome Section UI.
* TODO: Backend would return a similar JSON structure in the future which we will replace this with.
*/
export const BANNER_DATA: BannerCardData[] = [
// Info banner with inline link
{
id: "info-banner-v1",
icon: "lightbulb",
title: "Use Cline in Right Sidebar",
description:
"For the best experience, drag the Cline icon to your right sidebar. This keeps your file explorer and editor visible while you chat with Cline, making it easier to navigate your codebase and see changes in real-time. [See how →](https://docs.cline.bot/features/customization/opening-cline-in-sidebar)",
},
// Announcement with conditional actions based on user auth state
{
id: "new-model-opus-4-5-cline-users",
icon: "megaphone",
title: "Claude Opus 4.5 Now Available",
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
actions: [
{
title: "Try Now",
action: BannerActionType.SetModel,
arg: "anthropic/claude-opus-4.5",
},
],
isClineUserOnly: true, // Only Cline users see this
},
{
id: "new-model-opus-4-5-non-cline-users",
icon: "megaphone",
title: "Claude Opus 4.5 Now Available",
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
actions: [
{
title: "Get Started",
action: BannerActionType.ShowAccount,
},
],
isClineUserOnly: false, // Only non-Cline users see this
},
// Platform-specific banner (macOS/Linux)
{
id: "cli-install-unix-v1",
icon: "terminal",
title: "CLI & Subagents Available",
platforms: ["mac", "linux"] satisfies BannerCardData["platforms"],
description:
"Use Cline in your terminal and enable subagent capabilities. [Learn more](https://docs.cline.bot/cline-cli/overview)",
actions: [
{
title: "Install",
action: BannerActionType.InstallCli,
},
{
title: "Enable Subagents",
action: BannerActionType.ShowFeatureSettings,
},
],
},
// Platform-specific banner (Windows)
{
id: "cli-info-windows-v1",
icon: "terminal",
title: "Cline CLI Info",
platforms: ["windows"] satisfies BannerCardData["platforms"],
description:
"Available for macOS and Linux. Coming soon to other platforms. [Learn more](https://docs.cline.bot/cline-cli/overview)",
},
]
-3
View File
@@ -53,9 +53,6 @@ export interface GlobalState {
workspaceRoots: WorkspaceRoot[] | undefined
primaryRootIndex: number
multiRootEnabled: boolean
lastDismissedInfoBannerVersion: number
lastDismissedModelBannerVersion: number
lastDismissedCliBannerVersion: number
nativeToolCallEnabled: boolean
remoteRulesToggles: ClineRulesToggles
remoteWorkflowToggles: ClineRulesToggles
@@ -1,5 +1,5 @@
import { BANNER_DATA, BannerAction, BannerActionType, BannerCardData } from "@shared/cline/banner"
import { EmptyRequest, Int64Request } from "@shared/proto/index.cline"
import { BannerAction, BannerActionType } from "@shared/cline/banner"
import { EmptyRequest } from "@shared/proto/index.cline"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import BannerCarousel from "@/components/common/BannerCarousel"
import WhatsNewModal from "@/components/common/WhatsNewModal"
@@ -7,11 +7,9 @@ import HistoryPreview from "@/components/history/HistoryPreview"
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
import HomeHeader from "@/components/welcome/HomeHeader"
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import { convertBannerData } from "@/utils/bannerUtils"
import { getCurrentPlatform } from "@/utils/platformUtils"
import { WelcomeSectionProps } from "../../types/chatTypes"
const CURRENT_INFO_BANNER_VERSION = 1
@@ -30,14 +28,11 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
taskHistory,
shouldShowQuickWins,
}) => {
const { lastDismissedInfoBannerVersion, lastDismissedCliBannerVersion, lastDismissedModelBannerVersion } = useExtensionState()
// Track if we've shown the "What's New" modal this session
const [hasShownWhatsNewModal, setHasShownWhatsNewModal] = useState(false)
const [showWhatsNewModal, setShowWhatsNewModal] = useState(false)
const { clineUser } = useClineAuth()
const { openRouterModels, setShowChatModelSelector, navigateToSettings, subagentsEnabled } = useExtensionState()
const { openRouterModels, setShowChatModelSelector, navigateToSettings, activeBanners } = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
// Show modal when there's a new announcement and we haven't shown it this session
@@ -54,49 +49,6 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
hideAnnouncement()
}, [hideAnnouncement])
/**
* Check if a banner has been dismissed based on its version
*/
const isBannerDismissed = useCallback(
(bannerId: string): boolean => {
if (bannerId.startsWith("info-banner")) {
return (lastDismissedInfoBannerVersion ?? 0) >= CURRENT_INFO_BANNER_VERSION
}
if (bannerId.startsWith("new-model")) {
return (lastDismissedModelBannerVersion ?? 0) >= CURRENT_MODEL_BANNER_VERSION
}
if (bannerId.startsWith("cli-")) {
return (lastDismissedCliBannerVersion ?? 0) >= CURRENT_CLI_BANNER_VERSION
}
return false
},
[lastDismissedInfoBannerVersion, lastDismissedModelBannerVersion, lastDismissedCliBannerVersion],
)
/**
* Banner configuration from backend
* In production, this would come from an API/gRPC call
* For now, using EXAMPLE_BANNER_DATA with version-based filtering
*/
const bannerConfig = useMemo((): BannerCardData[] => {
// Filter banners based on version tracking and user status
return BANNER_DATA.filter((banner) => {
if (isBannerDismissed(banner.id)) {
return false
}
if (banner.isClineUserOnly !== undefined) {
return banner.isClineUserOnly === !!clineUser
}
if (banner.platforms && !banner.platforms.includes(getCurrentPlatform())) {
return false
}
return true
})
}, [isBannerDismissed, clineUser])
/**
* Action handler - maps action types to actual implementations
*/
@@ -152,32 +104,21 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
* Dismissal handler - updates version tracking
*/
const handleBannerDismiss = useCallback((bannerId: string) => {
// Map banner IDs to version updates
if (bannerId.startsWith("info-banner")) {
StateServiceClient.updateInfoBannerVersion({ value: CURRENT_INFO_BANNER_VERSION }).catch(console.error)
} else if (bannerId.startsWith("new-model")) {
StateServiceClient.updateModelBannerVersion(Int64Request.create({ value: CURRENT_MODEL_BANNER_VERSION })).catch(
console.error,
)
} else if (bannerId.startsWith("cli-")) {
StateServiceClient.updateCliBannerVersion(Int64Request.create({ value: CURRENT_CLI_BANNER_VERSION })).catch(
console.error,
)
}
StateServiceClient.dismissBanner({ value: bannerId })
}, [])
/**
* Build array of active banners for carousel
* Build array of banner data for carousel from state
*/
const activeBanners = useMemo(() => {
const bannerData = useMemo(() => {
// Convert to BannerData format for carousel
return bannerConfig.map((banner) =>
return (activeBanners || []).map((banner) =>
convertBannerData(banner, {
onAction: handleBannerAction,
onDismiss: handleBannerDismiss,
}),
)
}, [bannerConfig, clineUser, subagentsEnabled, handleBannerAction, handleBannerDismiss])
}, [activeBanners, handleBannerAction, handleBannerDismiss])
return (
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
@@ -187,7 +128,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
{!showWhatsNewModal && (
<>
<div className="animate-fade-in">
<BannerCarousel banners={activeBanners} />
<BannerCarousel banners={bannerData} />
</div>
{!shouldShowQuickWins && taskHistory.length > 0 && (
<div className="animate-fade-in opacity-0">
@@ -45,7 +45,6 @@ export interface ExtensionStateContextType extends ExtensionState {
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
totalTasksSize: number | null
lastDismissedCliBannerVersion: number
availableTerminalProfiles: TerminalProfile[]
@@ -238,17 +237,16 @@ export const ExtensionStateContextProvider: React.FC<{
clineWebToolsEnabled: { user: true, featureFlag: false },
autoCondenseThreshold: undefined,
favoritedModelIds: [],
lastDismissedInfoBannerVersion: 0,
lastDismissedModelBannerVersion: 0,
remoteConfigSettings: {},
backgroundCommandRunning: false,
backgroundCommandTaskId: undefined,
lastDismissedCliBannerVersion: 0,
subagentsEnabled: false,
backgroundEditEnabled: false,
skillsEnabled: false,
globalSkillsToggles: {},
localSkillsToggles: {},
activeBanners: [],
// NEW: Add workspace information with defaults
workspaceRoots: [],
+3 -3
View File
@@ -15,8 +15,8 @@ export function convertBannerData(
): BannerData {
const { onAction, onDismiss } = handlers
// Filter and process actions
const filteredActions =
// Process actions
const actions =
banner.actions?.map((action) => ({
label: action.title,
onClick: () => onAction(action),
@@ -29,7 +29,7 @@ export function convertBannerData(
) : undefined,
title: banner.title,
description: banner.description,
actions: filteredActions.length > 0 ? filteredActions : undefined,
actions: actions.length > 0 ? actions : undefined,
onDismiss: () => onDismiss(banner.id),
}
}