Skip to content

Commit 41ffbab

Browse files
committed
Freebuff waiting room client
1 parent c7e5807 commit 41ffbab

File tree

20 files changed

+1074
-3
lines changed

20 files changed

+1074
-3
lines changed

cli/src/app.tsx

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { useShallow } from 'zustand/react/shallow'
44

55
import { Chat } from './chat'
66
import { ChatHistoryScreen } from './components/chat-history-screen'
7+
import { FreebuffSupersededScreen } from './components/freebuff-superseded-screen'
78
import { LoginModal } from './components/login-modal'
89
import { ProjectPickerScreen } from './components/project-picker-screen'
910
import { TerminalLink } from './components/terminal-link'
11+
import { WaitingRoomScreen } from './components/waiting-room-screen'
1012
import { useAuthQuery } from './hooks/use-auth-query'
1113
import { useAuthState } from './hooks/use-auth-state'
14+
import { useFreebuffSession } from './hooks/use-freebuff-session'
1215
import { useLogo } from './hooks/use-logo'
1316
import { useSheenAnimation } from './hooks/use-sheen-animation'
1417
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
@@ -297,8 +300,8 @@ export const App = ({
297300
const chatKey = resumeChatId ?? 'current'
298301

299302
return (
300-
<Chat
301-
key={chatKey}
303+
<AuthedSurface
304+
chatKey={chatKey}
302305
headerContent={headerContent}
303306
initialPrompt={initialPrompt}
304307
agentId={agentId}
@@ -316,3 +319,88 @@ export const App = ({
316319
/>
317320
)
318321
}
322+
323+
interface AuthedSurfaceProps {
324+
chatKey: string
325+
headerContent: React.ReactNode
326+
initialPrompt: string | null
327+
agentId?: string
328+
fileTree: FileTreeNode[]
329+
inputRef: React.MutableRefObject<MultilineInputHandle | null>
330+
setIsAuthenticated: React.Dispatch<React.SetStateAction<boolean | null>>
331+
setUser: React.Dispatch<React.SetStateAction<import('./utils/auth').User | null>>
332+
logoutMutation: ReturnType<typeof useAuthState>['logoutMutation']
333+
continueChat: boolean
334+
continueChatId: string | undefined
335+
authStatus: AuthStatus
336+
initialMode: AgentMode | undefined
337+
gitRoot: string | null | undefined
338+
onSwitchToGitRoot: () => void
339+
}
340+
341+
/**
342+
* Rendered only after auth is confirmed. Owns the freebuff waiting-room gate
343+
* so `useFreebuffSession` runs exactly once per authed session (not before
344+
* we have a token).
345+
*/
346+
const AuthedSurface = ({
347+
chatKey,
348+
headerContent,
349+
initialPrompt,
350+
agentId,
351+
fileTree,
352+
inputRef,
353+
setIsAuthenticated,
354+
setUser,
355+
logoutMutation,
356+
continueChat,
357+
continueChatId,
358+
authStatus,
359+
initialMode,
360+
gitRoot,
361+
onSwitchToGitRoot,
362+
}: AuthedSurfaceProps) => {
363+
const { session, error: sessionError } = useFreebuffSession()
364+
365+
// Terminal state: a 409 from the gate means another CLI rotated our
366+
// instance id. Show a dedicated screen and stop polling — don't fall back
367+
// into the waiting room, which would look like normal queued progress.
368+
if (IS_FREEBUFF && session?.status === 'superseded') {
369+
return <FreebuffSupersededScreen />
370+
}
371+
372+
// Route every non-admitted state through the waiting room:
373+
// null → initial POST in flight
374+
// 'queued' → waiting our turn
375+
// 'none' → server lost our row; hook is about to re-POST
376+
// Falling through to <Chat> on 'none' would leave the user unable to send
377+
// any free-mode request until the next poll cycle.
378+
if (
379+
IS_FREEBUFF &&
380+
(session === null ||
381+
session.status === 'queued' ||
382+
session.status === 'none')
383+
) {
384+
return <WaitingRoomScreen session={session} error={sessionError} />
385+
}
386+
387+
return (
388+
<Chat
389+
key={chatKey}
390+
headerContent={headerContent}
391+
initialPrompt={initialPrompt}
392+
agentId={agentId}
393+
fileTree={fileTree}
394+
inputRef={inputRef}
395+
setIsAuthenticated={setIsAuthenticated}
396+
setUser={setUser}
397+
logoutMutation={logoutMutation}
398+
continueChat={continueChat}
399+
continueChatId={continueChatId}
400+
authStatus={authStatus}
401+
initialMode={initialMode}
402+
gitRoot={gitRoot}
403+
onSwitchToGitRoot={onSwitchToGitRoot}
404+
/>
405+
)
406+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import React from 'react'
3+
4+
import { useLogo } from '../hooks/use-logo'
5+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
6+
import { useTheme } from '../hooks/use-theme'
7+
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
8+
9+
/**
10+
* Terminal state shown after a 409 session_superseded response. Another CLI on
11+
* the same account rotated our instance id and we've stopped polling — the
12+
* user needs to close the other instance and restart.
13+
*/
14+
export const FreebuffSupersededScreen: React.FC = () => {
15+
const theme = useTheme()
16+
const { contentMaxWidth } = useTerminalDimensions()
17+
const blockColor = getLogoBlockColor(theme.name)
18+
const accentColor = getLogoAccentColor(theme.name)
19+
const { component: logoComponent } = useLogo({
20+
availableWidth: contentMaxWidth,
21+
accentColor,
22+
blockColor,
23+
})
24+
25+
return (
26+
<box
27+
style={{
28+
width: '100%',
29+
height: '100%',
30+
flexDirection: 'column',
31+
backgroundColor: theme.background,
32+
alignItems: 'center',
33+
justifyContent: 'center',
34+
paddingLeft: 2,
35+
paddingRight: 2,
36+
gap: 1,
37+
}}
38+
>
39+
<box style={{ marginBottom: 1 }}>{logoComponent}</box>
40+
<text
41+
style={{ fg: theme.foreground, marginBottom: 1 }}
42+
attributes={TextAttributes.BOLD}
43+
>
44+
Another freebuff instance took over this account.
45+
</text>
46+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
47+
Only one CLI per account can be active at a time.
48+
</text>
49+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
50+
Close the other instance, then restart freebuff here.
51+
</text>
52+
<box style={{ marginTop: 1 }}>
53+
<text style={{ fg: theme.muted }}>
54+
Press <span fg={theme.primary}>Ctrl+C</span> to exit.
55+
</text>
56+
</box>
57+
</box>
58+
)
59+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { useRenderer } from '@opentui/react'
3+
import React, { useEffect, useMemo, useState } from 'react'
4+
5+
import { AdBanner } from './ad-banner'
6+
import { ChoiceAdBanner } from './choice-ad-banner'
7+
import { ShimmerText } from './shimmer-text'
8+
import { useGravityAd } from '../hooks/use-gravity-ad'
9+
import { useLogo } from '../hooks/use-logo'
10+
import { useSheenAnimation } from '../hooks/use-sheen-animation'
11+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
12+
import { useTheme } from '../hooks/use-theme'
13+
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
14+
15+
import type { FreebuffSessionResponse } from '../types/freebuff-session'
16+
17+
interface WaitingRoomScreenProps {
18+
session: FreebuffSessionResponse | null
19+
error: string | null
20+
}
21+
22+
const formatWait = (ms: number): string => {
23+
if (!Number.isFinite(ms) || ms <= 0) return 'any moment now'
24+
const totalSeconds = Math.round(ms / 1000)
25+
if (totalSeconds < 60) return `~${totalSeconds}s`
26+
const minutes = Math.round(totalSeconds / 60)
27+
if (minutes < 60) return `~${minutes} min`
28+
const hours = Math.floor(minutes / 60)
29+
const rem = minutes % 60
30+
return rem === 0 ? `~${hours}h` : `~${hours}h ${rem}m`
31+
}
32+
33+
const formatElapsed = (ms: number): string => {
34+
if (!Number.isFinite(ms) || ms < 0) return '0s'
35+
const totalSeconds = Math.floor(ms / 1000)
36+
const minutes = Math.floor(totalSeconds / 60)
37+
const seconds = totalSeconds % 60
38+
if (minutes === 0) return `${seconds}s`
39+
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
40+
}
41+
42+
export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
43+
session,
44+
error,
45+
}) => {
46+
const theme = useTheme()
47+
const renderer = useRenderer()
48+
const { terminalWidth, contentMaxWidth } = useTerminalDimensions()
49+
50+
const [sheenPosition, setSheenPosition] = useState(0)
51+
const blockColor = getLogoBlockColor(theme.name)
52+
const accentColor = getLogoAccentColor(theme.name)
53+
const { applySheenToChar } = useSheenAnimation({
54+
logoColor: theme.foreground,
55+
accentColor,
56+
blockColor,
57+
terminalWidth: renderer?.width ?? terminalWidth,
58+
sheenPosition,
59+
setSheenPosition,
60+
})
61+
const { component: logoComponent } = useLogo({
62+
availableWidth: contentMaxWidth,
63+
accentColor,
64+
blockColor,
65+
applySheenToChar,
66+
})
67+
68+
// Always enable ads in the waiting room — this is where monetization lives.
69+
const { ad, adData, recordImpression } = useGravityAd({ enabled: true })
70+
71+
// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
72+
// the user wanders away and comes back.
73+
const queuedAtMs = useMemo(() => {
74+
if (session?.status === 'queued') return Date.parse(session.queuedAt)
75+
return null
76+
}, [session])
77+
const [now, setNow] = useState(() => Date.now())
78+
useEffect(() => {
79+
const id = setInterval(() => setNow(Date.now()), 1000)
80+
return () => clearInterval(id)
81+
}, [])
82+
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0
83+
84+
const isQueued = session?.status === 'queued'
85+
86+
return (
87+
<box
88+
style={{
89+
width: '100%',
90+
height: '100%',
91+
flexDirection: 'column',
92+
backgroundColor: theme.background,
93+
}}
94+
>
95+
<box
96+
style={{
97+
flexGrow: 1,
98+
flexDirection: 'column',
99+
alignItems: 'center',
100+
justifyContent: 'center',
101+
paddingLeft: 2,
102+
paddingRight: 2,
103+
gap: 1,
104+
}}
105+
>
106+
<box style={{ marginBottom: 1 }}>{logoComponent}</box>
107+
108+
<box
109+
style={{
110+
flexDirection: 'column',
111+
alignItems: 'center',
112+
gap: 0,
113+
maxWidth: contentMaxWidth,
114+
}}
115+
>
116+
{error && !session && (
117+
<text style={{ fg: theme.secondary, wrapMode: 'word' }}>
118+
{error}
119+
</text>
120+
)}
121+
122+
{((!session && !error) || session?.status === 'none') && (
123+
<text style={{ fg: theme.muted }}>
124+
<ShimmerText text="Joining the waiting room…" />
125+
</text>
126+
)}
127+
128+
{isQueued && session && (
129+
<>
130+
<text
131+
style={{ fg: theme.foreground, marginBottom: 1 }}
132+
>
133+
<ShimmerText text="You're in the waiting room" />
134+
</text>
135+
136+
<box
137+
style={{
138+
flexDirection: 'column',
139+
alignItems: 'center',
140+
gap: 0,
141+
}}
142+
>
143+
<text style={{ fg: theme.foreground }}>
144+
Position{' '}
145+
<span fg={theme.primary} attributes={TextAttributes.BOLD}>
146+
{session.position}
147+
</span>
148+
<span fg={theme.muted}> of {session.queueDepth}</span>
149+
</text>
150+
<text style={{ fg: theme.foreground }}>
151+
Estimated wait:{' '}
152+
<span fg={theme.primary}>
153+
{formatWait(session.estimatedWaitMs)}
154+
</span>
155+
</text>
156+
<text style={{ fg: theme.muted }}>
157+
Waiting for {formatElapsed(elapsedMs)}
158+
</text>
159+
</box>
160+
161+
<box style={{ marginTop: 1, alignItems: 'center' }}>
162+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
163+
Leave this window open — we'll ding when your session starts.
164+
</text>
165+
</box>
166+
</>
167+
)}
168+
169+
{/* Server says the waiting room is disabled — this screen should not
170+
normally render in that case, but show a minimal message just in
171+
case App.tsx's guard is bypassed. */}
172+
{session?.status === 'disabled' && (
173+
<text style={{ fg: theme.muted }}>Waiting room disabled.</text>
174+
)}
175+
</box>
176+
</box>
177+
178+
{/* Ad banner pinned to the bottom, same look-and-feel as in chat. */}
179+
{ad && (
180+
<box style={{ flexShrink: 0 }}>
181+
{adData?.variant === 'choice' ? (
182+
<ChoiceAdBanner
183+
ads={adData.ads}
184+
onImpression={recordImpression}
185+
/>
186+
) : (
187+
<AdBanner ad={ad} onDisableAds={() => {}} isFreeMode />
188+
)}
189+
</box>
190+
)}
191+
192+
{/* Horizontal separator (mirrors chat input divider style) */}
193+
{!ad && (
194+
<text style={{ fg: theme.muted, flexShrink: 0 }}>
195+
{'─'.repeat(terminalWidth)}
196+
</text>
197+
)}
198+
</box>
199+
)
200+
}

0 commit comments

Comments
 (0)