Status: Done. useCallControl in helper.ts reads SDK-computed TaskUIControls (per-leg: main, consult, activeLeg) instead of getControlsVisibility(). Action methods (task.hold(), task.end(), etc.) are unchanged.
- Store:
TASK_UI_CONTROLS_UPDATED→handleUIControlsUpdated→refreshTaskList()→ MobX re-render - Hook: Direct subscription on
currentTask.on(TASK_UI_CONTROLS_UPDATED)→setControls(updatedControls)for immediate button updates
const [controls, setControls] = useState<TaskUIControls>(
currentTask?.uiControls ?? getDefaultUIControls()
);
// Main leg buttons
controls.main.hold
controls.main.end
controls.main.wrapup
// Consult panel
controls.consult.endConsult
controls.consult.mergeToConference
controls.activeLeg // 'main' | 'consult' — hold/switch UI during consultbuildCallControlButtons() in call-control.utils.ts maps controls.main.* and optional consult panel from controls.consult.*.
| Prop | Status |
|---|---|
deviceType, featureFlags |
Removed — SDK UIControlConfig handles gating |
conferenceEnabled |
Retained — app-level override in button builders |
agentId |
Retained — timers, buddy agents, participant lookup |
getControlsVisibility + 22 get*ButtonVisibility functions deleted from task-util.ts. See store-task-utils-migration.md.
File: packages/contact-center/task/src/helper.ts
Hook: useCallControl(props: useCallControlProps)
- Control visibility: Calls
getControlsVisibility()→ 22 controls + 7 state flags - Hold/Resume:
toggleHold()→task.hold()/task.resume()/task.hold(mediaResourceId)/task.resume(mediaResourceId) - Mute:
toggleMute()→task.toggleMute()(local state tracking) - Recording:
toggleRecording()→task.pauseRecording()/task.resumeRecording() - End call:
endCall()→task.end() - Wrapup:
wrapupCall()→task.wrapup() - Transfer:
transferCall()→task.transfer() - Consult:
consultCall()→task.consult(),endConsultCall()→task.endConsult() - Consult transfer:
consultTransfer()→task.transfer()(consult) /task.transferConference()(conference) — SDK no longer hasconsultTransfer(), use.transfer()for consult - Conference:
consultConference()→task.consultConference(),exitConference()→task.exitConference() - Switch calls:
switchToConsult()→task.hold(mainMediaId)(single call),switchToMainCall()→task.resume(consultMediaId)(single call) - Auto-wrapup timer:
cancelAutoWrapup()→task.cancelAutoWrapupTimer() - Hold timer: via
useHoldTimer(currentTask)hook - Event callbacks: Registers hold/resume/end/wrapup/recording callbacks via
setTaskCallback
{
// Controls (from getControlsVisibility)
accept, decline, end, muteUnmute, holdResume,
pauseResumeRecording, recordingIndicator,
transfer, conference, exitConference, mergeConference,
consult, endConsult, consultTransfer, consultTransferConsult,
mergeConferenceConsult, muteUnmuteConsult,
switchToMainCall, switchToConsult, wrapup,
// State flags (from getControlsVisibility)
isConferenceInProgress, isConsultInitiated, isConsultInitiatedAndAccepted,
isConsultReceived, isConsultInitiatedOrAccepted, isHeld, consultCallHeld,
// Hook state
isMuted, isRecording, holdTime, buddyAgents,
consultAgentName, lastTargetType, secondsUntilAutoWrapup,
// Actions
toggleHold, toggleMute, toggleRecording, endCall, wrapupCall,
transferCall, consultCall, endConsultCall, consultTransfer,
consultConference, exitConference, switchToConsult, switchToMainCall,
cancelAutoWrapup,
}- Remove
getControlsVisibility()call entirely - Read
task.uiControlsdirectly for all control states - Subscribe to
task:ui-controls-updatedfor re-renders - Keep all action methods (hold, mute, end, etc.) — SDK methods unchanged
- Simplify state flags — derive from
uiControlsor remove entirely - Keep hold timer, auto-wrapup, mute state — these are widget-layer concerns
{
// Controls (directly from task.uiControls)
controls: TaskUIControls, // { accept, decline, hold, mute, end, transfer, ... }
// Hook state (kept)
isMuted: boolean,
isRecording: boolean,
holdTime: number,
buddyAgents: Agent[],
consultAgentName: string,
lastTargetType: string,
secondsUntilAutoWrapup: number,
// Actions (kept — SDK methods unchanged)
toggleHold, toggleMute, toggleRecording, endCall, wrapupCall,
transferCall, consultCall, endConsultCall, consultTransfer,
consultConference, exitConference, switchToConsult, switchToMainCall,
cancelAutoWrapup,
}Access via controls.main.* or controls.consult.*:
| Old Property | New Property | Change |
|---|---|---|
accept |
controls.main.accept |
Per-leg |
decline |
controls.main.decline |
Per-leg |
end |
controls.main.end |
Per-leg |
muteUnmute |
controls.main.mute |
Renamed |
holdResume |
controls.main.hold |
Renamed |
pauseResumeRecording |
controls.main.recording |
Renamed |
recordingIndicator |
controls.main.recording |
Same control — badge vs toggle in UI |
transfer |
controls.main.transfer |
Per-leg |
conference |
controls.main.conference / controls.consult.conference |
Per-leg |
exitConference |
controls.main.exitConference |
Per-leg |
mergeConference |
controls.main.mergeToConference |
Renamed |
consult |
controls.main.consult |
Initiate consult button |
endConsult |
controls.consult.endConsult |
Consult panel |
consultTransfer |
controls.main.transfer / controls.consult.transfer |
consultTransfer hidden in SDK |
switchToMainCall / switchToConsult |
controls.main.switch / controls.consult.switch |
Renamed to switch |
wrapup |
controls.main.wrapup |
Per-leg |
| Old Flag | New Approach |
|---|---|
isConferenceInProgress |
Use task.data.isConferenceInProgress (SDK computes and provides directly). For visibility-only gating, controls.exitConference.isVisible also works. Do NOT call the store helper getIsConferenceInProgress — it is dead code. |
isConsultInitiated |
Do NOT use controls.endConsult.isVisible as "initiated only" — that control is visible for both initiated and accepted consult. Use task.data.consultStatus to distinguish phases (e.g. consultInitiated vs consultAccepted). |
isConsultInitiatedAndAccepted |
Removed — SDK handles |
isConsultReceived |
Removed — SDK handles |
isConsultInitiatedOrAccepted |
controls.endConsult.isVisible |
isHeld |
Do NOT derive from controls.hold.isEnabled — get from the task object (SDK state machine tracks hold state internally). controls.hold.isEnabled is an action flag (whether the hold button is clickable), not the actual hold state — it can be false during consult/conference even when the call is not held. findHoldStatus() is dead code and will be removed (see store-task-utils-migration.md). |
consultCallHeld |
Do NOT use controls.switchToConsult.isVisible — that reflects button visibility, not actual hold state. Get from the task object. findHoldStatus() is dead code and will be removed. |
| Action | SDK Method | Change |
|---|---|---|
toggleHold |
task.hold() / task.resume() |
None |
toggleMute |
task.toggleMute() |
None |
toggleRecording |
task.pauseRecording() / task.resumeRecording() |
None |
endCall |
task.end() |
None |
wrapupCall |
task.wrapup() |
None |
transferCall |
task.transfer() |
None |
consultCall |
task.consult() |
None |
endConsultCall |
task.endConsult() |
None |
consultTransfer |
task.transfer() (consult) / task.transferConference() (conference) |
consultTransfer() no longer exists — use .transfer() for all non-conference transfer |
consultConference |
task.consultConference() |
None |
exitConference |
task.exitConference() |
None |
switchToConsult |
task.hold(mainMediaId) |
Single SDK call — holds main call; SDK auto-switches to consult leg |
switchToMainCall |
task.resume(consultMediaId) |
Single SDK call — resumes consult leg; SDK auto-switches to main call |
cancelAutoWrapup |
task.cancelAutoWrapupTimer() |
None |
export function useCallControl(props: useCallControlProps) {
const task = props.currentTask;
// OLD: Widget computes controls
const controls = getControlsVisibility(
props.deviceType,
props.featureFlags,
task,
props.agentId,
conferenceEnabled,
props.logger
);
// Event callbacks for hold, resume, end, wrapup, recording
useEffect(() => {
if (!task) return;
store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, task.data.interactionId);
store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, task.data.interactionId);
// ... 4 more callbacks
return () => {
store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, task.data.interactionId);
// ... cleanup
};
}, [task]);
return { ...controls, isMuted, isRecording, /* ... actions */ };
}export function useCallControl(props: useCallControlProps) {
const task = props.currentTask;
const [controls, setControls] = useState<TaskUIControls>(
task?.uiControls ?? getDefaultUIControls()
);
useEffect(() => {
if (!task) {
setControls(getDefaultUIControls());
return;
}
setControls(task.uiControls ?? getDefaultUIControls());
const onControlsUpdated = (updatedControls: TaskUIControls) => {
setControls(updatedControls);
};
task.on(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated);
return () => {
task.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated);
};
}, [task]);
// isHeld: isInteractionOnHold + consult activeLeg + conference hold flags
// ... event callbacks for hold, recording, wrapup host notifications ...
return { controls, isHeld, isMuted, isRecording, conferenceEnabled, /* actions */ };
}File: task/src/helper.ts, lines 634-653
Rule: Use the same event name in both setTaskCallback and removeTaskCallback so cleanup matches registration. The store registers with task.on(event, callback) and removes with task.off(event, callback); mismatched event names leave listeners attached.
// Correct: use TASK_RECORDING_* in BOTH set and remove
store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId);
store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId);
// ...
store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId);
store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId);Note: Keep AGENT_WRAPPEDUP for wrapup until SDK migration renames to TASK_WRAPPEDUP; then align both set and remove to the new name.
// Line 930-933: controlVisibility is a useMemo
const controlVisibility = useMemo(
() => getControlsVisibility(deviceType, featureFlags, currentTask, agentId, conferenceEnabled, logger),
[deviceType, featureFlags, currentTask, agentId, conferenceEnabled, logger]
);
// Line 939: Auto-wrapup timer depends on controlVisibility.wrapup
useEffect(() => {
if (currentTask?.autoWrapup && controlVisibility?.wrapup) { ... }
}, [currentTask?.autoWrapup, controlVisibility?.wrapup]);
// Line 974: State timer depends on controlVisibility
useEffect(() => {
const stateTimerData = calculateStateTimerData(currentTask, controlVisibility, agentId);
...
}, [currentTask, controlVisibility, agentId]);
// Line 982: Consult timer depends on controlVisibility
useEffect(() => {
const consultTimerData = calculateConsultTimerData(currentTask, controlVisibility, agentId);
...
}, [currentTask, controlVisibility, agentId]);Migration impact: calculateStateTimerData() and calculateConsultTimerData() in timer-utils.ts accept controlVisibility as a parameter. These must be updated to accept TaskUIControls instead (with new control names).
// Line 704-705:
if (!controlVisibility?.muteUnmute) {
logger.warn('Mute control not available', ...);
return;
}Migration: Change to controls.mute.
// Lines 766-773: After wrapup, sets next task as current and updates agent state
.then(() => {
const taskKeys = Object.keys(store.taskList);
if (taskKeys.length > 0) {
store.setCurrentTask(store.taskList[taskKeys[0]]);
store.setState({ developerName: ENGAGED_LABEL, name: ENGAGED_USERNAME });
}
})Migration: This logic stays. Post-wrapup task selection is a widget-layer concern.
// Line 898: Decides between transfer (consult) vs transferConference
if (currentTask.data.isConferenceInProgress) {
await currentTask.transferConference();
} else {
await currentTask.transfer(); // consultTransfer() no longer exists — use .transfer()
}Migration: Prefer currentTask.data.isConferenceInProgress (direct variable from SDK task data; see State Flags table). Alternatively use controls.transferConference.isVisible to decide. Note: task.consultTransfer() is no longer a public method; use task.transfer() for consult transfer.
Lines 326-446: ~120 lines of logic to find the consulting agent's name from interaction.participants and callProcessingDetails.consultDestinationAgentName. This is display-only logic and NOT related to control visibility. Keep as-is.
const isTelephonyTaskActive = useMemo(() => {
return Object.values(store.taskList).some(
(task) => task?.data?.interaction?.mediaType === MEDIA_TYPE_TELEPHONY_LOWER
);
}, [store.taskList]);Migration: Unaffected — this checks media type for outdial gating, not control visibility.
Widgets do NOT need to provide UIControlConfig. The SDK builds it from agent profile, callProcessingDetails, interaction.mediaType, and voice/WebRTC layer config. See "Props removed" table in Summary and Migration Gotcha #1 for details. Retain agentId — required by timer utils for participant lookup.
SDK sample app uses setTimeout(..., 0) before updating UI after task:wrapup. Consider adding similar guard in hook if wrapup controls flicker.
File: task/src/Utils/timer-utils.ts
The calculateStateTimerData() and calculateConsultTimerData() functions accept controlVisibility as a parameter with old control names. These must be migrated:
export function calculateStateTimerData(
task: ITask,
controlVisibility: ReturnType<typeof getControlsVisibility>,
agentId: string
) {
if (controlVisibility?.wrapup?.isVisible) {
return { label: 'Wrap Up', timestamp: task.data.wrapUpTimestamp };
}
// Uses controlVisibility.isConsultInitiatedOrAccepted, controlVisibility.isHeld, etc.
}export function calculateStateTimerData(
task: ITask,
controls: TaskUIControls,
agentId: string
) {
if (controls.wrapup.isVisible) {
return { label: 'Wrap Up', timestamp: task.data.wrapUpTimestamp };
}
const isConsulting = controls.endConsult.isVisible;
const isConferencing = task.data.isConferenceInProgress;
// Get hold state from task object — do NOT use controls.hold.isEnabled
}| Area | Lines | What to change |
|---|---|---|
| Event registration/cleanup | 634-653 | Use same event names in set and remove (e.g. TASK_RECORDING_* in both). |
| controlVisibility useMemo | 930-933 | Replace with controls from currentTask.uiControls (SDK handles feature-flag gating internally). |
| toggleMute guard | 704-705 | Change controlVisibility?.muteUnmute to controls?.mute?.isVisible. |
| Auto-wrapup effect | 935-968 | Depend on controls?.wrapup instead of controlVisibility?.wrapup. |
| State/consult timer effects | 970-984 | Pass controls into calculateStateTimerData / calculateConsultTimerData; update timer-utils to accept TaskUIControls. |
| Return object | 1016 | Return controls instead of controlVisibility. |
-
UIControlConfigis built by SDK: Widgets do NOT provide it. The SDK handles feature-flag gating internally viaconfig.isEndTaskEnabled,config.isEndConsultEnabled,config.isRecordingEnabled. Widget propsdeviceTypeandfeatureFlagscan be removed.conferenceEnabledis RETAINED — it is an application-level config (not a feature flag) that gates conference UI at the consumer level. There is noapplyFeatureGatesfunction. RetainagentId— timer utils need it for participant lookup. -
isHeldderivation: Hold control can beVISIBLE_DISABLEDin conference/consulting states without meaning the call is held. Do NOT derive fromcontrols.hold.isEnabled— it is an action flag (button clickability), not hold state. Get hold state from the task object (SDK tracks hold state internally).findHoldStatus()is dead code and will be removed (see store-task-utils-migration.md). -
Recording control semantics:
recording.isEnabledmeans the toggle button is actionable (clickable), not that recording is active. Active/paused state should come from recording events (TASK_RECORDING_PAUSED/TASK_RECORDING_RESUMED) or task state — not fromisEnabled. Userecording.isVisiblefor the recording badge/indicator. -
exitConferencevisibility change: In the new SDK,exitConferenceisVISIBLE_DISABLED(not hidden) during consulting-from-conference. Old widget logic hid it.
| File | Action |
|---|---|
task/src/helper.ts |
Refactor useCallControl as described above |
task/src/Utils/task-util.ts |
Delete getControlsVisibility + all 22 get*ButtonVisibility functions (dead code). Keep findHoldTimestamp(interaction, mType) for hold timer. findHoldStatus is dead code — remove it. |
task/src/Utils/timer-utils.ts |
Update to accept TaskUIControls instead of controlVisibility |
task/src/task.types.ts |
Update useCallControlProps return type |
task/tests/helper.ts |
Update all useCallControl tests |
cc-components/.../CallControl/call-control.tsx |
Update to accept new controls prop shape |
cc-components/.../CallControl/call-control.utils.ts |
Simplify (remove old control mapping) |
| Criterion | Status |
|---|---|
| SDK controls render in CallControl UI (main + consult legs) | Done |
| Hold / mute / recording / consult / conference / wrapup flows | Done |
| Auto-wrapup and hold timers | Done |
conferenceEnabled app-level gating |
Done |
getControlsVisibility removed |
Done |
| All actions call correct SDK methods | Done |
Parent: migration-overview.md Updated: 2026-05-20
- Issue: After migration, the hold button icon/tooltip did not toggle on click, and multi-login hold/resume did not sync across systems.
- Root Cause: The old
controlVisibility.isHeldwas removed.controls.hold.isEnabledis an action flag, not state.task.data.isOnHoldis not populated by SDK at runtime. The SDK state machine also lackedHOLD_SUCCESS/UNHOLD_SUCCESStransitions for multi-login scenarios. - SDK Source of Truth:
uiControlsComputer.tsderivesisHeldfromserverHold ?? state === TaskState.HELD.controls.holdisVISIBLE_ENABLEDin bothCONNECTEDandHELDstates — it's an action flag, not a state indicator. - Fix Pattern (in
useCallControlhook —helper.ts):import { isInteractionOnHold } from '@webex/cc-store'; const [isHeld, setIsHeld] = useState<boolean>(() => currentTask ? isInteractionOnHold(currentTask) : false ); useEffect(() => { setIsHeld(currentTask ? isInteractionOnHold(currentTask) : false); }, [currentTask]); // In holdCallback: setIsHeld(true); // In resumeCallback: setIsHeld(false); // Return isHeld from hook
- SDK Fix: Added
HOLD_SUCCESShandler toCONNECTEDstate andUNHOLD_SUCCESShandler toHELDstate inTaskStateMachine.tsfor multi-login sync.
- Issue: During the task-refactor migration, the
conferenceEnabledprop was removed from the widget APIs. This prop is not a feature flag — it is an application-level configuration passed fromApp.tsxthat controls whether conference-related UI controls should be available to the agent. Without it, applications cannot disable conference features regardless of SDKuiControls. - Root Cause: The migration assumed all UI visibility is driven exclusively by
task.uiControlsfrom the SDK state machine. However,conferenceEnabledis an application-level override that gates conference availability at the consumer level, independent of the SDK's computed state. - Design Decision (Option A — Widget-Side Override at Button Level):
conferenceEnabledis applied directly in the button builder functions (buildCallControlButtonsandcreateConsultButtons) where conference-related buttons are defined. Whenfalse, theisVisibleproperty of conference buttons (conference,exitConference,merge) is forced tofalseregardless of SDKuiControls. Whentrue(default), SDK controls pass through unchanged. - Gating Pattern (in button builder functions):
// call-control.utils.ts — buildCallControlButtons // conferenceEnabled param defaults to true { id: 'conference', isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false) && !!handleConsultConferencePress, }, { id: 'exitConference', isVisible: conferenceEnabled && (controls?.exitConference?.isVisible ?? false), }, // call-control-custom.utils.ts — createConsultButtons { key: 'conference', isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false), },
- Prop Flow:
App.tsx→CallControl/CallControlCAD→useCallControlhook → returned as prop →CallControlComponent→buildCallControlButtons()/CallControlConsultComponent→createConsultButtons() - Files Changed:
cc-components/…/task.types.ts: AddedconferenceEnabled: booleantoControlProps,CallControlComponentProps,CallControlConsultComponentsPropscc-components/…/call-control.utils.ts: AddedconferenceEnabledparam tobuildCallControlButtons, gatedconferenceandexitConferencebuttonscc-components/…/call-control-custom.utils.ts: AddedconferenceEnabledparam tocreateConsultButtons, gatedconference(merge) buttoncc-components/…/call-control.tsx: DestructuredconferenceEnabled, passed tobuildCallControlButtonscc-components/…/call-control-consult.tsx: DestructuredconferenceEnabled, passed tocreateConsultButtonscc-components/…/call-control-cad.tsx: DestructuredconferenceEnabled, passed toCallControlConsultComponenttask/src/task.types.ts: AddedconferenceEnabledtoCallControlPropsanduseCallControlPropstask/src/helper.ts: DestructuredconferenceEnabled(defaulttrue), returned from hooktask/src/CallControl/index.tsxandCallControlCAD/index.tsx: PassconferenceEnabledtouseCallControlcc-widgets/src/wc.ts: ExposedconferenceEnabledas r2wcbooleanprop onWebCallControlandWebCallControlCAD
- Consumer Usage: Apps pass
conferenceEnabled={true|false}as a prop to<CallControl>or<CallControlCAD>. Web component consumers set theconference-enabledattribute. Defaults totrueif not provided. - Result: Conference buttons (merge, exit conference) are hidden when
conferenceEnabledisfalse, while all other SDK-driven controls remain unaffected.