From 176968ee56d95c336df2f9060c97187b6f38dc80 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 30 Apr 2026 12:12:18 -0700 Subject: [PATCH 1/3] Improve freebuff model picker UX --- .../components/freebuff-model-selector.tsx | 85 +++++++++++++------ cli/src/hooks/use-freebuff-session.ts | 22 +++-- .../freebuff-model-navigation.test.ts | 37 ++++---- cli/src/utils/freebuff-model-navigation.ts | 26 ++---- common/src/types/freebuff-session.ts | 11 +++ .../app/api/v1/freebuff/session/_handlers.ts | 1 + .../free-session/__tests__/public-api.test.ts | 19 +++++ web/src/server/free-session/public-api.ts | 64 ++++++++++++-- 8 files changed, 183 insertions(+), 82 deletions(-) diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index f9376c5db..0e8ec58a8 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -19,7 +19,7 @@ import { useFreebuffSessionStore } from '../state/freebuff-session-store' import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' import { - nextSelectableFreebuffModelId, + nextFreebuffModelId, resolveFreebuffModelCommitTarget, } from '../utils/freebuff-model-navigation' @@ -124,11 +124,17 @@ export const FreebuffModelSelector: React.FC = () => { // when the user's selection moves between queues. The tagline is shown // inline with the name now, so it's no longer part of this slot. const hintWidth = useMemo( - () => Math.max('No wait'.length, '999 ahead'.length), + () => + Math.max( + 'No wait'.length, + '999 ahead'.length, + 'Used today'.length, + 'Limit used'.length, + ), [], ) - // Decide row vs column layout based on whether both buttons actually fit + // Decide row vs column layout based on whether the buttons actually fit // side-by-side. Each button's inner text is // "● {displayName} · {tagline} · {hours} {hint}", // plus 2 cols of border and 2 cols of padding. Buttons are separated by a @@ -157,16 +163,28 @@ export const FreebuffModelSelector: React.FC = () => { // 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 rateLimitsByModel = + session && 'rateLimitsByModel' in session + ? session.rateLimitsByModel + : undefined + const isJoinable = useCallback( + (modelId: string) => { + if (!isFreebuffModelAvailable(modelId, new Date(now))) return false + const rateLimit = rateLimitsByModel?.[modelId] + return !rateLimit || rateLimit.recentCount < rateLimit.limit + }, + [now, rateLimitsByModel], + ) const pick = useCallback( (modelId: string) => { if (pending) return if (modelId === committedModelId) return - if (!isFreebuffModelAvailable(modelId, new Date(now))) return + if (!isJoinable(modelId)) return setPending(modelId) joinFreebuffQueue(modelId).finally(() => setPending(null)) }, - [pending, committedModelId, now], + [pending, committedModelId, isJoinable], ) // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or @@ -187,10 +205,8 @@ export const FreebuffModelSelector: React.FC = () => { if (isCommit) { const targetId = resolveFreebuffModelCommitTarget({ focusedId, - selectedId: selectedModel, committedId: committedModelId, - isSelectable: (modelId) => - isFreebuffModelAvailable(modelId, new Date(now)), + isSelectable: isJoinable, }) if (targetId) { key.preventDefault?.() @@ -198,19 +214,17 @@ export const FreebuffModelSelector: React.FC = () => { } return } - const targetId = nextSelectableFreebuffModelId({ + const targetId = nextFreebuffModelId({ modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id), focusedId, direction: isForward ? 'forward' : 'backward', - isSelectable: (modelId) => - isFreebuffModelAvailable(modelId, new Date(now)), }) if (targetId) { key.preventDefault?.() setFocusedId(targetId) } }, - [pending, pick, focusedId, selectedModel, committedModelId, now], + [pending, pick, focusedId, committedModelId, isJoinable], ), ) @@ -233,32 +247,47 @@ export const FreebuffModelSelector: React.FC = () => { // '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 is the safe fallback if focus ever lands on a - // closed row (for example when deployment hours change). + // way, selectedModel marks the user's current preference even if + // focus has moved to a different row. const isSelected = model.id === selectedModel const isHovered = hoveredId === model.id const isFocused = focusedId === model.id && !isSelected const isAvailable = isFreebuffModelAvailable(model.id, new Date(now)) - const indicator = isSelected ? '●' : '○' - const indicatorColor = isSelected ? theme.primary : theme.muted + const rateLimit = rateLimitsByModel?.[model.id] + const isQuotaExhausted = + rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit + const canJoin = isAvailable && !isQuotaExhausted + const indicator = isSelected ? '●' : isFocused ? '›' : '○' + const indicatorColor = isSelected + ? theme.primary + : isFocused + ? theme.foreground + : theme.muted const labelColor = - isSelected && isAvailable ? theme.foreground : theme.muted + (isSelected || isFocused) && canJoin + ? theme.foreground + : theme.muted // Clickable whenever picking would actually do something — i.e. // anything except re-picking the queue we're already in. const interactable = - !pending && isAvailable && model.id !== committedModelId + !pending && canJoin && model.id !== committedModelId const ahead = aheadByModel?.[model.id] const hint = !isAvailable ? 'Closed' - : ahead === undefined - ? '' - : ahead === 0 - ? 'No wait' - : `${ahead} ahead` + : isQuotaExhausted + ? model.id === FREEBUFF_GEMINI_PRO_MODEL_ID + ? 'Used today' + : 'Limit used' + : ahead === undefined + ? '' + : ahead === 0 + ? 'No wait' + : `${ahead} ahead` + const hintColor = canJoin ? theme.muted : theme.secondary const borderColor = isSelected ? theme.primary - : (isFocused || isHovered) && interactable + : isFocused || isHovered ? theme.foreground : theme.border @@ -267,7 +296,7 @@ export const FreebuffModelSelector: React.FC = () => { key={model.id} onClick={() => { setFocusedId(model.id) - if (isAvailable) pick(model.id) + if (canJoin) pick(model.id) }} onMouseOver={() => interactable && setHoveredId(model.id)} onMouseOut={() => @@ -286,7 +315,9 @@ export const FreebuffModelSelector: React.FC = () => { {model.displayName} @@ -295,7 +326,7 @@ export const FreebuffModelSelector: React.FC = () => { {model.availability === 'deployment_hours' && ( · {deploymentAvailabilityLabel} )} - {hint.padEnd(hintWidth)} + {hint.padEnd(hintWidth)} ) diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index c78d4bbd0..362ff1858 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -516,11 +516,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult { // tick/apply path because a server-side row that hasn't been // swept yet would trip the startup-takeover branch into an // auto-POST — the exact silent-rejoin this mode exists to - // prevent. But the picker still needs live queue depths for its - // "N ahead" hints, so kick off a fire-and-forget GET and extract - // just queueDepthByModel from the response, ignoring whatever - // status it claims. Polling resumes when the user commits to a - // model via joinFreebuffQueue. + // prevent. But the picker still needs live queue depths and quota + // snapshots, so kick off a fire-and-forget GET and extract only + // picker metadata from the response, ignoring whatever status it + // claims. Polling resumes when the user commits to a model via + // joinFreebuffQueue. apply({ status: 'none' }) const fetchController = abortController callSession('GET', token, { signal: fetchController.signal }) @@ -536,7 +536,17 @@ export function useFreebuffSession(): UseFreebuffSessionResult { response.status === 'none' || response.status === 'queued' ? response.queueDepthByModel : undefined - if (depths) apply({ status: 'none', queueDepthByModel: depths }) + const rateLimits = + 'rateLimitsByModel' in response + ? response.rateLimitsByModel + : undefined + if (depths || rateLimits) { + apply({ + status: 'none', + ...(depths ? { queueDepthByModel: depths } : {}), + ...(rateLimits ? { rateLimitsByModel: rateLimits } : {}), + }) + } }) .catch(() => { // Silent — blank hints are acceptable if the fetch fails. diff --git a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts index 4723245ba..512822705 100644 --- a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts +++ b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts @@ -1,79 +1,73 @@ import { describe, expect, test } from 'bun:test' import { - nextSelectableFreebuffModelId, + nextFreebuffModelId, resolveFreebuffModelCommitTarget, } from '../freebuff-model-navigation' -describe('nextSelectableFreebuffModelId', () => { - test('skips unavailable models when moving forward', () => { +describe('nextFreebuffModelId', () => { + test('moves to the next model when moving forward', () => { const modelIds = ['glm', 'minimax'] expect( - nextSelectableFreebuffModelId({ + nextFreebuffModelId({ modelIds, focusedId: 'minimax', direction: 'forward', - isSelectable: (id) => id !== 'glm', }), - ).toBe('minimax') + ).toBe('glm') }) - test('skips unavailable models when moving backward', () => { + test('moves to the previous model when moving backward', () => { const modelIds = ['glm', 'minimax'] expect( - nextSelectableFreebuffModelId({ + nextFreebuffModelId({ modelIds, focusedId: 'minimax', direction: 'backward', - isSelectable: (id) => id !== 'glm', }), - ).toBe('minimax') + ).toBe('glm') }) - test('moves to the next available model when more than one is selectable', () => { + test('wraps through every model regardless of selectability', () => { const modelIds = ['glm', 'minimax', 'other'] expect( - nextSelectableFreebuffModelId({ + nextFreebuffModelId({ modelIds, focusedId: 'minimax', direction: 'forward', - isSelectable: (id) => id !== 'glm', }), ).toBe('other') }) - test('returns null when no selectable model exists', () => { + test('returns null when no model exists', () => { expect( - nextSelectableFreebuffModelId({ - modelIds: ['glm'], + nextFreebuffModelId({ + modelIds: [], focusedId: 'glm', direction: 'forward', - isSelectable: () => false, }), ).toBeNull() }) }) describe('resolveFreebuffModelCommitTarget', () => { - test('falls back to the selected model when focus is on a closed model', () => { + test('returns null when focus is on a closed model', () => { expect( resolveFreebuffModelCommitTarget({ focusedId: 'glm', - selectedId: 'minimax', committedId: null, isSelectable: (id) => id !== 'glm', }), - ).toBe('minimax') + ).toBeNull() }) test('commits the focused model when it is selectable', () => { expect( resolveFreebuffModelCommitTarget({ focusedId: 'minimax', - selectedId: 'glm', committedId: null, isSelectable: (id) => id === 'minimax', }), @@ -84,7 +78,6 @@ describe('resolveFreebuffModelCommitTarget', () => { expect( resolveFreebuffModelCommitTarget({ focusedId: 'minimax', - selectedId: 'minimax', committedId: 'minimax', isSelectable: () => true, }), diff --git a/cli/src/utils/freebuff-model-navigation.ts b/cli/src/utils/freebuff-model-navigation.ts index eef067d5c..fa952f23f 100644 --- a/cli/src/utils/freebuff-model-navigation.ts +++ b/cli/src/utils/freebuff-model-navigation.ts @@ -1,37 +1,25 @@ -export function nextSelectableFreebuffModelId(params: { +export function nextFreebuffModelId(params: { modelIds: readonly string[] focusedId: string direction: 'forward' | 'backward' - isSelectable: (modelId: string) => boolean }): string | null { - const { modelIds, focusedId, direction, isSelectable } = params + const { modelIds, focusedId, direction } = params if (modelIds.length === 0) return null const currentIdx = modelIds.indexOf(focusedId) - if (currentIdx === -1) return null + if (currentIdx === -1) return modelIds[0] ?? null const step = direction === 'forward' ? 1 : -1 - // Include a full wrap back to the current item so arrows stay on the same - // selectable model when every peer is unavailable. - for (let offset = 1; offset <= modelIds.length; offset++) { - const idx = - (currentIdx + step * offset + modelIds.length) % modelIds.length - const candidate = modelIds[idx] - if (isSelectable(candidate)) return candidate - } - - return null + return modelIds[(currentIdx + step + modelIds.length) % modelIds.length] } export function resolveFreebuffModelCommitTarget(params: { focusedId: string - selectedId: string committedId: string | null isSelectable: (modelId: string) => boolean }): string | null { - const { focusedId, selectedId, committedId, isSelectable } = params - const targetId = isSelectable(focusedId) ? focusedId : selectedId + const { focusedId, committedId, isSelectable } = params - if (!isSelectable(targetId) || targetId === committedId) return null - return targetId + if (!isSelectable(focusedId) || focusedId === committedId) return null + return focusedId } diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index 9a1b3dad4..e2e02a7cc 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -20,6 +20,11 @@ export interface FreebuffSessionRateLimit { recentCount: number } +export type FreebuffSessionRateLimitByModel = Record< + string, + FreebuffSessionRateLimit +> + export type FreebuffCountryBlockReason = | 'country_not_allowed' | 'anonymized_or_unknown_country' @@ -55,6 +60,10 @@ export type FreebuffSessionServerResponse = * committing the user to a queue. Present on GET responses; not * returned from POST (POST never produces `none`). */ queueDepthByModel?: Record + /** Current quota snapshots for rate-limited models, keyed by model id. + * Lets the picker show exhausted daily/session caps before the user + * commits to a queue. */ + rateLimitsByModel?: FreebuffSessionRateLimitByModel } | { status: 'queued' @@ -75,6 +84,7 @@ export type FreebuffSessionServerResponse = * for unlimited models or when the status was produced outside the * rate-limit check path (e.g. pure read via GET). */ rateLimit?: FreebuffSessionRateLimit + rateLimitsByModel?: FreebuffSessionRateLimitByModel } | { status: 'active' @@ -88,6 +98,7 @@ export type FreebuffSessionServerResponse = * for unlimited models or when the status was produced outside the * rate-limit check path (e.g. pure read via GET). */ rateLimit?: FreebuffSessionRateLimit + rateLimitsByModel?: FreebuffSessionRateLimitByModel } | { /** Session is over. While `instanceId` is present we're inside the diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 05c120677..fc468d947 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -276,6 +276,7 @@ export async function getFreebuffSession( status: 'none', message: 'Call POST to join the waiting room.', queueDepthByModel: state.queueDepthByModel, + rateLimitsByModel: state.rateLimitsByModel, }, { status: 200 }, ) 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 4a2cd4006..421edbb9b 100644 --- a/web/src/server/free-session/__tests__/public-api.test.ts +++ b/web/src/server/free-session/__tests__/public-api.test.ts @@ -745,6 +745,25 @@ describe('getSessionState', () => { expect(state).toEqual({ status: 'none', queueDepthByModel: {} }) }) + test('no row surfaces exhausted Gemini quota before joining', async () => { + const now = deps._now() + deps.admits.push({ + user_id: 'u1', + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + admitted_at: new Date(now.getTime() - 23 * 60 * 60 * 1000), + }) + + const state = await getSessionState({ userId: 'u1', deps }) + expect(state.status).toBe('none') + if (state.status !== 'none') throw new Error('unreachable') + expect(state.rateLimitsByModel?.[FREEBUFF_GEMINI_PRO_MODEL_ID]).toEqual({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + limit: 1, + windowHours: 24, + recentCount: 1, + }) + }) + test('active session with matching instance id returns active', async () => { await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) const row = deps.rows.get('u1')! diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index a921e9daa..75732af95 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -90,6 +90,33 @@ async function fetchRateLimitSnapshot( } } +async function fetchRateLimitSnapshotsByModel( + userId: string, + deps: SessionDeps, + opts: { includeUnused?: boolean } = {}, +): Promise> { + const entries = await Promise.all( + Object.keys(RATE_LIMITS).map(async (model) => { + const snapshot = await fetchRateLimitSnapshot(userId, model, deps) + return snapshot && (opts.includeUnused || snapshot.info.recentCount > 0) + ? ([model, snapshot.info] as const) + : null + }), + ) + return Object.fromEntries( + entries.filter( + (entry): entry is readonly [string, FreebuffSessionRateLimit] => + entry !== null, + ), + ) +} + +function nonEmptyRateLimitsByModel( + rateLimitsByModel: Record, +): { rateLimitsByModel: Record } | {} { + return Object.keys(rateLimitsByModel).length > 0 ? { rateLimitsByModel } : {} +} + export interface SessionDeps { getSessionRow: (userId: string) => Promise joinOrTakeOver: (params: { @@ -365,9 +392,22 @@ async function attachRateLimit( deps: SessionDeps, ): Promise { if (view.status !== 'queued' && view.status !== 'active') return view - const snapshot = await fetchRateLimitSnapshot(userId, view.model, deps) - if (!snapshot) return view - return { ...view, rateLimit: snapshot.info } + const allRateLimitsByModel = await fetchRateLimitSnapshotsByModel( + userId, + deps, + { includeUnused: true }, + ) + const rateLimit = allRateLimitsByModel[view.model] + const rateLimitsByModel = Object.fromEntries( + Object.entries(allRateLimitsByModel).filter( + ([, snapshot]) => snapshot.recentCount > 0, + ), + ) + return { + ...view, + ...(rateLimit ? { rateLimit } : {}), + ...nonEmptyRateLimitsByModel(rateLimitsByModel), + } } /** @@ -404,11 +444,19 @@ export async function getSessionState(params: { // 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(), - }) + // queue, plus per-user quota snapshots so exhausted models are visible + // before POST. + const noneResponse = async (): Promise => { + const [queueDepthByModel, rateLimitsByModel] = await Promise.all([ + deps.queueDepthsByModel(), + fetchRateLimitSnapshotsByModel(params.userId, deps), + ]) + return { + status: 'none', + queueDepthByModel, + ...nonEmptyRateLimitsByModel(rateLimitsByModel), + } + } if (!row) return noneResponse() From b2b123391bb9cbbb1ba18ab49f6b41df80a3588d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 30 Apr 2026 12:21:15 -0700 Subject: [PATCH 2/3] Avoid extra active quota polling --- .../free-session/__tests__/public-api.test.ts | 26 +++++++++++++++++++ web/src/server/free-session/public-api.ts | 5 ++++ 2 files changed, 31 insertions(+) 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 421edbb9b..265c2872b 100644 --- a/web/src/server/free-session/__tests__/public-api.test.ts +++ b/web/src/server/free-session/__tests__/public-api.test.ts @@ -827,6 +827,32 @@ describe('getSessionState', () => { }) }) + test('active session only fetches quota for its own model', async () => { + deps._tick(new Date('2026-04-17T16:00:00Z')) + let listRecentAdmitsCalls = 0 + const originalListRecentAdmits = deps.listRecentAdmits + deps.listRecentAdmits = async (params) => { + listRecentAdmitsCalls++ + return originalListRecentAdmits(params) + } + + await requestSession({ userId: 'u1', model: 'moonshotai/kimi-k2.6', deps }) + const row = deps.rows.get('u1')! + row.status = 'active' + row.admitted_at = deps._now() + row.expires_at = new Date(deps._now().getTime() + SESSION_LEN) + listRecentAdmitsCalls = 0 + + const state = await getSessionState({ + userId: 'u1', + claimedInstanceId: row.active_instance_id, + deps, + }) + + expect(state.status).toBe('active') + expect(listRecentAdmitsCalls).toBe(1) + }) + test('omitted claimedInstanceId on active session returns active (read-only)', async () => { // Polling without an id (e.g. very first GET before POST has resolved) // must not be classified as superseded — only an explicit mismatch is. diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index 75732af95..688dbc405 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -392,6 +392,11 @@ async function attachRateLimit( deps: SessionDeps, ): Promise { if (view.status !== 'queued' && view.status !== 'active') return view + if (view.status === 'active') { + const snapshot = await fetchRateLimitSnapshot(userId, view.model, deps) + return snapshot ? { ...view, rateLimit: snapshot.info } : view + } + const allRateLimitsByModel = await fetchRateLimitSnapshotsByModel( userId, deps, From f3f77f2c0c62ed9c695b8daf9fcee6142033a1f4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 30 Apr 2026 12:59:19 -0700 Subject: [PATCH 3/3] Simplify freebuff model picker changes --- .../components/freebuff-model-selector.tsx | 14 ++----- cli/src/hooks/use-freebuff-session.ts | 14 ++----- .../freebuff-model-navigation.test.ts | 37 +----------------- cli/src/utils/freebuff-model-navigation.ts | 11 ------ web/src/server/free-session/public-api.ts | 38 ++++++++++--------- 5 files changed, 27 insertions(+), 87 deletions(-) diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 0e8ec58a8..0001a4da9 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -18,10 +18,7 @@ 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 { - nextFreebuffModelId, - resolveFreebuffModelCommitTarget, -} from '../utils/freebuff-model-navigation' +import { nextFreebuffModelId } from '../utils/freebuff-model-navigation' import type { KeyEvent } from '@opentui/core' @@ -203,14 +200,9 @@ export const FreebuffModelSelector: React.FC = () => { name === 'return' || name === 'enter' || name === 'space' if (!isForward && !isBackward && !isCommit) return if (isCommit) { - const targetId = resolveFreebuffModelCommitTarget({ - focusedId, - committedId: committedModelId, - isSelectable: isJoinable, - }) - if (targetId) { + if (isJoinable(focusedId) && focusedId !== committedModelId) { key.preventDefault?.() - pick(targetId) + pick(focusedId) } return } diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 362ff1858..154312601 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -532,19 +532,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult { ) { return } - const depths = - response.status === 'none' || response.status === 'queued' - ? response.queueDepthByModel - : undefined - const rateLimits = - 'rateLimitsByModel' in response - ? response.rateLimitsByModel - : undefined - if (depths || rateLimits) { + if (response.status === 'none' || response.status === 'queued') { apply({ status: 'none', - ...(depths ? { queueDepthByModel: depths } : {}), - ...(rateLimits ? { rateLimitsByModel: rateLimits } : {}), + queueDepthByModel: response.queueDepthByModel, + rateLimitsByModel: response.rateLimitsByModel, }) } }) diff --git a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts index 512822705..0df2a19a1 100644 --- a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts +++ b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { - nextFreebuffModelId, - resolveFreebuffModelCommitTarget, -} from '../freebuff-model-navigation' +import { nextFreebuffModelId } from '../freebuff-model-navigation' describe('nextFreebuffModelId', () => { test('moves to the next model when moving forward', () => { @@ -52,35 +49,3 @@ describe('nextFreebuffModelId', () => { ).toBeNull() }) }) - -describe('resolveFreebuffModelCommitTarget', () => { - test('returns null when focus is on a closed model', () => { - expect( - resolveFreebuffModelCommitTarget({ - focusedId: 'glm', - committedId: null, - isSelectable: (id) => id !== 'glm', - }), - ).toBeNull() - }) - - test('commits the focused model when it is selectable', () => { - expect( - resolveFreebuffModelCommitTarget({ - focusedId: 'minimax', - committedId: null, - isSelectable: (id) => id === 'minimax', - }), - ).toBe('minimax') - }) - - test('returns null when the target is already committed', () => { - expect( - resolveFreebuffModelCommitTarget({ - focusedId: 'minimax', - committedId: 'minimax', - isSelectable: () => true, - }), - ).toBeNull() - }) -}) diff --git a/cli/src/utils/freebuff-model-navigation.ts b/cli/src/utils/freebuff-model-navigation.ts index fa952f23f..d1f748d8c 100644 --- a/cli/src/utils/freebuff-model-navigation.ts +++ b/cli/src/utils/freebuff-model-navigation.ts @@ -12,14 +12,3 @@ export function nextFreebuffModelId(params: { const step = direction === 'forward' ? 1 : -1 return modelIds[(currentIdx + step + modelIds.length) % modelIds.length] } - -export function resolveFreebuffModelCommitTarget(params: { - focusedId: string - committedId: string | null - isSelectable: (modelId: string) => boolean -}): string | null { - const { focusedId, committedId, isSelectable } = params - - if (!isSelectable(focusedId) || focusedId === committedId) return null - return focusedId -} diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index 688dbc405..a311ff941 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -90,17 +90,14 @@ async function fetchRateLimitSnapshot( } } -async function fetchRateLimitSnapshotsByModel( +async function fetchRateLimitsByModel( userId: string, deps: SessionDeps, - opts: { includeUnused?: boolean } = {}, ): Promise> { const entries = await Promise.all( Object.keys(RATE_LIMITS).map(async (model) => { const snapshot = await fetchRateLimitSnapshot(userId, model, deps) - return snapshot && (opts.includeUnused || snapshot.info.recentCount > 0) - ? ([model, snapshot.info] as const) - : null + return snapshot ? ([model, snapshot.info] as const) : null }), ) return Object.fromEntries( @@ -111,6 +108,16 @@ async function fetchRateLimitSnapshotsByModel( ) } +function onlyUsedRateLimitsByModel( + rateLimitsByModel: Record, +): Record { + return Object.fromEntries( + Object.entries(rateLimitsByModel).filter( + ([, snapshot]) => snapshot.recentCount > 0, + ), + ) +} + function nonEmptyRateLimitsByModel( rateLimitsByModel: Record, ): { rateLimitsByModel: Record } | {} { @@ -397,21 +404,14 @@ async function attachRateLimit( return snapshot ? { ...view, rateLimit: snapshot.info } : view } - const allRateLimitsByModel = await fetchRateLimitSnapshotsByModel( - userId, - deps, - { includeUnused: true }, - ) + const allRateLimitsByModel = await fetchRateLimitsByModel(userId, deps) const rateLimit = allRateLimitsByModel[view.model] - const rateLimitsByModel = Object.fromEntries( - Object.entries(allRateLimitsByModel).filter( - ([, snapshot]) => snapshot.recentCount > 0, - ), - ) return { ...view, ...(rateLimit ? { rateLimit } : {}), - ...nonEmptyRateLimitsByModel(rateLimitsByModel), + ...nonEmptyRateLimitsByModel( + onlyUsedRateLimitsByModel(allRateLimitsByModel), + ), } } @@ -454,12 +454,14 @@ export async function getSessionState(params: { const noneResponse = async (): Promise => { const [queueDepthByModel, rateLimitsByModel] = await Promise.all([ deps.queueDepthsByModel(), - fetchRateLimitSnapshotsByModel(params.userId, deps), + fetchRateLimitsByModel(params.userId, deps), ]) return { status: 'none', queueDepthByModel, - ...nonEmptyRateLimitsByModel(rateLimitsByModel), + ...nonEmptyRateLimitsByModel( + onlyUsedRateLimitsByModel(rateLimitsByModel), + ), } }