Skip to content

Commit a79cd53

Browse files
committed
New friendlier startup UX
1 parent b66174d commit a79cd53

8 files changed

Lines changed: 138 additions & 56 deletions

File tree

cli/src/app.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,12 +370,11 @@ const AuthedSurface = ({
370370
return <FreebuffSupersededScreen />
371371
}
372372

373-
// Route every non-admitted state through the waiting room:
374-
// null → initial POST in flight
373+
// Route every non-admitted state through the pre-chat screen:
374+
// null → initial GET in flight (brief)
375+
// 'none' → no seat yet; show model-picker landing
375376
// 'queued' → waiting our turn
376-
// 'none' → server lost our row; hook is about to re-POST
377-
// Falling through to <Chat> on 'none' would leave the user unable to send
378-
// any free-mode request until the next poll cycle.
377+
// 'country_blocked' → terminal region-gate message
379378
//
380379
// 'ended' deliberately falls through to <Chat>: the agent may still be
381380
// finishing work under the server-side grace period, and the chat surface

cli/src/components/freebuff-model-selector.tsx

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
55
import { Button } from './button'
66
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
77

8-
import { switchFreebuffModel } from '../hooks/use-freebuff-session'
8+
import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
99
import { useFreebuffModelStore } from '../state/freebuff-model-store'
1010
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1111
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
@@ -14,17 +14,20 @@ import { useTheme } from '../hooks/use-theme'
1414
import type { KeyEvent } from '@opentui/core'
1515

1616
/**
17-
* Lets the user pick which model's queue they're in. Switching triggers a
18-
* re-POST: the server moves them to the back of the new model's queue, which
19-
* means switching is *not free* — they lose their place in the original line.
17+
* Dual-purpose model picker:
18+
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
19+
* a model is their explicit commitment to enter — this triggers the POST.
20+
* - In-queue switcher (session 'queued'): picking a *different* model moves
21+
* the user to the back of that queue (lose place in original). Picking the
22+
* model they're already in is a no-op.
2023
*
21-
* To prevent accidental queue loss, keyboard navigation is two-step: Tab /
22-
* arrow keys move a focus highlight, and Enter commits the switch. Mouse
23-
* clicks are still one-step (the click target is intentional).
24+
* To prevent accidental queue loss while queued, keyboard navigation is
25+
* two-step: Tab / arrow keys move a focus highlight, and Enter commits the
26+
* switch. Mouse clicks are still one-step. On the landing screen, pressing
27+
* Enter on the already-focused model also commits — there's nothing to lose.
2428
*
2529
* Each row shows a live "N ahead" count sourced from the server's
26-
* `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
27-
* "12 ahead") rather than a blind preference toggle.
30+
* `queueDepthByModel` snapshot so the choice is informed.
2831
*/
2932
export const FreebuffModelSelector: React.FC = () => {
3033
const theme = useTheme()
@@ -42,19 +45,30 @@ export const FreebuffModelSelector: React.FC = () => {
4245
setFocusedId(selectedModel)
4346
}, [selectedModel])
4447

45-
// For the user's current queue, "ahead" is `position - 1` (themselves don't
46-
// count). For every other queue, switching would land them at the back, so
47-
// it's that queue's full depth. Null before the first queued snapshot so
48-
// the UI doesn't flash misleading zeros.
48+
// Landing ('none'): depths come from the server snapshot, no "self" to
49+
// subtract. In-queue ('queued'): for the user's queue, "ahead" is
50+
// `position - 1` (themselves don't count); for every other queue, switching
51+
// would land them at the back, so it's that queue's full depth. Null before
52+
// any snapshot so the UI doesn't flash misleading zeros.
4953
const aheadByModel = useMemo<Record<string, number> | null>(() => {
50-
if (session?.status !== 'queued') return null
51-
const depths = session.queueDepthByModel ?? {}
52-
const out: Record<string, number> = {}
53-
for (const { id } of FREEBUFF_MODELS) {
54-
out[id] =
55-
id === session.model ? Math.max(0, session.position - 1) : depths[id] ?? 0
54+
if (session?.status === 'none') {
55+
const depths = session.queueDepthByModel ?? {}
56+
const out: Record<string, number> = {}
57+
for (const { id } of FREEBUFF_MODELS) out[id] = depths[id] ?? 0
58+
return out
5659
}
57-
return out
60+
if (session?.status === 'queued') {
61+
const depths = session.queueDepthByModel ?? {}
62+
const out: Record<string, number> = {}
63+
for (const { id } of FREEBUFF_MODELS) {
64+
out[id] =
65+
id === session.model
66+
? Math.max(0, session.position - 1)
67+
: depths[id] ?? 0
68+
}
69+
return out
70+
}
71+
return null
5872
}, [session])
5973

6074
// Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
@@ -87,14 +101,20 @@ export const FreebuffModelSelector: React.FC = () => {
87101
return total > terminalWidth - 4
88102
}, [hintWidth, terminalWidth])
89103

104+
// "Already committed to this model" — only when the server has us queued
105+
// on it. On the landing screen (status 'none'), nothing is committed yet,
106+
// so picking the focused model is always a real action (first join).
107+
const committedModelId =
108+
session?.status === 'queued' ? session.model : null
109+
90110
const pick = useCallback(
91111
(modelId: string) => {
92112
if (pending) return
93-
if (modelId === selectedModel) return
113+
if (modelId === committedModelId) return
94114
setPending(modelId)
95-
switchFreebuffModel(modelId).finally(() => setPending(null))
115+
joinFreebuffQueue(modelId).finally(() => setPending(null))
96116
},
97-
[pending, selectedModel],
117+
[pending, committedModelId],
98118
)
99119

100120
// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
@@ -112,7 +132,7 @@ export const FreebuffModelSelector: React.FC = () => {
112132
const isCommit = name === 'return' || name === 'enter' || name === 'space'
113133
if (!isForward && !isBackward && !isCommit) return
114134
if (isCommit) {
115-
if (focusedId !== selectedModel) {
135+
if (focusedId !== committedModelId) {
116136
key.preventDefault?.()
117137
pick(focusedId)
118138
}
@@ -130,7 +150,7 @@ export const FreebuffModelSelector: React.FC = () => {
130150
setFocusedId(target.id)
131151
}
132152
},
133-
[pending, pick, focusedId, selectedModel],
153+
[pending, pick, focusedId, committedModelId],
134154
),
135155
)
136156

@@ -150,13 +170,19 @@ export const FreebuffModelSelector: React.FC = () => {
150170
}}
151171
>
152172
{FREEBUFF_MODELS.map((model) => {
173+
// 'Selected' means the dot is filled and the label is bold. On the
174+
// landing screen ('none') this tracks the pre-focused pick; on the
175+
// queued screen it tracks the model the server has us on. Either
176+
// way, selectedModel reflects the intent of "what Enter commits to."
153177
const isSelected = model.id === selectedModel
154178
const isHovered = hoveredId === model.id
155179
const isFocused = focusedId === model.id && !isSelected
156180
const indicator = isSelected ? '●' : '○'
157181
const indicatorColor = isSelected ? theme.primary : theme.muted
158182
const labelColor = isSelected ? theme.foreground : theme.muted
159-
const interactable = !pending && !isSelected
183+
// Clickable whenever picking would actually do something — i.e.
184+
// anything except re-picking the queue we're already in.
185+
const interactable = !pending && model.id !== committedModelId
160186
const ahead = aheadByModel?.[model.id]
161187
const hint =
162188
ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ahead} ahead`

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
9292
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0
9393

9494
const isQueued = session?.status === 'queued'
95+
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
96+
// state: show the picker with live N-ahead hints and a prompt. Picking a
97+
// model triggers joinFreebuffQueue, which POSTs and transitions us to
98+
// 'queued' (waiting room) or straight to 'active' (chat) if no wait.
99+
const isLanding = session?.status === 'none'
95100

96101
return (
97102
<box
@@ -160,12 +165,21 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
160165
</text>
161166
)}
162167

163-
{((!session && !error) || session?.status === 'none') && (
168+
{!session && !error && (
164169
<text style={{ fg: theme.muted }}>
165-
<ShimmerText text="Joining the waiting room…" />
170+
<ShimmerText text="Connecting…" />
166171
</text>
167172
)}
168173

174+
{isLanding && (
175+
<>
176+
<text style={{ fg: theme.foreground, marginBottom: 1 }}>
177+
Pick a model to start
178+
</text>
179+
<FreebuffModelSelector />
180+
</>
181+
)}
182+
169183
{isQueued && session && (
170184
<>
171185
<text style={{ fg: theme.foreground, marginBottom: 1 }}>

cli/src/hooks/use-freebuff-session.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ interface PollController {
132132
refresh: () => Promise<void>
133133
apply: (next: FreebuffSessionResponse) => void
134134
abort: () => void
135-
setHasPosted: (value: boolean) => void
136135
}
137136

138137
let controller: PollController | null = null
@@ -168,14 +167,18 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {})
168167
}
169168

170169
/**
171-
* User picked a different model in the waiting room. Persist the choice and
172-
* re-POST so the server moves them to the back of the new model's queue. If
173-
* the server has already admitted them on a different model, it responds
170+
* Join (or re-queue for) `model`. Dual-purpose:
171+
* - First join: called from the pre-chat landing picker. The session starts
172+
* at `none` (GET-only); this is the user's explicit commitment to enter.
173+
* - Switch: called when the user picks a different model from within the
174+
* waiting room. Server moves them to the back of the new model's queue.
175+
*
176+
* If the server has already admitted them on a different model, it responds
174177
* with `model_locked`; the tick loop silently reverts the local selection to
175178
* the locked model so the active session stays intact. Users who really want
176179
* to switch can /end-session deliberately.
177180
*/
178-
export async function switchFreebuffModel(model: string): Promise<void> {
181+
export async function joinFreebuffQueue(model: string): Promise<void> {
179182
if (!IS_FREEBUFF) return
180183
const { setSelectedModel } = useFreebuffModelStore.getState()
181184
setSelectedModel(model)
@@ -256,9 +259,13 @@ interface UseFreebuffSessionResult {
256259

257260
/**
258261
* Manages the freebuff waiting-room session lifecycle:
259-
* - POST on mount to join the queue / rotate instance id
262+
* - GET on mount to probe state (no auto-join; the user picks a model in
263+
* the landing screen, which calls joinFreebuffQueue)
264+
* - if the probe sees an existing seat, POSTs once to take over (rotates
265+
* the instance id so any other CLI on the same account is superseded)
260266
* - polls GET while queued (fast) or active (slow) to keep state fresh
261-
* - re-POSTs on explicit refresh (chat gate rejected us)
267+
* - re-POSTs on explicit refresh (chat gate rejected us, user switched
268+
* models, user rejoined after ending)
262269
* - DELETE on unmount so the slot frees up for the next user
263270
* - plays a bell on transition from queued → active
264271
*/
@@ -288,7 +295,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
288295
let abortController = new AbortController()
289296
let timer: ReturnType<typeof setTimeout> | null = null
290297
let previousStatus: FreebuffSessionResponse['status'] | null = null
291-
let hasPosted = false
298+
// Method for the NEXT tick. GET is read-only; POST claims/rotates a seat.
299+
// Startup is GET (probe before committing). After any POST completes we
300+
// flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin;
301+
// the startup takeover branch does the same when the probe finds a seat.
302+
let nextMethod: 'GET' | 'POST' = 'GET'
292303

293304
const apply = (next: FreebuffSessionResponse) => {
294305
setSession(next)
@@ -311,10 +322,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
311322

312323
const tick = async () => {
313324
if (cancelled) return
314-
// POST when we don't yet hold a seat; thereafter GET. The
315-
// active|ended → none edge is special-cased below so we don't silently
316-
// re-POST out from under an in-flight agent.
317-
const method: 'POST' | 'GET' = hasPosted ? 'GET' : 'POST'
325+
const method = nextMethod
318326
const instanceId = getFreebuffInstanceId()
319327
const model = getSelectedFreebuffModel()
320328
try {
@@ -324,7 +332,10 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
324332
model,
325333
})
326334
if (cancelled) return
327-
hasPosted = true
335+
// After any successful call, default back to GET polling. The
336+
// takeover and model_locked branches below override this when they
337+
// need another POST.
338+
nextMethod = 'GET'
328339

329340
// Race recovery: user picked a different model in the waiting room at
330341
// the exact moment the server admitted them with the original model.
@@ -337,6 +348,23 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
337348
return
338349
}
339350

351+
// Startup takeover: the initial probe GET saw we already hold a seat
352+
// (from a prior CLI instance). POST now to rotate our instance id so
353+
// any other CLI on this account is superseded on its next poll.
354+
// `previousStatus === null` fences this to the very first tick only.
355+
// Pin the selected model to whatever the server thinks we're on so
356+
// the POST preserves our queue position instead of switching queues.
357+
if (
358+
method === 'GET' &&
359+
previousStatus === null &&
360+
(next.status === 'queued' || next.status === 'active')
361+
) {
362+
useFreebuffModelStore.getState().setSelectedModel(next.model)
363+
nextMethod = 'POST'
364+
schedule(0)
365+
return
366+
}
367+
340368
if (previousStatus === 'queued' && next.status === 'active') {
341369
playAdmissionSound()
342370
}
@@ -374,17 +402,14 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
374402
// Reset previousStatus so the queued→active bell still fires after
375403
// a forced re-POST.
376404
previousStatus = null
377-
hasPosted = false
405+
nextMethod = 'POST'
378406
await tick()
379407
},
380408
apply,
381409
abort: () => {
382410
clearTimer()
383411
abortController.abort()
384412
},
385-
setHasPosted: (value) => {
386-
hasPosted = value
387-
},
388413
}
389414

390415
tick()

common/src/types/freebuff-session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export type FreebuffSessionServerResponse =
1717
* grace window. */
1818
status: 'none'
1919
message?: string
20+
/** Snapshot of every model's queue depth so the CLI can render live
21+
* "N ahead" hints on the pre-join model picker without first
22+
* committing the user to a queue. Present on GET responses; not
23+
* returned from POST (POST never produces `none`). */
24+
queueDepthByModel?: Record<string, number>
2025
}
2126
| {
2227
status: 'queued'

web/src/app/api/v1/freebuff/session/_handlers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,11 @@ export async function getFreebuffSession(
166166
})
167167
if (state.status === 'none') {
168168
return NextResponse.json(
169-
{ status: 'none', message: 'Call POST to join the waiting room.' },
169+
{
170+
status: 'none',
171+
message: 'Call POST to join the waiting room.',
172+
queueDepthByModel: state.queueDepthByModel,
173+
},
170174
{ status: 200 },
171175
)
172176
}

web/src/server/free-session/__tests__/public-api.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,9 @@ describe('getSessionState', () => {
206206
expect(state).toEqual({ status: 'disabled' })
207207
})
208208

209-
test('no row returns none', async () => {
209+
test('no row returns none with empty queue-depth snapshot', async () => {
210210
const state = await getSessionState({ userId: 'u1', deps })
211-
expect(state).toEqual({ status: 'none' })
211+
expect(state).toEqual({ status: 'none', queueDepthByModel: {} })
212212
})
213213

214214
test('active session with matching instance id returns active', async () => {
@@ -284,7 +284,7 @@ describe('getSessionState', () => {
284284
claimedInstanceId: row.active_instance_id,
285285
deps,
286286
})
287-
expect(state).toEqual({ status: 'none' })
287+
expect(state).toEqual({ status: 'none', queueDepthByModel: {} })
288288
})
289289
})
290290

web/src/server/free-session/public-api.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,16 @@ export async function getSessionState(params: {
181181
return { status: 'disabled' }
182182
}
183183
const row = await deps.getSessionRow(params.userId)
184-
if (!row) return { status: 'none' }
184+
185+
// Build a `none` response with live queue depths so the CLI's pre-join
186+
// picker can show "N ahead" hints without first committing the user to a
187+
// queue. Cheap snapshot — no user-scoped state.
188+
const noneResponse = async (): Promise<FreebuffSessionServerResponse> => ({
189+
status: 'none',
190+
queueDepthByModel: await deps.queueDepthsByModel(),
191+
})
192+
193+
if (!row) return noneResponse()
185194

186195
if (
187196
row.status === 'active' &&
@@ -192,7 +201,7 @@ export async function getSessionState(params: {
192201
}
193202

194203
const view = await viewForRow(params.userId, deps, row)
195-
if (!view) return { status: 'none' }
204+
if (!view) return noneResponse()
196205
return view
197206
}
198207

0 commit comments

Comments
 (0)