@@ -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+
131138interface 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
208284export 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-
255298interface 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