@@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
55import { Button } from './button'
66import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
77
8- import { switchFreebuffModel } from '../hooks/use-freebuff-session'
8+ import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
99import { useFreebuffModelStore } from '../state/freebuff-model-store'
1010import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1111import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
@@ -14,17 +14,20 @@ import { useTheme } from '../hooks/use-theme'
1414import type { KeyEvent } from '@opentui/core'
1515
1616/**
17- * Lets the user pick which model's queue they're in. Switching triggers a
18- * re-POST: the server moves them to the back of the new model's queue, which
19- * means switching is *not free* — they lose their place in the original line.
17+ * Dual-purpose model picker:
18+ * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
19+ * a model is their explicit commitment to enter — this triggers the POST.
20+ * - In-queue switcher (session 'queued'): picking a *different* model moves
21+ * the user to the back of that queue (lose place in original). Picking the
22+ * model they're already in is a no-op.
2023 *
21- * To prevent accidental queue loss, keyboard navigation is two-step: Tab /
22- * arrow keys move a focus highlight, and Enter commits the switch. Mouse
23- * clicks are still one-step (the click target is intentional).
24+ * To prevent accidental queue loss while queued, keyboard navigation is
25+ * two-step: Tab / arrow keys move a focus highlight, and Enter commits the
26+ * switch. Mouse clicks are still one-step. On the landing screen, pressing
27+ * Enter on the already-focused model also commits — there's nothing to lose.
2428 *
2529 * Each row shows a live "N ahead" count sourced from the server's
26- * `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
27- * "12 ahead") rather than a blind preference toggle.
30+ * `queueDepthByModel` snapshot so the choice is informed.
2831 */
2932export const FreebuffModelSelector : React . FC = ( ) => {
3033 const theme = useTheme ( )
@@ -42,19 +45,30 @@ export const FreebuffModelSelector: React.FC = () => {
4245 setFocusedId ( selectedModel )
4346 } , [ selectedModel ] )
4447
45- // For the user's current queue, "ahead" is `position - 1` (themselves don't
46- // count). For every other queue, switching would land them at the back, so
47- // it's that queue's full depth. Null before the first queued snapshot so
48- // the UI doesn't flash misleading zeros.
48+ // Landing ('none'): depths come from the server snapshot, no "self" to
49+ // subtract. In-queue ('queued'): for the user's queue, "ahead" is
50+ // `position - 1` (themselves don't count); for every other queue, switching
51+ // would land them at the back, so it's that queue's full depth. Null before
52+ // any snapshot so the UI doesn't flash misleading zeros.
4953 const aheadByModel = useMemo < Record < string , number > | null > ( ( ) => {
50- if ( session ?. status !== 'queued' ) return null
51- const depths = session . queueDepthByModel ?? { }
52- const out : Record < string , number > = { }
53- for ( const { id } of FREEBUFF_MODELS ) {
54- out [ id ] =
55- id === session . model ? Math . max ( 0 , session . position - 1 ) : depths [ id ] ?? 0
54+ if ( session ?. status === 'none' ) {
55+ const depths = session . queueDepthByModel ?? { }
56+ const out : Record < string , number > = { }
57+ for ( const { id } of FREEBUFF_MODELS ) out [ id ] = depths [ id ] ?? 0
58+ return out
5659 }
57- return out
60+ if ( session ?. status === 'queued' ) {
61+ const depths = session . queueDepthByModel ?? { }
62+ const out : Record < string , number > = { }
63+ for ( const { id } of FREEBUFF_MODELS ) {
64+ out [ id ] =
65+ id === session . model
66+ ? Math . max ( 0 , session . position - 1 )
67+ : depths [ id ] ?? 0
68+ }
69+ return out
70+ }
71+ return null
5872 } , [ session ] )
5973
6074 // Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
@@ -87,14 +101,20 @@ export const FreebuffModelSelector: React.FC = () => {
87101 return total > terminalWidth - 4
88102 } , [ hintWidth , terminalWidth ] )
89103
104+ // "Already committed to this model" — only when the server has us queued
105+ // on it. On the landing screen (status 'none'), nothing is committed yet,
106+ // so picking the focused model is always a real action (first join).
107+ const committedModelId =
108+ session ?. status === 'queued' ? session . model : null
109+
90110 const pick = useCallback (
91111 ( modelId : string ) => {
92112 if ( pending ) return
93- if ( modelId === selectedModel ) return
113+ if ( modelId === committedModelId ) return
94114 setPending ( modelId )
95- switchFreebuffModel ( modelId ) . finally ( ( ) => setPending ( null ) )
115+ joinFreebuffQueue ( modelId ) . finally ( ( ) => setPending ( null ) )
96116 } ,
97- [ pending , selectedModel ] ,
117+ [ pending , committedModelId ] ,
98118 )
99119
100120 // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
@@ -112,7 +132,7 @@ export const FreebuffModelSelector: React.FC = () => {
112132 const isCommit = name === 'return' || name === 'enter' || name === 'space'
113133 if ( ! isForward && ! isBackward && ! isCommit ) return
114134 if ( isCommit ) {
115- if ( focusedId !== selectedModel ) {
135+ if ( focusedId !== committedModelId ) {
116136 key . preventDefault ?.( )
117137 pick ( focusedId )
118138 }
@@ -130,7 +150,7 @@ export const FreebuffModelSelector: React.FC = () => {
130150 setFocusedId ( target . id )
131151 }
132152 } ,
133- [ pending , pick , focusedId , selectedModel ] ,
153+ [ pending , pick , focusedId , committedModelId ] ,
134154 ) ,
135155 )
136156
@@ -150,13 +170,19 @@ export const FreebuffModelSelector: React.FC = () => {
150170 } }
151171 >
152172 { FREEBUFF_MODELS . map ( ( model ) => {
173+ // 'Selected' means the dot is filled and the label is bold. On the
174+ // landing screen ('none') this tracks the pre-focused pick; on the
175+ // queued screen it tracks the model the server has us on. Either
176+ // way, selectedModel reflects the intent of "what Enter commits to."
153177 const isSelected = model . id === selectedModel
154178 const isHovered = hoveredId === model . id
155179 const isFocused = focusedId === model . id && ! isSelected
156180 const indicator = isSelected ? '●' : '○'
157181 const indicatorColor = isSelected ? theme . primary : theme . muted
158182 const labelColor = isSelected ? theme . foreground : theme . muted
159- const interactable = ! pending && ! isSelected
183+ // Clickable whenever picking would actually do something — i.e.
184+ // anything except re-picking the queue we're already in.
185+ const interactable = ! pending && model . id !== committedModelId
160186 const ahead = aheadByModel ?. [ model . id ]
161187 const hint =
162188 ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ ahead } ahead`
0 commit comments