diff --git a/cli/src/app.tsx b/cli/src/app.tsx index a83214114..add3ce9f0 100644 --- a/cli/src/app.tsx +++ b/cli/src/app.tsx @@ -370,12 +370,11 @@ const AuthedSurface = ({ return } - // Route every non-admitted state through the waiting room: - // null → initial POST in flight + // Route every non-admitted state through the pre-chat screen: + // null → initial GET in flight (brief) + // 'none' → no seat yet; show model-picker landing // 'queued' → waiting our turn - // 'none' → server lost our row; hook is about to re-POST - // Falling through to on 'none' would leave the user unable to send - // any free-mode request until the next poll cycle. + // 'country_blocked' → terminal region-gate message // // 'ended' deliberately falls through to : the agent may still be // finishing work under the server-side grace period, and the chat surface diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 9ce4faa20..d4cb7b918 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -5,28 +5,33 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from './button' import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models' -import { switchFreebuffModel } from '../hooks/use-freebuff-session' +import { joinFreebuffQueue } from '../hooks/use-freebuff-session' import { useFreebuffModelStore } from '../state/freebuff-model-store' import { useFreebuffSessionStore } from '../state/freebuff-session-store' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' import type { KeyEvent } from '@opentui/core' /** - * Lets the user pick which model's queue they're in. Switching triggers a - * re-POST: the server moves them to the back of the new model's queue, which - * means switching is *not free* — they lose their place in the original line. + * Dual-purpose model picker: + * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking + * a model is their explicit commitment to enter — this triggers the POST. + * - In-queue switcher (session 'queued'): picking a *different* model moves + * the user to the back of that queue (lose place in original). Picking the + * model they're already in is a no-op. * - * To prevent accidental queue loss, keyboard navigation is two-step: Tab / - * arrow keys move a focus highlight, and Enter commits the switch. Mouse - * clicks are still one-step (the click target is intentional). + * To prevent accidental queue loss while queued, keyboard navigation is + * two-step: Tab / arrow keys move a focus highlight, and Enter commits the + * switch. Mouse clicks are still one-step. On the landing screen, pressing + * Enter on the already-focused model also commits — there's nothing to lose. * * Each row shows a live "N ahead" count sourced from the server's - * `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs - * "12 ahead") rather than a blind preference toggle. + * `queueDepthByModel` snapshot so the choice is informed. */ export const FreebuffModelSelector: React.FC = () => { const theme = useTheme() + const { terminalWidth } = useTerminalDimensions() const selectedModel = useFreebuffModelStore((s) => s.selectedModel) const session = useFreebuffSessionStore((s) => s.session) const [pending, setPending] = useState(null) @@ -40,19 +45,30 @@ export const FreebuffModelSelector: React.FC = () => { setFocusedId(selectedModel) }, [selectedModel]) - // For the user's current queue, "ahead" is `position - 1` (themselves don't - // count). For every other queue, switching would land them at the back, so - // it's that queue's full depth. Null before the first queued snapshot so - // the UI doesn't flash misleading zeros. + // Landing ('none'): depths come from the server snapshot, no "self" to + // subtract. In-queue ('queued'): for the user's queue, "ahead" is + // `position - 1` (themselves don't count); for every other queue, switching + // would land them at the back, so it's that queue's full depth. Null before + // any snapshot so the UI doesn't flash misleading zeros. const aheadByModel = useMemo | null>(() => { - if (session?.status !== 'queued') return null - const depths = session.queueDepthByModel ?? {} - const out: Record = {} - for (const { id } of FREEBUFF_MODELS) { - out[id] = - id === session.model ? Math.max(0, session.position - 1) : depths[id] ?? 0 + if (session?.status === 'none') { + const depths = session.queueDepthByModel ?? {} + const out: Record = {} + for (const { id } of FREEBUFF_MODELS) out[id] = depths[id] ?? 0 + return out } - return out + if (session?.status === 'queued') { + const depths = session.queueDepthByModel ?? {} + const out: Record = {} + for (const { id } of FREEBUFF_MODELS) { + out[id] = + id === session.model + ? Math.max(0, session.position - 1) + : depths[id] ?? 0 + } + return out + } + return null }, [session]) // Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so @@ -64,32 +80,59 @@ export const FreebuffModelSelector: React.FC = () => { [], ) + // Decide row vs column layout based on whether both buttons actually fit + // side-by-side. Each button's inner text is "● {displayName} · {tagline} {hint}", + // plus 2 cols of border and 2 cols of padding. Buttons are separated by a + // gap of 2. If the total exceeds the terminal width, stack vertically. + const stackVertically = useMemo(() => { + const BUTTON_CHROME = 4 // 2 border + 2 padding + const GAP = 2 + const total = FREEBUFF_MODELS.reduce((sum, model, idx) => { + const inner = + 2 /* indicator + space */ + + model.displayName.length + + 3 /* " · " */ + + model.tagline.length + + 2 /* " " */ + + hintWidth + return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0) + }, 0) + // Leave a small margin for the surrounding padding on the waiting-room screen. + return total > terminalWidth - 4 + }, [hintWidth, terminalWidth]) + + // "Already committed to this model" — only when the server has us queued + // on it. On the landing screen (status 'none'), nothing is committed yet, + // so picking the focused model is always a real action (first join). + const committedModelId = + session?.status === 'queued' ? session.model : null + const pick = useCallback( (modelId: string) => { if (pending) return - if (modelId === selectedModel) return + if (modelId === committedModelId) return setPending(modelId) - switchFreebuffModel(modelId).finally(() => setPending(null)) + joinFreebuffQueue(modelId).finally(() => setPending(null)) }, - [pending, selectedModel], + [pending, committedModelId], ) - // Tab / Shift+Tab and Left/Right arrow keys move the focus highlight only; - // Enter or Space commits the switch. Two-step navigation prevents the user - // from accidentally giving up their place in line by tabbing past their - // queue. Up/Down intentionally do nothing so they don't fight other - // vertical UI. + // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or + // Space commits the switch. Two-step navigation prevents the user from + // accidentally giving up their place in line by tabbing past their queue. useKeyboard( useCallback( (key: KeyEvent) => { if (pending) return const name = key.name ?? '' - const isForward = name === 'right' || (name === 'tab' && !key.shift) - const isBackward = name === 'left' || (name === 'tab' && key.shift) + const isForward = + name === 'right' || name === 'down' || (name === 'tab' && !key.shift) + const isBackward = + name === 'left' || name === 'up' || (name === 'tab' && key.shift) const isCommit = name === 'return' || name === 'enter' || name === 'space' if (!isForward && !isBackward && !isCommit) return if (isCommit) { - if (focusedId !== selectedModel) { + if (focusedId !== committedModelId) { key.preventDefault?.() pick(focusedId) } @@ -107,7 +150,7 @@ export const FreebuffModelSelector: React.FC = () => { setFocusedId(target.id) } }, - [pending, pick, focusedId, selectedModel], + [pending, pick, focusedId, committedModelId], ), ) @@ -121,18 +164,25 @@ export const FreebuffModelSelector: React.FC = () => { > {FREEBUFF_MODELS.map((model) => { + // 'Selected' means the dot is filled and the label is bold. On the + // landing screen ('none') this tracks the pre-focused pick; on the + // queued screen it tracks the model the server has us on. Either + // way, selectedModel reflects the intent of "what Enter commits to." const isSelected = model.id === selectedModel const isHovered = hoveredId === model.id const isFocused = focusedId === model.id && !isSelected const indicator = isSelected ? '●' : '○' const indicatorColor = isSelected ? theme.primary : theme.muted const labelColor = isSelected ? theme.foreground : theme.muted - const interactable = !pending && !isSelected + // Clickable whenever picking would actually do something — i.e. + // anything except re-picking the queue we're already in. + const interactable = !pending && model.id !== committedModelId const ahead = aheadByModel?.[model.id] const hint = ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ahead} ahead` diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index b9e76530b..2c2a65f5c 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -92,6 +92,11 @@ export const WaitingRoomScreen: React.FC = ({ const elapsedMs = queuedAtMs ? now - queuedAtMs : 0 const isQueued = session?.status === 'queued' + // 'none' = user hasn't joined any queue yet. We're in the pre-chat landing + // state: show the picker with live N-ahead hints and a prompt. Picking a + // model triggers joinFreebuffQueue, which POSTs and transitions us to + // 'queued' (waiting room) or straight to 'active' (chat) if no wait. + const isLanding = session?.status === 'none' return ( = ({ )} - {((!session && !error) || session?.status === 'none') && ( + {!session && !error && ( - + )} + {isLanding && ( + <> + + Pick a model to start + + + + )} + {isQueued && session && ( <> diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 077382009..9c006766a 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -132,7 +132,6 @@ interface PollController { refresh: () => Promise apply: (next: FreebuffSessionResponse) => void abort: () => void - setHasPosted: (value: boolean) => void } let controller: PollController | null = null @@ -168,14 +167,18 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {}) } /** - * User picked a different model in the waiting room. Persist the choice and - * re-POST so the server moves them to the back of the new model's queue. If - * the server has already admitted them on a different model, it responds + * Join (or re-queue for) `model`. Dual-purpose: + * - First join: called from the pre-chat landing picker. The session starts + * at `none` (GET-only); this is the user's explicit commitment to enter. + * - Switch: called when the user picks a different model from within the + * waiting room. Server moves them to the back of the new model's queue. + * + * If the server has already admitted them on a different model, it responds * with `model_locked`; the tick loop silently reverts the local selection to * the locked model so the active session stays intact. Users who really want * to switch can /end-session deliberately. */ -export async function switchFreebuffModel(model: string): Promise { +export async function joinFreebuffQueue(model: string): Promise { if (!IS_FREEBUFF) return const { setSelectedModel } = useFreebuffModelStore.getState() setSelectedModel(model) @@ -256,9 +259,13 @@ interface UseFreebuffSessionResult { /** * Manages the freebuff waiting-room session lifecycle: - * - POST on mount to join the queue / rotate instance id + * - GET on mount to probe state (no auto-join; the user picks a model in + * the landing screen, which calls joinFreebuffQueue) + * - if the probe sees an existing seat, POSTs once to take over (rotates + * the instance id so any other CLI on the same account is superseded) * - polls GET while queued (fast) or active (slow) to keep state fresh - * - re-POSTs on explicit refresh (chat gate rejected us) + * - re-POSTs on explicit refresh (chat gate rejected us, user switched + * models, user rejoined after ending) * - DELETE on unmount so the slot frees up for the next user * - plays a bell on transition from queued → active */ @@ -288,7 +295,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult { let abortController = new AbortController() let timer: ReturnType | null = null let previousStatus: FreebuffSessionResponse['status'] | null = null - let hasPosted = false + // Method for the NEXT tick. GET is read-only; POST claims/rotates a seat. + // Startup is GET (probe before committing). After any POST completes we + // flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin; + // the startup takeover branch does the same when the probe finds a seat. + let nextMethod: 'GET' | 'POST' = 'GET' const apply = (next: FreebuffSessionResponse) => { setSession(next) @@ -311,10 +322,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult { const tick = async () => { if (cancelled) return - // POST when we don't yet hold a seat; thereafter GET. The - // active|ended → none edge is special-cased below so we don't silently - // re-POST out from under an in-flight agent. - const method: 'POST' | 'GET' = hasPosted ? 'GET' : 'POST' + const method = nextMethod const instanceId = getFreebuffInstanceId() const model = getSelectedFreebuffModel() try { @@ -324,7 +332,10 @@ export function useFreebuffSession(): UseFreebuffSessionResult { model, }) if (cancelled) return - hasPosted = true + // After any successful call, default back to GET polling. The + // takeover and model_locked branches below override this when they + // need another POST. + nextMethod = 'GET' // Race recovery: user picked a different model in the waiting room at // the exact moment the server admitted them with the original model. @@ -337,6 +348,23 @@ export function useFreebuffSession(): UseFreebuffSessionResult { return } + // Startup takeover: the initial probe GET saw we already hold a seat + // (from a prior CLI instance). POST now to rotate our instance id so + // any other CLI on this account is superseded on its next poll. + // `previousStatus === null` fences this to the very first tick only. + // Pin the selected model to whatever the server thinks we're on so + // the POST preserves our queue position instead of switching queues. + if ( + method === 'GET' && + previousStatus === null && + (next.status === 'queued' || next.status === 'active') + ) { + useFreebuffModelStore.getState().setSelectedModel(next.model) + nextMethod = 'POST' + schedule(0) + return + } + if (previousStatus === 'queued' && next.status === 'active') { playAdmissionSound() } @@ -374,7 +402,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult { // Reset previousStatus so the queued→active bell still fires after // a forced re-POST. previousStatus = null - hasPosted = false + nextMethod = 'POST' await tick() }, apply, @@ -382,9 +410,6 @@ export function useFreebuffSession(): UseFreebuffSessionResult { clearTimer() abortController.abort() }, - setHasPosted: (value) => { - hasPosted = value - }, } tick() diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index bb8936b41..363224d39 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -17,6 +17,11 @@ export type FreebuffSessionServerResponse = * grace window. */ status: 'none' message?: string + /** Snapshot of every model's queue depth so the CLI can render live + * "N ahead" hints on the pre-join model picker without first + * committing the user to a queue. Present on GET responses; not + * returned from POST (POST never produces `none`). */ + queueDepthByModel?: Record } | { status: 'queued' diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index f84f7776b..1a98cb3e3 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.42", + "version": "0.0.43", "description": "The world's strongest free coding agent", "license": "MIT", "bin": { diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 073e7522f..b1f1f4c93 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -166,7 +166,11 @@ export async function getFreebuffSession( }) if (state.status === 'none') { return NextResponse.json( - { status: 'none', message: 'Call POST to join the waiting room.' }, + { + status: 'none', + message: 'Call POST to join the waiting room.', + queueDepthByModel: state.queueDepthByModel, + }, { status: 200 }, ) } diff --git a/web/src/llm-api/fireworks-config.ts b/web/src/llm-api/fireworks-config.ts index d7683afb1..fb6d59580 100644 --- a/web/src/llm-api/fireworks-config.ts +++ b/web/src/llm-api/fireworks-config.ts @@ -11,6 +11,6 @@ export const FIREWORKS_ACCOUNT_ID = 'james-65d217' export const FIREWORKS_DEPLOYMENT_MAP: Record = { // 'minimax/minimax-m2.5': 'accounts/james-65d217/deployments/lnfid5h9', // 'moonshotai/kimi-k2.5': 'accounts/james-65d217/deployments/mx8l5rq2', - 'minimax/minimax-m2.7': 'accounts/james-65d217/deployments/nrdudqxd', + // 'minimax/minimax-m2.7': 'accounts/james-65d217/deployments/nrdudqxd', 'z-ai/glm-5.1': 'accounts/james-65d217/deployments/mjb4i7ea', } diff --git a/web/src/server/free-session/__tests__/public-api.test.ts b/web/src/server/free-session/__tests__/public-api.test.ts index 7585d8927..ca1dee539 100644 --- a/web/src/server/free-session/__tests__/public-api.test.ts +++ b/web/src/server/free-session/__tests__/public-api.test.ts @@ -206,9 +206,9 @@ describe('getSessionState', () => { expect(state).toEqual({ status: 'disabled' }) }) - test('no row returns none', async () => { + test('no row returns none with empty queue-depth snapshot', async () => { const state = await getSessionState({ userId: 'u1', deps }) - expect(state).toEqual({ status: 'none' }) + expect(state).toEqual({ status: 'none', queueDepthByModel: {} }) }) test('active session with matching instance id returns active', async () => { @@ -284,7 +284,7 @@ describe('getSessionState', () => { claimedInstanceId: row.active_instance_id, deps, }) - expect(state).toEqual({ status: 'none' }) + expect(state).toEqual({ status: 'none', queueDepthByModel: {} }) }) }) diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index be4506eb1..10150d8f1 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -181,7 +181,16 @@ export async function getSessionState(params: { return { status: 'disabled' } } const row = await deps.getSessionRow(params.userId) - if (!row) return { status: 'none' } + + // Build a `none` response with live queue depths so the CLI's pre-join + // picker can show "N ahead" hints without first committing the user to a + // queue. Cheap snapshot — no user-scoped state. + const noneResponse = async (): Promise => ({ + status: 'none', + queueDepthByModel: await deps.queueDepthsByModel(), + }) + + if (!row) return noneResponse() if ( row.status === 'active' && @@ -192,7 +201,7 @@ export async function getSessionState(params: { } const view = await viewForRow(params.userId, deps, row) - if (!view) return { status: 'none' } + if (!view) return noneResponse() return view }