From cfe9e0bb7793b51e14d36e84976f35de77306368 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 20 Apr 2026 14:06:41 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20clean=20outbound=20call=20gating=20?= =?UTF-8?q?=E2=80=94=20confirmedAnswered=20state=20with=203s=20debounce=20?= =?UTF-8?q?(#568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace layered customerAnswered/wasAnsweredRef with clean two-concern design: - customerAnswered: live derived value (is customer on line right now?) - confirmedAnswered: latched state (did real conversation happen?) Inbound: immediate. Outbound: 3s debounce filters voicemail. Never resets until handleReset — survives acw→ended timing gap. Buttons use confirmedAnswered for outbound (no flash during voicemail), customerAnswered for inbound (immediate). Disposition routing uses confirmedAnswered for both directions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 83 +++++++++---------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 81c48e9..481060e 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -109,33 +109,40 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const isOutbound = callDirectionRef.current === 'OUTBOUND'; - // For outbound: Ozonetel sends 'in-call' even for voicemail (~4s before ACW). - // Debounce: only treat as answered if 'in-call' holds for 5+ seconds. - // For inbound: customer is already there — active means answered, no delay. - const rawAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call'); - const [customerAnswered, setCustomerAnswered] = useState(false); + // customerAnswered — live signal (is customer on the line RIGHT NOW?) + const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call'); + + // confirmedAnswered — latched state (did a real conversation happen?) + // Inbound: set true on active (immediate). Outbound: set true after + // in-call holds 5+ seconds (filters voicemail). Never resets — survives + // the acw→ended timing gap. Used for disposition routing AND outbound + // button gating. + const [confirmedAnswered, setConfirmedAnswered] = useState(false); + const unansweredDisposeFired = useRef(false); + useEffect(() => { - if (!isOutbound) { - setCustomerAnswered(rawAnswered); - return; + if (!isOutbound && callState === 'active') { + setConfirmedAnswered(true); } - if (rawAnswered) { + }, [callState, isOutbound]); + + useEffect(() => { + if (isOutbound && customerAnswered && !confirmedAnswered) { const timer = setTimeout(() => { console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`); - setCustomerAnswered(true); - }, 5000); + setConfirmedAnswered(true); + }, 3000); return () => clearTimeout(timer); } - setCustomerAnswered(false); - }, [rawAnswered, isOutbound]); + }, [customerAnswered, isOutbound, confirmedAnswered]); - const wasAnsweredRef = useRef(callState === 'active'); - const unansweredDisposeFired = useRef(false); + // Button gating: inbound uses live signal, outbound uses debounced latch + const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered; // ── DEBUG: trace every state change ── useEffect(() => { - console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} rawAnswered=${rawAnswered} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`); - }, [callState, ozonetelState, isOutbound, rawAnswered, customerAnswered]); + console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`); + }, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]); useEffect(() => { console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); @@ -153,19 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }; }, [callUcid]); - // Detect caller disconnect: call was active and ended without agent pressing End. - // For outbound: use live customerAnswered (not the latch) — Ozonetel may briefly - // fire 'in-call' then 'acw' even when no one answered (voicemail, timeout). - // If customerAnswered is false at call end, route to Back to Worklist. + // Detect caller disconnect: call ended without agent pressing End. + // Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered. useEffect(() => { if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return; - const trulyAnswered = isOutbound ? customerAnswered : wasAnsweredRef.current; - console.log(`[CALL-DBG] ▶ CALL ENDED: trulyAnswered=${trulyAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current} callState=${callState}`); - if (trulyAnswered) { + console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`); + if (confirmedAnswered) { setCallerDisconnected(true); setDispositionOpen(true); } - }, [callState, dispositionOpen, isOutbound, customerAnswered]); + }, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; @@ -235,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const handleReset = () => { setDispositionOpen(false); setCallerDisconnected(false); + setConfirmedAnswered(false); setActionsTaken([]); setCallState('idle'); setCallerNumber(null); @@ -243,12 +248,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete onCallComplete?.(); }; - // Unanswered call — for outbound use live flag, for inbound use latch - const trulyAnsweredAtEnd = isOutbound ? customerAnswered : wasAnsweredRef.current; - // Auto-dispose unanswered outbound calls to release agent from ACW immediately useEffect(() => { - if (!trulyAnsweredAtEnd && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) { + if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) { unansweredDisposeFired.current = true; const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}'); console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`); @@ -264,7 +266,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete notes: 'Auto-disposed — customer did not answer', }).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err)); } - }, [trulyAnsweredAtEnd, isOutbound, callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps + }, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps // Outbound ringing if (callState === 'ringing-out') { @@ -315,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete ); } - if (!trulyAnsweredAtEnd && (callState === 'ended' || callState === 'failed')) { - console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: trulyAnswered=${trulyAnsweredAtEnd} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`); + if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) { + console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`); return (
@@ -331,10 +333,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete // Active call if (callState === 'active' || dispositionOpen) { - if (customerAnswered && !wasAnsweredRef.current) { - console.log(`[CALL-DBG] ▶ wasAnsweredRef SET TRUE: ozonetel=${ozonetelState} callState=${callState}`); - } - if (customerAnswered) wasAnsweredRef.current = true; return ( <>
@@ -416,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete