Compare commits

...
Author SHA1 Message Date
abeatrix 864737ab41 move announcement logic from Controller to BannerService
Move announcement display logic from Controller into BannerService to improve separation of concerns. Key changes:

- Remove announcement-related code from Controller (getLatestAnnouncementId, shouldShowAnnouncement)
- Initialize BannerService in AuthService constructor with auth token
- Move announcement visibility logic into BannerService.getActiveBanners()
- Add feature flag check (BANNER_EXTENSION) to control banner display
- Make BannerService authToken optional and call fetchBanners() on init

This refactoring encapsulates banner/announcement logic within BannerService, making the Controller cleaner and giving BannerService full control over when and how banners are displayed.
2026-01-15 16:33:28 -08:00
abeatrix 8ca77ec592 demo: move initialization to auth flow and add retry logic
- Move BannerService initialization from extension startup to AuthService after user authentication
- Change constructor to accept auth token directly instead of Controller dependency
- Increase cache duration from 5 minutes to 24 hours to reduce API calls
- Add retry logic with 1-hour delay and max 3 retries on failures
- Remove shared fetch promise pattern in favor of retry mechanism
- Remove circular dependency between BannerService and AuthService

This refactoring prevents API hammering by fetching banners less frequently and only after authentication, while improving error handling with retry logic.
2026-01-14 23:02:51 -08:00
6 changed files with 80 additions and 105 deletions
-5
View File
@@ -15,7 +15,6 @@ import { HostProvider } from "@/hosts/host-provider"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { StateManager } from "./core/storage/StateManager"
import { ExtensionRegistryInfo } from "./registry"
import { BannerService } from "./services/banner/BannerService"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
@@ -76,10 +75,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await showVersionUpdateAnnouncement(context)
// Initialize banner service (TEMPORARILY DISABLED - not fetching banners to prevent API hammering)
BannerService.initialize(webview.controller)
// DISABLED: .getActiveBanners(true)
telemetryService.captureExtensionActivated()
return webview
-5
View File
@@ -37,7 +37,6 @@ import { telemetryService } from "@/services/telemetry"
import { BannerCardData } from "@/shared/cline/banner"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { getCwd, getDesktopDir } from "@/utils/path"
import { PromptRegistry } from "../prompts/system-prompt"
import {
@@ -812,7 +811,6 @@ export class Controller {
// Get API configuration from cache for immediate access
const onboardingModels = getClineOnboardingModels()
const apiConfiguration = this.stateManager.getApiConfiguration()
const lastShownAnnouncementId = this.stateManager.getGlobalStateKey("lastShownAnnouncementId")
const taskHistory = this.stateManager.getGlobalStateKey("taskHistory")
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
@@ -872,8 +870,6 @@ export class Controller {
.sort((a, b) => b.ts - a.ts)
.slice(0, 100) // for now we're only getting the latest 100 tasks, but a better solution here is to only pass in 3 for recent task history, and then get the full task history on demand when going to the task history view (maybe with pagination?)
const latestAnnouncementId = getLatestAnnouncementId()
const shouldShowAnnouncement = lastShownAnnouncementId !== latestAnnouncementId
const platform = process.platform as Platform
const distinctId = getDistinctId()
const version = ExtensionRegistryInfo.version
@@ -936,7 +932,6 @@ export class Controller {
subagentTerminalOutputLineLimit,
customPrompt,
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
favoritedModelIds,
autoCondenseThreshold,
backgroundCommandRunning: this.backgroundCommandRunning,
+6
View File
@@ -7,6 +7,7 @@ import { setWelcomeViewCompleted } from "@/core/controller/state/setWelcomeViewC
import { HostProvider } from "@/hosts/host-provider"
import { telemetryService } from "@/services/telemetry"
import { openExternal } from "@/utils/env"
import { BannerService } from "../banner/BannerService"
import { AuthInvalidTokenError, AuthNetworkError } from "../error/ClineError"
import { featureFlagsService } from "../feature-flags"
import { Logger } from "../logging/Logger"
@@ -78,6 +79,8 @@ export class AuthService {
protected constructor(controller: Controller) {
this._provider = new ClineAuthProvider()
this._controller = controller
this.getAuthToken().then((token) => BannerService.initialize(token || undefined))
}
/**
@@ -409,6 +412,9 @@ export class AuthService {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
// Poll feature flags immediately for authenticated users to ensure cache is populated
await featureFlagsService.poll(this._clineAuthInfo?.userInfo?.id)
// Initialize banner service (TEMPORARILY DISABLED - not fetching banners to prevent API hammering)
BannerService.initialize(this._clineAuthInfo?.idToken)
} else {
// Poll feature flags for unauthenticated state
await featureFlagsService.poll(undefined)
+72 -94
View File
@@ -2,11 +2,13 @@ import type { Banner, BannerRules, BannersResponse } from "@shared/ClineBanner"
import { BannerActionType, type BannerCardData } from "@shared/cline/banner"
import axios from "axios"
import { ClineEnv } from "@/config"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { HostProvider } from "@/hosts/host-provider"
import { getAxiosSettings } from "@/shared/net"
import { AuthService } from "../auth/AuthService"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { buildBasicClineHeaders } from "../EnvUtils"
import { featureFlagsService } from "../feature-flags"
import { getDistinctId } from "../logging/distinctId"
import { Logger } from "../logging/Logger"
@@ -18,15 +20,16 @@ export class BannerService {
private readonly _baseUrl = ClineEnv.config().apiBaseUrl
private _cachedBanners: Banner[] = []
private _lastFetchTime: number = 0
private readonly CACHE_DURATION_MS = 5 * 60 * 1000 // 5 minutes
private _controller: Controller
private _authService?: AuthService
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
private readonly RETRY_DELAY_MS = 60 * 60 * 1000 // 1 hour retry delay on failure
private readonly MAX_RETRIES = 3
private actionTypes: Set<string>
private _fetchPromise: Promise<Banner[]> | null = null
private _retryCount: number = 0
private _retryTimeoutId: ReturnType<typeof setTimeout> | null = null
private constructor(controller: Controller) {
this._controller = controller
private constructor(private authToken?: string) {
this.actionTypes = new Set<string>(Object.values(BannerActionType))
this.fetchBanners()
}
/**
@@ -35,11 +38,11 @@ export class BannerService {
* @returns The initialized BannerService instance
* @throws Error if already initialized
*/
public static initialize(controller: Controller): BannerService {
public static initialize(authToken?: string): BannerService {
if (BannerService.instance) {
throw new Error("BannerService has already been initialized.")
}
BannerService.instance = new BannerService(controller)
BannerService.instance = new BannerService(authToken)
return BannerService.instance
}
@@ -69,45 +72,12 @@ export class BannerService {
}
/**
* Sets the AuthService instance for testing purposes
* In production, AuthService is loaded dynamically when needed
* Fetches banners from the API with simple retry logic.
* Called once on init, then every 24 hours via cache expiry.
* On rate limit (429), retries up to 3 times with 1-hour delays.
*/
public setAuthService(authService: AuthService): void {
this._authService = authService
}
/**
* Fetches active banners from the API
* Backend handles all filtering based on ide and user context
* Extension only filters by providers (API provider configuration)
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of banners that match current environment
*/
private async internalGetActiveBanners(forceRefresh = false): Promise<Banner[]> {
private async fetchBanners(): Promise<Banner[]> {
try {
// Return cached banners if still valid
const now = Date.now()
if (!forceRefresh && this._cachedBanners.length > 0 && now - this._lastFetchTime < this.CACHE_DURATION_MS) {
Logger.log("BannerService: Returning cached banners")
return this._cachedBanners
}
if (this._fetchPromise && !forceRefresh) {
return this._fetchPromise
}
this._fetchPromise = this.fetchActiveBanners()
return this._fetchPromise
} catch (error) {
// Log error but don't throw - banner fetching shouldn't break the extension
Logger.error("BannerService: Error getting internal banners", error)
return []
}
}
private async fetchActiveBanners(): Promise<Banner[]> {
try {
const now = Date.now()
const ideType = await this.getIdeType()
const extensionVersion = await this.getExtensionVersion()
const osType = await this.getOSType()
@@ -122,8 +92,7 @@ export class BannerService {
const url = urlObj.toString()
Logger.log(`BannerService: Fetching banners from ${url}`)
const authService = this.getAuthServiceInstance()
const token: string | null = (await authService?.getAuthToken()) || null
const token: string | null = this.authToken || null
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -140,31 +109,39 @@ export class BannerService {
})
if (!response.data?.data || !Array.isArray(response.data.data.items)) {
Logger.log("BannerService: Invalid response format - items array is missing or malformed")
Logger.log("BannerService: Invalid response format")
return []
}
const backendFilteredBanners = response.data.data.items
Logger.log(`BannerService: Received ${backendFilteredBanners.length} banners from backend (already filtered)`)
// Client-side filtering: Only filter by providers
const matchingBanners = backendFilteredBanners.filter((banner) => this.matchesProviderRule(banner))
Logger.log(`BannerService: ${matchingBanners.length} banners match provider requirements`)
// Update cache
// Success - update cache and reset retry state
this._cachedBanners = matchingBanners
this._lastFetchTime = now
if (matchingBanners.length > 0) {
Logger.log(`BannerService: ${matchingBanners.length} active banner(s) fetched.`)
this._lastFetchTime = Date.now()
this._retryCount = 0
if (this._retryTimeoutId) {
clearTimeout(this._retryTimeoutId)
this._retryTimeoutId = null
}
Logger.log(`BannerService: Fetched ${matchingBanners.length} banner(s)`)
return matchingBanners
} catch (error) {
// Log error but don't throw - banner fetching shouldn't break the extension
Logger.error("BannerService: Error fetching banners", error)
return []
} finally {
this._fetchPromise = null
// Handle rate limiting with retry
if (axios.isAxiosError(error) && error.response?.status === 429) {
if (this._retryCount < this.MAX_RETRIES) {
this._retryCount++
Logger.log(`BannerService: Rate limited, scheduling retry ${this._retryCount}/${this.MAX_RETRIES} in 1 hour`)
this._retryTimeoutId = setTimeout(() => this.fetchBanners(), this.RETRY_DELAY_MS)
} else {
Logger.log(`BannerService: Rate limited, max retries (${this.MAX_RETRIES}) reached`)
}
} else {
Logger.error("BannerService: Error fetching banners", error)
}
return this._cachedBanners
}
}
@@ -196,8 +173,8 @@ export class BannerService {
return true
}
const apiConfiguration = this._controller.stateManager.getApiConfiguration()
const currentMode = this._controller.stateManager.getGlobalSettingsKey("mode")
const apiConfiguration = StateManager.get()?.getApiConfiguration()
const currentMode = StateManager.get()?.getGlobalSettingsKey("mode")
const selectedProvider =
currentMode === "plan" ? apiConfiguration?.planModeApiProvider : apiConfiguration?.actModeApiProvider
@@ -290,29 +267,16 @@ export class BannerService {
}
/**
* 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
* Clears the banner cache and resets retry state
*/
public clearCache(): void {
this._cachedBanners = []
this._lastFetchTime = 0
this._retryCount = 0
if (this._retryTimeoutId) {
clearTimeout(this._retryTimeoutId)
this._retryTimeoutId = null
}
Logger.log("BannerService: Cache cleared")
}
@@ -366,7 +330,7 @@ export class BannerService {
*/
public async dismissBanner(bannerId: string): Promise<void> {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
const dismissedBanners = StateManager.get()?.getGlobalStateKey("dismissedBanners") || []
if (dismissedBanners.some((b) => b.bannerId === bannerId)) {
Logger.log(`BannerService: Banner ${bannerId} already dismissed`)
@@ -377,7 +341,7 @@ export class BannerService {
dismissedAt: Date.now(),
}
this._controller.stateManager.setGlobalState("dismissedBanners", [...dismissedBanners, newDismissal])
StateManager.get()?.setGlobalState("dismissedBanners", [...dismissedBanners, newDismissal])
await this.sendBannerEvent(bannerId, "dismiss")
@@ -396,7 +360,7 @@ export class BannerService {
*/
public isBannerDismissed(bannerId: string): boolean {
try {
const dismissedBanners = this._controller.stateManager.getGlobalStateKey("dismissedBanners") || []
const dismissedBanners = StateManager.get().getGlobalStateKey("dismissedBanners") || []
return dismissedBanners.some((b) => b.bannerId === bannerId)
} catch (error) {
Logger.error(`BannerService: Error checking if banner is dismissed`, error)
@@ -438,16 +402,30 @@ export class BannerService {
}
/**
* Gets banners that haven't been dismissed by the user
* Gets banners that haven't been dismissed by the user.
* Fetches from API if cache is empty or expired (24 hours).
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of non-dismissed banners converted to BannerCardData format
*
* TEMPORARILY DISABLED: Returning empty array to prevent API calls
*/
public async getActiveBanners(forceRefresh = false): Promise<BannerCardData[]> {
// Disable all banner fetching to prevent blocking the extension
Logger.log("BannerService: Banner fetching is temporarily disabled")
return []
const latestAnnouncementId = getLatestAnnouncementId()
const lastShownAnnouncementId = StateManager.get().getGlobalStateKey("lastShownAnnouncementId")
const shouldShowAnnouncement = lastShownAnnouncementId !== latestAnnouncementId
if (!shouldShowAnnouncement || !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.BANNER_EXTENSION)) {
return []
}
const now = Date.now()
const cacheExpired = now - this._lastFetchTime >= this.CACHE_DURATION_MS
const shouldFetch = forceRefresh || this._cachedBanners.length === 0 || cacheExpired
if (shouldFetch) {
await this.fetchBanners()
}
return this._cachedBanners
.map((banner) => this.convertToBannerCardData(banner))
.filter((b): b is BannerCardData => b !== null)
}
/**
-1
View File
@@ -59,7 +59,6 @@ export interface ExtensionState {
enableCheckpointsSetting?: boolean
platform: Platform
environment?: Environment
shouldShowAnnouncement: boolean
taskHistory: HistoryItem[]
telemetrySetting: TelemetrySetting
shellIntegrationTimeout: number
@@ -9,6 +9,7 @@ export enum FeatureFlag {
WEBTOOLS = "webtools",
// Feature flag for showing the new onboarding flow or old welcome view.
ONBOARDING_MODELS = "onboarding_models",
BANNER_EXTENSION = "banner_extension",
}
export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPayload>> = {
@@ -16,6 +17,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.HOOKS]: false,
[FeatureFlag.WEBTOOLS]: false,
[FeatureFlag.ONBOARDING_MODELS]: process.env.E2E_TEST === "true" ? { models: {} } : undefined,
[FeatureFlag.BANNER_EXTENSION]: process.env.IS_DEV === "true",
}
export const FEATURE_FLAGS = Object.values(FeatureFlag)