import { useEffect, useCallback, useRef, type PropsWithChildren } from 'react'; import { useAtom, useSetAtom } from 'jotai'; import { sipConnectionStatusAtom, sipCallStateAtom, sipCallerNumberAtom, sipIsMutedAtom, sipIsOnHoldAtom, sipCallDurationAtom, sipCallStartTimeAtom, sipCallUcidAtom, } from '@/state/sip-state'; import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager'; import { apiClient } from '@/lib/api-client'; import type { SIPConfig } from '@/types/sip'; // SIP config comes exclusively from the Agent entity (stored on login). // No env var fallback — users without an Agent entity don't connect SIP. const getSipConfig = (): SIPConfig | null => { try { const stored = localStorage.getItem('helix_agent_config'); if (stored) { const config = JSON.parse(stored); if (config.sipUri && config.sipWsServer) { return { displayName: 'Helix Agent', uri: config.sipUri, password: config.sipPassword, wsServer: config.sipWsServer, stunServers: 'stun:stun.l.google.com:19302', }; } } } catch {} return null; }; export const SipProvider = ({ children }: PropsWithChildren) => { const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom); const [callState, setCallState] = useAtom(sipCallStateAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallDuration = useSetAtom(sipCallDurationAtom); const setCallStartTime = useSetAtom(sipCallStartTimeAtom); // Register Jotai setters so the singleton SIP manager can update atoms useEffect(() => { registerSipStateUpdater({ setConnectionStatus, setCallState, setCallerNumber, setCallUcid, }); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); // Auto-connect SIP on mount — only if Agent entity has SIP config useEffect(() => { const config = getSipConfig(); if (config) { connectSip(config); } else { console.log('[SIP] No agent SIP config — skipping connection'); } }, []); // Call duration timer useEffect(() => { if (callState === 'active') { const start = new Date(); setCallStartTime(start); const interval = window.setInterval(() => { setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000)); }, 1000); return () => clearInterval(interval); } setCallDuration(0); setCallStartTime(null); }, [callState, setCallDuration, setCallStartTime]); // Ringtone on incoming call useEffect(() => { if (callState === 'ringing-in') { import('@/lib/ringtone').then(({ startRingtone }) => startRingtone()); } else { import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone()); } }, [callState]); // No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done) // and resets to idle via the "Back to Worklist" button // Layer 1: Warn on page refresh/close during active call // Layer 2: Fire sendBeacon to auto-dispose if user confirms leave // These two layers protect against the "agent refreshes mid-call → stuck in ACW" bug. // Layer 3 (server-side ACW timeout) lives in supervisor.service.ts. // // IMPORTANT: beforeunload reads callState via a ref (not the dep array) // because adding callState to deps causes the cleanup to fire on every // state transition → disconnectSip() → kills the call mid-flight. const callStateRef = useRef(callState); callStateRef.current = callState; useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { const ucid = localStorage.getItem('helix_active_ucid'); const state = callStateRef.current; // Layer 1: Show browser "Leave page?" dialog during active calls if (state === 'active' || state === 'ringing-in' || state === 'ringing-out') { e.preventDefault(); e.returnValue = ''; } // Layer 2: Fire disposition beacon if there's an active UCID if (ucid) { const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}'); const payload = JSON.stringify({ ucid, disposition: 'CALLBACK_REQUESTED', agentId: agentCfg.ozonetelAgentId, autoDisposed: true, }); navigator.sendBeacon('/api/ozonetel/dispose', new Blob([payload], { type: 'application/json' })); localStorage.removeItem('helix_active_ucid'); } }; const handleUnload = () => disconnectSip(true); window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('unload', handleUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('unload', handleUnload); disconnectSip(true); // force — component is unmounting }; }, []); // empty deps — runs once on mount, cleanup only on unmount return <>{children}; }; // Hook for components to access SIP actions + state export const useSip = () => { const [connectionStatus] = useAtom(sipConnectionStatusAtom); const [callState, setCallState] = useAtom(sipCallStateAtom); const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom); const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom); const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom); const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom); const [callDuration] = useAtom(sipCallDurationAtom); const makeCall = useCallback((phoneNumber: string) => { getSipClient()?.call(phoneNumber); setCallerNumber(phoneNumber); }, [setCallerNumber]); // Ozonetel outbound dial — single path for all outbound calls const dialOutbound = useCallback(async (phoneNumber: string): Promise => { // Block outbound calls when agent is on Break or Training const agentCfg = localStorage.getItem('helix_agent_config'); if (agentCfg) { const { useAgentState: _ } = await import('@/hooks/use-agent-state'); // Read state from the SSE endpoint directly (can't use hook here) const agentId = JSON.parse(agentCfg).ozonetelAgentId; try { const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`); const stateData = await stateRes.json(); if (stateData.state === 'break' || stateData.state === 'training') { const { notify } = await import('@/lib/toast'); notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls'); return; } } catch {} } console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`); setCallState('ringing-out'); setCallerNumber(phoneNumber); setOutboundPending(true); const safetyTimeout = setTimeout(() => { console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending'); setOutboundPending(false); }, 30000); try { // Send agent config so the sidecar dials with the correct agent ID + campaign const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}'); const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber, agentId: agentConfig.ozonetelAgentId, campaignName: agentConfig.campaignName, }); console.log('[DIAL] Dial API response:', result); clearTimeout(safetyTimeout); // Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound if (result?.ucid) { console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`); setCallUcid(result.ucid); } } catch (err) { console.error('[DIAL] Dial API failed:', err); clearTimeout(safetyTimeout); setOutboundPending(false); setCallState('idle'); setCallerNumber(null); throw new Error('Dial failed'); } }, [setCallState, setCallerNumber, setCallUcid]); const answer = useCallback(() => getSipClient()?.answer(), []); const reject = useCallback(() => getSipClient()?.reject(), []); const hangup = useCallback(() => getSipClient()?.hangup(), []); const toggleMute = useCallback(() => { if (isMuted) { getSipClient()?.unmute(); } else { getSipClient()?.mute(); } setIsMuted(!isMuted); }, [isMuted, setIsMuted]); const toggleHold = useCallback(() => { if (isOnHold) { getSipClient()?.unhold(); } else { getSipClient()?.hold(); } setIsOnHold(!isOnHold); }, [isOnHold, setIsOnHold]); return { connectionStatus, callState, callerNumber, callUcid, isMuted, isOnHold, callDuration, isRegistered: connectionStatus === 'registered', isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState), ozonetelStatus: 'logged-in' as const, ozonetelError: null as string | null, connect: () => { const c = getSipConfig(); if (c) connectSip(c); }, disconnect: disconnectSip, makeCall, dialOutbound, answer, reject, hangup, toggleMute, toggleHold, }; };