Skip to content

Commit e8fd2c8

Browse files
committed
On session end, go back to model selection screen
1 parent 950b2b4 commit e8fd2c8

2 files changed

Lines changed: 124 additions & 66 deletions

File tree

cli/src/components/session-ended-banner.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useKeyboard } from '@opentui/react'
33
import React, { useCallback, useState } from 'react'
44

55
import { Button } from './button'
6-
import { refreshFreebuffSession } from '../hooks/use-freebuff-session'
6+
import { returnToFreebuffLanding } from '../hooks/use-freebuff-session'
77
import { useTheme } from '../hooks/use-theme'
88
import { BORDER_CHARS } from '../utils/ui-constants'
99

@@ -35,10 +35,14 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
3535
const rejoin = useCallback(() => {
3636
if (!canRejoin) return
3737
setRejoining(true)
38-
// Once the POST lands, the hook flips status to 'queued' and app.tsx
39-
// swaps us into <WaitingRoomScreen>, unmounting this banner. No need to
40-
// clear `rejoining` on success — the component will be gone.
41-
refreshFreebuffSession({ resetChat: true }).catch(() => setRejoining(false))
38+
// Drop back to the landing picker (status: 'none') so the user picks a
39+
// model and hits Enter again to commit, instead of being silently
40+
// re-queued. app.tsx swaps us into <WaitingRoomScreen> on the
41+
// transition, unmounting this banner — no need to clear `rejoining` on
42+
// success.
43+
returnToFreebuffLanding({ resetChat: true }).catch(() =>
44+
setRejoining(false),
45+
)
4246
}, [canRejoin])
4347

4448
useKeyboard(

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

Lines changed: 115 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,20 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
124124
// --- Poll-loop control surface ---------------------------------------------
125125
//
126126
// The hook below registers a controller object here on mount; module-level
127-
// imperative functions (refresh / mark superseded / mark ended / etc.) talk
127+
// imperative functions (restart / mark superseded / mark ended / etc.) talk
128128
// to it without going through React. Non-React callers (chat-completions
129129
// gate, exit paths) hit those functions directly.
130130

131+
/** How the next tick should behave after a forced restart.
132+
* - 'rejoin' → POST: claim/rotate a seat (used after explicit end-and-rejoin
133+
* or when the chat gate kicks us back to the queue).
134+
* - 'landing' → GET: drop to the model-picker (status 'none') so the user
135+
* reconfirms a model before rejoining. */
136+
type RestartMode = 'rejoin' | 'landing'
137+
131138
interface PollController {
132-
refresh: () => Promise<void>
139+
/** Cancel the in-flight tick + timer and start a fresh one in `mode`. */
140+
restart: (mode: RestartMode) => Promise<void>
133141
apply: (next: FreebuffSessionResponse) => void
134142
abort: () => void
135143
}
@@ -152,18 +160,88 @@ export function getFreebuffInstanceId(): string | undefined {
152160
}
153161
}
154162

163+
/** True when the session row represents a server-side slot the caller is
164+
* holding (queued, active, or in the post-expiry grace window with a live
165+
* instance id). DELETE only matters in those states; otherwise we'd fire a
166+
* spurious request the server has nothing to act on. */
167+
function shouldReleaseSlot(
168+
current: FreebuffSessionResponse | null,
169+
): boolean {
170+
if (!current) return false
171+
return (
172+
current.status === 'queued' ||
173+
current.status === 'active' ||
174+
(current.status === 'ended' && Boolean(current.instanceId))
175+
)
176+
}
177+
178+
/** Best-effort DELETE of the caller's session row, gated on actually holding
179+
* one. Used both by exit paths and any flow that wants the next POST to
180+
* start clean (rejoin, return-to-landing). Always swallows errors — the
181+
* server-side sweep is the backstop. */
182+
async function releaseFreebuffSlot(): Promise<void> {
183+
const current = useFreebuffSessionStore.getState().session
184+
if (!shouldReleaseSlot(current)) return
185+
const { token } = getAuthTokenDetails()
186+
if (!token) return
187+
try {
188+
await callSession('DELETE', token)
189+
} catch {
190+
// swallow
191+
}
192+
}
193+
194+
async function resetChatStore(): Promise<void> {
195+
const { useChatStore } = await import('../state/chat-store')
196+
useChatStore.getState().reset()
197+
}
198+
199+
interface RestartOpts {
200+
resetChat?: boolean
201+
/** DELETE the held slot before restarting so the next POST starts clean. */
202+
releaseSlot?: boolean
203+
}
204+
205+
async function restartFreebuffSession(
206+
mode: RestartMode,
207+
opts: RestartOpts = {},
208+
): Promise<void> {
209+
if (!IS_FREEBUFF) return
210+
// Halt the running poll loop before we touch local stores or DELETE the
211+
// slot. Otherwise an in-flight GET could land mid-reset and overwrite
212+
// state, or the next scheduled tick could fire between DELETE and
213+
// restart() with stale assumptions. restart() re-aborts and re-arms
214+
// below; the extra abort here is cheap.
215+
controller?.abort()
216+
if (opts.resetChat) await resetChatStore()
217+
if (opts.releaseSlot) await releaseFreebuffSlot()
218+
await controller?.restart(mode)
219+
}
220+
155221
/**
156222
* Re-POST to the server (rejoining the queue / rotating the instance id).
157223
* Pass `resetChat: true` to also wipe local chat history — used when
158224
* rejoining after a session ended so the next admitted session starts fresh.
159225
*/
160-
export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {}): Promise<void> {
161-
if (!IS_FREEBUFF) return
162-
if (opts.resetChat) {
163-
const { useChatStore } = await import('../state/chat-store')
164-
useChatStore.getState().reset()
165-
}
166-
await controller?.refresh()
226+
export function refreshFreebuffSession(
227+
opts: { resetChat?: boolean } = {},
228+
): Promise<void> {
229+
return restartFreebuffSession('rejoin', { resetChat: opts.resetChat })
230+
}
231+
232+
/**
233+
* Drop back to the pre-join landing state (model picker) instead of auto
234+
* re-queuing. Used after a session ends: the user lands on the picker so
235+
* they consciously choose a model and hit Enter to join, rather than being
236+
* silently re-queued for whatever model they last used.
237+
*/
238+
export function returnToFreebuffLanding(
239+
opts: { resetChat?: boolean } = {},
240+
): Promise<void> {
241+
return restartFreebuffSession('landing', {
242+
resetChat: opts.resetChat,
243+
releaseSlot: true,
244+
})
167245
}
168246

169247
/**
@@ -178,31 +256,29 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {})
178256
* the locked model so the active session stays intact. Users who really want
179257
* to switch can /end-session deliberately.
180258
*/
181-
export async function joinFreebuffQueue(model: string): Promise<void> {
182-
if (!IS_FREEBUFF) return
183-
const { setSelectedModel } = useFreebuffModelStore.getState()
184-
setSelectedModel(model)
185-
await controller?.refresh()
259+
export function joinFreebuffQueue(model: string): Promise<void> {
260+
if (!IS_FREEBUFF) return Promise.resolve()
261+
useFreebuffModelStore.getState().setSelectedModel(model)
262+
return restartFreebuffSession('rejoin')
186263
}
187264

188265
/**
189266
* End the current session and immediately rejoin the queue. Used by the
190267
* "switch model" confirmation flow when the server returned `model_locked`,
191268
* and by any UI that lets the user exit an active session early.
192269
*/
193-
export async function endAndRejoinFreebuffSession(): Promise<void> {
270+
export function endAndRejoinFreebuffSession(): Promise<void> {
271+
return restartFreebuffSession('rejoin', { resetChat: true, releaseSlot: true })
272+
}
273+
274+
/**
275+
* Best-effort DELETE of the caller's session row. Used by exit paths that
276+
* skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly
277+
* instead of waiting for the server-side expiry sweep.
278+
*/
279+
export async function endFreebuffSessionBestEffort(): Promise<void> {
194280
if (!IS_FREEBUFF) return
195-
const { token } = getAuthTokenDetails()
196-
if (!token) return
197-
try {
198-
await callSession('DELETE', token)
199-
} catch {
200-
// Best-effort — even if DELETE fails the re-POST below will eventually
201-
// succeed once the server-side sweep catches up.
202-
}
203-
const { useChatStore } = await import('../state/chat-store')
204-
useChatStore.getState().reset()
205-
await controller?.refresh()
281+
await releaseFreebuffSlot()
206282
}
207283

208284
export function markFreebuffSessionSuperseded(): void {
@@ -219,39 +295,6 @@ export function markFreebuffSessionEnded(): void {
219295
controller?.apply({ status: 'ended' })
220296
}
221297

222-
/** True when the session row represents a server-side slot the caller is
223-
* holding (queued, active, or in the post-expiry grace window with a live
224-
* instance id). DELETE only matters in those states; otherwise we'd fire a
225-
* spurious request the server has nothing to act on. */
226-
function shouldReleaseSlot(
227-
current: FreebuffSessionResponse | null,
228-
): boolean {
229-
if (!current) return false
230-
return (
231-
current.status === 'queued' ||
232-
current.status === 'active' ||
233-
(current.status === 'ended' && Boolean(current.instanceId))
234-
)
235-
}
236-
237-
/**
238-
* Best-effort DELETE of the caller's session row. Used by exit paths that
239-
* skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly
240-
* instead of waiting for the server-side expiry sweep.
241-
*/
242-
export async function endFreebuffSessionBestEffort(): Promise<void> {
243-
if (!IS_FREEBUFF) return
244-
const current = useFreebuffSessionStore.getState().session
245-
if (!shouldReleaseSlot(current)) return
246-
const { token } = getAuthTokenDetails()
247-
if (!token) return
248-
try {
249-
await callSession('DELETE', token)
250-
} catch {
251-
// swallow — we're exiting
252-
}
253-
}
254-
255298
interface UseFreebuffSessionResult {
256299
session: FreebuffSessionResponse | null
257300
error: string | null
@@ -394,14 +437,25 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
394437
}
395438

396439
controller = {
397-
refresh: async () => {
440+
restart: async (mode) => {
398441
clearTimer()
399442
// Abort any in-flight fetch so it can't race us and overwrite state.
400443
abortController.abort()
401444
abortController = new AbortController()
402445
// Reset previousStatus so the queued→active bell still fires after
403-
// a forced re-POST.
446+
// a forced restart, and so the active|ended → none synthesis below
447+
// doesn't bounce a 'landing' restart straight back to 'ended'.
404448
previousStatus = null
449+
if (mode === 'landing') {
450+
// Land on the picker without a probe GET. If the preceding
451+
// DELETE hasn't propagated, a GET here could still see
452+
// queued/active and trip the startup-takeover branch below into
453+
// an auto-POST — the exact silent-rejoin this mode exists to
454+
// avoid. Polling resumes when the user commits to a model via
455+
// joinFreebuffQueue.
456+
apply({ status: 'none' })
457+
return
458+
}
405459
nextMethod = 'POST'
406460
await tick()
407461
},

0 commit comments

Comments
 (0)