Skip to content

Commit 8ee55ab

Browse files
committed
Improve waiting room screen
1 parent e25cde5 commit 8ee55ab

File tree

2 files changed

+88
-25
lines changed

2 files changed

+88
-25
lines changed

cli/src/components/waiting-room-screen.tsx

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useKeyboard, useRenderer } from '@opentui/react'
33
import React, { useCallback, useEffect, useMemo, useState } from 'react'
44

55
import { AdBanner } from './ad-banner'
6+
import { Button } from './button'
67
import { ChoiceAdBanner } from './choice-ad-banner'
78
import { ShimmerText } from './shimmer-text'
89
import { endFreebuffSessionBestEffort } from '../hooks/use-freebuff-session'
@@ -74,26 +75,41 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
7475
})
7576

7677
// Always enable ads in the waiting room — this is where monetization lives.
77-
const { ad, adData, recordImpression } = useGravityAd({ enabled: true })
78+
// forceStart bypasses the "wait for first user message" gate inside the hook,
79+
// which would otherwise block ads here since no conversation exists yet.
80+
const { ad, adData, recordImpression } = useGravityAd({
81+
enabled: true,
82+
forceStart: true,
83+
})
84+
85+
// Release the seat + flush analytics before exit. Used by both Ctrl+C and
86+
// the top-right X button so they always do the same cleanup.
87+
const handleExit = useCallback(() => {
88+
const cleanup = Promise.allSettled([
89+
flushAnalytics(),
90+
endFreebuffSessionBestEffort(),
91+
])
92+
withTimeout(cleanup, EXIT_CLEANUP_TIMEOUT_MS, undefined).finally(() => {
93+
process.exit(0)
94+
})
95+
}, [])
7896

7997
// Ctrl+C exits. Stdin is in raw mode, so SIGINT never fires — the key comes
80-
// through as a normal OpenTUI key event. Release the seat before exit so
81-
// the next user in line doesn't have to wait for server-side expiry.
98+
// through as a normal OpenTUI key event.
8299
useKeyboard(
83-
useCallback((key: KeyEvent) => {
84-
if (key.ctrl && key.name === 'c') {
85-
key.preventDefault?.()
86-
const cleanup = Promise.allSettled([
87-
flushAnalytics(),
88-
endFreebuffSessionBestEffort(),
89-
])
90-
withTimeout(cleanup, EXIT_CLEANUP_TIMEOUT_MS, undefined).finally(() => {
91-
process.exit(0)
92-
})
93-
}
94-
}, []),
100+
useCallback(
101+
(key: KeyEvent) => {
102+
if (key.ctrl && key.name === 'c') {
103+
key.preventDefault?.()
104+
handleExit()
105+
}
106+
},
107+
[handleExit],
108+
),
95109
)
96110

111+
const [exitHover, setExitHover] = useState(false)
112+
97113
// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
98114
// the user wanders away and comes back.
99115
const queuedAtMs = useMemo(() => {
@@ -118,14 +134,45 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
118134
backgroundColor: theme.background,
119135
}}
120136
>
137+
{/* Top-right exit affordance so mouse users have a clear way out even
138+
when they don't know Ctrl+C works. width: '100%' is required for
139+
justifyContent: 'flex-end' to actually push the X to the right. */}
140+
<box
141+
style={{
142+
width: '100%',
143+
flexDirection: 'row',
144+
justifyContent: 'flex-end',
145+
paddingTop: 1,
146+
paddingRight: 2,
147+
flexShrink: 0,
148+
}}
149+
>
150+
<Button
151+
onClick={handleExit}
152+
onMouseOver={() => setExitHover(true)}
153+
onMouseOut={() => setExitHover(false)}
154+
style={{ paddingLeft: 1, paddingRight: 1 }}
155+
>
156+
<text
157+
style={{ fg: exitHover ? theme.foreground : theme.muted }}
158+
attributes={exitHover ? TextAttributes.BOLD : TextAttributes.NONE}
159+
>
160+
161+
</text>
162+
</Button>
163+
</box>
164+
121165
<box
122166
style={{
123167
flexGrow: 1,
124168
flexDirection: 'column',
125169
alignItems: 'center',
126-
justifyContent: 'center',
170+
// flex-end so the logo + title + info clump sits just above the ad,
171+
// matching how chat anchors its header/messages to the input bar.
172+
justifyContent: 'flex-end',
127173
paddingLeft: 2,
128174
paddingRight: 2,
175+
paddingBottom: 1,
129176
gap: 1,
130177
}}
131178
>
@@ -165,9 +212,16 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
165212
}}
166213
>
167214
{session.position === 1 ? (
168-
<text style={{ fg: theme.primary, alignSelf: 'flex-start' }}>
169-
<ShimmerText text="Next in line" />
170-
</text>
215+
<>
216+
<text style={{ fg: theme.primary, alignSelf: 'flex-start' }}>
217+
<ShimmerText text="Next in line" />
218+
</text>
219+
<text style={{ fg: theme.muted, alignSelf: 'flex-start' }}>
220+
{session.queueDepth === 1
221+
? 'just you in line right now'
222+
: `${session.queueDepth} people in line`}
223+
</text>
224+
</>
171225
) : (
172226
<text style={{ fg: theme.foreground, alignSelf: 'flex-start' }}>
173227
<span fg={theme.muted}>Position </span>

cli/src/hooks/use-gravity-ad.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,14 @@ function nextFromChoiceCache(ctrl: GravityController): AdResponse[] | null {
9696
*
9797
* Activity is tracked via the global activity-tracker module.
9898
*/
99-
export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState => {
99+
export const useGravityAd = (options?: {
100+
enabled?: boolean
101+
/** Skip the "wait for first user message" gate. Used by the freebuff
102+
* waiting room, which has no conversation but still needs ads. */
103+
forceStart?: boolean
104+
}): GravityAdState => {
100105
const enabled = options?.enabled ?? true
106+
const forceStart = options?.forceStart ?? false
101107
const [ad, setAd] = useState<AdResponse | null>(null)
102108
const [adData, setAdData] = useState<AdData | null>(null)
103109
const [isLoading, setIsLoading] = useState(false)
@@ -115,9 +121,12 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
115121
const shouldHideAds = !enabled || (isVeryCompactHeight && !isFreeMode)
116122

117123
// Use Zustand selector instead of manual subscription - only rerenders when value changes
118-
const hasUserMessaged = useChatStore((s) =>
124+
const hasUserMessagedStore = useChatStore((s) =>
119125
s.messages.some((m) => m.variant === 'user'),
120126
)
127+
// forceStart lets callers (e.g. the waiting room) opt out of the
128+
// "wait for the first user message" gate.
129+
const shouldStart = forceStart || hasUserMessagedStore
121130

122131
// Single consolidated controller ref
123132
const ctrlRef = useRef<GravityController>({
@@ -358,9 +367,9 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
358367
})
359368
}, [])
360369

361-
// Start rotation when user sends first message
370+
// Start rotation when user sends first message (or immediately if forced).
362371
useEffect(() => {
363-
if (!hasUserMessaged || !getAdsEnabled() || shouldHideAds) return
372+
if (!shouldStart || !getAdsEnabled() || shouldHideAds) return
364373

365374
setIsLoading(true)
366375

@@ -390,10 +399,10 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
390399
clearInterval(id)
391400
ctrlRef.current.intervalId = null
392401
}
393-
}, [hasUserMessaged, shouldHideAds])
402+
}, [shouldStart, shouldHideAds])
394403

395404
// Don't return ad when ads should be hidden
396-
const visible = hasUserMessaged && !shouldHideAds
405+
const visible = shouldStart && !shouldHideAds
397406
return {
398407
ad: visible ? ad : null,
399408
adData: visible ? adData : null,

0 commit comments

Comments
 (0)