2 Commits

Author SHA1 Message Date
cfe9e0bb77 fix: clean outbound call gating — confirmedAnswered state with 3s debounce (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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) <noreply@anthropic.com>
2026-04-20 14:06:41 +05:30
923c99bf17 fix: outbound call — debounce customer-answered, auto-dispose on no-answer (#568)
Ozonetel sends 'in-call' even for voicemail (~4s before ACW), which
briefly enabled action buttons and poisoned wasAnsweredRef. Three fixes:

1. Debounce customerAnswered for outbound: require 'in-call' to hold 5s
   before enabling buttons (filters voicemail/IVR pickup)
2. Use live customerAnswered (not stale latch) for outbound call-end
   routing — unanswered calls go to Back to Worklist, not disposition
3. Auto-dispose with NO_ANSWER on unanswered outbound to release agent
   from ACW immediately (was waiting 30s for server safety net)

Also: hide AI FAB for CC agents in app-shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:47:02 +05:30
2 changed files with 70 additions and 22 deletions

View File

@@ -106,13 +106,43 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const agentConfig = localStorage.getItem('helix_agent_config'); const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState); const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
// For outbound calls, SIP goes 'active' when the agent's bridge connects
// (before customer answers). Ozonetel state stays 'calling' until customer
// picks up, then transitions to 'in-call'. Use this to gate action buttons.
const customerAnswered = callState === 'active' && ozonetelState !== 'calling';
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const wasAnsweredRef = useRef(callState === 'active'); const isOutbound = callDirectionRef.current === 'OUTBOUND';
// 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 && callState === 'active') {
setConfirmedAnswered(true);
}
}, [callState, isOutbound]);
useEffect(() => {
if (isOutbound && customerAnswered && !confirmedAnswered) {
const timer = setTimeout(() => {
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
setConfirmedAnswered(true);
}, 3000);
return () => clearTimeout(timer);
}
}, [customerAnswered, isOutbound, confirmedAnswered]);
// 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} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
useEffect(() => { useEffect(() => {
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
@@ -130,13 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}; };
}, [callUcid]); }, [callUcid]);
// Detect caller disconnect: call was active and ended without agent pressing End // Detect caller disconnect: call ended without agent pressing End.
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
useEffect(() => { useEffect(() => {
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
if (confirmedAnswered) {
setCallerDisconnected(true); setCallerDisconnected(true);
setDispositionOpen(true); setDispositionOpen(true);
} }
}, [callState, dispositionOpen]); }, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const firstName = lead?.contactName?.firstName ?? ''; const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -206,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleReset = () => { const handleReset = () => {
setDispositionOpen(false); setDispositionOpen(false);
setCallerDisconnected(false); setCallerDisconnected(false);
setConfirmedAnswered(false);
setActionsTaken([]); setActionsTaken([]);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
@@ -214,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onCallComplete?.(); onCallComplete?.();
}; };
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
useEffect(() => {
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}`);
apiClient.post('/api/ozonetel/dispose', {
ucid: callUcid,
disposition: 'NO_ANSWER',
agentId: agentCfg.ozonetelAgentId,
callerPhone,
direction: 'OUTBOUND',
durationSec: 0,
leadId: lead?.id ?? null,
leadName: fullName || null,
notes: 'Auto-disposed — customer did not answer',
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
}
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
// Outbound ringing // Outbound ringing
if (callState === 'ringing-out') { if (callState === 'ringing-out') {
return ( return (
@@ -263,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
); );
} }
// Unanswered call (ringing → ended without ever reaching active) if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
return ( return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center"> <div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
@@ -279,7 +333,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Active call // Active call
if (callState === 'active' || dispositionOpen) { if (callState === 'active' || dispositionOpen) {
if (customerAnswered) wasAnsweredRef.current = true;
return ( return (
<> <>
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}> <div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
@@ -361,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
isDisabled={!customerAnswered} isDisabled={!buttonsEnabled}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}> onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'} {leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
</Button> </Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'} <Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!customerAnswered} isDisabled={!buttonsEnabled}
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button> onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'} <Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
isDisabled={!customerAnswered} isDisabled={!buttonsEnabled}
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button> onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto" <Button size="sm" color="primary-destructive" className="ml-auto"
@@ -553,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={dispositionOpen} isOpen={dispositionOpen}
callerName={fullName || phoneDisplay} callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected} callerDisconnected={callerDisconnected}
// wasAnsweredRef only flips true once callState reaches callAnswered={confirmedAnswered}
// 'active'. Outbound callbacks that never connect keep
// this false, which narrows the disposition options to
// no-answer outcomes and prevents SLA-gaming dispositions
// like Info Provided on a call the customer never took.
callAnswered={wasAnsweredRef.current}
actionsTaken={actionsTaken} actionsTaken={actionsTaken}
onSubmit={handleDisposition} onSubmit={handleDisposition}
onDismiss={() => { onDismiss={() => {

View File

@@ -144,7 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<main className="flex flex-1 flex-col overflow-hidden">{children}</main> <main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div> </div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />} {isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
{isAdmin && <AiFloatingButton />} {isAdmin && !isCCAgent && <AiFloatingButton />}
</div> </div>
<MaintOtpModal <MaintOtpModal
isOpen={isOpen} isOpen={isOpen}