@@ -3,16 +3,28 @@ import { useKeyboard } from '@opentui/react'
33import React , { useCallback , useEffect , useMemo , useState } from 'react'
44
55import { Button } from './button'
6- import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
6+ import {
7+ DEFAULT_FREEBUFF_MODEL_ID ,
8+ FREEBUFF_DEPLOYMENT_HOURS_LABEL ,
9+ FREEBUFF_GLM_MODEL_ID ,
10+ FREEBUFF_MODELS ,
11+ isFreebuffModelAvailable ,
12+ } from '@codebuff/common/constants/freebuff-models'
713
814import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
15+ import { useNow } from '../hooks/use-now'
916import { useFreebuffModelStore } from '../state/freebuff-model-store'
1017import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1118import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1219import { useTheme } from '../hooks/use-theme'
1320
1421import type { KeyEvent } from '@opentui/core'
1522
23+ const FREEBUFF_MODEL_SELECTOR_MODELS = [
24+ ...FREEBUFF_MODELS . filter ( ( model ) => model . id === FREEBUFF_GLM_MODEL_ID ) ,
25+ ...FREEBUFF_MODELS . filter ( ( model ) => model . id !== FREEBUFF_GLM_MODEL_ID ) ,
26+ ]
27+
1628/**
1729 * Dual-purpose model picker:
1830 * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -33,7 +45,9 @@ export const FreebuffModelSelector: React.FC = () => {
3345 const theme = useTheme ( )
3446 const { terminalWidth } = useTerminalDimensions ( )
3547 const selectedModel = useFreebuffModelStore ( ( s ) => s . selectedModel )
48+ const setSelectedModel = useFreebuffModelStore ( ( s ) => s . setSelectedModel )
3649 const session = useFreebuffSessionStore ( ( s ) => s . session )
50+ const now = useNow ( 60_000 )
3751 const [ pending , setPending ] = useState < string | null > ( null )
3852 const [ hoveredId , setHoveredId ] = useState < string | null > ( null )
3953 // Keyboard cursor — separate from the actually-selected model so that
@@ -45,6 +59,15 @@ export const FreebuffModelSelector: React.FC = () => {
4559 setFocusedId ( selectedModel )
4660 } , [ selectedModel ] )
4761
62+ useEffect ( ( ) => {
63+ if (
64+ ( session ?. status === 'none' || ! session ) &&
65+ ! isFreebuffModelAvailable ( selectedModel , new Date ( now ) )
66+ ) {
67+ setSelectedModel ( DEFAULT_FREEBUFF_MODEL_ID )
68+ }
69+ } , [ now , selectedModel , session , setSelectedModel ] )
70+
4871 // Landing ('none'): depths come from the server snapshot, no "self" to
4972 // subtract. In-queue ('queued'): for the user's queue, "ahead" is
5073 // `position - 1` (themselves don't count); for every other queue, switching
@@ -85,18 +108,22 @@ export const FreebuffModelSelector: React.FC = () => {
85108 )
86109
87110 // Decide row vs column layout based on whether both buttons actually fit
88- // side-by-side. Each button's inner text is "● {displayName} · {tagline} {hint}",
111+ // side-by-side. Each button's inner text is
112+ // "● {displayName} · {tagline} · {hours} {hint}",
89113 // plus 2 cols of border and 2 cols of padding. Buttons are separated by a
90114 // gap of 2. If the total exceeds the terminal width, stack vertically.
91115 const stackVertically = useMemo ( ( ) => {
92116 const BUTTON_CHROME = 4 // 2 border + 2 padding
93117 const GAP = 2
94- const total = FREEBUFF_MODELS . reduce ( ( sum , model , idx ) => {
118+ const total = FREEBUFF_MODEL_SELECTOR_MODELS . reduce ( ( sum , model , idx ) => {
95119 const inner =
96120 2 /* indicator + space */ +
97121 model . displayName . length +
98122 3 /* " · " */ +
99123 model . tagline . length +
124+ ( model . availability === 'deployment_hours'
125+ ? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL . length
126+ : 0 ) +
100127 2 /* " " */ +
101128 hintWidth
102129 return sum + inner + BUTTON_CHROME + ( idx > 0 ? GAP : 0 )
@@ -115,10 +142,11 @@ export const FreebuffModelSelector: React.FC = () => {
115142 ( modelId : string ) => {
116143 if ( pending ) return
117144 if ( modelId === committedModelId ) return
145+ if ( ! isFreebuffModelAvailable ( modelId , new Date ( now ) ) ) return
118146 setPending ( modelId )
119147 joinFreebuffQueue ( modelId ) . finally ( ( ) => setPending ( null ) )
120148 } ,
121- [ pending , committedModelId ] ,
149+ [ pending , committedModelId , now ] ,
122150 )
123151
124152 // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
@@ -136,25 +164,30 @@ export const FreebuffModelSelector: React.FC = () => {
136164 const isCommit = name === 'return' || name === 'enter' || name === 'space'
137165 if ( ! isForward && ! isBackward && ! isCommit ) return
138166 if ( isCommit ) {
139- if ( focusedId !== committedModelId ) {
167+ if (
168+ focusedId !== committedModelId &&
169+ isFreebuffModelAvailable ( focusedId , new Date ( now ) )
170+ ) {
140171 key . preventDefault ?.( )
141172 pick ( focusedId )
142173 }
143174 return
144175 }
145- const currentIdx = FREEBUFF_MODELS . findIndex ( ( m ) => m . id === focusedId )
176+ const currentIdx = FREEBUFF_MODEL_SELECTOR_MODELS . findIndex (
177+ ( m ) => m . id === focusedId ,
178+ )
146179 if ( currentIdx === - 1 ) return
147- const len = FREEBUFF_MODELS . length
180+ const len = FREEBUFF_MODEL_SELECTOR_MODELS . length
148181 const nextIdx = isForward
149182 ? ( currentIdx + 1 ) % len
150183 : ( currentIdx - 1 + len ) % len
151- const target = FREEBUFF_MODELS [ nextIdx ]
184+ const target = FREEBUFF_MODEL_SELECTOR_MODELS [ nextIdx ]
152185 if ( target ) {
153186 key . preventDefault ?.( )
154187 setFocusedId ( target . id )
155188 }
156189 } ,
157- [ pending , pick , focusedId , committedModelId ] ,
190+ [ pending , pick , focusedId , committedModelId , now ] ,
158191 ) ,
159192 )
160193
@@ -173,23 +206,30 @@ export const FreebuffModelSelector: React.FC = () => {
173206 alignItems : 'flex-start' ,
174207 } }
175208 >
176- { FREEBUFF_MODELS . map ( ( model ) => {
209+ { FREEBUFF_MODEL_SELECTOR_MODELS . map ( ( model ) => {
177210 // 'Selected' means the dot is filled and the label is bold. On the
178211 // landing screen ('none') this tracks the pre-focused pick; on the
179212 // queued screen it tracks the model the server has us on. Either
180213 // way, selectedModel reflects the intent of "what Enter commits to."
181214 const isSelected = model . id === selectedModel
182215 const isHovered = hoveredId === model . id
183216 const isFocused = focusedId === model . id && ! isSelected
217+ const isAvailable = isFreebuffModelAvailable ( model . id , new Date ( now ) )
184218 const indicator = isSelected ? '●' : '○'
185219 const indicatorColor = isSelected ? theme . primary : theme . muted
186- const labelColor = isSelected ? theme . foreground : theme . muted
220+ const labelColor = isSelected && isAvailable ? theme . foreground : theme . muted
187221 // Clickable whenever picking would actually do something — i.e.
188222 // anything except re-picking the queue we're already in.
189- const interactable = ! pending && model . id !== committedModelId
223+ const interactable = ! pending && isAvailable && model . id !== committedModelId
190224 const ahead = aheadByModel ?. [ model . id ]
191225 const hint =
192- ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ ahead } ahead`
226+ ! isAvailable
227+ ? 'Closed'
228+ : ahead === undefined
229+ ? ''
230+ : ahead === 0
231+ ? 'No wait'
232+ : `${ ahead } ahead`
193233
194234 const borderColor = isSelected
195235 ? theme . primary
@@ -202,7 +242,7 @@ export const FreebuffModelSelector: React.FC = () => {
202242 key = { model . id }
203243 onClick = { ( ) => {
204244 setFocusedId ( model . id )
205- pick ( model . id )
245+ if ( isAvailable ) pick ( model . id )
206246 } }
207247 onMouseOver = { ( ) => interactable && setHoveredId ( model . id ) }
208248 onMouseOut = { ( ) => setHoveredId ( ( curr ) => ( curr === model . id ? null : curr ) ) }
@@ -223,6 +263,9 @@ export const FreebuffModelSelector: React.FC = () => {
223263 { model . displayName }
224264 </ span >
225265 < span fg = { theme . muted } > · { model . tagline } </ span >
266+ { model . availability === 'deployment_hours' && (
267+ < span fg = { theme . muted } > · { FREEBUFF_DEPLOYMENT_HOURS_LABEL } </ span >
268+ ) }
226269 < span fg = { theme . muted } > { hint . padEnd ( hintWidth ) } </ span >
227270 </ text >
228271 </ Button >
0 commit comments