mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c91fc19b3 |
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user