@@ -6,8 +6,7 @@ import { useChatStore } from '../state/chat-store'
66import { getAuthToken } from '../utils/auth'
77import { logger } from '../utils/logger'
88
9- const AD_DISPLAY_DURATION_MS = 60 * 1000 // 60 seconds per ad
10- const PREFETCH_BEFORE_MS = 5 * 1000 // Fetch next ad 5 seconds before swap
9+ const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
1110const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then stop
1211
1312// Ad response type (matches Gravity API response, credits added after impression)
@@ -31,41 +30,29 @@ export type GravityAdState = {
3130 * Hook for fetching and rotating Gravity ads.
3231 *
3332 * Behavior:
33+ * - Ads only start after the user sends their first message
3434 * - Ads rotate every 60 seconds
35- * - Next ad is pre-fetched 5 seconds before display for instant swap
3635 * - After 3 ads without user activity, rotation stops
3736 * - Any user activity resets the counter and resumes rotation
3837 */
3938export const useGravityAd = ( ) : GravityAdState => {
4039 const [ ad , setAd ] = useState < AdResponse | null > ( null )
4140 const [ isLoading , setIsLoading ] = useState ( false )
42- const [ shouldShowAd , setShouldShowAd ] = useState ( false )
41+ const [ isActive , setIsActive ] = useState ( false )
4342 const impressionFiredRef = useRef < Set < string > > ( new Set ( ) )
4443
45- // Pre-fetched next ad ready to display
46- const nextAdRef = useRef < AdResponse | null > ( null )
47-
4844 // Counter: how many ads shown since last user activity
4945 const adsShownRef = useRef < number > ( 0 )
5046
5147 // Is rotation currently paused (shown 3 ads without activity)?
5248 const isPausedRef = useRef < boolean > ( false )
5349
54- // Timers
55- const prefetchTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
56- const swapTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
57-
58- // Has the first ad been fetched?
59- const isStartedRef = useRef < boolean > ( false )
50+ // Rotation timer
51+ const rotationTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
6052
6153 // Fire impression via web API when ad changes (grants credits)
62- // Only fire impressions when ad is actually being shown
6354 useEffect ( ( ) => {
64- if (
65- shouldShowAd &&
66- ad ?. impUrl &&
67- ! impressionFiredRef . current . has ( ad . impUrl )
68- ) {
55+ if ( isActive && ad ?. impUrl && ! impressionFiredRef . current . has ( ad . impUrl ) ) {
6956 const currentImpUrl = ad . impUrl
7057 impressionFiredRef . current . add ( currentImpUrl )
7158 logger . info (
@@ -79,8 +66,6 @@ export const useGravityAd = (): GravityAdState => {
7966 return
8067 }
8168
82- // Call our web API to fire impression and grant credits
83- // Only send impUrl - server looks up trusted ad data from database
8469 fetch ( `${ WEBSITE_URL } /api/v1/ads/impression` , {
8570 method : 'POST' ,
8671 headers : {
@@ -98,7 +83,6 @@ export const useGravityAd = (): GravityAdState => {
9883 { creditsGranted : data . creditsGranted } ,
9984 '[gravity] Ad impression credits granted' ,
10085 )
101- // Update ad with credits from impression response
10286 setAd ( ( currentAd ) =>
10387 currentAd ?. impUrl === currentImpUrl
10488 ? { ...currentAd , credits : data . creditsGranted }
@@ -110,22 +94,17 @@ export const useGravityAd = (): GravityAdState => {
11094 logger . debug ( { err } , '[gravity] Failed to record ad impression' )
11195 } )
11296 }
113- } , [ ad , shouldShowAd ] )
97+ } , [ ad , isActive ] )
11498
115- // Clear all timers
116- const clearTimers = useCallback ( ( ) => {
117- if ( prefetchTimerRef . current ) {
118- clearTimeout ( prefetchTimerRef . current )
119- prefetchTimerRef . current = null
120- }
121- if ( swapTimerRef . current ) {
122- clearTimeout ( swapTimerRef . current )
123- swapTimerRef . current = null
99+ const clearTimer = useCallback ( ( ) => {
100+ if ( rotationTimerRef . current ) {
101+ clearTimeout ( rotationTimerRef . current )
102+ rotationTimerRef . current = null
124103 }
125104 } , [ ] )
126105
127- // Fetch an ad via web API and return it (for pre-fetching)
128- const fetchAdAsync = useCallback ( async ( ) : Promise < AdResponse | null > => {
106+ // Fetch an ad via web API
107+ const fetchAd = useCallback ( async ( ) : Promise < AdResponse | null > => {
129108 if ( ! getAdsEnabled ( ) ) return null
130109
131110 const authToken = getAuthToken ( )
@@ -134,11 +113,30 @@ export const useGravityAd = (): GravityAdState => {
134113 return null
135114 }
136115
116+ // Get message history from runState (populated after LLM responds)
137117 const currentRunState = useChatStore . getState ( ) . runState
138118 const messageHistory =
139119 currentRunState ?. sessionState ?. mainAgentState ?. messageHistory ?? [ ]
140120 const adMessages = convertToAdMessages ( messageHistory )
141121
122+ // Also check UI messages for the latest user message
123+ // (UI messages update immediately, runState.messageHistory updates after LLM responds)
124+ const uiMessages = useChatStore . getState ( ) . messages
125+ const latestUserMessage = [ ...uiMessages ]
126+ . reverse ( )
127+ . find ( ( msg ) => msg . variant === 'user' )
128+
129+ // If the latest UI user message isn't in our converted history, append it
130+ // This ensures we always include the most recent user message even before LLM responds
131+ if ( latestUserMessage ?. content ) {
132+ const lastAdUserMessage = [ ...adMessages ]
133+ . reverse ( )
134+ . find ( ( m ) => m . role === 'user' )
135+ if ( lastAdUserMessage ?. content !== latestUserMessage . content ) {
136+ adMessages . push ( { role : 'user' , content : latestUserMessage . content } )
137+ }
138+ }
139+
142140 try {
143141 const response = await fetch ( `${ WEBSITE_URL } /api/v1/ads` , {
144142 method : 'POST' ,
@@ -161,9 +159,7 @@ export const useGravityAd = (): GravityAdState => {
161159 const ad = data . ad as AdResponse | null
162160
163161 logger . info (
164- {
165- ad,
166- } ,
162+ { ad, request : { messages : adMessages } } ,
167163 '[gravity] Received ad response' ,
168164 )
169165 return ad
@@ -173,27 +169,19 @@ export const useGravityAd = (): GravityAdState => {
173169 }
174170 } , [ ] )
175171
176- // Schedule the next ad cycle
177- const scheduleNextCycle = useCallback ( ( ) => {
178- clearTimers ( )
172+ // Schedule ad rotation
173+ const scheduleRotation = useCallback ( ( ) => {
174+ clearTimer ( )
179175
180176 if ( ! getAdsEnabled ( ) || isPausedRef . current ) {
181177 logger . debug (
182178 { isPaused : isPausedRef . current } ,
183- '[gravity] Not scheduling next cycle ' ,
179+ '[gravity] Not scheduling rotation ' ,
184180 )
185181 return
186182 }
187183
188- // Schedule pre-fetch (55 seconds from now)
189- prefetchTimerRef . current = setTimeout ( async ( ) => {
190- logger . debug ( '[gravity] Pre-fetching next ad' )
191- nextAdRef . current = await fetchAdAsync ( )
192- } , AD_DISPLAY_DURATION_MS - PREFETCH_BEFORE_MS )
193-
194- // Schedule swap (60 seconds from now)
195- swapTimerRef . current = setTimeout ( ( ) => {
196- // Increment counter and check if we should pause
184+ rotationTimerRef . current = setTimeout ( async ( ) => {
197185 adsShownRef . current += 1
198186 logger . info (
199187 { adsShown : adsShownRef . current , max : MAX_ADS_AFTER_ACTIVITY } ,
@@ -203,68 +191,43 @@ export const useGravityAd = (): GravityAdState => {
203191 if ( adsShownRef . current >= MAX_ADS_AFTER_ACTIVITY ) {
204192 logger . info ( '[gravity] Max ads shown, pausing rotation' )
205193 isPausedRef . current = true
206- // Keep showing the current ad, just stop rotating
207194 return
208195 }
209196
210- // Swap to pre-fetched ad (or keep current if fetch failed)
211- if ( nextAdRef . current ) {
212- setAd ( nextAdRef . current )
213- nextAdRef . current = null
197+ const newAd = await fetchAd ( )
198+ if ( newAd ) {
199+ setAd ( newAd )
214200 }
215201
216- // Schedule next cycle
217- scheduleNextCycle ( )
218- } , AD_DISPLAY_DURATION_MS )
219- } , [ clearTimers , fetchAdAsync ] )
202+ scheduleRotation ( )
203+ } , AD_ROTATION_INTERVAL_MS )
204+ } , [ clearTimer , fetchAd ] )
220205
221206 // Report user activity - resets counter and resumes rotation if paused
222207 const reportActivity = useCallback ( ( ) => {
223208 const wasPaused = isPausedRef . current
224-
225- // Reset counter
226209 adsShownRef . current = 0
227210
228211 if ( wasPaused ) {
229212 logger . info ( '[gravity] User active, resuming ad rotation' )
230213 isPausedRef . current = false
231- // Restart the cycle from current ad
232- scheduleNextCycle ( )
233- }
234- } , [ scheduleNextCycle ] )
235-
236- // Prefetch ad on startup (before any messages are sent)
237- useEffect ( ( ) => {
238- const adsEnabled = getAdsEnabled ( )
239- const hasAuth = ! ! getAuthToken ( )
240-
241- if ( adsEnabled && hasAuth && ! isStartedRef . current ) {
242- logger . info ( '[gravity] Prefetching ad on startup' )
243- isStartedRef . current = true
244- setIsLoading ( true )
245-
246- // Prefetch first ad immediately
247- fetchAdAsync ( ) . then ( ( firstAd ) => {
248- setAd ( firstAd )
249- setIsLoading ( false )
250- scheduleNextCycle ( )
251- } )
214+ scheduleRotation ( )
252215 }
253- } , [ fetchAdAsync , scheduleNextCycle ] )
216+ } , [ scheduleRotation ] )
254217
255218 // Subscribe to UI messages to detect first user message
256- // Only show ads after the user has sent at least one message (clean startup UX)
257- // We use UI messages instead of runState.messageHistory because UI messages
258- // update immediately when the user sends a message
219+ // We use UI messages (not runState.messageHistory) because UI messages
220+ // update immediately when the user sends a message, allowing us to fetch
221+ // ads sooner rather than waiting for the assistant to respond
259222 useEffect ( ( ) => {
260- if ( shouldShowAd || ! getAdsEnabled ( ) ) {
223+ if ( isActive || ! getAdsEnabled ( ) ) {
261224 return
262225 }
263226
264227 // Check initial state
265228 const initialMessages = useChatStore . getState ( ) . messages
266229 if ( initialMessages . some ( ( msg ) => msg . variant === 'user' ) ) {
267- setShouldShowAd ( true )
230+ setIsActive ( true )
268231 return
269232 }
270233
@@ -275,21 +238,35 @@ export const useGravityAd = (): GravityAdState => {
275238
276239 if ( hasUserMessage ) {
277240 unsubscribe ( )
278- logger . info ( '[gravity] First user message detected, showing ads' )
279- setShouldShowAd ( true )
241+ logger . info ( '[gravity] First user message detected, starting ads' )
242+ setIsActive ( true )
280243 }
281244 } )
282245
283246 return unsubscribe
284- } , [ shouldShowAd ] )
247+ } , [ isActive ] )
248+
249+ // Fetch first ad and start rotation when becoming active
250+ useEffect ( ( ) => {
251+ if ( ! isActive ) return
252+
253+ setIsLoading ( true )
254+ fetchAd ( ) . then ( ( firstAd ) => {
255+ if ( firstAd ) {
256+ setAd ( firstAd )
257+ }
258+ // Always start rotation, even if first fetch returned null
259+ scheduleRotation ( )
260+ setIsLoading ( false )
261+ } )
262+ } , [ isActive , fetchAd , scheduleRotation ] )
285263
286- // Clear timers only on unmount
264+ // Cleanup timer on unmount
287265 useEffect ( ( ) => {
288- return ( ) => clearTimers ( )
289- } , [ clearTimers ] )
266+ return ( ) => clearTimer ( )
267+ } , [ clearTimer ] )
290268
291- // Only return the ad if we should show it (after first user message)
292- return { ad : shouldShowAd ? ad : null , isLoading, reportActivity }
269+ return { ad : isActive ? ad : null , isLoading, reportActivity }
293270}
294271
295272type AdMessage = { role : 'user' | 'assistant' ; content : string }
@@ -303,13 +280,17 @@ const convertToAdMessages = (messages: Message[]): AdMessage[] => {
303280 . filter (
304281 ( message ) => message . role === 'assistant' || message . role === 'user' ,
305282 )
283+ . filter ( ( message ) => ! message . tags ?. includes ( 'USER_PROMPT' ) )
306284 . map ( ( message ) => ( {
307285 role : message . role ,
308286 content : message . content
309287 . filter ( ( c ) => c . type === 'text' )
310- . map ( ( c ) => c . text )
311- . join ( '\n\n' ) ,
288+ . map ( ( c ) => c . text . trim ( ) )
289+ . filter ( ( c ) => c !== '' )
290+ . join ( '\n\n' )
291+ . trim ( ) ,
312292 } ) )
293+ . filter ( ( message ) => message . content !== '' )
313294
314295 return adMessages
315296}
0 commit comments