From b29f7a4445edee5121c0fc7b1bc4272d3969fe20 Mon Sep 17 00:00:00 2001 From: Gravity Date: Thu, 9 Apr 2026 14:30:53 -0700 Subject: [PATCH 1/7] feat: add Choice Ad placement type with 50/50 A/B test Introduces a new "Choice Ad" placement that shows 4 ads as side-by-side cards instead of a single banner. Users are deterministically assigned to either the existing banner or the new choice variant via a hash of their userId (50/50 split). Backend: requests 4 placement IDs (choice-ad-1 through choice-ad-4) from Gravity API for the choice variant. All returned ads are stored to ad_impression and impressions fire for each card. Client: new ChoiceAdBanner component renders equal-width bordered cards with CTA button + domain per card, hover effects, and theme-aware styling. Gracefully handles partial fills (1-4 ads). Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/chat.tsx | 21 +- cli/src/components/choice-ad-banner.tsx | 242 ++++++++++++++++++++++++ cli/src/hooks/use-gravity-ad.ts | 145 +++++++++++--- web/src/app/api/v1/ads/_post.ts | 140 +++++++++----- 4 files changed, 475 insertions(+), 73 deletions(-) create mode 100644 cli/src/components/choice-ad-banner.tsx diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 97eb0a0a3d..30509a44cf 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -14,6 +14,7 @@ import { useShallow } from 'zustand/react/shallow' import { getAdsEnabled, handleAdsDisable } from './commands/ads' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' import { AdBanner } from './components/ad-banner' +import { ChoiceAdBanner } from './components/choice-ad-banner' import { ChatInputBar } from './components/chat-input-bar' import { LoadPreviousButton } from './components/load-previous-button' import { ReviewScreen } from './components/review-screen' @@ -168,7 +169,7 @@ export const Chat = ({ }) const hasSubscription = subscriptionData?.hasSubscription ?? false - const { ad } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) + const { ad, adData } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false) const handleDisableAds = useCallback(() => { @@ -1445,11 +1446,19 @@ export const Chat = ({ )} {ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && ( - + adData?.variant === 'choice' ? ( + + ) : ( + + ) )} {reviewMode ? ( diff --git a/cli/src/components/choice-ad-banner.tsx b/cli/src/components/choice-ad-banner.tsx new file mode 100644 index 0000000000..f3dcb239d3 --- /dev/null +++ b/cli/src/components/choice-ad-banner.tsx @@ -0,0 +1,242 @@ +import { TextAttributes } from '@opentui/core' +import { safeOpen } from '../utils/open-url' +import React, { useState, useMemo } from 'react' + +import { Button } from './button' +import { Clickable } from './clickable' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' +import { useTheme } from '../hooks/use-theme' +import { IS_FREEBUFF } from '../utils/constants' + +import type { AdResponse } from '../hooks/use-gravity-ad' + +interface ChoiceAdBannerProps { + ads: AdResponse[] + onDisableAds: () => void + isFreeMode: boolean +} + +const CARD_HEIGHT = 5 // border-top + description + spacer + cta row + border-bottom + +const extractDomain = (url: string): string => { + try { + const parsed = new URL(url) + return parsed.hostname.replace(/^www\./, '') + } catch { + return url + } +} + +/** + * Calculate evenly distributed column widths that sum exactly to availableWidth. + * Distributes remainder pixels across the first N columns so there's no gap. + */ +function columnWidths(count: number, availableWidth: number): number[] { + const base = Math.floor(availableWidth / count) + const remainder = availableWidth - base * count + return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0)) +} + +export const ChoiceAdBanner: React.FC = ({ ads, onDisableAds, isFreeMode }) => { + const theme = useTheme() + const { separatorWidth, terminalWidth } = useTerminalDimensions() + const [hoveredIndex, setHoveredIndex] = useState(null) + const [showInfoPanel, setShowInfoPanel] = useState(false) + const [isAdLabelHovered, setIsAdLabelHovered] = useState(false) + const [isHideHovered, setIsHideHovered] = useState(false) + const [isCloseHovered, setIsCloseHovered] = useState(false) + + // Available width for cards (terminal minus left/right margin of 1 each) + const colAvail = terminalWidth - 2 + const widths = useMemo(() => columnWidths(ads.length, colAvail), [ads.length, colAvail]) + + // Sum of all credits across choice ads + const totalCredits = ads.reduce((sum, ad) => sum + (ad.credits ?? 0), 0) + + // Hover colors + const hoverBorderColor = theme.link + const hoverBgColor = theme.name === 'light' ? '#e8f0fe' : '#1a2332' + + return ( + + {/* Horizontal divider line */} + {'─'.repeat(terminalWidth)} + + {/* Header: "Sponsored picks" + credits + Ad label */} + + Sponsored picks + + {!IS_FREEBUFF && totalCredits > 0 && ( + +{totalCredits} credits + )} + {!IS_FREEBUFF ? ( + setShowInfoPanel(true)} + onMouseOver={() => setIsAdLabelHovered(true)} + onMouseOut={() => setIsAdLabelHovered(false)} + > + + {isAdLabelHovered && !showInfoPanel ? 'Ad ?' : ' Ad'} + + + ) : ( + {' Ad'} + )} + + + + {/* Card columns */} + + {ads.map((ad, i) => { + const isHovered = hoveredIndex === i + const domain = extractDomain(ad.url) + const ctaText = ad.cta || ad.title || 'Learn more' + + return ( + + ) + })} + + + {/* Info panel: shown when Ad label is clicked */} + {showInfoPanel && ( + + {' ' + '┄'.repeat(separatorWidth - 2)} + + + {IS_FREEBUFF + ? 'Ads help keep Freebuff free.' + : 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'} + + + + + {isFreeMode && !IS_FREEBUFF ? ( + + Ads are required in Free mode. + + ) : ( + <> + + · + + Use /ads:enable to show again + + + )} + + + )} + + ) +} diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index ee825baf56..36e8c6c3f1 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -27,8 +27,15 @@ export type AdResponse = { credits?: number // Set after impression is recorded (in cents) } +export type AdVariant = 'banner' | 'choice' + +export type AdData = + | { variant: 'banner'; ad: AdResponse } + | { variant: 'choice'; ads: AdResponse[] } + export type GravityAdState = { ad: AdResponse | null + adData: AdData | null isLoading: boolean } @@ -36,6 +43,9 @@ export type GravityAdState = { type GravityController = { cache: AdResponse[] cacheIndex: number + choiceCache: AdResponse[][] // Cache of choice ad sets (each entry is 4 ads) + choiceCacheIndex: number + variant: AdVariant | null // Assigned variant from backend impressionsFired: Set adsShownSinceActivity: number tickInFlight: boolean @@ -57,6 +67,23 @@ function nextFromCache(ctrl: GravityController): AdResponse | null { return ad } +// Pure helper: add a choice ad set to the choice cache +function addToChoiceCache(ctrl: GravityController, ads: AdResponse[]): void { + // Deduplicate by checking if any set has the same first impUrl + const key = ads[0]?.impUrl + if (key && ctrl.choiceCache.some((set) => set[0]?.impUrl === key)) return + if (ctrl.choiceCache.length >= MAX_AD_CACHE_SIZE) ctrl.choiceCache.shift() + ctrl.choiceCache.push(ads) +} + +// Pure helper: get the next cached choice ad set +function nextFromChoiceCache(ctrl: GravityController): AdResponse[] | null { + if (ctrl.choiceCache.length === 0) return null + const set = ctrl.choiceCache[ctrl.choiceCacheIndex % ctrl.choiceCache.length]! + ctrl.choiceCacheIndex = (ctrl.choiceCacheIndex + 1) % ctrl.choiceCache.length + return set +} + /** * Hook for fetching and rotating Gravity ads. * @@ -71,6 +98,7 @@ function nextFromCache(ctrl: GravityController): AdResponse | null { export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => { const enabled = options?.enabled ?? true const [ad, setAd] = useState(null) + const [adData, setAdData] = useState(null) const [isLoading, setIsLoading] = useState(false) // Check if terminal height is too small to show ads @@ -94,6 +122,9 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => const ctrlRef = useRef({ cache: [], cacheIndex: 0, + choiceCache: [], + choiceCacheIndex: 0, + variant: null, impressionsFired: new Set(), adsShownSinceActivity: 0, tickInFlight: false, @@ -145,6 +176,22 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => ? { ...cur, credits: data.creditsGranted } : cur, ) + // Also update credits in adData for choice ads + setAdData((cur) => { + if (!cur) return cur + if (cur.variant === 'choice') { + return { + ...cur, + ads: cur.ads.map((a) => + a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a, + ), + } + } + if (cur.variant === 'banner' && cur.ad.impUrl === impUrl) { + return { ...cur, ad: { ...cur.ad, credits: data.creditsGranted } } + } + return cur + }) } }) .catch((err) => { @@ -152,14 +199,29 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => }) } - // Show an ad and fire impression + // Show a single banner ad and fire impression const showAd = (next: AdResponse): void => { setAd(next) + setAdData({ variant: 'banner', ad: next }) recordImpressionOnce(next.impUrl) } + // Show a choice ad set and fire impressions for all + const showChoiceAds = (ads: AdResponse[]): void => { + setAd(ads[0] ?? null) // Keep backwards compat for ad field + setAdData({ variant: 'choice', ads }) + for (const choiceAd of ads) { + recordImpressionOnce(choiceAd.impUrl) + } + } + + type FetchAdResult = + | { variant: 'banner'; ad: AdResponse } + | { variant: 'choice'; ads: AdResponse[] } + | null + // Fetch an ad via web API - const fetchAd = async (): Promise => { + const fetchAd = async (): Promise => { // Don't fetch ads when they should be hidden if (shouldHideAdsRef.current) return null if (!getAdsEnabled()) return null @@ -223,7 +285,17 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => } const data = await response.json() - return data.ad as AdResponse | null + const variant = data.variant ?? 'banner' + + if (variant === 'choice' && Array.isArray(data.ads) && data.ads.length > 0) { + return { variant: 'choice', ads: data.ads as AdResponse[] } + } + + if (data.ad) { + return { variant: 'banner', ad: data.ad as AdResponse } + } + + return null } catch (err) { logger.error({ err }, '[gravity] Failed to fetch ad') return null @@ -245,21 +317,34 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => ctrl.adsShownSinceActivity < MAX_ADS_AFTER_ACTIVITY && isUserActive(ACTIVITY_THRESHOLD_MS) - let next: AdResponse | null = null - - if (canFetchNew) { - next = await fetchAd() - if (next) addToCache(ctrl, next) - } - - // Fall back to cached ads if no new ad - if (!next) { - next = nextFromCache(ctrl) - } - - if (next) { - ctrl.adsShownSinceActivity += 1 - showAd(next) + const result = canFetchNew ? await fetchAd() : null + + if (result) { + ctrl.variant = result.variant + if (result.variant === 'choice') { + addToChoiceCache(ctrl, result.ads) + ctrl.adsShownSinceActivity += 1 + showChoiceAds(result.ads) + } else { + addToCache(ctrl, result.ad) + ctrl.adsShownSinceActivity += 1 + showAd(result.ad) + } + } else { + // Fall back to cached ads + if (ctrl.variant === 'choice') { + const cachedSet = nextFromChoiceCache(ctrl) + if (cachedSet) { + ctrl.adsShownSinceActivity += 1 + showChoiceAds(cachedSet) + } + } else { + const next = nextFromCache(ctrl) + if (next) { + ctrl.adsShownSinceActivity += 1 + showAd(next) + } + } } } finally { ctrl.tickInFlight = false @@ -283,11 +368,18 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => // Fetch first ad immediately void (async () => { - const firstAd = await fetchAd() - if (firstAd) { - addToCache(ctrlRef.current, firstAd) - showAd(firstAd) - ctrlRef.current.adsShownSinceActivity = 1 + const result = await fetchAd() + if (result) { + const ctrl = ctrlRef.current + ctrl.variant = result.variant + if (result.variant === 'choice') { + addToChoiceCache(ctrl, result.ads) + showChoiceAds(result.ads) + } else { + addToCache(ctrl, result.ad) + showAd(result.ad) + } + ctrl.adsShownSinceActivity = 1 } setIsLoading(false) })() @@ -303,7 +395,12 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => }, [hasUserMessaged, shouldHideAds]) // Don't return ad when ads should be hidden - return { ad: hasUserMessaged && !shouldHideAds ? ad : null, isLoading } + const visible = hasUserMessaged && !shouldHideAds + return { + ad: visible ? ad : null, + adData: visible ? adData : null, + isLoading, + } } type AdMessage = { role: 'user' | 'assistant'; content: string } diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 1e8cc407e1..9a07f58c36 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto' + import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { buildArray } from '@codebuff/common/util/array' import { getErrorObject } from '@codebuff/common/util/error' @@ -18,6 +20,26 @@ import type { NextRequest } from 'next/server' const DEFAULT_PAYOUT = 0.04 +// A/B test: 50% of users see the "choice" ad variant (4 ads as bullet points) +type AdVariant = 'banner' | 'choice' + +const CHOICE_AD_PLACEMENT_IDS = [ + 'choice-ad-1', + 'choice-ad-2', + 'choice-ad-3', + 'choice-ad-4', +] + +/** + * Deterministically assign a user to an ad variant based on their userId. + * Uses a hash so the assignment is stable across requests. + */ +function getAdVariant(userId: string): AdVariant { + const hash = createHash('sha256').update(`ad-variant:${userId}`).digest() + // Use first byte: even = banner, odd = choice (50/50 split) + return hash[0] % 2 === 0 ? 'banner' : 'choice' +} + const messageSchema = z.object({ role: z.string(), content: z.string(), @@ -143,13 +165,23 @@ export async function postAds(params: { } : undefined + // Determine A/B test variant for this user + const variant = getAdVariant(userId) + + // Build placements based on variant + const placements = + variant === 'choice' + ? CHOICE_AD_PLACEMENT_IDS.map((id) => ({ + placement: 'below_response', + placement_id: id, + })) + : [{ placement: 'below_response', placement_id: 'code-assist-ad' }] + try { const requestBody = { messages: filteredMessages, sessionId: sessionId ?? userId, - placements: [ - { placement: 'below_response', placement_id: 'code-assist-ad' }, - ], + placements, testAd: serverEnv.CB_ENVIRONMENT !== 'prod', relevancy: 0.3, ...(device ? { device } : {}), @@ -174,7 +206,7 @@ export async function postAds(params: { { request: requestBody, status: response.status }, '[ads] No ad available from Gravity API', ) - return NextResponse.json({ ad: null }, { status: 200 }) + return NextResponse.json({ ad: null, variant }, { status: 200 }) } // Check response.ok BEFORE parsing JSON to handle HTML error pages gracefully @@ -196,7 +228,7 @@ export async function postAds(params: { { request: requestBody, response: errorBody, status: response.status }, '[ads] Gravity API returned error', ) - return NextResponse.json({ ad: null }, { status: 200 }) + return NextResponse.json({ ad: null, variant }, { status: 200 }) } // Now safe to parse JSON body since response.ok is true @@ -207,16 +239,72 @@ export async function postAds(params: { { request: requestBody, response: ads, status: response.status }, '[ads] No ads returned from Gravity API', ) - return NextResponse.json({ ad: null }, { status: 200 }) + return NextResponse.json({ ad: null, variant }, { status: 200 }) } - const ad = ads[0] + // Store all returned ads in the database + for (const ad of ads) { + const payout = ad.payout || DEFAULT_PAYOUT + try { + await db.insert(schema.adImpression).values({ + user_id: userId, + ad_text: ad.adText, + title: ad.title, + cta: ad.cta, + url: ad.url, + favicon: ad.favicon, + click_url: ad.clickUrl, + imp_url: ad.impUrl, + payout: String(payout), + credits_granted: 0, + }) + } catch (error) { + logger.warn( + { + userId, + impUrl: ad.impUrl, + status: response.status, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to create ad_impression record (likely duplicate)', + ) + } + } + + // Strip payout from all ads before returning to client + const sanitizeAd = (ad: Record) => { + const { payout: _payout, ...rest } = ad + return rest + } + + if (variant === 'choice') { + // Return all ads for the choice variant (up to 4) + const sanitizedAds = ads.map(sanitizeAd) + logger.info( + { + variant, + adCount: sanitizedAds.length, + request: requestBody, + status: response.status, + }, + '[ads] Fetched choice ads from Gravity API', + ) + + return NextResponse.json({ ads: sanitizedAds, variant }) + } + + // Banner variant: return single ad (existing behavior) + const ad = ads[0] const payout = ad.payout || DEFAULT_PAYOUT logger.info( { ad, + variant, request: requestBody, status: response.status, payout: { @@ -229,41 +317,7 @@ export async function postAds(params: { '[ads] Fetched ad from Gravity API', ) - // Insert ad_impression row to database (served_at = now) - // This stores the trusted ad data server-side so we don't have to trust the client later - try { - await db.insert(schema.adImpression).values({ - user_id: userId, - ad_text: ad.adText, - title: ad.title, - cta: ad.cta, - url: ad.url, - favicon: ad.favicon, - click_url: ad.clickUrl, - imp_url: ad.impUrl, - payout: String(payout), - credits_granted: 0, // Will be updated when impression is fired - }) - } catch (error) { - // If insert fails (e.g., duplicate impUrl), log but continue - // The ad can still be shown, it just won't be tracked - logger.warn( - { - userId, - impUrl: ad.impUrl, - status: response.status, - error: - error instanceof Error - ? { name: error.name, message: error.message } - : error, - }, - '[ads] Failed to create ad_impression record (likely duplicate)', - ) - } - - // Return ad to client without payout (credits will come from impression endpoint) - const { payout: _payout, ...adWithoutPayout } = ad - return NextResponse.json({ ad: adWithoutPayout }) + return NextResponse.json({ ad: sanitizeAd(ad), variant }) } catch (error) { logger.error( { @@ -278,7 +332,7 @@ export async function postAds(params: { '[ads] Failed to fetch ad from Gravity API', ) return NextResponse.json( - { ad: null, error: getErrorObject(error) }, + { ad: null, variant, error: getErrorObject(error) }, { status: 500 }, ) } From e345a1e9d33948f4f38994a5a18b64c815cd5b6e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 10 Apr 2026 19:10:43 -0700 Subject: [PATCH 2/7] Lower relevancy --- web/src/app/api/v1/ads/_post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 9a07f58c36..0a01357584 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -183,7 +183,7 @@ export async function postAds(params: { sessionId: sessionId ?? userId, placements, testAd: serverEnv.CB_ENVIRONMENT !== 'prod', - relevancy: 0.3, + relevancy: 0.1, ...(device ? { device } : {}), user: { id: userId, From 31c320c2e679a4104a4be97f2b5fe606de5be9bc Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 10 Apr 2026 19:54:20 -0700 Subject: [PATCH 3/7] Clean up style, ensure minimum width of ads --- cli/src/chat.tsx | 3 +- cli/src/components/choice-ad-banner.tsx | 178 ++++++------------------ cli/src/hooks/use-gravity-ad.ts | 7 +- 3 files changed, 46 insertions(+), 142 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 30509a44cf..c4fa1b70a1 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -169,7 +169,7 @@ export const Chat = ({ }) const hasSubscription = subscriptionData?.hasSubscription ?? false - const { ad, adData } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) + const { ad, adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription }) const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false) const handleDisableAds = useCallback(() => { @@ -1451,6 +1451,7 @@ export const Chat = ({ ads={adData.ads} onDisableAds={handleDisableAds} isFreeMode={IS_FREEBUFF || agentMode === 'FREE'} + onImpression={recordImpression} /> ) : ( void - isFreeMode: boolean + onImpression?: (impUrl: string) => void } -const CARD_HEIGHT = 5 // border-top + description + spacer + cta row + border-bottom +const CARD_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom +const MAX_DESC_LINES = 2 +const MIN_CARD_WIDTH = 60 // Minimum width per ad card to remain readable + +function truncateToLines(text: string, lineWidth: number, maxLines: number): string { + if (lineWidth <= 0) return text + const maxChars = lineWidth * maxLines + if (text.length <= maxChars) return text + return text.slice(0, maxChars - 1) + '…' +} const extractDomain = (url: string): string => { try { @@ -37,21 +43,31 @@ function columnWidths(count: number, availableWidth: number): number[] { return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0)) } -export const ChoiceAdBanner: React.FC = ({ ads, onDisableAds, isFreeMode }) => { +export const ChoiceAdBanner: React.FC = ({ ads, onImpression }) => { const theme = useTheme() - const { separatorWidth, terminalWidth } = useTerminalDimensions() + const { terminalWidth } = useTerminalDimensions() const [hoveredIndex, setHoveredIndex] = useState(null) - const [showInfoPanel, setShowInfoPanel] = useState(false) - const [isAdLabelHovered, setIsAdLabelHovered] = useState(false) - const [isHideHovered, setIsHideHovered] = useState(false) - const [isCloseHovered, setIsCloseHovered] = useState(false) // Available width for cards (terminal minus left/right margin of 1 each) const colAvail = terminalWidth - 2 - const widths = useMemo(() => columnWidths(ads.length, colAvail), [ads.length, colAvail]) - // Sum of all credits across choice ads - const totalCredits = ads.reduce((sum, ad) => sum + (ad.credits ?? 0), 0) + // Only show as many ads as fit with a healthy minimum width; hide the rest + const maxVisible = Math.max(1, Math.floor(colAvail / MIN_CARD_WIDTH)) + const visibleAds = useMemo( + () => (ads.length > maxVisible ? ads.slice(0, maxVisible) : ads), + [ads, maxVisible], + ) + + const widths = useMemo(() => columnWidths(visibleAds.length, colAvail), [visibleAds.length, colAvail]) + + // Fire impressions only for visible ads + useEffect(() => { + if (onImpression) { + for (const ad of visibleAds) { + onImpression(ad.impUrl) + } + } + }, [visibleAds, onImpression]) // Hover colors const hoverBorderColor = theme.link @@ -64,46 +80,6 @@ export const ChoiceAdBanner: React.FC = ({ ads, onDisableAd flexDirection: 'column', }} > - {/* Horizontal divider line */} - {'─'.repeat(terminalWidth)} - - {/* Header: "Sponsored picks" + credits + Ad label */} - - Sponsored picks - - {!IS_FREEBUFF && totalCredits > 0 && ( - +{totalCredits} credits - )} - {!IS_FREEBUFF ? ( - setShowInfoPanel(true)} - onMouseOver={() => setIsAdLabelHovered(true)} - onMouseOut={() => setIsAdLabelHovered(false)} - > - - {isAdLabelHovered && !showInfoPanel ? 'Ad ?' : ' Ad'} - - - ) : ( - {' Ad'} - )} - - - {/* Card columns */} = ({ ads, onDisableAd flexDirection: 'row', }} > - {ads.map((ad, i) => { + {visibleAds.map((ad, i) => { const isHovered = hoveredIndex === i const domain = extractDomain(ad.url) const ctaText = ad.cta || ad.title || 'Learn more' @@ -136,10 +112,14 @@ export const ChoiceAdBanner: React.FC = ({ ads, onDisableAd backgroundColor: isHovered ? hoverBgColor : undefined, }} > - - {ad.adText} - + + + {truncateToLines(ad.adText, widths[i] - 8, MAX_DESC_LINES)} + + {' Ad'} + + {/* Bottom: CTA + domain */} = ({ ads, onDisableAd {domain} + ) })} + - {/* Info panel: shown when Ad label is clicked */} - {showInfoPanel && ( - - {' ' + '┄'.repeat(separatorWidth - 2)} - - - {IS_FREEBUFF - ? 'Ads help keep Freebuff free.' - : 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'} - - - - - {isFreeMode && !IS_FREEBUFF ? ( - - Ads are required in Free mode. - - ) : ( - <> - - · - - Use /ads:enable to show again - - - )} - - - )} ) } diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 36e8c6c3f1..4ed964c47a 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -37,6 +37,7 @@ export type GravityAdState = { ad: AdResponse | null adData: AdData | null isLoading: boolean + recordImpression: (impUrl: string) => void } // Consolidated controller state for the ad rotation logic @@ -206,13 +207,10 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => recordImpressionOnce(next.impUrl) } - // Show a choice ad set and fire impressions for all + // Show a choice ad set (impressions are fired by the component for visible ads only) const showChoiceAds = (ads: AdResponse[]): void => { setAd(ads[0] ?? null) // Keep backwards compat for ad field setAdData({ variant: 'choice', ads }) - for (const choiceAd of ads) { - recordImpressionOnce(choiceAd.impUrl) - } } type FetchAdResult = @@ -400,6 +398,7 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => ad: visible ? ad : null, adData: visible ? adData : null, isLoading, + recordImpression: recordImpressionOnce, } } From c2e56501a4f2bf11aefaf9b41229c323c863e42b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 10 Apr 2026 19:59:51 -0700 Subject: [PATCH 4/7] Ads no longer grant credits --- cli/src/chat.tsx | 2 - cli/src/commands/ads.ts | 2 +- cli/src/components/ad-banner.tsx | 7 +- cli/src/components/choice-ad-banner.tsx | 2 + cli/src/components/usage-banner.tsx | 5 +- cli/src/data/slash-commands.ts | 4 +- web/src/app/api/v1/ads/_post.ts | 2 +- web/src/app/api/v1/ads/impression/_post.ts | 102 ++------------------- web/src/app/api/v1/ads/impression/route.ts | 2 - 9 files changed, 16 insertions(+), 112 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index c4fa1b70a1..22422e1918 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1449,8 +1449,6 @@ export const Chat = ({ adData?.variant === 'choice' ? ( ) : ( diff --git a/cli/src/commands/ads.ts b/cli/src/commands/ads.ts index f111f3a66b..6170047b27 100644 --- a/cli/src/commands/ads.ts +++ b/cli/src/commands/ads.ts @@ -16,7 +16,7 @@ export const handleAdsEnable = (): { return { postUserMessage: (messages) => [ ...messages, - getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'), + getSystemMessage('Ads enabled. You will see contextual ads above the input.'), ], } } diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx index 08ccf4ad40..4910952a73 100644 --- a/cli/src/components/ad-banner.tsx +++ b/cli/src/components/ad-banner.tsx @@ -150,10 +150,7 @@ export const AdBanner: React.FC = ({ ad, onDisableAds, isFreeMode {domain} )} - - {!IS_FREEBUFF && ad.credits != null && ad.credits > 0 && ( - +{ad.credits} credits - )} + {/* Info panel: shown when Ad label is clicked, below the ad */} @@ -179,7 +176,7 @@ export const AdBanner: React.FC = ({ ad, onDisableAds, isFreeMode {IS_FREEBUFF ? 'Ads help keep Freebuff free.' - : 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'} + : 'Ads are optional. Feel free to hide them anytime.'}