Skip to content

Commit cf23dc1

Browse files
committed
Clean up waiting room
1 parent a39cf94 commit cf23dc1

4 files changed

Lines changed: 174 additions & 165 deletions

File tree

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

Lines changed: 163 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,25 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
3939
* the user to the back of that queue (lose place in original). Picking the
4040
* model they're already in is a no-op.
4141
*
42-
* To prevent accidental queue loss while queued, keyboard navigation is
43-
* two-step: Tab / arrow keys move a focus highlight, and Enter commits the
44-
* switch. Mouse clicks are still one-step. On the landing screen, pressing
45-
* Enter on the already-focused model also commits — there's nothing to lose.
42+
* Keyboard navigation: Tab / arrow keys move the green highlight; Enter (or
43+
* Space) commits the focused row. Mouse click commits in one step.
4644
*
47-
* Each row shows a live "N ahead" count sourced from the server's
48-
* `queueDepthByModel` snapshot so the choice is informed.
45+
* Always stacked vertically. On narrow terminals where the longest one-line
46+
* label wouldn't fit, the secondary details (warning / deployment hours)
47+
* 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.
4953
*/
5054
export const FreebuffModelSelector: React.FC = () => {
5155
const theme = useTheme()
52-
const { terminalWidth } = useTerminalDimensions()
56+
// contentMaxWidth (not terminalWidth) is the real budget — the parent
57+
// waiting-room screen wraps this picker in a `maxWidth: contentMaxWidth`
58+
// box (capped at 80 cols), so a wide terminal doesn't actually let us
59+
// sprawl the buttons across it.
60+
const { contentMaxWidth } = useTerminalDimensions()
5361
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
5462
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
5563
const session = useFreebuffSessionStore((s) => s.session)
@@ -83,70 +91,64 @@ export const FreebuffModelSelector: React.FC = () => {
8391
}
8492
}, [now, selectedModel, session, setSelectedModel])
8593

86-
// Landing ('none'): depths come from the server snapshot, no "self" to
87-
// subtract. In-queue ('queued'): for the user's queue, "ahead" is
88-
// `position - 1` (themselves don't count); for every other queue, switching
89-
// would land them at the back, so it's that queue's full depth. Null before
90-
// any snapshot so the UI doesn't flash misleading zeros — in particular,
91-
// landing mode after a session ends initially sets status='none' with no
92-
// queueDepthByModel; returning null here keeps the hint blank until the
93-
// fetch lands, instead of showing "No wait" on every row.
94-
const aheadByModel = useMemo<Record<string, number> | null>(() => {
95-
if (session?.status === 'none') {
96-
if (!session.queueDepthByModel) return null
97-
const depths = session.queueDepthByModel
98-
const out: Record<string, number> = {}
99-
for (const { id } of FREEBUFF_MODELS) out[id] = depths[id] ?? 0
100-
return out
101-
}
102-
if (session?.status === 'queued') {
103-
const depths = session.queueDepthByModel ?? {}
104-
const out: Record<string, number> = {}
105-
for (const { id } of FREEBUFF_MODELS) {
106-
out[id] =
107-
id === session.model
108-
? Math.max(0, session.position - 1)
109-
: (depths[id] ?? 0)
94+
const BUTTON_CHROME = 4 // 2 border + 2 padding
95+
96+
// 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.
103+
const { wrapDetails, buttonOuterWidth } = useMemo(() => {
104+
const detailsTextLen = (model: FreebuffModelOption): number => {
105+
const parts: number[] = []
106+
if (model.availability === 'deployment_hours') {
107+
parts.push(deploymentAvailabilityLabel.length)
110108
}
111-
return out
109+
if (model.warning) parts.push(model.warning.length)
110+
if (parts.length === 0) return 0
111+
return parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 3 /* " · " */
112112
}
113-
return null
114-
}, [session])
115113

116-
// Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
117-
// buttons don't visibly resize when the queue depth ticks down (12 → 9) or
118-
// when the user's selection moves between queues. The tagline is shown
119-
// inline with the name now, so it's no longer part of this slot.
120-
const hintWidth = useMemo(
121-
() => Math.max('No wait'.length, '999 ahead'.length, 'Limit used'.length),
122-
[],
123-
)
124-
125-
// Decide row vs column layout based on whether the buttons actually fit
126-
// side-by-side. Each button's inner text is
127-
// "● {displayName} · {tagline} · {hours/warning} {hint}",
128-
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
129-
// gap of 2. If the total exceeds the terminal width, stack vertically.
130-
const stackVertically = useMemo(() => {
131-
const BUTTON_CHROME = 4 // 2 border + 2 padding
132-
const GAP = 2
133-
const total = FREEBUFF_MODEL_SELECTOR_MODELS.reduce((sum, model, idx) => {
134-
const inner =
114+
const oneLineLen = (model: FreebuffModelOption): number => {
115+
const inlineDetails = detailsTextLen(model)
116+
return (
135117
2 /* indicator + space */ +
136118
model.displayName.length +
137119
3 /* " · " */ +
138120
model.tagline.length +
139-
(model.availability === 'deployment_hours'
140-
? 3 + deploymentAvailabilityLabel.length
141-
: 0) +
142-
(model.warning ? 3 + model.warning.length : 0) +
143-
2 /* " " */ +
144-
hintWidth
145-
return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0)
146-
}, 0)
147-
// Leave a small margin for the surrounding padding on the waiting-room screen.
148-
return total > terminalWidth - 4
149-
}, [deploymentAvailabilityLabel, hintWidth, terminalWidth])
121+
(inlineDetails > 0 ? 3 + inlineDetails : 0)
122+
)
123+
}
124+
125+
const labelLineLen = (model: FreebuffModelOption): number =>
126+
2 + model.displayName.length + 3 + model.tagline.length
127+
128+
const detailsLineLen = (model: FreebuffModelOption): number => {
129+
const len = detailsTextLen(model)
130+
return len === 0 ? 0 : 2 /* indent */ + len
131+
}
132+
133+
const maxOneLineOuter =
134+
Math.max(...FREEBUFF_MODEL_SELECTOR_MODELS.map(oneLineLen)) +
135+
BUTTON_CHROME
136+
if (maxOneLineOuter <= contentMaxWidth) {
137+
return { wrapDetails: false, buttonOuterWidth: maxOneLineOuter }
138+
}
139+
const maxTwoLineInner = Math.max(
140+
...FREEBUFF_MODEL_SELECTOR_MODELS.map((m) =>
141+
Math.max(labelLineLen(m), detailsLineLen(m)),
142+
),
143+
)
144+
return {
145+
wrapDetails: true,
146+
buttonOuterWidth: Math.min(
147+
maxTwoLineInner + BUTTON_CHROME,
148+
contentMaxWidth,
149+
),
150+
}
151+
}, [contentMaxWidth, deploymentAvailabilityLabel])
150152

151153
// "Already committed to this model" — only when the server has us queued
152154
// on it. On the landing screen (status 'none'), nothing is committed yet,
@@ -177,8 +179,8 @@ export const FreebuffModelSelector: React.FC = () => {
177179
)
178180

179181
// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
180-
// Space commits the switch. Two-step navigation prevents the user from
181-
// accidentally giving up their place in line by tabbing past their queue.
182+
// Space commits the focused row. Two-step navigation lets the user preview
183+
// the highlight before committing.
182184
useKeyboard(
183185
useCallback(
184186
(key: KeyEvent) => {
@@ -220,103 +222,109 @@ export const FreebuffModelSelector: React.FC = () => {
220222
gap: 0,
221223
}}
222224
>
223-
<box
224-
style={{
225-
flexDirection: stackVertically ? 'column' : 'row',
226-
gap: stackVertically ? 0 : 2,
227-
alignItems: 'flex-start',
228-
}}
229-
>
230-
{FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => {
231-
// 'Selected' means the dot is filled and the label is bold. On the
232-
// landing screen ('none') this tracks the pre-focused pick; on the
233-
// queued screen it tracks the model the server has us on. Either
234-
// way, selectedModel marks the user's current preference even if
235-
// focus has moved to a different row.
236-
const isSelected = model.id === selectedModel
237-
const isHovered = hoveredId === model.id
238-
const isFocused = focusedId === model.id && !isSelected
239-
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
240-
const rateLimit = rateLimitsByModel?.[model.id]
241-
const isQuotaExhausted =
242-
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
243-
const canJoin = isAvailable && !isQuotaExhausted
244-
const indicator = isSelected ? '●' : isFocused ? '›' : '○'
245-
const indicatorColor = isSelected
246-
? theme.primary
247-
: isFocused
248-
? theme.foreground
249-
: theme.muted
250-
const labelColor =
251-
(isSelected || isFocused) && canJoin
252-
? theme.foreground
253-
: theme.muted
254-
// Clickable whenever picking would actually do something — i.e.
255-
// anything except re-picking the queue we're already in.
256-
const interactable =
257-
!pending && canJoin && model.id !== committedModelId
258-
const ahead = aheadByModel?.[model.id]
259-
const hint = !isAvailable
260-
? 'Closed'
261-
: isQuotaExhausted
262-
? 'Limit used'
263-
: ahead === undefined
264-
? ''
265-
: ahead === 0
266-
? 'No wait'
267-
: `${ahead} ahead`
268-
const hintColor = canJoin ? theme.muted : theme.secondary
225+
{FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => {
226+
// Single visual state: the focused row IS the highlight. The user's
227+
// saved/committed pick is not shown separately — it just sets where
228+
// focus lands when the picker opens. Pressing Enter on the focused
229+
// row commits it.
230+
const isHovered = hoveredId === model.id
231+
const isFocused = focusedId === model.id
232+
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
237+
// Clickable whenever picking would actually do something — i.e.
238+
// anything except re-picking the queue we're already in.
239+
const interactable =
240+
!pending && canJoin && model.id !== committedModelId
241+
const hint = !isAvailable
242+
? 'Closed'
243+
: isQuotaExhausted
244+
? 'Limit used'
245+
: ''
246+
247+
// Focused row: green border + green name to tie back to the border.
248+
// The rest of the row keeps the normal muted/secondary palette so
249+
// the highlight stays subtle. Off-focus rows are entirely default.
250+
const indicator = isFocused ? '›' : ' '
251+
const fgColor = isFocused
252+
? theme.primary
253+
: canJoin
254+
? theme.foreground
255+
: theme.muted
256+
const mutedColor = theme.muted
257+
const warningColor = theme.secondary
258+
const hintColor = theme.secondary
259+
260+
const borderColor = isFocused
261+
? theme.primary
262+
: isHovered
263+
? theme.foreground
264+
: theme.border
269265

270-
const borderColor = isSelected
271-
? theme.primary
272-
: isFocused || isHovered
273-
? theme.foreground
274-
: theme.border
266+
const showInlineHours =
267+
!wrapDetails && model.availability === 'deployment_hours'
268+
const showInlineWarning = !wrapDetails && !!model.warning
269+
const showWrappedDetails =
270+
wrapDetails &&
271+
(model.availability === 'deployment_hours' || !!model.warning)
275272

276-
return (
277-
<Button
278-
key={model.id}
279-
onClick={() => {
280-
setFocusedId(model.id)
281-
if (canJoin) pick(model.id)
282-
}}
283-
onMouseOver={() => interactable && setHoveredId(model.id)}
284-
onMouseOut={() =>
285-
setHoveredId((curr) => (curr === model.id ? null : curr))
286-
}
287-
style={{
288-
borderStyle: 'single',
289-
borderColor,
290-
paddingLeft: 1,
291-
paddingRight: 1,
292-
}}
293-
border={['top', 'bottom', 'left', 'right']}
294-
>
273+
return (
274+
<Button
275+
key={model.id}
276+
onClick={() => {
277+
setFocusedId(model.id)
278+
if (canJoin) pick(model.id)
279+
}}
280+
onMouseOver={() => interactable && setHoveredId(model.id)}
281+
onMouseOut={() =>
282+
setHoveredId((curr) => (curr === model.id ? null : curr))
283+
}
284+
style={{
285+
borderStyle: 'single',
286+
borderColor,
287+
paddingLeft: 1,
288+
paddingRight: 1,
289+
width: buttonOuterWidth,
290+
}}
291+
border={['top', 'bottom', 'left', 'right']}
292+
>
293+
<text>
294+
<span fg={fgColor}>{indicator} </span>
295+
<span
296+
fg={fgColor}
297+
attributes={
298+
isFocused ? TextAttributes.BOLD : TextAttributes.NONE
299+
}
300+
>
301+
{model.displayName}
302+
</span>
303+
<span fg={mutedColor}> · {model.tagline}</span>
304+
{showInlineHours && (
305+
<span fg={mutedColor}> · {deploymentAvailabilityLabel}</span>
306+
)}
307+
{showInlineWarning && (
308+
<span fg={warningColor}> · {model.warning}</span>
309+
)}
310+
{hint && <span fg={hintColor}> {hint}</span>}
311+
</text>
312+
{showWrappedDetails && (
295313
<text>
296-
<span fg={indicatorColor}>{indicator} </span>
297-
<span
298-
fg={labelColor}
299-
attributes={
300-
isSelected || isFocused
301-
? TextAttributes.BOLD
302-
: TextAttributes.NONE
303-
}
304-
>
305-
{model.displayName}
306-
</span>
307-
<span fg={theme.muted}> · {model.tagline}</span>
314+
<span> </span>
308315
{model.availability === 'deployment_hours' && (
309-
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
316+
<span fg={mutedColor}>{deploymentAvailabilityLabel}</span>
310317
)}
318+
{model.availability === 'deployment_hours' &&
319+
model.warning && <span fg={mutedColor}> · </span>}
311320
{model.warning && (
312-
<span fg={theme.secondary}> · {model.warning}</span>
321+
<span fg={warningColor}>{model.warning}</span>
313322
)}
314-
<span fg={hintColor}> {hint.padEnd(hintWidth)}</span>
315323
</text>
316-
</Button>
317-
)
318-
})}
319-
</box>
324+
)}
325+
</Button>
326+
)
327+
})}
320328
</box>
321329
)
322330
}

cli/src/components/waiting-room-screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
138138

139139
const isQueued = session?.status === 'queued'
140140
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
141-
// state: show the picker with live N-ahead hints and a prompt. Picking a
141+
// state: show the picker with live N-in-line hints and a prompt. Picking a
142142
// model triggers joinFreebuffQueue, which POSTs and transitions us to
143143
// 'queued' (waiting room) or straight to 'active' (chat) if no wait.
144144
const isLanding = session?.status === 'none'

common/src/constants/freebuff-models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const FREEBUFF_MODELS = [
7272
{
7373
id: FREEBUFF_KIMI_MODEL_ID,
7474
displayName: 'Kimi K2.6',
75-
tagline: 'Smart',
75+
tagline: 'Balanced',
7676
availability: 'always',
7777
},
7878
{

0 commit comments

Comments
 (0)