feat: wire up controller and UI for banners

This commit is contained in:
Zhongying Qiao
2025-11-24 10:07:27 -08:00
parent 0eb757ba72
commit 571f833eae
8 changed files with 228 additions and 1 deletions
+47
View File
@@ -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;
}
@@ -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<Empty> {
try {
await controller.dismissBanner(request.bannerId)
return Empty.create({})
} catch (error) {
console.error("Failed to dismiss banner:", error)
return Empty.create({})
}
}
@@ -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<BannersResponse> {
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: [],
}
}
}
@@ -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<Empty> {
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({})
}
}
+4
View File
@@ -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,
}
}
+17
View File
@@ -104,6 +104,23 @@ export interface ExtensionState {
remoteConfigSettings?: Partial<RemoteConfigFields>
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 {
@@ -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<WelcomeSectionProps> = ({
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<WelcomeSectionProps> = ({
{showAnnouncement && <Announcement hideAnnouncement={hideAnnouncement} version={version} />}
{/* {shouldShowNewModelBanner && <NewModelBanner />} */}
{shouldShowCliBanner && <CliInstallBanner />}
{apiBanners?.map((banner) => (
<ApiBanner banner={banner} key={banner.id} />
))}
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!shouldShowQuickWins && taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
</div>
@@ -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<ApiBannerProps> = ({ 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 (
<div className={`rounded-lg border ${severityClass} p-4 mb-3 relative`} data-testid="api-banner">
<button
aria-label="Dismiss banner"
className="absolute top-2 right-2 p-1 hover:bg-black/10 rounded"
data-testid="dismiss-banner"
onClick={handleDismiss}>
<X size={16} />
</button>
<div className="pr-8">
{banner.titleMd && <div className="font-semibold mb-1">{banner.titleMd}</div>}
{banner.bodyMd && <div className="text-sm opacity-90">{banner.bodyMd}</div>}
{banner.ctaText && banner.ctaUrl && (
<button
className="mt-2 text-sm underline hover:no-underline"
data-testid="banner-cta"
onClick={handleCtaClick}>
{banner.ctaText}
</button>
)}
</div>
</div>
)
}
export default ApiBanner