/** * useCallSession — React hook that owns the voice_call lifecycle. * * One instance per mounted `CallOverlayInner`. Composes callApi + * callSocket into a single, cleanup-safe React surface: * * const session = useCallSession({ enabled, backendUrl, authToken, * conversationId, personaId, * deviceInfo }) * * session.status → 'idle' | 'creating' | 'connecting' * | 'live' | 'reconnecting' * | 'unavailable' | 'error' | 'closed' * session.sendTranscript(text) → route STT capture to the backend * session.end() → graceful user-initiated close * session.onAssistantTranscript → subscribe to assistant replies * session.onAssistantFiller → subscribe to "hmm…" events * session.onAssistantBackchannel → subscribe to "mm-hm" events * session.lastError → surface for the UI * * When `enabled` flips to true we: * 1. POST /v1/voice-call/sessions (via callApi) * 2. Open the WebSocket (via callSocket) * 3. Wire status + event forwarding into React state * * Critically, if the POST returns 404/501 (backend has * VOICE_CALL_ENABLED=false) we surface `status='unavailable'` and * stop — the caller then falls back to the chat-REST path. That * graceful-degradation path is what lets us ship this module * independent of the backend flag rollout. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createCallSession, resolveWsUrl, CallApiError, type CreateCallSessionRequest, type CreateCallSessionResponse, } from './callApi' import { CallSocket, type AssistantBackchannelPayload, type AssistantCancelPayload, type AssistantFillerPayload, type AssistantPartialPayload, type AssistantTranscriptPayload, type AssistantTurnEndPayload, type CallCloseReason, type CallLifecycleStatus, } from './callSocket' export type CallSessionStatus = | 'idle' | 'creating' | 'connecting' | 'live' | 'reconnecting' | 'draining' | 'closed' | 'unavailable' | 'error' export interface UseCallSessionArgs { /** The hook is inert until this flips to true. Flipping back to * false triggers a graceful dispose. */ enabled: boolean /** Resolved HomePilot backend URL (scheme + host). */ backendUrl: string /** Bearer JWT for the REST handshake + WS query param. */ authToken: string | null /** Session-creation request fields. */ request: CreateCallSessionRequest } export interface CallSessionHandle { status: CallSessionStatus /** Server-side canonical status last seen (live / ending / ended). */ callState: 'live' | 'ending' | 'ended' | null /** Raw close reason once the session terminates. Useful for * differentiating user-end vs timeout vs backend-disabled. */ closeReason: CallCloseReason | null /** Any terminal error. 'unavailable' is NOT an error — it's a * legitimate graceful-degradation signal. */ lastError: string | null /** Route a final STT transcript into the call turn. No-op outside * the 'live' | 'reconnecting' states. */ sendTranscript: (text: string) => void /** Graceful end — sends call.control end, closes the socket. */ end: () => void /** Bookkeeping. Server persists the *type* of the event only. */ sendUiState: (p: { muted?: boolean; speaker_on?: boolean; backgrounded?: boolean }) => void /** Phase 2 — interim user STT (secondary barge-in signal). */ sendTranscriptPartial: (p: { text: string; stable_prefix_len?: number }) => void /** Phase 3 — explicit user interrupt carrying the active turn_id. */ sendBargeIn: (turn_id: string) => void /** Whether this session negotiated streaming. Pulled from the * session-create capabilities response. False = unary mode. */ streamingNegotiated: boolean /** Whether this session negotiated barge-in (implies streaming). */ bargeInNegotiated: boolean /** Subscribe helpers — each returns an unsubscribe fn. Stable * identities via useRef-backed implementation so the caller can * pass them to useEffect deps without re-subscription storms. */ onAssistantTranscript: (fn: (p: AssistantTranscriptPayload) => void) => () => void onAssistantFiller: (fn: (p: AssistantFillerPayload) => void) => () => void onAssistantBackchannel: (fn: (p: AssistantBackchannelPayload) => void) => () => void /** Phase 2 subscribes. */ onAssistantPartial: (fn: (p: AssistantPartialPayload) => void) => () => void onAssistantTurnEnd: (fn: (p: AssistantTurnEndPayload) => void) => () => void onAssistantCancel: (fn: (p: AssistantCancelPayload) => void) => () => void } // When the backend returns 404/501 for voice_call (VOICE_CALL_ENABLED=false) // we remember the miss per-backend and short-circuit future session creates // for UNAVAILABLE_BACKOFF_MS. Without this, every CallOverlay mount posts a // fresh 404 against the same flag, spamming the console and delaying the // fallback path by a full network round-trip. // // Industry practice: the backend feature-flag rarely flips mid-session, so // the backoff is long-lived (10 min) and persisted to sessionStorage so it // survives React 18 StrictMode's double-invoke of passive effects + in-tab // navigations that tear the CallOverlay down and re-mount it. Clearing // sessionStorage ( or calling clearVoiceCallUnavailable ) forces a re-probe. const UNAVAILABLE_BACKOFF_MS = 10 * 60 * 1000 const BACKOFF_STORAGE_KEY = 'homepilot_voice_call_unavailable_until' const unavailableUntilByBackend = new Map() // In-flight dedupe — StrictMode's double-invoke of the mount effect fires // two parallel POSTs before either resolves, so the backoff is useless in // that window. Share a single Promise per (backendUrl, authToken) so the // two invokes observe the same network result instead of racing two probes. const inflightByBackend = new Map>() function _readPersistedBackoff(backendUrl: string): number { if (typeof window === 'undefined') return 0 try { const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY) if (!raw) return 0 const map = JSON.parse(raw) as Record const until = Number(map[backendUrl] ?? 0) return Number.isFinite(until) ? until : 0 } catch { return 0 } } function _writePersistedBackoff(backendUrl: string, until: number): void { if (typeof window === 'undefined') return try { const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY) const map = (raw ? JSON.parse(raw) : {}) as Record map[backendUrl] = until window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map)) } catch { /* ignore quota / private-mode errors */ } } function _clearPersistedBackoff(backendUrl: string): void { if (typeof window === 'undefined') return try { const raw = window.sessionStorage.getItem(BACKOFF_STORAGE_KEY) if (!raw) return const map = JSON.parse(raw) as Record delete map[backendUrl] window.sessionStorage.setItem(BACKOFF_STORAGE_KEY, JSON.stringify(map)) } catch { /* ignore */ } } /** Resolve the active backoff deadline for a backend. Checks the in-memory * cache first, falls through to sessionStorage. Expired entries return 0. */ function _backoffUntil(backendUrl: string): number { const mem = unavailableUntilByBackend.get(backendUrl) ?? 0 const persisted = _readPersistedBackoff(backendUrl) const until = Math.max(mem, persisted) if (until && until <= Date.now()) { unavailableUntilByBackend.delete(backendUrl) _clearPersistedBackoff(backendUrl) return 0 } return until } /** Force a re-probe of the voice_call backend on the next CallOverlay mount. * Exposed for future "retry now" UI affordances. Non-destructive: no-op if * no backoff was set. */ export function clearVoiceCallUnavailable(backendUrl: string): void { unavailableUntilByBackend.delete(backendUrl) _clearPersistedBackoff(backendUrl) } export function useCallSession(args: UseCallSessionArgs): CallSessionHandle { const { enabled, backendUrl, authToken, request } = args const [status, setStatus] = useState('idle') const [callState, setCallState] = useState<'live' | 'ending' | 'ended' | null>(null) const [closeReason, setCloseReason] = useState(null) const [lastError, setLastError] = useState(null) const socketRef = useRef(null) const sessionRef = useRef(null) // Stable subscribe surface. Keep listener sets here so they survive // socket reconnects — the old socket's listeners are torn down with // it, but user subscriptions persist across reconnects and are // re-attached when a new socket comes up. const txListeners = useRef(new Set<(p: AssistantTranscriptPayload) => void>()) const fillerListeners = useRef(new Set<(p: AssistantFillerPayload) => void>()) const bcListeners = useRef(new Set<(p: AssistantBackchannelPayload) => void>()) // Phase 2/3 listener sets. Kept separate so an old subscribe set // doesn't churn when a new one mounts (stream wire-up rebinds often // during CallOverlay renders). const partialListeners = useRef(new Set<(p: AssistantPartialPayload) => void>()) const turnEndListeners = useRef(new Set<(p: AssistantTurnEndPayload) => void>()) const cancelListeners = useRef(new Set<(p: AssistantCancelPayload) => void>()) // Negotiated modes — re-derived on session create, stashed here so // every render reads the same value without re-running the effect. const [streamingNegotiated, setStreamingNegotiated] = useState(false) const [bargeInNegotiated, setBargeInNegotiated] = useState(false) // Ref'd primitive so the session-create effect doesn't re-trigger // when the request object identity changes every render. const requestRef = useRef(request) useEffect(() => { requestRef.current = request }, [request]) // Main lifecycle. Runs once per (enabled, backendUrl, authToken) // triple — which is what we want: flipping enabled true/false // drives open/close; swapping backendUrl or re-auth reconnects. useEffect(() => { if (!enabled) return let disposed = false const teardown = () => { disposed = true socketRef.current?.dispose('unmounted') socketRef.current = null sessionRef.current = null } const run = async () => { setStatus('creating') setLastError(null) setCloseReason(null) setCallState(null) // Skip the POST entirely if this backend has been 404/501-ing // recently. _backoffUntil() consults the in-memory map + the // sessionStorage mirror so StrictMode double-invokes + page // reloads share the verdict. Expired entries self-clear. const skipUntil = _backoffUntil(backendUrl) if (skipUntil) { // eslint-disable-next-line no-console console.info( '[useCallSession] skipping createCallSession — backend flagged unavailable', { backendUrl, resumesAt: new Date(skipUntil).toISOString() }, ) setStatus('unavailable') return } // Share the handshake Promise across concurrent callers so // React 18 StrictMode's two parallel mount effects can't fire // two POSTs (the backoff is written only after the response // lands, so a second probe racing the first would otherwise // get through before the first writes its verdict). let handshake: CreateCallSessionResponse try { let inflight = inflightByBackend.get(backendUrl) if (!inflight) { inflight = createCallSession(backendUrl, requestRef.current, authToken) .finally(() => { inflightByBackend.delete(backendUrl) }) inflightByBackend.set(backendUrl, inflight) } handshake = await inflight } catch (err) { if (disposed) return if (err instanceof CallApiError && err.isUnavailable) { const until = Date.now() + UNAVAILABLE_BACKOFF_MS unavailableUntilByBackend.set(backendUrl, until) _writePersistedBackoff(backendUrl, until) // eslint-disable-next-line no-console console.info( '[useCallSession] voice_call unavailable — falling back to chat REST until', new Date(until).toISOString(), ) setStatus('unavailable') return } // eslint-disable-next-line no-console console.error('[useCallSession] createCallSession failed', err) setStatus('error') setLastError(err instanceof Error ? err.message : String(err)) return } if (disposed) return // Handshake worked — clear any prior backoff so new flag flips // take effect immediately instead of waiting out the window. unavailableUntilByBackend.delete(backendUrl) _clearPersistedBackoff(backendUrl) sessionRef.current = handshake const url = resolveWsUrl( handshake.ws_url, handshake.session_id, handshake.resume_token, authToken, backendUrl, ) const sock = new CallSocket({ url }) socketRef.current = sock // Translate socket lifecycle → hook status. sock.on('statusChange', (s: CallLifecycleStatus) => { if (disposed) return setStatus(s === 'idle' ? 'idle' : s) }) sock.on('callState', (p) => { if (disposed) return setCallState(p.status) }) sock.on('closed', ({ reason }) => { if (disposed) return setStatus('closed') setCloseReason(reason) }) sock.on('serverError', (p) => { // Non-terminal by contract; surface for the UI but keep the // socket open. The server will close explicitly if fatal. if (disposed) return setLastError(`${p.code}: ${p.message}`) }) sock.on('assistantTranscript', (p) => { for (const fn of txListeners.current) fn(p) }) sock.on('assistantFiller', (p) => { for (const fn of fillerListeners.current) fn(p) }) sock.on('assistantBackchannel', (p) => { for (const fn of bcListeners.current) fn(p) }) // Phase 2/3 event fan-outs. sock.on('assistantPartial', (p) => { for (const fn of partialListeners.current) fn(p) }) sock.on('assistantTurnEnd', (p) => { for (const fn of turnEndListeners.current) fn(p) }) sock.on('assistantCancel', (p) => { for (const fn of cancelListeners.current) fn(p) }) // Reflect negotiated modes from the session-create response. const caps = handshake.capabilities as | { streaming?: boolean; barge_in?: boolean } | undefined setStreamingNegotiated(!!caps?.streaming) setBargeInNegotiated(!!caps?.streaming && !!caps?.barge_in) sock.connect() } void run() return teardown }, [enabled, backendUrl, authToken]) // Public actions. Guarded against stale-socket calls. -------------- const sendTranscript = useCallback((text: string) => { const sock = socketRef.current if (!sock) return const trimmed = text.trim() if (!trimmed) return sock.sendTranscript({ text: trimmed }) }, []) const end = useCallback(() => { socketRef.current?.end() }, []) const sendUiState = useCallback( (p: { muted?: boolean; speaker_on?: boolean; backgrounded?: boolean }) => { socketRef.current?.sendUiState(p) }, [], ) const sendTranscriptPartial = useCallback( (p: { text: string; stable_prefix_len?: number }) => { socketRef.current?.sendTranscriptPartial(p) }, [], ) const sendBargeIn = useCallback((turn_id: string) => { socketRef.current?.sendBargeIn(turn_id) }, []) const onAssistantTranscript = useCallback( (fn: (p: AssistantTranscriptPayload) => void) => { txListeners.current.add(fn) return () => { txListeners.current.delete(fn) } }, [], ) const onAssistantFiller = useCallback( (fn: (p: AssistantFillerPayload) => void) => { fillerListeners.current.add(fn) return () => { fillerListeners.current.delete(fn) } }, [], ) const onAssistantBackchannel = useCallback( (fn: (p: AssistantBackchannelPayload) => void) => { bcListeners.current.add(fn) return () => { bcListeners.current.delete(fn) } }, [], ) const onAssistantPartial = useCallback( (fn: (p: AssistantPartialPayload) => void) => { partialListeners.current.add(fn) return () => { partialListeners.current.delete(fn) } }, [], ) const onAssistantTurnEnd = useCallback( (fn: (p: AssistantTurnEndPayload) => void) => { turnEndListeners.current.add(fn) return () => { turnEndListeners.current.delete(fn) } }, [], ) const onAssistantCancel = useCallback( (fn: (p: AssistantCancelPayload) => void) => { cancelListeners.current.add(fn) return () => { cancelListeners.current.delete(fn) } }, [], ) return useMemo(() => ({ status, callState, closeReason, lastError, sendTranscript, end, sendUiState, sendTranscriptPartial, sendBargeIn, streamingNegotiated, bargeInNegotiated, onAssistantTranscript, onAssistantFiller, onAssistantBackchannel, onAssistantPartial, onAssistantTurnEnd, onAssistantCancel, }), [ status, callState, closeReason, lastError, sendTranscript, end, sendUiState, sendTranscriptPartial, sendBargeIn, streamingNegotiated, bargeInNegotiated, onAssistantTranscript, onAssistantFiller, onAssistantBackchannel, onAssistantPartial, onAssistantTurnEnd, onAssistantCancel, ]) }