From 571f833eaef6451826e8a8ba587bc5724a5d2fa2 Mon Sep 17 00:00:00 2001 From: Zhongying Qiao Date: Mon, 24 Nov 2025 10:07:27 -0800 Subject: [PATCH] feat: wire up controller and UI for banners --- proto/cline/banners.proto | 47 ++++++++++++ src/core/controller/banner/DismissBanner.ts | 19 +++++ .../controller/banner/GetActiveBanners.ts | 39 ++++++++++ .../controller/banner/TrackBannerEvent.ts | 23 ++++++ src/core/controller/index.ts | 4 + src/shared/ExtensionMessage.ts | 17 +++++ .../components/layout/WelcomeSection.tsx | 6 +- .../src/components/common/ApiBanner.tsx | 74 +++++++++++++++++++ 8 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 proto/cline/banners.proto create mode 100644 src/core/controller/banner/DismissBanner.ts create mode 100644 src/core/controller/banner/GetActiveBanners.ts create mode 100644 src/core/controller/banner/TrackBannerEvent.ts create mode 100644 webview-ui/src/components/common/ApiBanner.tsx diff --git a/proto/cline/banners.proto b/proto/cline/banners.proto new file mode 100644 index 0000000000..f2d32860f8 --- /dev/null +++ b/proto/cline/banners.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package cline; + +import "google/protobuf/empty.proto"; + +// Banner service for fetching and managing banners +service BannerService { + // Get active banners that haven't been dismissed + rpc GetActiveBanners(google.protobuf.Empty) returns (BannersResponse); + + // Dismiss a banner + rpc DismissBanner(DismissBannerRequest) returns (google.protobuf.Empty); + + // Track a banner event (seen, click, dismiss) + rpc TrackBannerEvent(TrackBannerEventRequest) returns (google.protobuf.Empty); +} + +// Response containing active banners +message BannersResponse { + repeated Banner banners = 1; +} + +// Request to dismiss a banner +message DismissBannerRequest { + string banner_id = 1; +} + +// Request to track a banner event +message TrackBannerEventRequest { + string banner_id = 1; + string event_type = 2; // "seen", "dismiss", or "click" +} + +// Banner message +message Banner { + string id = 1; + string title_md = 2; + string body_md = 3; + string severity = 4; // "info", "warning", "error" + string placement = 5; // "top", "inline" + optional string cta_text = 6; + optional string cta_url = 7; + optional string active_from = 8; + optional string active_to = 9; + string rules_json = 10; +} diff --git a/src/core/controller/banner/DismissBanner.ts b/src/core/controller/banner/DismissBanner.ts new file mode 100644 index 0000000000..9d9ad6b741 --- /dev/null +++ b/src/core/controller/banner/DismissBanner.ts @@ -0,0 +1,19 @@ +import type { DismissBannerRequest } from "@/shared/proto/cline/banners" +import { Empty } from "@/shared/proto/google/protobuf/empty" +import type { Controller } from ".." + +/** + * Dismisses a banner + * @param controller The controller instance + * @param request The request containing the banner ID to dismiss + * @returns Empty response + */ +export async function DismissBanner(controller: Controller, request: DismissBannerRequest): Promise { + try { + await controller.dismissBanner(request.bannerId) + return Empty.create({}) + } catch (error) { + console.error("Failed to dismiss banner:", error) + return Empty.create({}) + } +} diff --git a/src/core/controller/banner/GetActiveBanners.ts b/src/core/controller/banner/GetActiveBanners.ts new file mode 100644 index 0000000000..fc7714c05d --- /dev/null +++ b/src/core/controller/banner/GetActiveBanners.ts @@ -0,0 +1,39 @@ +import type { BannersResponse } from "@/shared/proto/cline/banners" +import { Banner } from "@/shared/proto/cline/banners" +import type { Controller } from ".." + +/** + * Gets active banners that haven't been dismissed + * @param controller The controller instance + * @returns BannersResponse with active banners + */ +export async function GetActiveBanners(controller: Controller): Promise { + try { + const banners = await controller.fetchBannersForDisplay() + + // Convert to proto Banner format + const protoBanners = banners.map((banner) => + Banner.create({ + id: banner.id, + titleMd: banner.titleMd, + bodyMd: banner.bodyMd, + severity: banner.severity, + placement: banner.placement, + ctaText: banner.ctaText, + ctaUrl: banner.ctaUrl, + activeFrom: banner.activeFrom, + activeTo: banner.activeTo, + rulesJson: banner.rulesJson, + }), + ) + + return { + banners: protoBanners, + } + } catch (error) { + console.error("Failed to get active banners:", error) + return { + banners: [], + } + } +} diff --git a/src/core/controller/banner/TrackBannerEvent.ts b/src/core/controller/banner/TrackBannerEvent.ts new file mode 100644 index 0000000000..7cf9221328 --- /dev/null +++ b/src/core/controller/banner/TrackBannerEvent.ts @@ -0,0 +1,23 @@ +import type { TrackBannerEventRequest } from "@/shared/proto/cline/banners" +import { Empty } from "@/shared/proto/google/protobuf/empty" +import type { Controller } from ".." + +/** + * Tracks a banner event (seen, dismiss, click) + * @param controller The controller instance + * @param request The request containing banner ID and event type + * @returns Empty response + */ +export async function TrackBannerEvent(controller: Controller, request: TrackBannerEventRequest): Promise { + try { + // Currently only "dismiss" is supported in the backend + if (request.eventType === "dismiss") { + await controller.trackBannerEvent(request.bannerId, "dismiss") + } + // For "seen" and "click", we'd need to extend the backend method + return Empty.create({}) + } catch (error) { + console.error("Failed to track banner event:", error) + return Empty.create({}) + } +} diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts index a9fa01caca..b577954831 100644 --- a/src/core/controller/index.ts +++ b/src/core/controller/index.ts @@ -892,6 +892,9 @@ export class Controller { const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0 const subagentsEnabled = this.stateManager.getGlobalSettingsKey("subagentsEnabled") + // Fetch API banners + const apiBanners = await this.fetchBannersForDisplay() + const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles") const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles") @@ -995,6 +998,7 @@ export class Controller { user: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"), featureFlag: featureFlagsService.getNativeToolCallEnabled(), }, + apiBanners, } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index a5eb16e260..b030956ad9 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -104,6 +104,23 @@ export interface ExtensionState { remoteConfigSettings?: Partial subagentsEnabled?: boolean nativeToolCallSetting?: ClineFeatureSetting + apiBanners?: Banner[] +} + +/** + * API-fetched banner from the banner service + */ +export interface Banner { + id: string + titleMd: string + bodyMd: string + severity: "info" | "warning" | "error" + placement: "top" | "inline" + ctaText?: string + ctaUrl?: string + activeFrom?: string + activeTo?: string + rulesJson: string } export interface ClineMessage { diff --git a/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx b/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx index 92eb11af8e..26e2bb2eb6 100644 --- a/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx +++ b/webview-ui/src/components/chat/chat-view/components/layout/WelcomeSection.tsx @@ -1,5 +1,6 @@ import React from "react" import Announcement from "@/components/chat/Announcement" +import ApiBanner from "@/components/common/ApiBanner" import CliInstallBanner, { CURRENT_CLI_BANNER_VERSION } from "@/components/common/CliInstallBanner" import InfoBanner, { CURRENT_INFO_BANNER_VERSION } from "@/components/common/InfoBanner" import HistoryPreview from "@/components/history/HistoryPreview" @@ -22,7 +23,7 @@ export const WelcomeSection: React.FC = ({ taskHistory, shouldShowQuickWins, }) => { - const { lastDismissedInfoBannerVersion, lastDismissedCliBannerVersion } = useExtensionState() + const { lastDismissedInfoBannerVersion, lastDismissedCliBannerVersion, apiBanners } = useExtensionState() const shouldShowInfoBanner = lastDismissedInfoBannerVersion < CURRENT_INFO_BANNER_VERSION // const shouldShowNewModelBanner = lastDismissedModelBannerVersion < CURRENT_MODEL_BANNER_VERSION @@ -40,6 +41,9 @@ export const WelcomeSection: React.FC = ({ {showAnnouncement && } {/* {shouldShowNewModelBanner && } */} {shouldShowCliBanner && } + {apiBanners?.map((banner) => ( + + ))} {!shouldShowQuickWins && taskHistory.length > 0 && } diff --git a/webview-ui/src/components/common/ApiBanner.tsx b/webview-ui/src/components/common/ApiBanner.tsx new file mode 100644 index 0000000000..c034e7b3e2 --- /dev/null +++ b/webview-ui/src/components/common/ApiBanner.tsx @@ -0,0 +1,74 @@ +import { X } from "lucide-react" +import React, { useCallback } from "react" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { BannerServiceClient } from "@/services/grpc-client" +import type { Banner } from "../../../../src/shared/ExtensionMessage" +import { DismissBannerRequest } from "../../../../src/shared/proto/cline/banners" + +interface ApiBannerProps { + banner: Banner +} + +export const ApiBanner: React.FC = ({ banner }) => { + const { apiBanners } = useExtensionState() + + const handleDismiss = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation() + try { + await BannerServiceClient.DismissBanner( + DismissBannerRequest.create({ + bannerId: banner.id, + }), + ) + } catch (error) { + console.error("Failed to dismiss banner:", error) + } + }, + [banner.id], + ) + + const handleCtaClick = useCallback(() => { + if (banner.ctaUrl) { + window.open(banner.ctaUrl, "_blank") + } + }, [banner.ctaUrl]) + + // Different colors based on severity + const severityStyles = { + info: "bg-blue-500/10 border-blue-500/30", + warning: "bg-yellow-500/10 border-yellow-500/30", + error: "bg-red-500/10 border-red-500/30", + } + + const severityClass = severityStyles[banner.severity] || severityStyles.info + + return ( +
+ + +
+ {banner.titleMd &&
{banner.titleMd}
} + + {banner.bodyMd &&
{banner.bodyMd}
} + + {banner.ctaText && banner.ctaUrl && ( + + )} +
+
+ ) +} + +export default ApiBanner