Compare commits

...

2 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)