11import { env } from '@codebuff/common/env'
22import { useEffect } from 'react'
33
4+ import {
5+ getSelectedFreebuffModel ,
6+ useFreebuffModelStore ,
7+ } from '../state/freebuff-model-store'
48import { useFreebuffSessionStore } from '../state/freebuff-session-store'
59import { getAuthTokenDetails } from '../utils/auth'
610import { IS_FREEBUFF } from '../utils/constants'
@@ -16,6 +20,11 @@ const POLL_INTERVAL_ERROR_MS = 10_000
1620 * account has rotated the id and respond with `{ status: 'superseded' }`. */
1721const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
1822
23+ /** Header sent on POST/GET telling the server which model's queue we want.
24+ * POST uses it to (re-)join that model's queue; GET uses it only for the
25+ * rare GET-before-POST edge where there's no row yet. */
26+ const FREEBUFF_MODEL_HEADER = 'x-freebuff-model'
27+
1928/** Play the terminal bell so users get an audible notification on admission. */
2029const playAdmissionSound = ( ) => {
2130 try {
@@ -33,12 +42,15 @@ const sessionEndpoint = (): string => {
3342async function callSession (
3443 method : 'POST' | 'GET' | 'DELETE' ,
3544 token : string ,
36- opts : { instanceId ?: string ; signal ?: AbortSignal } = { } ,
45+ opts : { instanceId ?: string ; model ?: string ; signal ?: AbortSignal } = { } ,
3746) : Promise < FreebuffSessionResponse > {
3847 const headers : Record < string , string > = { Authorization : `Bearer ${ token } ` }
3948 if ( method === 'GET' && opts . instanceId ) {
4049 headers [ FREEBUFF_INSTANCE_HEADER ] = opts . instanceId
4150 }
51+ if ( ( method === 'POST' || method === 'GET' ) && opts . model ) {
52+ headers [ FREEBUFF_MODEL_HEADER ] = opts . model
53+ }
4254 const resp = await fetch ( sessionEndpoint ( ) , {
4355 method,
4456 headers,
@@ -64,6 +76,17 @@ async function callSession(
6476 return body
6577 }
6678 }
79+ // 409 from POST means the user picked a different model than their active
80+ // session is bound to. Surface as a non-throw `model_locked` so the UI can
81+ // show a confirmation prompt (DELETE then re-POST to switch).
82+ if ( resp . status === 409 && method === 'POST' ) {
83+ const body = ( await resp . json ( ) . catch ( ( ) => null ) ) as
84+ | FreebuffSessionResponse
85+ | null
86+ if ( body && body . status === 'model_locked' ) {
87+ return body
88+ }
89+ }
6790 if ( ! resp . ok ) {
6891 const text = await resp . text ( ) . catch ( ( ) => '' )
6992 throw new Error (
@@ -95,6 +118,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
95118 case 'disabled' :
96119 case 'superseded' :
97120 case 'country_blocked' :
121+ case 'model_locked' :
98122 return null
99123 }
100124}
@@ -145,6 +169,39 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {})
145169 await controller ?. refresh ( )
146170}
147171
172+ /**
173+ * User picked a different model in the waiting room. Persist the choice and
174+ * re-POST so the server moves them to the back of the new model's queue. If
175+ * the user has an active session bound to a different model, the server
176+ * responds with `model_locked` and the UI prompts them to end first.
177+ */
178+ export async function switchFreebuffModel ( model : string ) : Promise < void > {
179+ if ( ! IS_FREEBUFF ) return
180+ const { setSelectedModel } = useFreebuffModelStore . getState ( )
181+ setSelectedModel ( model )
182+ await controller ?. refresh ( )
183+ }
184+
185+ /**
186+ * End the current session and immediately rejoin the queue. Used by the
187+ * "switch model" confirmation flow when the server returned `model_locked`,
188+ * and by any UI that lets the user exit an active session early.
189+ */
190+ export async function endAndRejoinFreebuffSession ( ) : Promise < void > {
191+ if ( ! IS_FREEBUFF ) return
192+ const { token } = getAuthTokenDetails ( )
193+ if ( ! token ) return
194+ try {
195+ await callSession ( 'DELETE' , token )
196+ } catch {
197+ // Best-effort — even if DELETE fails the re-POST below will eventually
198+ // succeed once the server-side sweep catches up.
199+ }
200+ const { useChatStore } = await import ( '../state/chat-store' )
201+ useChatStore . getState ( ) . reset ( )
202+ await controller ?. refresh ( )
203+ }
204+
148205export function markFreebuffSessionSuperseded ( ) : void {
149206 if ( ! IS_FREEBUFF ) return
150207 controller ?. abort ( )
@@ -250,10 +307,12 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
250307 // re-POST out from under an in-flight agent.
251308 const method : 'POST' | 'GET' = hasPosted ? 'GET' : 'POST'
252309 const instanceId = getFreebuffInstanceId ( )
310+ const model = getSelectedFreebuffModel ( )
253311 try {
254312 const next = await callSession ( method , token , {
255313 signal : abortController . signal ,
256314 instanceId,
315+ model,
257316 } )
258317 if ( cancelled ) return
259318 hasPosted = true
0 commit comments