Status: Done. Presentational components consume SDK TaskUIControls with per-leg structure (main, consult, activeLeg). The old flat ControlVisibility interface (22 controls + 7 state flags) is replaced by TaskUIControls imported from @webex/cc-store.
- Control visibility/enablement:
task.uiControls.main.*andtask.uiControls.consult.* - Active leg during consult:
task.uiControls.activeLeg('main'|'consult') - Hold state:
isHeldprop from hook (isInteractionOnHold+ consult/conference logic) — notcontrols.main.hold.isEnabled - Conference state: interaction
state === 'conference'/ task data — notexitConference.isVisiblealone
Widgets do not call getControlsVisibility() or findHoldStatus().
File: cc-components/src/components/task/task.types.ts
import type { TaskUIControls, InteractionUIControls, TaskUILeg } from '@webex/cc-store';
// TaskUIControls shape (SDK):
// {
// main: InteractionUIControls;
// consult: InteractionUIControls;
// activeLeg: 'main' | 'consult';
// }| Old flat prop | New path | Notes |
|---|---|---|
holdResume |
controls.main.hold |
Renamed |
muteUnmute |
controls.main.mute |
Renamed |
pauseResumeRecording / recordingIndicator |
controls.main.recording |
Single control; UI splits badge vs toggle |
mergeConference |
controls.main.mergeToConference |
Renamed |
switchToMainCall / switchToConsult |
controls.main.switch / controls.consult.switch |
Renamed to switch |
State flags (isConsultInitiated, etc.) |
Removed from props | Use controls.consult.endConsult, task.data, or hook isHeld |
CallControl passes controls: TaskUIControls to buildCallControlButtons(controls.main, ...) and consult panel via createConsultButtons(controls.consult, ...).
File: packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx
| Old Prop | New Prop | Change |
|---|---|---|
holdResume |
hold |
Rename |
muteUnmute |
mute |
Rename |
pauseResumeRecording |
recording |
Rename — toggle button (pause/resume) |
recordingIndicator |
recording |
Same SDK control — widget must preserve separate recording status badge UI. Use recording.isVisible for badge, recording.isEnabled for toggle |
mergeConference |
mergeToConference |
Rename. SDK also has a separate conference control; both are visible during consulting when initiator, agent joined, and not at max participants. Use mergeToConference for the Merge action; conference is a semantic alias for the same merge-from-consult flow. |
consultTransferConsult |
transfer / transferConference |
Split — use transfer for consult transfer, transferConference for conference transfer |
mergeConferenceConsult |
— | Remove (use mergeToConference) |
muteUnmuteConsult |
— | Remove (use mute) |
isConferenceInProgress |
— | Remove (use task.data.isConferenceInProgress directly; do not use controls.exitConference.isVisible as sole source) |
isConsultInitiated |
— | Remove (if needed, use task.data.consultStatus for consult phase distinction) |
isConsultInitiatedAndAccepted |
— | Remove |
isConsultReceived |
— | Remove |
isConsultInitiatedOrAccepted |
— | Remove |
isHeld |
isHeld |
Retain — get from the task object (SDK provides hold state). Do NOT derive from controls.hold.isEnabled. |
consultCallHeld |
— | Remove (get from the task object if needed for display) |
interface CallControlComponentProps {
controls: TaskUIControls; // { main, consult, activeLeg } from task.uiControls
isHeld: boolean; // Hook-derived; not controls.main.hold.isEnabled
conferenceEnabled: boolean; // App-level gating
// ... actions, buddyAgents, consultAgentName, media state
}File: packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx
- Uses
controls.consult.endConsult,controls.consult.mergeToConference,controls.consult.switch - Main-leg switch via
controls.main.switchwhen on consult leg conferenceEnabledgates merge/conference buttons
- Accept/decline from
acceptControl/declineControlprops (fromuiControls.main) isBrowserretained for outdial accept label text ("Accept" vs "Ringing...")isDeclineButtonEnabledretained as legacy bridge OR'd with SDK decline enablement
- Same per-task
uiControls.main.accept/declineviaextractTaskListItemData isBrowserretained for outdial label rules
- Receives
controls: TaskUIControlsandisHeldfrom hook - Outdial header number:
displayNumberusesdnisfor outdial,anifor inbound (header title) - Phone Number label: continues to use
ani(PROD parity) conferenceEnabledpassed to consult sub-component
- No uiControls — dial UI only; failure popup via host
setOutdialFailed
// call-control.tsx — old approach
const CallControlComponent = ({
// 22 individual control props
accept, decline, end, muteUnmute, holdResume,
pauseResumeRecording, recordingIndicator,
transfer, conference, exitConference, mergeConference,
consult, endConsult, consultTransfer, consultTransferConsult,
mergeConferenceConsult, muteUnmuteConsult,
switchToMainCall, switchToConsult, wrapup,
// 7 state flags
isConferenceInProgress, isConsultInitiated,
isConsultInitiatedAndAccepted, isConsultReceived,
isConsultInitiatedOrAccepted, isHeld, consultCallHeld,
// Actions and hook state
isMuted, isRecording, holdTime, onToggleHold, onToggleMute, ...
}) => {
return (
<div className="call-control">
{holdResume.isVisible && (
<Button onClick={() => onToggleHold(!isHeld)} disabled={!holdResume.isEnabled}>
{isHeld ? 'Resume' : 'Hold'}
</Button>
)}
{muteUnmute.isVisible && (
<Button onClick={onToggleMute} disabled={!muteUnmute.isEnabled}>
{isMuted ? 'Unmute' : 'Mute'}
</Button>
)}
{end.isVisible && (
<Button onClick={onEndCall} disabled={!end.isEnabled}>End</Button>
)}
{/* Consult sub-controls */}
{isConsultInitiatedOrAccepted && (
<div className="consult-controls">
{endConsult.isVisible && <Button onClick={onEndConsult}>End Consult</Button>}
{consultTransferConsult.isVisible && <Button>Consult Transfer</Button>}
{mergeConferenceConsult.isVisible && <Button>Merge</Button>}
{muteUnmuteConsult.isVisible && <Button>Mute Consult</Button>}
</div>
)}
{/* Conference sub-controls */}
{isConferenceInProgress && (
<div className="conference-controls">
{exitConference.isVisible && <Button>Exit Conference</Button>}
{mergeConference.isVisible && <Button>Merge Conference</Button>}
</div>
)}
</div>
);
};CallControl receives full TaskUIControls and buildCallControlButtons reads controls.main for the main strip and controls.consult for the consult panel (via createConsultButtons).
// call-control.tsx — current approach
const CallControlComponent = ({
controls, // TaskUIControls { main, consult, activeLeg }
isHeld, // From hook (isInteractionOnHold + consult/conference logic)
isMuted, isRecording, holdTime, conferenceEnabled,
onToggleHold, onToggleMute, onEndCall, ...
}: CallControlComponentProps) => {
const buttons = buildCallControlButtons(
isMuted, isRecording, isMuteButtonDisabled, currentMediaType,
controls, isHeld, ..., conferenceEnabled
);
const filteredButtons = filterButtonsForConsultation(buttons, controls);
// Consult strip: createConsultButtons(controls.consult, ...)
};Inside buildCallControlButtons, main-leg buttons use controls.main.*:
const mainCtrl = controls?.main;
// mainCtrl.hold, mainCtrl.mute, mainCtrl.transfer, mainCtrl.switch, etc.| Old flag | Current source |
|---|---|
isConferenceInProgress |
Interaction state === 'conference' or task data |
isConsultInitiatedOrAccepted |
controls.consult.endConsult.isVisible or controls.main.endConsult.isVisible |
isHeld |
Hook prop from isInteractionOnHold + consult/conference hold events — not controls.main.hold.isEnabled |
activeLeg |
controls.activeLeg ('main' | 'consult') for switch/hold UI |
Takes full TaskUIControls; reads controls.main for the main button strip.
| Old Reference | New Equivalent |
|---|---|
controlVisibility.muteUnmute |
controls.main.mute |
controlVisibility.isHeld |
isHeld param (hook-derived) |
controlVisibility.holdResume |
controls.main.hold |
controlVisibility.consult |
controls.main.consult |
controlVisibility.transfer |
controls.main.transfer |
controlVisibility.mergeConference |
controls.main.mergeToConference / controls.main.conference |
controlVisibility.pauseResumeRecording |
controls.main.recording |
controlVisibility.exitConference |
controls.main.exitConference (gated by conferenceEnabled) |
| Consult transfer during active consult | Shown when controls.consult.endConsult or controls.main.endConsult visible |
Reads controls.consult for the consult strip.
| Old Reference | New Equivalent |
|---|---|
controlVisibility.muteUnmuteConsult |
controls.consult.mute |
controlVisibility.switchToMainCall / switchToConsult |
controls.consult.switch / controls.main.switch |
controlVisibility.consultTransferConsult |
controls.consult.transfer / controls.consult.consultTransfer |
controlVisibility.mergeConferenceConsult |
controls.consult.mergeToConference |
controlVisibility.endConsult |
controls.consult.endConsult |
// OLD: uses consultInitiated flag (from getControlsVisibility state flags)
// NEW: use task.data.consultStatus from SDK for accurate consult phase.
// Do NOT derive consult-init state from controls.endConsult.isVisible (it spans both initiated and accepted).
// e.g. task.data.consultStatus === 'consultInitiated' for "initiated only" distinction.// OLD: uses consultInitiated boolean (derived from getControlsVisibility state flags)
// NEW: use task.data.consultStatus from SDK for accurate consult phase.
// e.g. task.data.consultStatus === 'consultInitiated' → 'Consult requested'
// task.data.consultStatus === 'consultAccepted' → 'Consulting'
// Do NOT derive from control visibility (endConsult.isVisible, mergeToConference.isEnabled);
// visibility can change for feature gating and misclassify phase.// OLD: controlVisibility: ControlVisibility
// NEW: controls: TaskUIControls// OLD: isConferenceInProgress?: boolean
// NEW: use task.data.isConferenceInProgress (SDK provides directly); not controls.exitConference.isVisiblecontrolVisibility: ControlVisibility→controls: TaskUIControlsisHeld: boolean→ get from the task object (SDK provides hold state); removefindHoldStatusderivationdeviceType: string→ REMOVE (SDK handles)featureFlags: {[key: string]: boolean}→ REMOVE (SDK handles)RESTORED — application-level config (not a feature flag), applied at button builder levelconferenceEnabled: boolean→ REMOVEagentId: string→ RETAIN (needed for timer participant lookup)
- task/src/CallControlCAD/index.tsx:
deviceTypeandfeatureFlagsare used today ingetControlsVisibility(task-util.ts lines 421–525). The SDK handles feature-flag-like gating internally viaconfig.isEndTaskEnabled,config.isEndConsultEnabled,config.isRecordingEnabledfrom agent profile andcallProcessingDetails. Since widgets will readtask.uiControlsinstead of callinggetControlsVisibility,deviceTypeandfeatureFlagscan be removed — the SDK has already computed them.conferenceEnabledis RETAINED — it is an application-level configuration (not a feature flag) passed from the consumer app. RetainagentIdfor timer participant lookup. - cc-components/.../CallControlCAD/call-control-cad.tsx: This view consumes
controlVisibility(and related state flags such asisConferenceInProgress,isHeld,isConsultReceived,recordingIndicator,isConsultInitiatedOrAccepted). It must be updated to useTaskUIControlsand the new prop shape when replacingControlVisibility; otherwise migration will leave stale references and break at compile or runtime.
| File | Reason |
|---|---|
AutoWrapupTimer.tsx |
Uses secondsUntilAutoWrapup only |
consult-transfer-popover-hooks.ts |
Pagination/search logic |
consult-transfer-list-item.tsx |
Display only |
consult-transfer-dial-number.tsx |
Input handling |
consult-transfer-empty-state.tsx |
Display only |
TaskTimer/index.tsx |
Timer display |
Task/index.tsx |
Task card display |
OutdialCall/outdial-call.tsx |
No task controls used |
Status: Done. Utils and WC layer updated for per-leg uiControls.
Current: Uses uiControls.main.accept/decline (or caller-passed acceptControl/declineControl). isBrowser retained for outdial accept label ("Accept" vs "Ringing..."). isDeclineButtonEnabled retained as legacy bridge OR'd with SDK decline enablement.
export const extractIncomingTaskData = (
incomingTask: ITask,
logger?,
acceptControl?: {isVisible: boolean; isEnabled: boolean},
declineControl?: {isVisible: boolean; isEnabled: boolean},
isDeclineButtonEnabled?: boolean,
isBrowser?: boolean
): IncomingTaskData => {
const accept = acceptControl ?? incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false};
const sdkDecline = declineControl ?? incomingTask?.uiControls?.main?.decline ?? {...};
const decline = { ...sdkDecline, isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled };
const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial);
const acceptText = accept.isVisible ? (showRinging ? 'Ringing...' : 'Accept') : undefined;
// ...
};Current: Same per-leg controls + legacy decline bridge + isBrowser for outdial label rules.
export const extractTaskListItemData = (
task: ITask,
agentId: string,
logger?: ILogger,
isDeclineButtonEnabled?: boolean,
isBrowser?: boolean
): TaskListItemData => {
const accept = task.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false};
const decline = { ...sdkDecline, isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled };
// Same showRinging / acceptText logic as IncomingTask
};Current: Receives controls: TaskUIControls, isHeld from hook, and conferenceEnabled.
// Outdial header uses dnis; inbound uses ani
const displayNumber = isOutdial ? dnis || ani : ani;
// Hold chip, recording badge, consult panel use controls.main / controls.consult
<CallControlComponent controls={controls} isHeld={isHeld} conferenceEnabled={conferenceEnabled} ... />Before: IncomingTask and TaskList Web Components expose isBrowser as a boolean prop.
const WebIncomingTask = r2wc(IncomingTaskComponent, {
props: {
incomingTask: 'json',
isBrowser: 'boolean',
accept: 'function',
reject: 'function',
},
});
const WebTaskList = r2wc(TaskListComponent, {
props: {
currentTask: 'json',
taskList: 'json',
isBrowser: 'boolean',
acceptTask: 'function',
declineTask: 'function',
logger: 'function',
},
});Current: isBrowser is retained on Web IncomingTask and Web TaskList for outdial accept label text. Visibility comes from uiControls.main; isBrowser is not used to gate button visibility.
const WebIncomingTask = r2wc(IncomingTaskComponent, {
props: {
incomingTask: 'json',
isBrowser: 'boolean', // Outdial label text only
acceptControl: 'json',
declineControl: 'json',
isDeclineButtonEnabled: 'boolean',
accept: 'function',
reject: 'function',
},
});conferenceEnabled exposed on WebCallControl and WebCallControlCAD.
| File | Status | Notes |
|---|---|---|
task.types.ts |
Done | TaskUIControls replaces ControlVisibility |
CallControl/call-control.tsx |
Done | Uses controls: TaskUIControls |
CallControl/call-control.utils.ts |
Done | buildCallControlButtons reads controls.main |
CallControlCustom/call-control-custom.utils.ts |
Done | createConsultButtons reads controls.consult |
IncomingTask/incoming-task.utils.tsx |
Done | uiControls.main + isBrowser + decline bridge |
TaskList/task-list.utils.ts |
Done | Same pattern as IncomingTask |
CallControlCAD/call-control-cad.tsx |
Done | Outdial displayNumber from dnis |
wc.ts |
Done | isBrowser retained for outdial labels; conferenceEnabled on CallControl |
| Component tests | Done | Mocks updated for TaskUIControls |
| Criterion | Status |
|---|---|
CallControl uses TaskUIControls (main + consult legs) |
Done |
buildCallControlButtons / createConsultButtons |
Done |
| IncomingTask / TaskList per-task main controls | Done |
CallControlCAD outdial dnis header display |
Done |
conferenceEnabled app-level gating |
Done |
isBrowser for outdial labels (WC + React) |
Done |
| Component tests updated | Done |
Parent: migration-overview.md Updated: 2026-05-20
- Issue: After accepting a call, both "Transfer" and "Transfer Call" buttons appeared simultaneously.
- Fix:
transferConsultbutton visibility gated on active consult (controls.consult.endConsultorcontrols.main.endConsult) and usescontrols.main.transfer— not the main blind-transfer button alone. - Result: Main "Transfer" shows in
CONNECTED; consult-strip transfer only during active consultation.
- Issue: (1) After clicking Hold, the button icon stayed as pause and tooltip stayed as "Hold the call" instead of changing to play/"Resume the call". (2) In multi-login scenarios, holding/resuming on one system did not reflect on the other system.
- Root Cause:
- The old
controlVisibility.isHeldwas removed during migration. The replacementcontrols.hold.isEnabledis an action flag (can the user click hold?), not the current hold state.task.data.isOnHoldexists in SDK types but is not populated at runtime. - For multi-login: The SDK's
TaskStateMachine.tsCONNECTEDstate had no handler forHOLD_SUCCESS(another system held), andHELDstate had no handler forUNHOLD_SUCCESS(another system resumed). These events were silently dropped.
- The old
- Fix (Widgets):
helper.ts(useCallControlhook): AddeduseState(isHeld)initialized fromisInteractionOnHold(currentTask). UpdatedholdCallbacktosetIsHeld(true)andresumeCallbacktosetIsHeld(false). AddeduseEffect([currentTask])to re-sync fromisInteractionOnHoldon task reference changes (covers multi-loginrefreshTaskList).call-control.utils.ts: AddedisHeld: booleanparameter tobuildCallControlButtons(). Hold button usesisHeld ? 'play-bold' : 'pause-bold'for icon andisHeld ? RESUME_CALL : HOLD_CALLfor tooltip.call-control.tsx: DestructuredisHeldfrom props, passed tobuildCallControlButtons()andhandleToggleHoldUtil().task.types.ts: Added'isHeld'toCallControlComponentPropspick list.
- Fix (SDK): Added
HOLD_SUCCESStransition inCONNECTEDstate andUNHOLD_SUCCESStransition inHELDstate ofTaskStateMachine.ts, both with actions['updateTaskData', 'setHoldState', 'emitTaskHold'/'emitTaskResume']. - Result: Hold button icon/tooltip toggles correctly on click. Multi-login hold/resume state syncs across systems via SDK state machine transitions.
- Issue: The
conferenceEnabledprop was removed from widget APIs during migration. This is an application-level configuration (not a feature flag) passed from the consumer app that controls whether conference-related UI controls are available to the agent. - Root Cause: The migration assumed all UI visibility is exclusively SDK-driven. However,
conferenceEnabledis a consumer-level override independent of SDK state. - Design Decision: Option A — widget-side override applied directly at the button builder level. When
conferenceEnabledisfalse, theisVisibleproperty of conference-related buttons (conference,exitConference,merge) is forced tofalseinbuildCallControlButtons()andcreateConsultButtons(). Defaults totrue. - Component-Layer Changes:
task.types.ts: AddedconferenceEnabled: booleantoControlProps,CallControlComponentProps,CallControlConsultComponentsPropscall-control.utils.ts: AddedconferenceEnabledparam tobuildCallControlButtons(), gatedconferenceandexitConferencebuttons viaconferenceEnabled && (controls?.…isVisible)call-control-custom.utils.ts: AddedconferenceEnabledparam tocreateConsultButtons(), gatedconference(merge) buttoncall-control.tsx: DestructuredconferenceEnabledfrom props, passed tobuildCallControlButtons()call-control-consult.tsx: DestructuredconferenceEnabled, passed tocreateConsultButtons()call-control-cad.tsx: DestructuredconferenceEnabled, passed toCallControlConsultComponentcc-widgets/src/wc.ts: ExposedconferenceEnabledas r2wcbooleanprop onWebCallControlandWebCallControlCAD
- No SDK changes required: Gating is applied at the widget component layer directly on button definitions.
- Result: Conference merge and exit buttons are hidden when
conferenceEnabled={false}. All other SDK-driven controls remain unaffected.