fix: three-layer ACW protection — prevent agent stuck in wrapping-up

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 12:29:29 +05:30
parent f57fbc1f24
commit 72012f099c
2 changed files with 47 additions and 4 deletions

View File

@@ -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}</>;
};