Skip to content

Commit 205e6b3

Browse files
committed
fix(task-refactor): fix call control timers, consult transfer, and wrapup state
1 parent 5373115 commit 205e6b3

2 files changed

Lines changed: 157 additions & 41 deletions

File tree

packages/contact-center/task/src/Utils/timer-utils.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {ITask, findHoldTimestamp, TaskUIControls} from '@webex/cc-store';
1+
import {ITask, TaskUIControls} from '@webex/cc-store';
22
import {
33
TIMER_LABEL_WRAP_UP,
44
TIMER_LABEL_POST_CALL,
@@ -15,6 +15,26 @@ export interface TimerData {
1515
timestamp: number;
1616
}
1717

18+
/**
19+
* Find the latest (most recently added) consult media from the interaction.
20+
*
21+
* After transfer → re-consult the backend may leave the OLD consult media
22+
* in the interaction alongside the NEW one. Using Array.find() would return
23+
* the first (stale) entry; we need the last one which is the active consult.
24+
*/
25+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26+
export function findLatestConsultMedia(interaction: any): any {
27+
if (!interaction?.media) return null;
28+
const allMedia = Object.values(interaction.media);
29+
let latest = null;
30+
for (const m of allMedia) {
31+
if ((m as {mType: string}).mType === 'consult') {
32+
latest = m;
33+
}
34+
}
35+
return latest;
36+
}
37+
1838
/**
1939
* Calculate state timer label and timestamp based on task state.
2040
* Priority: Wrap Up > Post Call
@@ -24,7 +44,6 @@ export function calculateStateTimerData(
2444
controls: TaskUIControls | null,
2545
agentId: string
2646
): TimerData {
27-
// Default return value
2847
const defaultTimer: TimerData = {label: null, timestamp: 0};
2948

3049
if (!currentTask || !controls) {
@@ -38,29 +57,24 @@ export function calculateStateTimerData(
3857
return defaultTimer;
3958
}
4059

41-
// Extract timestamps from participant data
4260
let wrapUpTimestamp = 0;
4361
let postCallTimestamp = 0;
4462

45-
// Wrap-up timestamp: use lastUpdated if currently in wrap-up, otherwise use wrapUpTimestamp
4663
if (participant.isWrapUp) {
4764
wrapUpTimestamp = participant.lastUpdated || 0;
4865
} else {
4966
wrapUpTimestamp = participant.wrapUpTimestamp || 0;
5067
}
5168

52-
// Post-call timestamp: use currentStateTimestamp
5369
postCallTimestamp = participant.currentStateTimestamp || 0;
5470

55-
// Priority 1: Wrap-up state (highest priority)
5671
if (controls.main?.wrapup?.isVisible && wrapUpTimestamp) {
5772
return {
5873
label: TIMER_LABEL_WRAP_UP,
5974
timestamp: wrapUpTimestamp,
6075
};
6176
}
6277

63-
// Priority 2: Post-call state (only if not in wrap-up)
6478
const isInPostCall = interaction?.state === 'post_call' || participant?.currentState === 'post_call';
6579
if (isInPostCall && postCallTimestamp) {
6680
return {
@@ -75,6 +89,12 @@ export function calculateStateTimerData(
7589
/**
7690
* Calculate consult timer label and timestamp based on consult state.
7791
* Handles consult on hold vs active consulting states.
92+
*
93+
* Approach mirrors the original next-branch pattern: derive consultCallHeld
94+
* from the consult media's isHold flag (task data), NOT from SDK uiControls
95+
* properties like activeLeg or switch button visibility. Those UI properties
96+
* have different lifecycle timing and broader semantics that cause false
97+
* positives (e.g., switch.isVisible is true during CONSULT_INITIATING).
7898
*/
7999
export function calculateConsultTimerData(
80100
currentTask: ITask | null,
@@ -94,33 +114,36 @@ export function calculateConsultTimerData(
94114
return defaultTimer;
95115
}
96116

97-
// Extract consult start timestamp
98117
let consultStartTimeStamp = 0;
99118
if (participant.consultTimestamp) {
100119
consultStartTimeStamp = participant.consultTimestamp;
101120
} else if (participant.lastUpdated) {
102121
consultStartTimeStamp = participant.lastUpdated;
103122
}
104123

105-
// If no consult timestamp, return default
106124
if (!consultStartTimeStamp) {
107125
return defaultTimer;
108126
}
109127

110-
// Derive consultCallHeld from controls: main.switch.isVisible means consult call is held (agent is on main, can switch to consult)
111-
const consultCallHeld = controls.main?.switch?.isVisible ?? false;
128+
// Use the LATEST consult media, not the first. After transfer → re-consult
129+
// the backend keeps the old consult media (with stale isHold=true) alongside
130+
// the new one. Array.find() would return the old stale entry.
131+
const consultMedia = findLatestConsultMedia(interaction);
132+
const isConsultMediaHeld = consultMedia?.isHold === true;
133+
const consultHoldTimestamp = consultMedia?.holdTimestamp ?? null;
134+
const consultCallHeld = isConsultMediaHeld && consultHoldTimestamp !== null && consultHoldTimestamp > 0;
112135

113136
if (consultCallHeld) {
114-
const consultHoldTimestamp = findHoldTimestamp(currentTask, 'consult');
115-
116137
return {
117138
label: TIMER_LABEL_CONSULT_ON_HOLD,
118-
timestamp: consultHoldTimestamp && consultHoldTimestamp > 0 ? consultHoldTimestamp : consultStartTimeStamp,
139+
timestamp: consultHoldTimestamp,
119140
};
120141
}
121142

122-
// Use task.data.consultStatus for consult phase distinction
123-
const isConsultInitiated = currentTask.data?.consultStatus === 'consultInitiated';
143+
// Distinguish "Consult Requested" from "Consulting" using participant data.
144+
const isConsultInitiated =
145+
participant?.consultState === 'consultInitiated' ||
146+
currentTask.data?.consultStatus === 'consultInitiated';
124147
const label = isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING;
125148

126149
return {

packages/contact-center/task/src/helper.ts

Lines changed: 118 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import store, {
2525
isInteractionOnHold,
2626
MEDIA_TYPE_TELEPHONY_LOWER,
2727
} from '@webex/cc-store';
28-
import {TIMER_LABEL_CONSULTING} from './Utils/constants';
29-
import {calculateStateTimerData, calculateConsultTimerData} from './Utils/timer-utils';
28+
import {TIMER_LABEL_CONSULTING, TIMER_LABEL_CONSULT_REQUESTED, TIMER_LABEL_CONSULT_ON_HOLD, TIMER_LABEL_WRAP_UP} from './Utils/constants';
29+
import {calculateStateTimerData, calculateConsultTimerData, findLatestConsultMedia} from './Utils/timer-utils';
3030
import {useHoldTimer} from './Utils/useHoldTimer';
3131
import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/services/config/types';
3232

@@ -279,7 +279,18 @@ export const useIncomingTask = (props: UseTaskProps) => {
279279
};
280280

281281
export const useCallControl = (props: useCallControlProps) => {
282-
const {currentTask, onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute, logger, isMuted, agentId, conferenceEnabled = true} = props;
282+
const {
283+
currentTask,
284+
onHoldResume,
285+
onEnd,
286+
onWrapUp,
287+
onRecordingToggle,
288+
onToggleMute,
289+
logger,
290+
isMuted,
291+
agentId,
292+
conferenceEnabled = true,
293+
} = props;
283294
const [isRecording, setIsRecording] = useState(true);
284295
const [controls, setControls] = useState<TaskUIControls>(currentTask?.uiControls ?? getDefaultUIControls());
285296
const [isHeld, setIsHeld] = useState<boolean>(() => (currentTask ? isInteractionOnHold(currentTask) : false));
@@ -296,6 +307,7 @@ export const useCallControl = (props: useCallControlProps) => {
296307
// Consult timer labels and timestamps
297308
const [consultTimerLabel, setConsultTimerLabel] = useState<string>(TIMER_LABEL_CONSULTING);
298309
const [consultTimerTimestamp, setConsultTimerTimestamp] = useState<number>(0);
310+
const prevIsConsultingRef = useRef(false);
299311
const [lastTargetType, setLastTargetType] = useState<TargetType>(TARGET_TYPE.AGENT);
300312
const [conferenceParticipants, setConferenceParticipants] = useState<Participant[]>([]);
301313
const lastWrapupAuxCodeIdRef = useRef<string | null>(null);
@@ -922,10 +934,6 @@ export const useCallControl = (props: useCallControlProps) => {
922934
}
923935

924936
try {
925-
// When consulting (even from within a conference), use regular transfer.
926-
// transferConference() is only for transferring the entire conference ownership,
927-
// not for transferring a consult to join the conference.
928-
// Check state machine: CONSULTING state means we should use transfer(), not transferConference()
929937
const currentState = currentTask.state?.value;
930938
const isCurrentlyConsulting = currentState === 'CONSULTING';
931939

@@ -936,13 +944,40 @@ export const useCallControl = (props: useCallControlProps) => {
936944
});
937945
await currentTask.transferConference();
938946
} else {
939-
logger.info('Consult transfer initiated', {module: 'useCallControl', method: 'consultTransfer'});
940-
await currentTask.transfer(
941-
store.lastConsultDestination ?? {
942-
to: currentTask.data.destAgentId,
943-
destinationType: 'agent' as DestinationType,
947+
let destination = store.lastConsultDestination;
948+
949+
if (!destination?.to) {
950+
// After page refresh, lastConsultDestination is lost (in-memory only).
951+
// Recover the transfer target from the consult media's participants.
952+
const myAgentId = store.cc.agentConfig?.agentId;
953+
const {interaction} = currentTask.data;
954+
const consultMediaId = findMediaResourceId(currentTask, 'consult');
955+
const consultMedia = consultMediaId ? interaction?.media?.[consultMediaId] : null;
956+
957+
let consultedAgentId: string | null = null;
958+
if (consultMedia?.participants) {
959+
consultedAgentId = consultMedia.participants.find((pid: string) => {
960+
const p = interaction?.participants?.[pid];
961+
return p && p.id !== myAgentId && p.pType === 'Agent';
962+
}) ?? null;
944963
}
945-
);
964+
965+
if (consultedAgentId) {
966+
destination = {to: consultedAgentId, destinationType: 'agent' as DestinationType};
967+
logger.info(`Recovered consult destination from interaction data: ${consultedAgentId}`, {
968+
module: 'useCallControl',
969+
method: 'consultTransfer',
970+
});
971+
}
972+
}
973+
974+
if (!destination?.to) {
975+
logError('Cannot transfer: consult destination not found', 'consultTransfer');
976+
return;
977+
}
978+
979+
logger.info('Consult transfer initiated', {module: 'useCallControl', method: 'consultTransfer'});
980+
await currentTask.transfer(destination);
946981
}
947982
} catch (error) {
948983
logError(`Error transferring consult call: ${error}`, 'consultTransfer');
@@ -966,17 +1001,39 @@ export const useCallControl = (props: useCallControlProps) => {
9661001
currentTask.cancelAutoWrapupTimer();
9671002
};
9681003

969-
// Add useEffect for auto wrap-up timer
1004+
// Derive stable primitives from MobX-observed task data so that effects
1005+
// re-fire when the backend pushes fresh interaction/participant state —
1006+
// not only when controls change. `currentTask` is a MobX proxy whose
1007+
// reference never changes, so effects would otherwise miss data-only
1008+
// updates.
1009+
const _interaction = currentTask?.data?.interaction;
1010+
const _participant = _interaction?.participants?.[agentId];
1011+
1012+
// Consult-timer primitives
1013+
const _consultMedia = findLatestConsultMedia(_interaction);
1014+
const consultMediaIsHold = !!_consultMedia?.isHold;
1015+
const consultMediaId = _consultMedia?.mediaResourceId ?? '';
1016+
const participantConsultState = _participant?.consultState ?? null;
1017+
1018+
// State-timer (wrap-up / post-call) primitives
1019+
const participantIsWrapUp = !!_participant?.isWrapUp;
1020+
const participantWrapUpTimestamp = _participant?.wrapUpTimestamp ?? 0;
1021+
const participantLastUpdated = _participant?.lastUpdated ?? 0;
1022+
const participantCurrentState = _participant?.currentState ?? null;
1023+
const interactionState = _interaction?.state ?? null;
1024+
1025+
// Auto wrap-up timer.
1026+
// `currentTask.autoWrapup` must remain a useEffect dependency so that when
1027+
// the SDK sets it (after the initial wrapup render), React detects the
1028+
// change on the next re-render and re-fires the effect.
9701029
useEffect(() => {
9711030
let timerId: ReturnType<typeof setInterval>;
9721031

9731032
if (currentTask?.autoWrapup && controls?.main?.wrapup) {
9741033
try {
975-
// Initialize time left from the autoWrapup object
9761034
const initialTimeLeft = currentTask.autoWrapup.getTimeLeftSeconds();
9771035
setsecondsUntilAutoWrapup(initialTimeLeft);
9781036

979-
// Update timer every second
9801037
timerId = setInterval(() => {
9811038
setsecondsUntilAutoWrapup((prevTime) => {
9821039
if (prevTime && prevTime > 0) {
@@ -994,27 +1051,63 @@ export const useCallControl = (props: useCallControlProps) => {
9941051
}
9951052
}
9961053

997-
// Clear the interval when component unmounts or when auto wrap-up is no longer active
9981054
return () => {
9991055
if (timerId) {
10001056
clearInterval(timerId);
10011057
}
10021058
};
10031059
}, [currentTask?.autoWrapup, controls?.main?.wrapup]);
10041060

1005-
// Calculate state timer label and timestamp using utils
1061+
// Calculate state timer label and timestamp (Wrap Up / Post Call).
1062+
// When the SDK sets wrapup controls visible (ContactEnded event), the
1063+
// participant data may not yet contain the wrapup timestamp (it arrives
1064+
// in the subsequent AgentWrapup event). Bridge this gap by showing the
1065+
// "Wrap Up" label immediately with Date.now() as a close approximation;
1066+
// the timer auto-corrects when the real timestamp arrives.
10061067
useEffect(() => {
10071068
const stateTimerData = calculateStateTimerData(currentTask, controls, agentId);
1008-
setStateTimerLabel(stateTimerData.label);
1009-
setStateTimerTimestamp(stateTimerData.timestamp);
1010-
}, [currentTask, controls, agentId]);
10111069

1012-
// Calculate consult timer label and timestamp using utils
1070+
if (stateTimerData.label && stateTimerData.timestamp) {
1071+
setStateTimerLabel(stateTimerData.label);
1072+
setStateTimerTimestamp(stateTimerData.timestamp);
1073+
} else if (controls?.main?.wrapup?.isVisible) {
1074+
setStateTimerLabel(TIMER_LABEL_WRAP_UP);
1075+
setStateTimerTimestamp((prev) => prev || Date.now());
1076+
} else {
1077+
setStateTimerLabel(stateTimerData.label);
1078+
setStateTimerTimestamp(stateTimerData.timestamp);
1079+
}
1080+
}, [
1081+
currentTask, controls, agentId,
1082+
participantIsWrapUp, participantWrapUpTimestamp, participantLastUpdated,
1083+
participantCurrentState, interactionState,
1084+
]);
1085+
1086+
// Calculate consult timer label and timestamp.
1087+
// The calculation relies on consult media's isHold + holdTimestamp as the
1088+
// sole source of truth for "Consult on Hold" (same as the next branch).
1089+
//
1090+
// On hidden→visible transition (new consult starts), stale data from the
1091+
// previous flow may produce "Consult on Hold". Override to safe defaults.
1092+
// We never early-return — the calculation always runs — so that when data
1093+
// is already fresh (e.g., Agent 1 accepts a consult and data says
1094+
// "Consulting"), the correct label is applied immediately.
10131095
useEffect(() => {
1096+
const isConsulting = controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible;
1097+
const wasConsulting = prevIsConsultingRef.current;
1098+
prevIsConsultingRef.current = !!isConsulting;
1099+
10141100
const consultTimerData = calculateConsultTimerData(currentTask, controls, agentId);
1015-
setConsultTimerLabel(consultTimerData.label);
1016-
setConsultTimerTimestamp(consultTimerData.timestamp);
1017-
}, [currentTask, controls, agentId]);
1101+
const justBecameConsulting = isConsulting && !wasConsulting;
1102+
1103+
if (justBecameConsulting && consultTimerData.label === TIMER_LABEL_CONSULT_ON_HOLD) {
1104+
setConsultTimerLabel(TIMER_LABEL_CONSULT_REQUESTED);
1105+
setConsultTimerTimestamp(0);
1106+
} else {
1107+
setConsultTimerLabel(consultTimerData.label);
1108+
setConsultTimerTimestamp(consultTimerData.timestamp);
1109+
}
1110+
}, [currentTask, controls, agentId, consultMediaIsHold, consultMediaId, participantConsultState]);
10181111

10191112
return {
10201113
currentTask,

0 commit comments

Comments
 (0)