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 }, ) }