Skip to content

Commit d93c103

Browse files
jahoomaclaude
andcommitted
Show per-model "N ahead" in freebuff selector
Server returns a queueDepthByModel snapshot with every queued session response via a single GROUP BY read, so each row of the model selector can show a live "3 ahead" / "No wait" hint instead of the static tagline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9827126 commit d93c103

9 files changed

Lines changed: 102 additions & 22 deletions

File tree

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { TextAttributes } from '@opentui/core'
22
import { useKeyboard } from '@opentui/react'
3-
import React, { useCallback, useState } from 'react'
3+
import React, { useCallback, useMemo, useState } from 'react'
44

55
import { Button } from './button'
66
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
77

88
import { switchFreebuffModel } from '../hooks/use-freebuff-session'
99
import { useFreebuffModelStore } from '../state/freebuff-model-store'
10+
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1011
import { useTheme } from '../hooks/use-theme'
1112

1213
import type { KeyEvent } from '@opentui/core'
@@ -15,13 +16,33 @@ import type { KeyEvent } from '@opentui/core'
1516
* Lets the user pick which model's queue they're in. Tapping (or pressing the
1617
* row's number key) on a different model triggers a re-POST: the server moves
1718
* them to the back of the new model's queue.
19+
*
20+
* Each row shows a live "N ahead" count sourced from the server's
21+
* `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
22+
* "12 ahead") rather than a blind preference toggle.
1823
*/
1924
export const FreebuffModelSelector: React.FC = () => {
2025
const theme = useTheme()
2126
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
27+
const session = useFreebuffSessionStore((s) => s.session)
2228
const [pending, setPending] = useState<string | null>(null)
2329
const [hoveredId, setHoveredId] = useState<string | null>(null)
2430

31+
// For the user's current queue, "ahead" is `position - 1` (themselves don't
32+
// count). For every other queue, switching would land them at the back, so
33+
// it's that queue's full depth. Null before the first queued snapshot so
34+
// the UI doesn't flash misleading zeros.
35+
const aheadByModel = useMemo<Record<string, number> | null>(() => {
36+
if (session?.status !== 'queued') return null
37+
const depths = session.queueDepthByModel ?? {}
38+
const out: Record<string, number> = {}
39+
for (const { id } of FREEBUFF_MODELS) {
40+
out[id] =
41+
id === session.model ? Math.max(0, session.position - 1) : depths[id] ?? 0
42+
}
43+
return out
44+
}, [session])
45+
2546
const pick = useCallback(
2647
(modelId: string) => {
2748
if (pending) return
@@ -71,6 +92,13 @@ export const FreebuffModelSelector: React.FC = () => {
7192
const indicatorColor = isSelected ? theme.primary : theme.muted
7293
const labelColor = isSelected ? theme.foreground : theme.muted
7394
const interactable = !pending && !isSelected
95+
const ahead = aheadByModel?.[model.id]
96+
const hint =
97+
ahead === undefined
98+
? model.tagline
99+
: ahead === 0
100+
? 'No wait'
101+
: `${ahead} ahead`
74102
return (
75103
<Button
76104
key={model.id}
@@ -88,7 +116,7 @@ export const FreebuffModelSelector: React.FC = () => {
88116
>
89117
{model.displayName}
90118
</span>
91-
<span fg={theme.muted}> {model.tagline}</span>
119+
<span fg={theme.muted}> {hint}</span>
92120
{isPending && <span fg={theme.muted}> switching…</span>}
93121
{isHovered && interactable && !isPending && (
94122
<span fg={theme.muted}></span>

common/src/types/freebuff-session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export type FreebuffSessionServerResponse =
2626
/** 1-indexed position in the queue for `model`. */
2727
position: number
2828
queueDepth: number
29+
/** Current depth of every model's queue, so the CLI can show a live
30+
* "N ahead" hint on each row of the model selector. Models with no
31+
* queued rows at snapshot time may be absent; the CLI should treat a
32+
* missing entry as 0. */
33+
queueDepthByModel: Record<string, number>
2934
estimatedWaitMs: number
3035
queuedAt: string
3136
}

docs/freebuff-waiting-room.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ Response shapes:
183183
"model": "z-ai/glm-5.1",
184184
"position": 17, // 1-indexed within this model's queue
185185
"queueDepth": 43, // size of this model's queue
186+
"queueDepthByModel": { // snapshot of every model's queue — powers the
187+
"z-ai/glm-5.1": 43, // "N ahead" hint in the selector. Missing
188+
"minimax/minimax-m2.7": 4 // entries should be treated as 0.
189+
},
186190
"estimatedWaitMs": 384000,
187191
"queuedAt": "2026-04-17T12:00:00Z"
188192
}

web/src/app/api/v1/freebuff/session/__tests__/session.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ function makeSessionDeps(overrides: Partial<SessionDeps> = {}): SessionDeps & {
3939
graceMs: 30 * 60 * 1000,
4040
now: () => now,
4141
getSessionRow: async (userId) => rows.get(userId) ?? null,
42-
queueDepth: async () => [...rows.values()].filter((r) => r.status === 'queued').length,
42+
queueDepthsByModel: async () => {
43+
const out: Record<string, number> = {}
44+
for (const r of rows.values()) {
45+
if (r.status !== 'queued') continue
46+
out[r.model] = (out[r.model] ?? 0) + 1
47+
}
48+
return out
49+
},
4350
queuePositionFor: async () => 1,
4451
endSession: async (userId) => {
4552
rows.delete(userId)

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ function makeDeps(overrides: Partial<SessionDeps> = {}): SessionDeps & {
4343
endSession: async (userId) => {
4444
rows.delete(userId)
4545
},
46-
queueDepth: async ({ model }) => {
47-
let n = 0
46+
queueDepthsByModel: async () => {
47+
const out: Record<string, number> = {}
4848
for (const r of rows.values()) {
49-
if (r.status === 'queued' && r.model === model) n++
49+
if (r.status !== 'queued') continue
50+
out[r.model] = (out[r.model] ?? 0) + 1
5051
}
51-
return n
52+
return out
5253
},
5354
queuePositionFor: async ({ userId, model, queuedAt }) => {
5455
let pos = 0
@@ -142,6 +143,22 @@ describe('requestSession', () => {
142143
expect(state.instanceId).toBe('inst-1')
143144
})
144145

146+
test('queued response includes a per-model depth snapshot for the selector', async () => {
147+
// Seed 2 users in glm + 1 in minimax so the returned map captures both.
148+
await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps })
149+
deps._tick(new Date(deps._now().getTime() + 1000))
150+
await requestSession({ userId: 'u2', model: DEFAULT_MODEL, deps })
151+
deps._tick(new Date(deps._now().getTime() + 1000))
152+
await requestSession({ userId: 'u3', model: 'minimax/minimax-m2.7', deps })
153+
154+
const state = await getSessionState({ userId: 'u1', deps })
155+
if (state.status !== 'queued') throw new Error('unreachable')
156+
expect(state.queueDepthByModel).toEqual({
157+
[DEFAULT_MODEL]: 2,
158+
'minimax/minimax-m2.7': 1,
159+
})
160+
})
161+
145162
test('second call from same user rotates instance id, keeps queue position', async () => {
146163
await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps })
147164
const second = await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps })

web/src/server/free-session/__tests__/session-view.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ describe('toSessionStateResponse', () => {
4444
const now = new Date('2026-04-17T12:00:00Z')
4545
const baseArgs = {
4646
graceMs: GRACE_MS,
47+
queueDepthByModel: {},
4748
}
4849

4950
test('returns null when row is null', () => {
5051
const view = toSessionStateResponse({
5152
row: null,
5253
position: 0,
53-
queueDepth: 0,
5454
...baseArgs,
5555
now,
5656
})
@@ -61,8 +61,8 @@ describe('toSessionStateResponse', () => {
6161
const view = toSessionStateResponse({
6262
row: row({ status: 'queued' }),
6363
position: 3,
64-
queueDepth: 10,
6564
...baseArgs,
65+
queueDepthByModel: { [TEST_MODEL]: 10, 'minimax/minimax-m2.7': 4 },
6666
now,
6767
})
6868
expect(view).toEqual({
@@ -71,6 +71,7 @@ describe('toSessionStateResponse', () => {
7171
model: TEST_MODEL,
7272
position: 3,
7373
queueDepth: 10,
74+
queueDepthByModel: { [TEST_MODEL]: 10, 'minimax/minimax-m2.7': 4 },
7475
estimatedWaitMs: 2 * WAIT_PER_SPOT_MS,
7576
queuedAt: now.toISOString(),
7677
})
@@ -82,7 +83,6 @@ describe('toSessionStateResponse', () => {
8283
const view = toSessionStateResponse({
8384
row: row({ status: 'active', admitted_at: admittedAt, expires_at: expiresAt }),
8485
position: 0,
85-
queueDepth: 0,
8686
...baseArgs,
8787
now,
8888
})
@@ -102,7 +102,6 @@ describe('toSessionStateResponse', () => {
102102
const view = toSessionStateResponse({
103103
row: row({ status: 'active', admitted_at: admittedAt, expires_at: expiresAt }),
104104
position: 0,
105-
queueDepth: 0,
106105
...baseArgs,
107106
now,
108107
})
@@ -124,7 +123,6 @@ describe('toSessionStateResponse', () => {
124123
expires_at: new Date(now.getTime() - GRACE_MS - 1),
125124
}),
126125
position: 0,
127-
queueDepth: 0,
128126
...baseArgs,
129127
now,
130128
})

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
FreeSessionModelLockedError,
1414
getSessionRow,
1515
joinOrTakeOver,
16-
queueDepth,
16+
queueDepthsByModel,
1717
queuePositionFor,
1818
} from './store'
1919
import { toSessionStateResponse } from './session-view'
@@ -29,7 +29,7 @@ export interface SessionDeps {
2929
now: Date
3030
}) => Promise<InternalSessionRow>
3131
endSession: (userId: string) => Promise<void>
32-
queueDepth: (params: { model: string }) => Promise<number>
32+
queueDepthsByModel: () => Promise<Record<string, number>>
3333
queuePositionFor: (params: {
3434
userId: string
3535
model: string
@@ -47,7 +47,7 @@ const defaultDeps: SessionDeps = {
4747
getSessionRow,
4848
joinOrTakeOver,
4949
endSession,
50-
queueDepth,
50+
queueDepthsByModel,
5151
queuePositionFor,
5252
isWaitingRoomEnabled,
5353
get graceMs() {
@@ -65,21 +65,21 @@ async function viewForRow(
6565
deps: SessionDeps,
6666
row: InternalSessionRow,
6767
): Promise<SessionStateResponse | null> {
68-
const [position, depth] =
68+
const [position, depthsByModel] =
6969
row.status === 'queued'
7070
? await Promise.all([
7171
deps.queuePositionFor({
7272
userId,
7373
model: row.model,
7474
queuedAt: row.queued_at,
7575
}),
76-
deps.queueDepth({ model: row.model }),
76+
deps.queueDepthsByModel(),
7777
])
78-
: [0, 0]
78+
: [0, {}]
7979
return toSessionStateResponse({
8080
row,
8181
position,
82-
queueDepth: depth,
82+
queueDepthByModel: depthsByModel,
8383
graceMs: deps.graceMs,
8484
now: nowOf(deps),
8585
})

web/src/server/free-session/session-view.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import type { InternalSessionRow, SessionStateResponse } from './types'
1212
export function toSessionStateResponse(params: {
1313
row: InternalSessionRow | null
1414
position: number
15-
queueDepth: number
15+
/** Snapshot of every model's queue depth at response time. Only consumed
16+
* by the `queued` variant — active/ended don't need the selector. */
17+
queueDepthByModel: Record<string, number>
1618
graceMs: number
1719
now: Date
1820
}): SessionStateResponse | null {
19-
const { row, position, queueDepth, graceMs, now } = params
21+
const { row, position, queueDepthByModel, graceMs, now } = params
2022
if (!row) return null
2123

2224
if (row.status === 'active' && row.expires_at) {
@@ -51,7 +53,8 @@ export function toSessionStateResponse(params: {
5153
instanceId: row.active_instance_id,
5254
model: row.model,
5355
position,
54-
queueDepth,
56+
queueDepth: queueDepthByModel[row.model] ?? 0,
57+
queueDepthByModel,
5558
estimatedWaitMs: estimateWaitMs({ position }),
5659
queuedAt: row.queued_at.toISOString(),
5760
}

web/src/server/free-session/store.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ export async function queueDepth(params: { model: string }): Promise<number> {
158158
return Number(rows[0]?.n ?? 0)
159159
}
160160

161+
/**
162+
* Single-query read of queued-row counts bucketed by model. Powers the
163+
* per-model "N ahead" hint in the waiting-room model selector — one round-trip
164+
* covers every model's queue depth, so the UI stays cheap to refresh.
165+
* Models with no queued rows are absent from the map; callers should default
166+
* missing keys to 0.
167+
*/
168+
export async function queueDepthsByModel(): Promise<Record<string, number>> {
169+
const rows = await db
170+
.select({ model: schema.freeSession.model, n: count() })
171+
.from(schema.freeSession)
172+
.where(eq(schema.freeSession.status, 'queued'))
173+
.groupBy(schema.freeSession.model)
174+
const out: Record<string, number> = {}
175+
for (const row of rows) out[row.model] = Number(row.n)
176+
return out
177+
}
178+
161179
export async function activeCount(): Promise<number> {
162180
const rows = await db
163181
.select({ n: count() })

0 commit comments

Comments
 (0)