From 72012f099c9cb302343cafb281fa945186fca90a Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 10 Apr 2026 12:29:29 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20three-layer=20ACW=20protection=20?= =?UTF-8?q?=E2=80=94=20prevent=20agent=20stuck=20in=20wrapping-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: when an agent refreshes the page during or after a call, the React state (UCID, callState, disposition modal) is wiped. The SIP BYE event fires but no component exists to trigger the disposition modal → no POST to /api/ozonetel/dispose → agent stuck in ACW. Layer 1 (beforeunload warning): Shows browser's native "Leave page?" dialog during active calls. Agent can cancel and stay. Layer 2 (sendBeacon auto-dispose): UCID persisted to localStorage when call activates. On page unload, navigator.sendBeacon fires /api/ozonetel/dispose with CALLBACK_REQUESTED. Guaranteed delivery even during page death. Cleared from localStorage when disposition modal submits normally. Layer 3 lives in helix-engage-server (separate commit). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 15 ++++++++ src/providers/sip-provider.tsx | 36 ++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 831be5d..2d975eb 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -56,6 +56,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Sync UCID to localStorage so sendBeacon can fire auto-dispose on page refresh. + // Cleared on disposition submit (handleDisposition below) or when call resets to idle. + useEffect(() => { + if (callUcid) { + localStorage.setItem('helix_active_ucid', callUcid); + } + return () => { + // Don't clear on unmount if disposition hasn't fired — the + // beforeunload handler in SipProvider needs it + }; + }, [callUcid]); + // Detect caller disconnect: call was active and ended without agent pressing End useEffect(() => { if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { @@ -115,6 +127,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete } } + // Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback + localStorage.removeItem('helix_active_ucid'); + notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); handleReset(); }; diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx index 9926898..d5c9ca3 100644 --- a/src/providers/sip-provider.tsx +++ b/src/providers/sip-provider.tsx @@ -89,15 +89,43 @@ export const SipProvider = ({ children }: PropsWithChildren) => { // No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done) // and resets to idle via the "Back to Worklist" button - // Cleanup on unmount + page unload + // 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. useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + const ucid = localStorage.getItem('helix_active_ucid'); + + // Layer 1: Show browser "Leave page?" dialog during active calls + if (callState === 'active' || callState === 'ringing-in' || callState === 'ringing-out') { + e.preventDefault(); + e.returnValue = ''; + } + + // Layer 2: Fire disposition beacon if there's an active UCID + // sendBeacon is guaranteed to fire even during page unload + if (ucid) { + const payload = JSON.stringify({ + ucid, + disposition: 'CALLBACK_REQUESTED', + autoDisposed: true, + }); + navigator.sendBeacon('/api/ozonetel/dispose', new Blob([payload], { type: 'application/json' })); + localStorage.removeItem('helix_active_ucid'); + } + }; + const handleUnload = () => disconnectSip(); - window.addEventListener('beforeunload', handleUnload); + + window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('unload', handleUnload); return () => { - window.removeEventListener('beforeunload', handleUnload); + window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('unload', handleUnload); disconnectSip(); }; - }, []); + }, [callState]); return <>{children}; };