@@ -7,8 +7,10 @@ import {
77 DEFAULT_FREEBUFF_MODEL_ID ,
88 FALLBACK_FREEBUFF_MODEL_ID ,
99 FREEBUFF_MODELS ,
10+ FREEBUFF_PREMIUM_SESSION_LIMIT ,
1011 getFreebuffDeploymentAvailabilityLabel ,
1112 isFreebuffModelAvailable ,
13+ isFreebuffPremiumModelId ,
1214} from '@codebuff/common/constants/freebuff-models'
1315
1416import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
@@ -31,6 +33,10 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
3133 ...FREEBUFF_MODELS . filter ( ( model ) => model . id !== DEFAULT_FREEBUFF_MODEL_ID ) ,
3234]
3335
36+ function formatSessionUnits ( units : number ) : string {
37+ return Number . isInteger ( units ) ? String ( units ) : units . toFixed ( 1 )
38+ }
39+
3440/**
3541 * Dual-purpose model picker:
3642 * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -45,11 +51,6 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
4551 * Always stacked vertically. On narrow terminals where the longest one-line
4652 * label wouldn't fit, the secondary details (warning / deployment hours)
4753 * spill onto an indented second line under the name.
48- *
49- * No queue-position hint: traffic doesn't reach the threshold where a wait
50- * would form, so showing "N in line" everywhere just adds noise (and width).
51- * The picker still surfaces "Closed" (outside deployment hours) and "Limit
52- * used" (per-user quota) inline since those gate the actual click.
5354 */
5455export const FreebuffModelSelector : React . FC = ( ) => {
5556 const theme = useTheme ( )
@@ -91,15 +92,30 @@ export const FreebuffModelSelector: React.FC = () => {
9192 }
9293 } , [ now , selectedModel , session , setSelectedModel ] )
9394
95+ const committedModelId = session ?. status === 'queued' ? session . model : null
96+ const rateLimitsByModel =
97+ session && 'rateLimitsByModel' in session
98+ ? session . rateLimitsByModel
99+ : undefined
100+
101+ const getQuotaHint = useCallback (
102+ ( modelId : string ) : string => {
103+ const rateLimit = rateLimitsByModel ?. [ modelId ]
104+ if ( rateLimit ) {
105+ return `${ formatSessionUnits ( rateLimit . recentCount ) } /${ rateLimit . limit } used`
106+ }
107+ return isFreebuffPremiumModelId ( modelId )
108+ ? `0/${ FREEBUFF_PREMIUM_SESSION_LIMIT } used`
109+ : 'Unlimited'
110+ } ,
111+ [ rateLimitsByModel ] ,
112+ )
113+
94114 const BUTTON_CHROME = 4 // 2 border + 2 padding
95115
96116 // Decide whether secondary details (warning / deployment hours) get their
97- // own indented line under the name. Trigger: the widest one-line button
98- // wouldn't fit in our content budget. All buttons share a uniform width so
99- // the column reads as a clean stack of equal choices. We size to the
100- // *label* — Closed / Limit used hints can transiently push the text past
101- // this width, but they're rare (deployment hours closing, daily quota hit)
102- // and a small one-time grow is fine.
117+ // own indented line under the name. All buttons share a uniform width so
118+ // the column reads as a clean stack of equal choices.
103119 const { wrapDetails, buttonOuterWidth } = useMemo ( ( ) => {
104120 const detailsTextLen = ( model : FreebuffModelOption ) : number => {
105121 const parts : number [ ] = [ ]
@@ -108,22 +124,34 @@ export const FreebuffModelSelector: React.FC = () => {
108124 }
109125 if ( model . warning ) parts . push ( model . warning . length )
110126 if ( parts . length === 0 ) return 0
111- return parts . reduce ( ( a , b ) => a + b , 0 ) + ( parts . length - 1 ) * 3 /* " · " */
127+ return (
128+ parts . reduce ( ( a , b ) => a + b , 0 ) + ( parts . length - 1 ) * 3
129+ ) /* " · " */
112130 }
113131
132+ const hintLen = ( model : FreebuffModelOption ) : number =>
133+ Math . max ( getQuotaHint ( model . id ) . length , 'Closed' . length )
134+
114135 const oneLineLen = ( model : FreebuffModelOption ) : number => {
115136 const inlineDetails = detailsTextLen ( model )
116137 return (
117138 2 /* indicator + space */ +
118139 model . displayName . length +
119140 3 /* " · " */ +
120141 model . tagline . length +
121- ( inlineDetails > 0 ? 3 + inlineDetails : 0 )
142+ ( inlineDetails > 0 ? 3 + inlineDetails : 0 ) +
143+ 1 /* space before hint */ +
144+ hintLen ( model )
122145 )
123146 }
124147
125148 const labelLineLen = ( model : FreebuffModelOption ) : number =>
126- 2 + model . displayName . length + 3 + model . tagline . length
149+ 2 +
150+ model . displayName . length +
151+ 3 +
152+ model . tagline . length +
153+ 1 +
154+ hintLen ( model )
127155
128156 const detailsLineLen = ( model : FreebuffModelOption ) : number => {
129157 const len = detailsTextLen ( model )
@@ -148,16 +176,8 @@ export const FreebuffModelSelector: React.FC = () => {
148176 contentMaxWidth ,
149177 ) ,
150178 }
151- } , [ contentMaxWidth , deploymentAvailabilityLabel ] )
179+ } , [ contentMaxWidth , deploymentAvailabilityLabel , getQuotaHint ] )
152180
153- // "Already committed to this model" — only when the server has us queued
154- // on it. On the landing screen (status 'none'), nothing is committed yet,
155- // so picking the focused model is always a real action (first join).
156- const committedModelId = session ?. status === 'queued' ? session . model : null
157- const rateLimitsByModel =
158- session && 'rateLimitsByModel' in session
159- ? session . rateLimitsByModel
160- : undefined
161181 const isJoinable = useCallback (
162182 ( modelId : string ) => {
163183 if ( ! isFreebuffModelAvailable ( modelId , new Date ( now ) ) ) return false
@@ -230,19 +250,13 @@ export const FreebuffModelSelector: React.FC = () => {
230250 const isHovered = hoveredId === model . id
231251 const isFocused = focusedId === model . id
232252 const isAvailable = isFreebuffModelAvailable ( model . id , new Date ( now ) )
233- const rateLimit = rateLimitsByModel ?. [ model . id ]
234- const isQuotaExhausted =
235- rateLimit !== undefined && rateLimit . recentCount >= rateLimit . limit
236- const canJoin = isAvailable && ! isQuotaExhausted
253+ const canJoin = isJoinable ( model . id )
237254 // Clickable whenever picking would actually do something — i.e.
238255 // anything except re-picking the queue we're already in.
239256 const interactable =
240257 ! pending && canJoin && model . id !== committedModelId
241- const hint = ! isAvailable
242- ? 'Closed'
243- : isQuotaExhausted
244- ? 'Limit used'
245- : ''
258+ const quotaHint = getQuotaHint ( model . id )
259+ const hint = isAvailable ? quotaHint : 'Closed'
246260
247261 // Focused row: green border + arrow indicator + bold name. The name
248262 // itself stays the normal foreground color so it doesn't shout — the
@@ -251,7 +265,7 @@ export const FreebuffModelSelector: React.FC = () => {
251265 const fgColor = canJoin ? theme . foreground : theme . muted
252266 const mutedColor = theme . muted
253267 const warningColor = theme . secondary
254- const hintColor = theme . secondary
268+ const hintColor = canJoin ? theme . muted : theme . secondary
255269
256270 const borderColor = isFocused
257271 ? theme . primary
@@ -303,16 +317,17 @@ export const FreebuffModelSelector: React.FC = () => {
303317 { showInlineWarning && (
304318 < span fg = { warningColor } > · { model . warning } </ span >
305319 ) }
306- { hint && < span fg = { hintColor } > { hint } </ span > }
320+ < span fg = { hintColor } > { hint } </ span >
307321 </ text >
308322 { showWrappedDetails && (
309323 < text >
310- < span > </ span >
324+ < span > </ span >
311325 { model . availability === 'deployment_hours' && (
312326 < span fg = { mutedColor } > { deploymentAvailabilityLabel } </ span >
313327 ) }
314- { model . availability === 'deployment_hours' &&
315- model . warning && < span fg = { mutedColor } > · </ span > }
328+ { model . availability === 'deployment_hours' && model . warning && (
329+ < span fg = { mutedColor } > · </ span >
330+ ) }
316331 { model . warning && (
317332 < span fg = { warningColor } > { model . warning } </ span >
318333 ) }
0 commit comments