import { type MutableRefObject, useEffect, useRef } from 'react' import { isNewChatRoute } from '@/app/routes' import { setResumeExhaustedSessionId } from '@/store/session' interface RouteResumeOptions { activeSessionId: string | null activeSessionIdRef: MutableRefObject creatingSessionRef: MutableRefObject currentView: string freshDraftReady: boolean gatewayState: string | undefined locationPathname: string resumeSession: (sessionId: string, focus: boolean) => Promise // Stored-session id whose most recent resume failed terminally (set by // useSessionActions, mirrored from $resumeFailedSessionId). While this equals // routedSessionId the window would otherwise latch on the loader forever, so // the bounded-retry effect below re-attempts the resume. resumeFailedSessionId: string | null // Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from // $resumeExhaustedSessionId). Only resumeSession clears this latch (manual // Retry / reconnect / reselect) — the auto-retry loop never does — so its // armed->cleared edge is an unambiguous "give me a fresh backoff cycle" // signal the effect below uses to reset the attempt counter. resumeExhaustedSessionId: string | null routedSessionId: string | null runtimeIdByStoredSessionIdRef: MutableRefObject> selectedStoredSessionId: string | null selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: (focus: boolean) => unknown } // Bounded auto-retry for a stranded session window. A resume can fail terminally // (gateway RPC reject + REST fallback failure) on a transiently wedged backend — // dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a // retry the loader latches forever. We retry with backoff, capped, so a // genuinely dead backend doesn't hot-loop the resume. const MAX_RESUME_RETRIES = 4 const RESUME_RETRY_BASE_MS = 1_000 const RESUME_RETRY_MAX_MS = 8_000 function resumeRetryDelayMs(attempt: number): number { return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt) } // HashRouter boot edge case: pathname briefly reads `/` before the hash is // parsed. If the hash references a real session, defer; resume picks it up // next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states. function rawHashLooksLikeSession(): boolean { if (typeof window === 'undefined') { return false } const hash = window.location.hash.replace(/^#/, '') if (!hash || hash === '/') { return false } return ( !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/messaging') && !hash.startsWith('/artifacts') ) } export function useRouteResume({ activeSessionId, activeSessionIdRef, creatingSessionRef, currentView, freshDraftReady, gatewayState, locationPathname, resumeSession, resumeFailedSessionId, resumeExhaustedSessionId, routedSessionId, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft }: RouteResumeOptions) { const lastPathnameRef = useRef(null) const seenGatewayStateRef = useRef(false) const wasGatewayOpenRef = useRef(false) // Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed // by the session id we're retrying so switching chats resets the counter. const retrySessionIdRef = useRef(null) const retryAttemptRef = useRef(0) // Tracks the previous exhausted-latch value so we can detect its armed->cleared // edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry / // reconnect / reselect; that transition is our cue to reset the attempt counter // for a fresh backoff cycle on the SAME session (the auto-retry loop itself // never touches this latch, so it can't spuriously trigger the reset). const prevResumeExhaustedRef = useRef(null) useEffect(() => { const gatewayOpen = gatewayState === 'open' const pathnameChanged = lastPathnameRef.current !== locationPathname // Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef // stays false until the first effect run, so a session that mounts with the gateway // already open is not mistaken for "became open" and does not double-resume with the // pathname-driven initial resume below. const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen lastPathnameRef.current = locationPathname seenGatewayStateRef.current = true wasGatewayOpenRef.current = gatewayOpen if (currentView !== 'chat' || !gatewayOpen) { return } if (routedSessionId) { const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId) const alreadyActive = routedSessionId === selectedStoredSessionIdRef.current && Boolean(cachedRuntime) && cachedRuntime === activeSessionIdRef.current // Self-heal a desynced view: the route points at a session that isn't the // loaded one. A create/stream race can leave selected/active null while // the route stays on /:sid (symptom: brand-new chat shows "Thinking" then // an empty transcript even though the turn completed and persisted). The // pathname didn't change, so the normal gate would skip and the view stays // stuck empty forever. selectedStoredSessionIdRef is set synchronously at // resume entry, so this can't loop; the resume's cached fast-path restores // the already-streamed messages without a refetch. // // Crucially this must NOT fire during a /:sid -> /new transition, where // startFreshSessionDraft nulls selected/active one render before the // pathname flips to / (same null+/:sid signature). freshDraftReady is the // discriminator: it's true while heading into a blank new chat, false when // genuinely stranded on a routed session. const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady // Resume when the route meaningfully changed, the gateway just opened, or // we're stranded on a routed session that never loaded. The first two // guard against a transient /:sid re-resume during "new chat" state clears // before the pathname updates from /:sid -> /. const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession // On a reconnect (gatewayBecameOpen) re-resume even when the route looks // `alreadyActive`: the cached runtime id can be stale once the gateway // rebinds/reaps the session on its side, and trusting it strands Desktop on // a dead id ("session not found"). Otherwise keep skipping when already active. if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) { void resumeSession(routedSessionId, true) } return } if ( isNewChatRoute(locationPathname) && !creatingSessionRef.current && (selectedStoredSessionId || activeSessionId || !freshDraftReady) && !rawHashLooksLikeSession() ) { startFreshSessionDraft(true) } }, [ activeSessionId, activeSessionIdRef, creatingSessionRef, currentView, freshDraftReady, gatewayState, locationPathname, resumeSession, routedSessionId, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft ]) // Bounded auto-retry: when the routed session's resume failed terminally // (resumeFailedSessionId matches the route), schedule a backoff retry so the // window recovers on its own instead of latching the loader forever. This is // the safety net the main effect above can't provide: after a failed resume, // selectedStoredSessionIdRef.current already equals the route (resumeSession // sets it synchronously at entry) and the pathname/gateway are unchanged, so // none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire // again. resumeSession clears resumeFailedSessionId on its next attempt; a // success keeps it clear (the effect's guard then no-ops), a repeat failure // re-arms it and we back off further, capped at MAX_RESUME_RETRIES. useEffect(() => { // Detect the exhausted-latch armed->cleared edge for the current route. Only // resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect / // reselect) — the auto-retry loop never touches it — so this transition // uniquely means "the user asked for another go." Reset the attempt counter // for a fresh bounded backoff cycle on the SAME session. Without this, // retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset // below only fires on a route CHANGE to a different session), so a manual // retry on the same stranded session would get exactly ONE attempt and then // immediately re-arm the exhausted error — never the renewed backoff cycle // the store/session.ts + use-session-actions.ts comments promise. (Point 2) const wasExhausted = prevResumeExhaustedRef.current prevResumeExhaustedRef.current = resumeExhaustedSessionId if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) { retrySessionIdRef.current = routedSessionId retryAttemptRef.current = 0 } if (currentView !== 'chat' || gatewayState !== 'open') { return } const stranded = Boolean(routedSessionId) && resumeFailedSessionId === routedSessionId && !creatingSessionRef.current if (!stranded) { // Route moved off the stranded session (or it recovered) — reset the // counter so a future failure on another session starts fresh, and clear // any exhausted-latch armed for a session we're no longer viewing (never // the current route: that's the error state we want to keep showing). // resumeSession also clears it on a fresh attempt; this covers a plain // route-change away from the stranded window. if (retrySessionIdRef.current !== routedSessionId) { retrySessionIdRef.current = null retryAttemptRef.current = 0 setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current)) } return } // New stranded session id → reset the attempt counter. if (retrySessionIdRef.current !== routedSessionId) { retrySessionIdRef.current = routedSessionId retryAttemptRef.current = 0 } if (retryAttemptRef.current >= MAX_RESUME_RETRIES) { // Give up auto-retrying a persistently dead backend; the user can still // reconnect / reselect (which resets the counter via the branch above). // Surface an explicit error + manual Retry in the chat view instead of // spinning the loader forever — resumeSession (manual Retry / reconnect / // reselect) clears this latch and resets the counter for a fresh cycle. setResumeExhaustedSessionId(routedSessionId) return } const attempt = retryAttemptRef.current const sessionId = routedSessionId as string const timer = setTimeout(() => { // Re-check liveness at fire time: a resume may have landed while we waited. if ( creatingSessionRef.current || selectedStoredSessionIdRef.current !== sessionId || activeSessionIdRef.current !== null ) { return } // Consume an attempt ONLY now that a resume is actually dispatching. // Incrementing at schedule time (the old behavior) let unrelated dep // changes during the 1s–8s backoff window — a transient gatewayState // flip, a non-referentially-stable resumeSession — clear the pending // timer and re-run the effect, burning an attempt without any resume // having fired. A flapping backend could then hit MAX in a couple of // re-renders with far fewer than MAX real attempts. (Point 3) retryAttemptRef.current += 1 void resumeSession(sessionId, true) }, resumeRetryDelayMs(attempt)) return () => clearTimeout(timer) }, [ activeSessionIdRef, creatingSessionRef, currentView, gatewayState, resumeSession, resumeFailedSessionId, resumeExhaustedSessionId, routedSessionId, selectedStoredSessionIdRef ]) }