Skip to content

Commit 7c283c7

Browse files
committed
Fix freebuff model picker enter
1 parent fc9a76d commit 7c283c7

5 files changed

Lines changed: 165 additions & 20 deletions

File tree

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

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { useFreebuffModelStore } from '../state/freebuff-model-store'
1717
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1818
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1919
import { useTheme } from '../hooks/use-theme'
20+
import {
21+
nextSelectableFreebuffModelId,
22+
resolveFreebuffModelCommitTarget,
23+
} from '../utils/freebuff-model-navigation'
2024

2125
import type { KeyEvent } from '@opentui/core'
2226

@@ -169,30 +173,32 @@ export const FreebuffModelSelector: React.FC = () => {
169173
const isCommit = name === 'return' || name === 'enter' || name === 'space'
170174
if (!isForward && !isBackward && !isCommit) return
171175
if (isCommit) {
172-
if (
173-
focusedId !== committedModelId &&
174-
isFreebuffModelAvailable(focusedId, new Date(now))
175-
) {
176+
const targetId = resolveFreebuffModelCommitTarget({
177+
focusedId,
178+
selectedId: selectedModel,
179+
committedId: committedModelId,
180+
isSelectable: (modelId) =>
181+
isFreebuffModelAvailable(modelId, new Date(now)),
182+
})
183+
if (targetId) {
176184
key.preventDefault?.()
177-
pick(focusedId)
185+
pick(targetId)
178186
}
179187
return
180188
}
181-
const currentIdx = FREEBUFF_MODEL_SELECTOR_MODELS.findIndex(
182-
(m) => m.id === focusedId,
183-
)
184-
if (currentIdx === -1) return
185-
const len = FREEBUFF_MODEL_SELECTOR_MODELS.length
186-
const nextIdx = isForward
187-
? (currentIdx + 1) % len
188-
: (currentIdx - 1 + len) % len
189-
const target = FREEBUFF_MODEL_SELECTOR_MODELS[nextIdx]
190-
if (target) {
189+
const targetId = nextSelectableFreebuffModelId({
190+
modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id),
191+
focusedId,
192+
direction: isForward ? 'forward' : 'backward',
193+
isSelectable: (modelId) =>
194+
isFreebuffModelAvailable(modelId, new Date(now)),
195+
})
196+
if (targetId) {
191197
key.preventDefault?.()
192-
setFocusedId(target.id)
198+
setFocusedId(targetId)
193199
}
194200
},
195-
[pending, pick, focusedId, committedModelId, now],
201+
[pending, pick, focusedId, selectedModel, committedModelId, now],
196202
),
197203
)
198204

@@ -215,7 +221,8 @@ export const FreebuffModelSelector: React.FC = () => {
215221
// 'Selected' means the dot is filled and the label is bold. On the
216222
// landing screen ('none') this tracks the pre-focused pick; on the
217223
// queued screen it tracks the model the server has us on. Either
218-
// way, selectedModel reflects the intent of "what Enter commits to."
224+
// way, selectedModel is the safe fallback if focus ever lands on a
225+
// closed row (for example when deployment hours change).
219226
const isSelected = model.id === selectedModel
220227
const isHovered = hoveredId === model.id
221228
const isFocused = focusedId === model.id && !isSelected

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
173173
maxWidth: contentMaxWidth,
174174
}}
175175
>
176-
{error && !session && (
176+
{error && (!session || session.status === 'none') && (
177177
<text style={{ fg: theme.secondary, wrapMode: 'word' }}>
178178
{error}
179179
</text>

cli/src/hooks/use-freebuff-session.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
376376
let abortController = new AbortController()
377377
let timer: ReturnType<typeof setTimeout> | null = null
378378
let previousStatus: FreebuffSessionResponse['status'] | null = null
379+
let restartGeneration = 0
379380
// Method for the NEXT tick. GET is read-only; POST claims/rotates a seat.
380381
// Startup is GET (probe before committing). After any POST completes we
381382
// flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin;
@@ -489,6 +490,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
489490

490491
controller = {
491492
restart: async (mode) => {
493+
const generation = ++restartGeneration
492494
clearTimer()
493495
// Abort any in-flight fetch so it can't race us and overwrite state.
494496
abortController.abort()
@@ -498,6 +500,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
498500
// doesn't bounce a 'landing' restart straight back to 'ended'.
499501
previousStatus = null
500502
if (mode === 'landing') {
503+
nextMethod = 'GET'
501504
// Land on the picker immediately. We can't go through the normal
502505
// tick/apply path because a server-side row that hasn't been
503506
// swept yet would trip the startup-takeover branch into an
@@ -511,7 +514,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
511514
const fetchController = abortController
512515
callSession('GET', token, { signal: fetchController.signal })
513516
.then((response) => {
514-
if (cancelled || fetchController.signal.aborted) return
517+
if (
518+
cancelled ||
519+
fetchController.signal.aborted ||
520+
generation !== restartGeneration
521+
) {
522+
return
523+
}
515524
const depths =
516525
response.status === 'none' || response.status === 'queued'
517526
? response.queueDepthByModel
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
nextSelectableFreebuffModelId,
5+
resolveFreebuffModelCommitTarget,
6+
} from '../freebuff-model-navigation'
7+
8+
describe('nextSelectableFreebuffModelId', () => {
9+
test('skips unavailable models when moving forward', () => {
10+
const modelIds = ['glm', 'minimax']
11+
12+
expect(
13+
nextSelectableFreebuffModelId({
14+
modelIds,
15+
focusedId: 'minimax',
16+
direction: 'forward',
17+
isSelectable: (id) => id !== 'glm',
18+
}),
19+
).toBe('minimax')
20+
})
21+
22+
test('skips unavailable models when moving backward', () => {
23+
const modelIds = ['glm', 'minimax']
24+
25+
expect(
26+
nextSelectableFreebuffModelId({
27+
modelIds,
28+
focusedId: 'minimax',
29+
direction: 'backward',
30+
isSelectable: (id) => id !== 'glm',
31+
}),
32+
).toBe('minimax')
33+
})
34+
35+
test('moves to the next available model when more than one is selectable', () => {
36+
const modelIds = ['glm', 'minimax', 'other']
37+
38+
expect(
39+
nextSelectableFreebuffModelId({
40+
modelIds,
41+
focusedId: 'minimax',
42+
direction: 'forward',
43+
isSelectable: (id) => id !== 'glm',
44+
}),
45+
).toBe('other')
46+
})
47+
48+
test('returns null when no selectable model exists', () => {
49+
expect(
50+
nextSelectableFreebuffModelId({
51+
modelIds: ['glm'],
52+
focusedId: 'glm',
53+
direction: 'forward',
54+
isSelectable: () => false,
55+
}),
56+
).toBeNull()
57+
})
58+
})
59+
60+
describe('resolveFreebuffModelCommitTarget', () => {
61+
test('falls back to the selected model when focus is on a closed model', () => {
62+
expect(
63+
resolveFreebuffModelCommitTarget({
64+
focusedId: 'glm',
65+
selectedId: 'minimax',
66+
committedId: null,
67+
isSelectable: (id) => id !== 'glm',
68+
}),
69+
).toBe('minimax')
70+
})
71+
72+
test('commits the focused model when it is selectable', () => {
73+
expect(
74+
resolveFreebuffModelCommitTarget({
75+
focusedId: 'minimax',
76+
selectedId: 'glm',
77+
committedId: null,
78+
isSelectable: (id) => id === 'minimax',
79+
}),
80+
).toBe('minimax')
81+
})
82+
83+
test('returns null when the target is already committed', () => {
84+
expect(
85+
resolveFreebuffModelCommitTarget({
86+
focusedId: 'minimax',
87+
selectedId: 'minimax',
88+
committedId: 'minimax',
89+
isSelectable: () => true,
90+
}),
91+
).toBeNull()
92+
})
93+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export function nextSelectableFreebuffModelId(params: {
2+
modelIds: readonly string[]
3+
focusedId: string
4+
direction: 'forward' | 'backward'
5+
isSelectable: (modelId: string) => boolean
6+
}): string | null {
7+
const { modelIds, focusedId, direction, isSelectable } = params
8+
if (modelIds.length === 0) return null
9+
10+
const currentIdx = modelIds.indexOf(focusedId)
11+
if (currentIdx === -1) return null
12+
13+
const step = direction === 'forward' ? 1 : -1
14+
for (let offset = 1; offset <= modelIds.length; offset++) {
15+
const idx =
16+
(currentIdx + step * offset + modelIds.length) % modelIds.length
17+
const candidate = modelIds[idx]
18+
if (isSelectable(candidate)) return candidate
19+
}
20+
21+
return null
22+
}
23+
24+
export function resolveFreebuffModelCommitTarget(params: {
25+
focusedId: string
26+
selectedId: string
27+
committedId: string | null
28+
isSelectable: (modelId: string) => boolean
29+
}): string | null {
30+
const { focusedId, selectedId, committedId, isSelectable } = params
31+
const targetId = isSelectable(focusedId) ? focusedId : selectedId
32+
33+
if (targetId === committedId) return null
34+
if (!isSelectable(targetId)) return null
35+
return targetId
36+
}

0 commit comments

Comments
 (0)