From 5158542d05a9ea325090bd6ab53c36fe0e58b025 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Thu, 13 Aug 2026 13:10:22 +0530 Subject: [PATCH 1/3] feat(contact-center): wxcc-6026 wxapp answer, mute sync, dtmf keypad --- .../CallControl/call-control-dtmf-keypad.tsx | 37 +++++ .../task/CallControl/call-control.styles.scss | 16 +++ .../task/CallControl/call-control.tsx | 12 +- .../task/CallControl/call-control.utils.ts | 18 ++- .../src/components/task/task.types.ts | 8 +- .../CallControl/call-control.snapshot.tsx | 1 + .../task/CallControl/call-control.tsx | 1 + .../task/CallControl/call-control.utils.tsx | 2 +- .../call-control-cad.snapshot.tsx | 1 + .../task/CallControlCAD/call-control-cad.tsx | 1 + .../store/ai-docs/store-spec.md | 6 +- .../store/src/storeEventsWrapper.ts | 25 +++- .../store/tests/storeEventsWrapper.ts | 81 +++++++++++ .../contact-center/task/ai-docs/task-spec.md | 20 ++- .../task/src/IncomingTask/index.tsx | 16 ++- packages/contact-center/task/src/helper.ts | 41 +++++- .../task/src/wxapp-task.utils.ts | 64 +++++++++ .../task/tests/CallControl/index.tsx | 1 + .../task/tests/CallControlCAD/index.tsx | 4 + .../task/tests/IncomingTask/index.tsx | 36 ++++- packages/contact-center/task/tests/helper.ts | 135 ++++++++++++++++++ .../task/tests/wxapp-task.utils.test.ts | 135 ++++++++++++++++++ .../cc/samples-cc-react-app/src/App.tsx | 99 ++++++++++++- 23 files changed, 735 insertions(+), 25 deletions(-) create mode 100644 packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx create mode 100644 packages/contact-center/task/src/wxapp-task.utils.ts create mode 100644 packages/contact-center/task/tests/wxapp-task.utils.test.ts diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx new file mode 100644 index 000000000..57665362f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {Button} from '@momentum-design/components/dist/react'; +import {KEY_LIST} from '../OutdialCall/constants'; +import type {ILogger} from '@webex/cc-store'; + +export type CallControlDtmfKeypadProps = { + onDigitPress: (digit: string) => void; + logger?: ILogger; +}; + +/** + * In-call DTMF keypad for wxApp telephony sessions (Extension login). + * Each key press sends a single tone via SDK transmitDtmfOnWebex(). + */ +const CallControlDtmfKeypad: React.FunctionComponent = ({onDigitPress, logger}) => { + const handleDigitPress = (digit: string) => { + logger?.info(`CC-Widgets: CallControl: DTMF digit pressed`, { + module: 'call-control-dtmf-keypad.tsx', + method: 'handleDigitPress', + }); + onDigitPress(digit); + }; + + return ( + + ); +}; + +export default CallControlDtmfKeypad; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss index 58b02d01f..12e5d006f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss @@ -27,6 +27,22 @@ margin-top: 1rem; } +.call-control-dtmf-keys { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + list-style: none; + padding: 0.5rem; + margin: 0; + min-width: 12rem; +} + +.call-control-dtmf-key { + width: 100%; + min-height: 2.5rem; +} + + .wrapup-group { display: flex; flex-direction: column; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index eb902e4b9..43d1b388e 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -5,6 +5,7 @@ import './call-control.styles.scss'; import {PopoverNext, TooltipNext, Text, ButtonCircle} from '@momentum-ui/react-collaboration'; import {Icon, Button, Select, Option} from '@momentum-design/components/dist/react'; import ConsultTransferPopoverComponent from './CallControlCustom/consult-transfer-popover'; +import CallControlDtmfKeypad from './call-control-dtmf-keypad'; import AutoWrapupTimer from '../AutoWrapupTimer/AutoWrapupTimer'; import type {MEDIA_CHANNEL as MediaChannelType} from '../task.types'; import {DestinationType} from '@webex/cc-store'; @@ -40,6 +41,7 @@ function CallControlComponent(props: CallControlComponentProps) { toggleHold, toggleRecording, toggleMute, + sendDtmf, isMuted, endCall, wrapupCall, @@ -168,13 +170,15 @@ function CallControlComponent(props: CallControlComponentProps) { { - logger.info(`CC-Widgets: CallControl: showing consult-transfer popover`, { + logger.info(`CC-Widgets: CallControl: showing ${button.menuType} popover`, { module: 'call-control.tsx', method: 'onShowPopover', }); setShowAgentMenu(true); setAgentMenuType(button.menuType as CallControlMenuType); - loadBuddyAgents(); + if (button.menuType !== 'Keypad') { + loadBuddyAgents(); + } }} onHide={() => { setShowAgentMenu(false); @@ -219,7 +223,9 @@ function CallControlComponent(props: CallControlComponentProps) { } > - {showAgentMenu && agentMenuType === button.menuType ? ( + {showAgentMenu && agentMenuType === button.menuType && button.menuType === 'Keypad' ? ( + + ) : showAgentMenu && agentMenuType === button.menuType ? ( { try { - const mainCtrl = controls?.main; + const mainCtrl = controls?.main as TaskMainControlsWithKeypad | undefined; const isTransferConferenceVisible = mainCtrl?.transferConference?.isVisible ?? false; const isTransferConferenceEnabled = mainCtrl?.transferConference?.isEnabled ?? false; const isTransferVisible = mainCtrl?.transfer?.isVisible ?? false; @@ -229,6 +235,16 @@ export const buildCallControlButtons = ( isVisible: mainCtrl?.mute?.isVisible ?? false, dataTestId: 'call-control:mute-toggle', }, + { + id: 'keypad', + icon: 'dialpad-bold', + tooltip: 'Keypad', + className: 'call-control-button', + disabled: !(mainCtrl?.keypad?.isEnabled ?? false), + isVisible: mainCtrl?.keypad?.isVisible ?? false, + menuType: 'Keypad', + dataTestId: 'call-control:keypad', + }, { id: 'switchToConsult', icon: 'call-swap-bold', diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 6192837be..e95b2a165 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -288,6 +288,11 @@ export interface ControlProps { */ toggleMute: () => void; + /** + * Sends a DTMF tone on wxApp engaged telephony calls. + */ + sendDtmf: (digit: string) => void; + /** * Function to handle ending the call. */ @@ -525,6 +530,7 @@ export type CallControlComponentProps = Pick< | 'toggleHold' | 'toggleRecording' | 'toggleMute' + | 'sendDtmf' | 'isMuted' | 'endCall' | 'wrapupCall' @@ -709,7 +715,7 @@ export interface CallControlConsultComponentsProps { /** * Type representing the possible menu types in call control. */ -export type CallControlMenuType = 'Consult' | 'Transfer' | 'ExitConference'; +export type CallControlMenuType = 'Consult' | 'Transfer' | 'ExitConference' | 'Keypad'; export const MEDIA_CHANNEL = { EMAIL: 'email', diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx index 2309338ab..b2da61945 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx @@ -87,6 +87,7 @@ describe('CallControlComponent Snapshots', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8363fb2ec..38864c073 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -70,6 +70,7 @@ describe('CallControlComponent', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index 789ac36f6..c7f6d529f 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -445,7 +445,7 @@ describe('CallControl Utils', () => { jest.fn() // mergeConference ); - expect(buttons).toHaveLength(10); // Updated to 10 to include switchToConsult, transferConsult, and conference buttons + expect(buttons).toHaveLength(11); // Includes keypad (WXCC-6026), switchToConsult, transferConsult, and conference buttons // Check mute button const muteButton = buttons.find((b) => b.id === 'mute'); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx index 6d6fc2d42..3271e118e 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx @@ -127,6 +127,7 @@ describe('CallControlCADComponent Snapshots', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index dd4888597..bf845645c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -96,6 +96,7 @@ describe('CallControlCADComponent', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 75a882569..09e7c09ab 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -31,9 +31,8 @@ as approved unknowns only when the human explicitly defers or does not know. |---|---|---|---| | `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/AGENTS.md` | overview / API / usage | migrated | Overview, Purpose, Public Surface, Use Cases; usage snippets condensed to behavior. | | `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/ARCHITECTURE.md` | architecture / sequence diagrams | reconciled | Design Overview, Data Flow, Sequence Diagram(s), Pitfalls. Diagrams re-derived from current `store.ts` / `storeEventsWrapper.ts`; see Conflicts note below for drift corrected. | +| `packages/contact-center/ai-docs/features/thick-client-answer/intake.md` | wxApp thick-client answer (WXCC-6026) | reference-only (implemented) | Mercury mute sync → `TASK_WXAPP_MUTE_STATE_UPDATED`; see Design Overview event handling | | `@webex/contact-center` package types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | SDK API reference (installed `.d.ts`) | reference-only | Linked as the authoritative source for SDK-shaped types/methods consumed via `store.cc.*`. | - -## Overview `@webex/cc-store` is the single shared MobX store for every Webex Contact Center widget. It is the sole boundary between widgets and the `@webex/contact-center` SDK: widgets never import the SDK directly — they read observables and call methods on the store, which proxies to `store.cc.*`. The package is structured in two layers. `Store` (`src/store.ts`) is a `makeAutoObservable` singleton (`Store.getInstance()`) that holds raw observable state and owns initialization/registration with the SDK. `StoreWrapper` (`src/storeEventsWrapper.ts`) is the default export — it wraps the singleton, getter-proxies every observable, owns all SDK event wiring (CC + task events), exposes mutators (all writes funnel through `runInAction`), list-fetch helpers, callback registration, and task-lifecycle handling. `src/index.ts` re-exports the `StoreWrapper` instance as the default export plus everything from `store.types.ts` (types, the `CC_EVENTS` / `TASK_EVENTS` enums, login/consult/campaign constants) and `task-utils.ts` (pure selectors over SDK `ITask` objects). `util.ts` extracts a fixed allow-list of feature flags from the agent `Profile` at registration time. @@ -111,13 +110,14 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | +| `STORE-R-022` | Per-task listener on **`TASK_WXAPP_MUTE_STATE_UPDATED`** (guarded by `wxAppMuteStateListeners` map) calls **`handleWxAppMuteStateUpdated`** → `setIsMuted(payload.muted)` only when the task matches `currentTask`; detached in **`handleTaskRemove`** | Webex App mute/unmute must sync embed UI without widgets calling Mercury; prevent duplicate listeners | `src/storeEventsWrapper.ts:507-509,942-946,999-1003` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | SDK must emit event; store does not call telephony REST | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`). With a host-supplied `webex`, the wrapper wires event listeners and registers synchronously. Without one, the store calls `Webex.init()`, arms a 6000ms timeout, and waits for the `ready` event before wiring listeners and registering; the timeout guards against an SDK that never becomes ready. Registration maps the agent `Profile` into observables once. -Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. +Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. **WxApp mute sync:** when the SDK emits **`TASK_WXAPP_MUTE_STATE_UPDATED`** (Mercury path in SDK), the store updates **`isMuted`** for the current task via **`handleWxAppMuteStateUpdated`** — widgets read `store.isMuted`; they never subscribe to Mercury directly. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`. diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4f1f35f60..8288b51fa 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -54,6 +54,7 @@ class StoreWrapper implements IStoreWrapper { // replacement task object (task:hydrate / task:merged) gets rebound. private realTimeAssistListeners: Record void}> = {}; + private wxAppMuteStateListeners: Record void> = {}; constructor() { this.store = Store.getInstance(); @@ -503,6 +504,10 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_REJECT, (reason) => this.handleTaskReject(taskToRemove, reason)); taskToRemove.off(TASK_EVENTS.TASK_OUTDIAL_FAILED, (reason) => this.handleOutdialFailed(reason)); taskToRemove.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, this.handleUIControlsUpdated); + if (taskId && this.wxAppMuteStateListeners[taskId]) { + taskToRemove.off(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, this.wxAppMuteStateListeners[taskId]); + delete this.wxAppMuteStateListeners[taskId]; + } taskToRemove.off(TASK_EVENTS.TASK_WRAPPEDUP, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONSULT_CREATED, this.handleConsultCreated); taskToRemove.off(TASK_EVENTS.TASK_OFFER_CONTACT, this.refreshTaskList); @@ -558,6 +563,7 @@ class StoreWrapper implements IStoreWrapper { } if (taskToRemove && this.store.currentTask?.data.interactionId === taskToRemove.data.interactionId) { this.setCurrentTask(null); + this.setIsMuted(false); } this.setState({ @@ -568,11 +574,12 @@ class StoreWrapper implements IStoreWrapper { }; handleTaskMuteState = (task: ITask): void => { - const isBrowser = this.deviceType === DEVICE_TYPE_BROWSER; - const webRtcEnabled = this.featureFlags?.webRtcEnabled; const isTelephony = task?.data?.interaction?.mediaType === MEDIA_TYPE_TELEPHONY_LOWER; - if (isBrowser && isTelephony && webRtcEnabled) { + // Each new telephony offer starts unmuted on Webex App / WebRTC media. + // Widgets track mute locally in store.isMuted — reset so a prior call's mute + // state does not leak into the next interaction (WXCC-6026 wxApp thick-client). + if (isTelephony) { this.setIsMuted(false); } }; @@ -799,6 +806,7 @@ class StoreWrapper implements IStoreWrapper { handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); + this.setIsMuted(false); this.refreshTaskList(); }; @@ -931,6 +939,12 @@ class StoreWrapper implements IStoreWrapper { this.refreshTaskList(); }; + handleWxAppMuteStateUpdated = (payload: {muted: boolean}, task: ITask) => { + if (this.currentTask?.data?.interactionId === task.data?.interactionId) { + this.setIsMuted(payload.muted); + } + }; + handleSwitchCall = () => { this.refreshTaskList(); }; @@ -982,6 +996,11 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.TASK_CAMPAIGN_CONTACT_UPDATED, this.refreshTaskList); const taskId = task.data?.interactionId; + if (taskId && !this.wxAppMuteStateListeners[taskId]) { + const wxAppMuteListener = (payload: {muted: boolean}) => this.handleWxAppMuteStateUpdated(payload, task); + this.wxAppMuteStateListeners[taskId] = wxAppMuteListener; + task.on(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, wxAppMuteListener); + } if (taskId && !this.realtimeTranscriptionListeners[taskId]) { this.realtimeTranscriptionListeners[taskId] = (payload: RealTimeTranscriptionEventPayload) => this.handleRealtimeTranscription(payload); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index e41c51e94..bd0d461a7 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -688,6 +688,34 @@ describe('storeEventsWrapper', () => { expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_CREATED, expect.any(Function)); }); + describe('handleTaskMuteState', () => { + it('resets isMuted on new incoming telephony task for Extension login', () => { + storeWrapper['store'].deviceType = 'EXTENSION'; + storeWrapper['store'].isMuted = true; + + storeWrapper.handleTaskMuteState(mockTask); + + expect(storeWrapper.isMuted).toBe(false); + }); + + it('resets isMuted when current task is removed after ending muted', () => { + storeWrapper['store'].isMuted = true; + storeWrapper['store'].currentTask = mockTask; + + storeWrapper.handleTaskRemove(mockTask); + + expect(storeWrapper.isMuted).toBe(false); + }); + + it('resets isMuted on task end', () => { + storeWrapper['store'].isMuted = true; + + storeWrapper.handleTaskEnd(); + + expect(storeWrapper.isMuted).toBe(false); + }); + }); + it('should call onErrorCallback and rethrow when store.init rejects with an Error', async () => { const cc = storeWrapper['store'].cc; const logger = storeWrapper['store'].logger; @@ -947,6 +975,59 @@ describe('storeEventsWrapper', () => { expect(storeWrapper.realTimeAssist[interactionId]).toBeUndefined(); }); + it('should update isMuted for current task on TASK_WXAPP_MUTE_STATE_UPDATED', () => { + const interactionId = 'interaction-wxapp-mute'; + const task = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + storeWrapper['store'].currentTask = task; + const setIsMutedSpy = jest.spyOn(storeWrapper, 'setIsMuted'); + + storeWrapper.handleWxAppMuteStateUpdated({muted: true}, task); + + expect(setIsMutedSpy).toHaveBeenCalledWith(true); + }); + + it('should ignore TASK_WXAPP_MUTE_STATE_UPDATED for non-current task', () => { + const task = makeMockTask({ + data: {interactionId: 'interaction-wxapp-mute', interaction: {state: 'connected'}}, + }); + storeWrapper['store'].currentTask = makeMockTask({ + data: {interactionId: 'other-interaction', interaction: {state: 'connected'}}, + }); + const setIsMutedSpy = jest.spyOn(storeWrapper, 'setIsMuted'); + + storeWrapper.handleWxAppMuteStateUpdated({muted: true}, task); + + expect(setIsMutedSpy).not.toHaveBeenCalled(); + }); + + it('should register one wxApp mute listener and remove it with the task', () => { + const interactionId = 'interaction-wxapp-mute-listener'; + const task = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + const registerTaskEventListeners = storeWrapper as unknown as { + registerTaskEventListeners: (taskToRegister: ITask) => void; + }; + + registerTaskEventListeners.registerTaskEventListeners(task); + registerTaskEventListeners.registerTaskEventListeners(task); + + const listenerCalls = (task.on as jest.Mock).mock.calls.filter( + ([event]) => event === TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED + ); + expect(listenerCalls).toHaveLength(1); + + storeWrapper['store'].currentTask = task; + const setIsMutedSpy = jest.spyOn(storeWrapper, 'setIsMuted'); + listenerCalls[0][1]({muted: false}); + expect(setIsMutedSpy).toHaveBeenCalledWith(false); + + storeWrapper.handleTaskRemove(task); + expect(task.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, listenerCalls[0][1]); + }); + it('should handle task removal', () => { const refreshTaskListSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..b35e57552 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -27,7 +27,7 @@ Every generated requirement below must cite concrete source evidence using `file | `ai-docs/_archive/.../task/ai-docs/widgets/OutdialCall/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Outdial + ANI flow → Sequence Diagram(s); login-mode behavior → Use Cases / Pitfalls. | | `ai-docs/_archive/.../task/ai-docs/widgets/TaskList/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Task selection / accept / decline flow → Sequence Diagram(s). | | `packages/contact-center/ai-docs/migration/*.md` (7 files) | architecture (planned refactor) | reference-only | Describes a planned SDK `task.uiControls` migration that is NOT in current code. Used only to mark conflicts; current behavior documented as-is. | -| `packages/contact-center/task/src/` | source of truth | migrated | All requirements, flows, state, and error tables derive from real code here. | +| `packages/contact-center/ai-docs/features/thick-client-answer/intake.md` | feature intake (WXCC-6026) | reference-only (implemented) | wxApp Answer/Decline/Mute + Mercury mute sync — see § Feature: Accept on Webex thick client | ## Overview `task` is the largest CC widget bundle: it exports six React/Web-Component widgets that together cover the full agent interaction lifecycle — being offered a task, accepting/declining it, controlling an active call (hold, mute, record, consult, transfer, conference, wrap-up), placing outbound calls, listing concurrent tasks, and rendering a live transcript. Each widget follows the repo-standard layering: a thin `observer()` widget wraps an `ErrorBoundary`, reads MobX state from `@webex/cc-store`, delegates business logic to a custom hook in `helper.ts`, and renders a presentational component from `@webex/cc-components`. The hook is the only place that touches the SDK (`task.*` / `store.cc.*`) and registers/unregisters store task-event callbacks. @@ -87,6 +87,22 @@ Compatibility notes: - Adding an optional prop/callback is additive (minor); removing or renaming one, or changing a callback payload shape, is breaking (major) — these widgets are consumed via r2wc Web Components in `@webex/cc-widgets`. - `conferenceEnabled` is normalized to `true` when undefined inside the `CallControl`/`CallControlCAD` wrappers; consumers relying on `undefined` getting `false` would break. +### Feature: Accept on Webex thick client (implemented — WXCC-6026) + +Canonical spec: [`intake.md`](../../ai-docs/features/thick-client-answer/intake.md). + +| Surface | Change | +|---|---| +| **Host init** | `webexConfig.cc.enableAnswerOnWebex: boolean` (default `false`) — set **before** `store.init()` | +| **IncomingTask** | When SDK `isWebexAppCallingOffer()` → `acceptOnWebex()` / `rejectOnWebex()` instead of `accept()` / `decline()` | +| **CallControl** | Engaged wxApp → **`toggleMuteForTask(task, intendedMuteState)`** → **`toggleMuteOnWebex({ muted })`**; **`transmitDtmfOnWebex()`** for keypad; visibility from SDK `uiControls` | +| **TaskList** | Inline Accept / Decline on wxApp offers — same branch as IncomingTask | +| **wxapp-task.utils.ts** | Shared wxApp helpers: `isWxAppEngagedCall`, `toggleMuteForTask`, accept/decline routing | + +**SDK scope:** telephony REST, uiControls, usersub publish, **Mercury mute sync** (`TASK_WXAPP_MUTE_STATE_UPDATED`). + +**Store scope:** `storeEventsWrapper` listens for **`TASK_WXAPP_MUTE_STATE_UPDATED`** per task → `handleWxAppMuteStateUpdated` → `setIsMuted()` when task is `currentTask`. Widgets never call Mercury directly. + ## Requires (dependencies) - `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask`, `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names: `packages/contact-center/store/src/store.types.ts`. - `@webex/cc-components` (internal): presentational components (`IncomingTaskComponent`, `TaskListComponent`, `CallControlComponent`, `CallControlCADComponent`, `OutdialCallComponent`, `RealTimeTranscriptComponent`) and types (`ControlProps`, `TaskProps`, `OutdialCallProps`, `Visibility`, `ControlVisibility`, `RealTimeTranscriptComponentProps`, `CampaignCallProcessingDetails`). @@ -104,7 +120,7 @@ Compatibility notes: | `TASK-R-005` | `useTaskList` wires `store.setTaskAssigned`/`setTaskRejected`/`setTaskSelected` only when the matching consumer callback (`onTaskAccepted`/`onTaskDeclined`/`onTaskSelected`) is provided; each wrapped callback is try/caught. | Avoid registering no-op store callbacks and isolate consumer-thrown errors. | `src/helper.ts` (`useTaskList` `useEffect`) | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided", "should handle errors in taskAssigned callback", "should handle errors in taskSelected callback") | none | PRESENT | | `TASK-R-006` | `CallControl.toggleHold(true/false)` calls `currentTask.hold()`/`currentTask.resume()`; `TASK_HOLD`/`TASK_RESUME` events fire `onHoldResume({isHeld, task})`. | Hold/resume must reflect real SDK state to the consumer. | `src/helper.ts` (`useCallControl.toggleHold`, `holdCallback`, `resumeCallback`) | `tests/helper.ts` ("should call onHoldResume with hold=true and handle success", "...hold=false...", "should log an error if hold fails", "should log an error if resume fails") | none | PRESENT | | `TASK-R-007` | `toggleRecording` calls `pauseRecording()` when `isRecording` else `resumeRecording({autoResumed:false})`; `TASK_RECORDING_PAUSED`/`TASK_RECORDING_RESUMED` callbacks set `isRecording` and fire `onRecordingToggle`. | Recording UI state must track SDK events, not just the click. | `src/helper.ts` (`useCallControl.toggleRecording`, `pauseRecordingCallback`, `resumeRecordingCallback`) | `tests/helper.ts` ("should pause the recording when pauseResume is called with true", "should fail and log error if pause failed", "should resume the recording when pauseResume is called with false") | Subscription uses `TASK_RECORDING_PAUSED/RESUMED`; cleanup removes `CONTACT_RECORDING_PAUSED/RESUMED` (mismatch — see Pitfalls) | PRESENT | -| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; otherwise `await currentTask.toggleMute()`, then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure it reports the prior `isMuted`. | Mute state must reflect SDK truth even under rapid toggles or failure. | `src/helper.ts` (`useCallControl.toggleMute`) | `tests/helper.ts` ("should successfully toggle mute from unmuted to muted", "should handle multiple rapid toggleMute calls correctly", "should not call onToggleMute callback on error if not provided") | none | PRESENT | +| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; wxApp engaged calls use **`toggleMuteForTask(currentTask, intendedMuteState)`** → **`toggleMuteOnWebex({ muted })`**; WebRTC uses `toggleMute()`; then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure reports prior `isMuted`. | Mute state must reflect SDK/store truth; wxApp must pass UI intent to avoid Mercury desync. | `src/helper.ts` (`useCallControl.toggleMute`), `src/wxapp-task.utils.ts` | `tests/helper.ts` (mute + wxApp routing), `tests/wxapp-task.utils.test.ts` | none | PRESENT | | `TASK-R-009` | `wrapupCall(reason, auxCodeId)` calls `currentTask.wrapup(...)`; on resolve it promotes the first remaining task in `store.taskList` to `currentTask` and sets agent state to ENGAGED. | After wrap-up the agent should auto-focus the next task and return to an engaged state. | `src/helper.ts` (`useCallControl.wrapupCall`) | `tests/helper.ts` ("should call wrapupCall", "should log an error if wrapup fails") | ENGAGED label/username are local constants (`ENGAGED_LABEL`, `ENGAGED_USERNAME`) | PRESENT | | `TASK-R-010` | Auto-wrap-up: when `currentTask.autoWrapup` and `controlVisibility.wrapup` are present, a 1s interval counts `secondsUntilAutoWrapup` down from `getTimeLeftSeconds()`; `cancelAutoWrapup` calls `currentTask.cancelAutoWrapupTimer()`. | Show and allow cancellation of the auto-wrap-up countdown. | `src/helper.ts` (`useCallControl` auto-wrapup `useEffect`, `cancelAutoWrapup`) | `tests/helper.ts` ("should initialize secondsUntilAutoWrapup to null when auto wrap-up is not active", "should call cancelAutoWrapup successfully", "should handle cancelAutoWrapup when currentTask is missing") | none | PRESENT | | `TASK-R-011` | `consultCall(dest, type, allowParticipantsToInteract)` sends `holdParticipants: !allowParticipantsToInteract`; for `type==='queue'` it sets/clears `store.isQueueConsultInProgress` + `currentConsultQueueId` around the call, including on error. | Queue consult requires tracking the in-flight queue id so `endConsult` can pass it. | `src/helper.ts` (`useCallControl.consultCall`, `endConsultCall`) | `tests/helper.ts` ("should call consultCall successfully", "should call consultCall with allowParticipantsToInteract set to true", "should call endConsultCall with queue parameters when queue consult is in progress") | none | PRESENT | diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index ead1009e3..7f92b5192 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -9,8 +9,20 @@ import {IncomingTaskProps} from '../task.types'; const IncomingTaskInternal: React.FunctionComponent = observer( ({incomingTask, onAccepted, onRejected}) => { - const {logger, isDeclineButtonEnabled, deviceType} = store; - const result = useIncomingTask({incomingTask, onAccepted, onRejected, logger}); + const {logger, isDeclineButtonEnabled, deviceType, taskList} = store; + const interactionId = incomingTask?.data?.interactionId; + const liveIncomingTask = interactionId && taskList[interactionId] ? taskList[interactionId] : incomingTask; + + if (interactionId && liveIncomingTask !== incomingTask) { + logger?.info('CC-Widgets: IncomingTask using live task from store.taskList', { + module: 'IncomingTask', + method: 'render', + interactionId, + acceptEnabled: liveIncomingTask?.uiControls?.main?.accept?.isEnabled, + }); + } + + const result = useIncomingTask({incomingTask: liveIncomingTask, onAccepted, onRejected, logger}); const props = { ...result, diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..d67b456d4 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -27,6 +27,13 @@ import store, { MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, } from '@webex/cc-store'; +import { + acceptTaskForOffer, + getKeypadControl, + rejectTaskForOffer, + toggleMuteForTask, + transmitDtmfForTask, +} from './wxapp-task.utils'; import { TIMER_LABEL_CONSULTING, TIMER_LABEL_CONSULT_REQUESTED, @@ -142,7 +149,7 @@ export const useTaskList = (props: UseTaskListProps) => { module: 'useTaskList', method: 'acceptTask', }); - task.accept().catch((error) => { + acceptTaskForOffer(task).catch((error) => { logError(`CC-Widgets: Error accepting task: ${error}`, 'acceptTask'); }); } catch (error) { @@ -159,7 +166,7 @@ export const useTaskList = (props: UseTaskListProps) => { module: 'useTaskList', method: 'declineTask', }); - task.decline().catch((error) => { + rejectTaskForOffer(task).catch((error) => { logError(`CC-Widgets: Error declining task: ${error}`, 'declineTask'); }); logger.log(`CC-Widgets: incoming task declined for ${task.data.interactionId}`, { @@ -211,6 +218,14 @@ export const useIncomingTask = (props: UseTaskProps) => { const acceptControl = incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; const sdkDeclineControl = incomingTask?.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; + + logger?.info('CC-Widgets: IncomingTask uiControls snapshot', { + module: 'useIncomingTask', + method: 'render', + interactionId: incomingTask?.data?.interactionId, + accept: acceptControl, + decline: sdkDeclineControl, + }); const declineControl = { ...sdkDeclineControl, isEnabled: sdkDeclineControl.isEnabled || store.isDeclineButtonEnabled, @@ -293,7 +308,7 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'accept', }); if (!incomingTask?.data.interactionId) return; - incomingTask.accept().catch((error) => { + acceptTaskForOffer(incomingTask).catch((error) => { logError(`CC-Widgets: Error accepting incoming task: ${error}`, 'accept'); }); logger.log(`CC-Widgets: incomingTask accepted`, { @@ -315,7 +330,7 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'reject', }); if (!incomingTask?.data.interactionId) return; - incomingTask.decline().catch((error) => { + rejectTaskForOffer(incomingTask).catch((error) => { logError(`CC-Widgets: Error rejecting incoming task: ${error}`, 'reject'); }); logger.log(`CC-Widgets: incomingTask rejected`, { @@ -826,7 +841,7 @@ export const useCallControl = (props: useCallControlProps) => { const intendedMuteState = !isMuted; try { - await currentTask.toggleMute(); + await toggleMuteForTask(currentTask, intendedMuteState); // Only update state after successful SDK call store.setIsMuted(intendedMuteState); @@ -857,6 +872,21 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const sendDtmf = async (digit: string) => { + try { + if (!getKeypadControl(controls)?.isVisible) { + logger.warn('Keypad control not available', {module: 'useCallControl', method: 'sendDtmf'}); + return; + } + + logger.info(`sendDtmf(${digit}) called`, {module: 'useCallControl', method: 'sendDtmf'}); + + await transmitDtmfForTask(currentTask, digit); + } catch (error) { + logger.error(`sendDtmf failed: ${error}`, {module: 'useCallControl', method: 'sendDtmf'}); + } + }; + const endCall = () => { try { logger.info('endCall() called', {module: 'useCallControl', method: 'endCall'}); @@ -1231,6 +1261,7 @@ export const useCallControl = (props: useCallControlProps) => { toggleHold, toggleRecording, toggleMute, + sendDtmf, isMuted, wrapupCall, isRecording, diff --git a/packages/contact-center/task/src/wxapp-task.utils.ts b/packages/contact-center/task/src/wxapp-task.utils.ts new file mode 100644 index 000000000..35ca2a475 --- /dev/null +++ b/packages/contact-center/task/src/wxapp-task.utils.ts @@ -0,0 +1,64 @@ +import {ITask, TaskUIControls} from '@webex/contact-center'; + +/** + * WxApp thick-client telephony methods on ITask (SDK @webex/contact-center — WXCC-6026). + * Duck-typed until SDK types publish acceptOnWebex / transmitDtmfOnWebex on ITask. + */ +export type WxAppTelephonyTask = ITask & { + isWebexAppCallingOffer?: () => boolean; + acceptOnWebex?: () => Promise; + rejectOnWebex?: () => Promise; + toggleMuteOnWebex?: (options?: {lineOwnerId?: string; muted?: boolean}) => Promise; + transmitDtmfOnWebex?: (options: {dtmf: string; lineOwnerId?: string}) => Promise; + getWebexCallingCallId?: () => string | null | undefined; +}; + +/** SDK P0 keypad control — may exist on main leg before InteractionUIControls ships keypad. */ +export type TaskMainControlsWithKeypad = TaskUIControls['main'] & { + keypad?: {isVisible: boolean; isEnabled: boolean}; +}; + +export const getKeypadControl = (controls: TaskUIControls | undefined) => + (controls?.main as TaskMainControlsWithKeypad | undefined)?.keypad; + +export const isWxAppCallingOffer = (task: ITask | null | undefined): boolean => { + const wxTask = task as WxAppTelephonyTask | null | undefined; + return typeof wxTask?.isWebexAppCallingOffer === 'function' && !!wxTask.isWebexAppCallingOffer(); +}; + +export const isWxAppEngagedCall = (task: ITask | null | undefined): boolean => { + const wxTask = task as WxAppTelephonyTask | null | undefined; + return typeof wxTask?.getWebexCallingCallId === 'function' && !!wxTask.getWebexCallingCallId(); +}; + +export const acceptTaskForOffer = (task: ITask): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppCallingOffer(task) && typeof wxTask.acceptOnWebex === 'function') { + return wxTask.acceptOnWebex(); + } + return task.accept(); +}; + +export const rejectTaskForOffer = (task: ITask): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppCallingOffer(task) && typeof wxTask.rejectOnWebex === 'function') { + return wxTask.rejectOnWebex(); + } + return task.decline(); +}; + +export const toggleMuteForTask = (task: ITask, muted: boolean): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppEngagedCall(task) && typeof wxTask.toggleMuteOnWebex === 'function') { + return wxTask.toggleMuteOnWebex({muted}); + } + return task.toggleMute(); +}; + +export const transmitDtmfForTask = (task: ITask, dtmf: string): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppEngagedCall(task) && typeof wxTask.transmitDtmfOnWebex === 'function') { + return wxTask.transmitDtmfOnWebex({dtmf}); + } + return Promise.resolve(); +}; diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 3e7bae023..ff205bc04 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -53,6 +53,7 @@ describe('CallControl Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index b30905ba9..6999ccf70 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -56,6 +56,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -127,6 +128,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -180,6 +182,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -236,6 +239,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx index e15667071..c84fe65f0 100644 --- a/packages/contact-center/task/tests/IncomingTask/index.tsx +++ b/packages/contact-center/task/tests/IncomingTask/index.tsx @@ -8,9 +8,15 @@ import '@testing-library/jest-dom'; // Mock the store jest.mock('@webex/cc-store', () => ({ - cc: {}, - deviceType: 'BROWSER', - dialNumber: '12345', + __esModule: true, + default: { + cc: {}, + deviceType: 'BROWSER', + dialNumber: '12345', + taskList: {}, + isDeclineButtonEnabled: false, + logger: undefined, + }, })); const onAcceptedCb = jest.fn(); @@ -19,6 +25,7 @@ const onRejectedCb = jest.fn(); describe('IncomingTask Component', () => { beforeEach(() => { jest.clearAllMocks(); + store.taskList = {}; // Suppress console.error for error boundary tests jest.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -26,6 +33,29 @@ describe('IncomingTask Component', () => { jest.restoreAllMocks(); }); + it('prefers live task from store.taskList over incoming prop snapshot', () => { + const useIncomingTaskSpy = jest.spyOn(helper, 'useIncomingTask'); + useIncomingTaskSpy.mockReturnValue({ + incomingTask: mockTask, + accept: jest.fn(), + reject: jest.fn(), + acceptControl: {isVisible: true, isEnabled: true}, + declineControl: {isVisible: true, isEnabled: true}, + }); + + const staleTask = {...mockTask, uiControls: {main: {accept: {isVisible: true, isEnabled: false}}}}; + const liveTask = {...mockTask, uiControls: {main: {accept: {isVisible: true, isEnabled: true}}}}; + store.taskList = {[mockTask.data.interactionId]: liveTask as typeof mockTask}; + + render(); + + expect(useIncomingTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ + incomingTask: liveTask, + }) + ); + }); + it('renders IncomingTaskPresentational with correct props', () => { const useIncomingTaskSpy = jest.spyOn(helper, 'useIncomingTask'); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..e046c0f11 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -7076,3 +7076,138 @@ describe('Task Hook Error Handling and Logging', () => { }); }); }); + +describe('WXCC-6026 wxApp thick-client routing', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('useIncomingTask accept routes to acceptOnWebex for wxApp offers', async () => { + const acceptOnWebex = jest.fn().mockResolvedValue(undefined); + const accept = jest.fn(); + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + acceptOnWebex, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.accept(); + }); + + expect(acceptOnWebex).toHaveBeenCalled(); + expect(accept).not.toHaveBeenCalled(); + }); + + it('useIncomingTask reject routes to rejectOnWebex for wxApp offers', async () => { + const rejectOnWebex = jest.fn().mockResolvedValue(undefined); + const decline = jest.fn(); + const wxAppTask = { + ...taskMock, + accept: jest.fn(), + decline, + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + rejectOnWebex, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.reject(); + }); + + expect(rejectOnWebex).toHaveBeenCalled(); + expect(decline).not.toHaveBeenCalled(); + }); + + it('useCallControl toggleMute routes to toggleMuteOnWebex for engaged wxApp calls', async () => { + const toggleMuteOnWebex = jest.fn().mockResolvedValue(undefined); + const toggleMute = jest.fn(); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + toggleMuteOnWebex, + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMuteOnWebex).toHaveBeenCalledWith({muted: true}); + expect(toggleMute).not.toHaveBeenCalled(); + }); + + it('useCallControl sendDtmf routes to transmitDtmfOnWebex for engaged wxApp calls', async () => { + const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + transmitDtmfOnWebex, + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + keypad: {isVisible: true, isEnabled: true}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.sendDtmf('5'); + }); + + expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); + }); +}); diff --git a/packages/contact-center/task/tests/wxapp-task.utils.test.ts b/packages/contact-center/task/tests/wxapp-task.utils.test.ts new file mode 100644 index 000000000..d51165192 --- /dev/null +++ b/packages/contact-center/task/tests/wxapp-task.utils.test.ts @@ -0,0 +1,135 @@ +import {ITask} from '@webex/contact-center'; +import { + acceptTaskForOffer, + isWxAppCallingOffer, + isWxAppEngagedCall, + rejectTaskForOffer, + toggleMuteForTask, + transmitDtmfForTask, +} from '../src/wxapp-task.utils'; + +const baseTask = { + accept: jest.fn().mockResolvedValue(undefined), + decline: jest.fn().mockResolvedValue(undefined), + toggleMute: jest.fn().mockResolvedValue(undefined), +} as unknown as ITask; + +describe('wxapp-task.utils', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('isWxAppCallingOffer', () => { + it('returns true when isWebexAppCallingOffer is true', () => { + const task = { + ...baseTask, + isWebexAppCallingOffer: () => true, + } as ITask; + expect(isWxAppCallingOffer(task)).toBe(true); + }); + + it('returns false when helper is missing', () => { + expect(isWxAppCallingOffer(baseTask)).toBe(false); + }); + }); + + describe('isWxAppEngagedCall', () => { + it('returns true when getWebexCallingCallId returns a call id', () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + } as ITask; + expect(isWxAppEngagedCall(task)).toBe(true); + }); + + it('returns false when call id is empty', () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => '', + } as ITask; + expect(isWxAppEngagedCall(task)).toBe(false); + }); + }); + + describe('acceptTaskForOffer', () => { + it('calls acceptOnWebex for wxApp offers', async () => { + const acceptOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + isWebexAppCallingOffer: () => true, + acceptOnWebex, + } as ITask; + + await acceptTaskForOffer(task); + + expect(acceptOnWebex).toHaveBeenCalled(); + expect(baseTask.accept).not.toHaveBeenCalled(); + }); + + it('calls accept for non-wxApp offers', async () => { + await acceptTaskForOffer(baseTask); + expect(baseTask.accept).toHaveBeenCalled(); + }); + }); + + describe('rejectTaskForOffer', () => { + it('calls rejectOnWebex for wxApp offers', async () => { + const rejectOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + isWebexAppCallingOffer: () => true, + rejectOnWebex, + } as ITask; + + await rejectTaskForOffer(task); + + expect(rejectOnWebex).toHaveBeenCalled(); + expect(baseTask.decline).not.toHaveBeenCalled(); + }); + }); + + describe('toggleMuteForTask', () => { + it('calls toggleMuteOnWebex with muted target for engaged wxApp calls', async () => { + const toggleMuteOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + toggleMuteOnWebex, + } as ITask; + + await toggleMuteForTask(task, true); + + expect(toggleMuteOnWebex).toHaveBeenCalledWith({muted: true}); + expect(baseTask.toggleMute).not.toHaveBeenCalled(); + }); + + it('calls toggleMute for non-wxApp engaged calls', async () => { + await toggleMuteForTask(baseTask, true); + expect(baseTask.toggleMute).toHaveBeenCalled(); + }); + }); + + describe('transmitDtmfForTask', () => { + it('calls transmitDtmfOnWebex for engaged wxApp calls', async () => { + const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + transmitDtmfOnWebex, + } as ITask; + + await transmitDtmfForTask(task, '5'); + + expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); + }); + + it('no-ops when transmitDtmfOnWebex is unavailable', async () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + } as ITask; + + await expect(transmitDtmfForTask(task, '1')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 38f2d975d..f4cf566a9 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState, useEffect, useRef} from 'react'; import { StationLogin, UserState, @@ -94,6 +94,8 @@ function App() { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; }); + const [enableAnswerOnWebex, setEnableAnswerOnWebex] = useState(false); + const wxAppPreferenceAppliedRef = useRef(false); const [isWebRTCWidgetSelectionLocked, setIsWebRTCWidgetSelectionLocked] = useState(() => { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; @@ -151,6 +153,7 @@ function App() { cc: { allowMultiLogin: isMultiLoginEnabled, disableWebRTCRegistration, + enableAnswerOnWebex: false, }, ...(integrationEnv && { services: { @@ -235,6 +238,25 @@ function App() { } }; + const handleEnableAnswerOnWebexChange = async () => { + const next = !enableAnswerOnWebex; + + setEnableAnswerOnWebex(next); + window.localStorage.setItem('enableAnswerOnWebex', next ? 'true' : 'false'); + + if (!store.isAgentLoggedIn) { + return; + } + + try { + await store.cc.setManageWebexCallingInWxcc(next); + } catch (error) { + console.error('setManageWebexCallingInWxcc failed:', error); + setEnableAnswerOnWebex(!next); + window.localStorage.setItem('enableAnswerOnWebex', !next ? 'true' : 'false'); + } + }; + const toggleDisableWebRTCRegistration = () => { const newValue = !disableWebRTCRegistration; @@ -396,6 +418,34 @@ function App() { window.localStorage.setItem('disableWebRTCRegistration', JSON.stringify(disableWebRTCRegistration)); }, [disableWebRTCRegistration]); + useEffect(() => { + if (!isSdkReady || !store.isAgentLoggedIn) { + if (!store.isAgentLoggedIn) { + wxAppPreferenceAppliedRef.current = false; + setEnableAnswerOnWebex(false); + } + + return; + } + + if (wxAppPreferenceAppliedRef.current) { + return; + } + + wxAppPreferenceAppliedRef.current = true; + + const savedPreference = window.localStorage.getItem('enableAnswerOnWebex') === 'true'; + if (!savedPreference) { + return; + } + + setEnableAnswerOnWebex(true); + store.cc.setManageWebexCallingInWxcc(true).catch((error) => { + console.error('setManageWebexCallingInWxcc failed:', error); + setEnableAnswerOnWebex(false); + }); + }, [isSdkReady, store.isAgentLoggedIn]); + useEffect(() => { if (!disableWebRTCRegistration) { setIsWebRTCWidgetSelectionLocked(false); @@ -950,6 +1000,53 @@ function App() { )} {(store.isAgentLoggedIn || isLoggedIn) && ( <> + {store.isAgentLoggedIn && ( +
+
+
+  Webex Calling (WxCC)  + +
+
+
+ )} + {selectedWidgets.userState && (
From 5fe744901ac3bf4a812f4054c8fad8b163cde380 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Thu, 13 Aug 2026 13:49:20 +0530 Subject: [PATCH 2/3] test(contact-center): wxcc-6026 wxapp routing and dtmf unit tests --- .../CallControl/call-control-dtmf-keypad.tsx | 41 +++++ .../task/CallControl/call-control.utils.tsx | 70 ++++++++ packages/contact-center/task/tests/helper.ts | 151 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx new file mode 100644 index 000000000..ce2e3db9e --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CallControlDtmfKeypad from '../../../../src/components/task/CallControl/call-control-dtmf-keypad'; +import {KEY_LIST} from '../../../../src/components/task/OutdialCall/constants'; +import {mockCC} from '@webex/test-fixtures'; + +describe('CallControlDtmfKeypad', () => { + const onDigitPress = jest.fn(); + const logger = mockCC.LoggerProxy; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all DTMF keys', async () => { + render(); + + const keypad = await screen.findByTestId('call-control-keypad-keys'); + await waitFor(() => { + expect(keypad.querySelectorAll('.call-control-dtmf-key')).toHaveLength(KEY_LIST.length); + }); + + KEY_LIST.forEach((key) => { + expect(within(keypad).getByText(key)).toBeInTheDocument(); + }); + }); + + it('calls onDigitPress and logs when a digit is pressed', async () => { + render(); + + const keypad = await screen.findByTestId('call-control-keypad-keys'); + fireEvent.click(within(keypad).getByText('5')); + + expect(onDigitPress).toHaveBeenCalledWith('5'); + expect(logger.info).toHaveBeenCalledWith('CC-Widgets: CallControl: DTMF digit pressed', { + module: 'call-control-dtmf-keypad.tsx', + method: 'handleDigitPress', + }); + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index c7f6d529f..1bce6cb97 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -474,6 +474,76 @@ describe('CallControl Utils', () => { }); }); + it('includes visible keypad button when main keypad control is enabled (WXCC-6026)', () => { + const controlsWithKeypad = { + ...mockControls, + main: { + ...mockControls.main, + keypad: {isVisible: true, isEnabled: true}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithKeypad, + false, + mockFunctions.handleMuteToggleFunc, + mockFunctions.handleToggleHoldFunc, + mockFunctions.toggleRecording, + mockFunctions.endCall, + mockFunctions.exitConference, + mockFunctions.switchToConsult, + jest.fn(), + jest.fn() + ); + + const keypadButton = buttons.find((b) => b.id === 'keypad'); + expect(keypadButton).toEqual({ + id: 'keypad', + icon: 'dialpad-bold', + tooltip: 'Keypad', + className: 'call-control-button', + disabled: false, + isVisible: true, + menuType: 'Keypad', + dataTestId: 'call-control:keypad', + }); + }); + + it('hides keypad button when main keypad control is not visible (WXCC-6026)', () => { + const controlsWithoutKeypad = { + ...mockControls, + main: { + ...mockControls.main, + keypad: {isVisible: false, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithoutKeypad, + false, + mockFunctions.handleMuteToggleFunc, + mockFunctions.handleToggleHoldFunc, + mockFunctions.toggleRecording, + mockFunctions.endCall, + mockFunctions.exitConference, + mockFunctions.switchToConsult, + jest.fn(), + jest.fn() + ); + + const keypadButton = buttons.find((b) => b.id === 'keypad'); + expect(keypadButton?.isVisible).toBe(false); + expect(keypadButton?.disabled).toBe(true); + }); + it('should build buttons with correct configuration when not muted and held', () => { const heldControls = createEnabledMainTaskUIControls({ wrapup: enabledControl, diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index e046c0f11..6addc3364 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -7210,4 +7210,155 @@ describe('WXCC-6026 wxApp thick-client routing', () => { expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); }); + + it('useTaskList acceptTask routes to acceptOnWebex for wxApp offers', async () => { + const acceptOnWebex = jest.fn().mockResolvedValue(undefined); + const accept = jest.fn(); + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + acceptOnWebex, + }; + const mockTaskList = {mockId1: wxAppTask}; + + const {result} = renderHook(() => useTaskList({cc: mockCC, onTaskAccepted, logger, taskList: mockTaskList})); + + act(() => { + result.current.acceptTask(wxAppTask); + }); + + await waitFor(() => { + expect(acceptOnWebex).toHaveBeenCalled(); + }); + expect(accept).not.toHaveBeenCalled(); + }); + + it('useTaskList declineTask routes to rejectOnWebex for wxApp offers', async () => { + const rejectOnWebex = jest.fn().mockResolvedValue(undefined); + const decline = jest.fn(); + const wxAppTask = { + ...taskMock, + accept: jest.fn(), + decline, + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + rejectOnWebex, + }; + const mockTaskList = {mockId1: wxAppTask}; + + const {result} = renderHook(() => useTaskList({cc: mockCC, onTaskDeclined, logger, taskList: mockTaskList})); + + act(() => { + result.current.declineTask(wxAppTask); + }); + + await waitFor(() => { + expect(rejectOnWebex).toHaveBeenCalled(); + }); + expect(decline).not.toHaveBeenCalled(); + }); + + it('useIncomingTask accept uses legacy accept for non-wxApp offers', async () => { + const accept = jest.fn().mockResolvedValue(undefined); + const acceptOnWebex = jest.fn(); + const legacyTask = { + ...taskMock, + accept, + decline: jest.fn(), + isWebexAppCallingOffer: jest.fn().mockReturnValue(false), + acceptOnWebex, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: legacyTask, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.accept(); + }); + + expect(accept).toHaveBeenCalled(); + expect(acceptOnWebex).not.toHaveBeenCalled(); + }); + + it('useCallControl toggleMute uses legacy toggleMute for non-wxApp calls', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const toggleMuteOnWebex = jest.fn(); + const legacyTask = { + ...mockTask, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue(null), + toggleMuteOnWebex, + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + const {result} = renderHook(() => + useCallControl({ + currentTask: legacyTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).toHaveBeenCalled(); + expect(toggleMuteOnWebex).not.toHaveBeenCalled(); + }); + + it('useCallControl sendDtmf no-ops when keypad control is not visible', async () => { + const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + transmitDtmfOnWebex, + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + keypad: {isVisible: false, isEnabled: false}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.sendDtmf('5'); + }); + + expect(transmitDtmfOnWebex).not.toHaveBeenCalled(); + expect(mockCC.LoggerProxy.warn).toHaveBeenCalledWith('Keypad control not available', { + module: 'useCallControl', + method: 'sendDtmf', + }); + }); }); From 7c5a3de68eb604bf995559b6328da27712570afd Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Thu, 20 Aug 2026 11:02:21 +0530 Subject: [PATCH 3/3] feat(contact-center): wxcc-6026 wxapp better together and enableWxBetterTogether rename --- .../call-control-consult.tsx | 6 +- .../call-control-custom.utils.ts | 11 +- .../CallControl/call-control-dtmf-keypad.tsx | 2 +- .../task/CallControl/call-control.tsx | 14 +- .../task/CallControl/call-control.utils.ts | 61 ++++ .../task/CallControlCAD/call-control-cad.tsx | 3 + .../task/IncomingTask/incoming-task.tsx | 14 +- .../task/IncomingTask/incoming-task.utils.tsx | 9 +- .../src/components/task/Task/index.tsx | 174 +++++----- .../components/task/TaskList/task-list.tsx | 7 +- .../task/TaskList/task-list.utils.ts | 10 +- .../telephony-action-toast.style.scss | 27 ++ .../telephony-action-toast.tsx | 32 ++ .../wxapp-offer-action-error.style.scss | 11 + .../wxapp-offer-action-error.tsx | 20 ++ .../src/components/task/task.types.ts | 33 ++ .../contact-center/cc-components/src/index.ts | 4 + .../call-control-custom.util.tsx | 59 ++++ .../task/CallControl/call-control.utils.tsx | 191 +++++++++++ .../task/IncomingTask/incoming-task.utils.tsx | 15 + .../store/ai-docs/store-spec.md | 1 + packages/contact-center/store/src/store.ts | 8 + .../contact-center/store/src/store.types.ts | 1 + .../store/src/storeEventsWrapper.ts | 47 +++ packages/contact-center/store/tests/store.ts | 32 ++ .../store/tests/storeEventsWrapper.ts | 106 ++++++ .../contact-center/task/ai-docs/task-spec.md | 14 +- .../task/src/CallControl/index.tsx | 20 +- .../task/src/CallControlCAD/index.tsx | 20 +- packages/contact-center/task/src/helper.ts | 104 +++++- .../contact-center/task/src/task.types.ts | 7 +- .../task/src/wxapp-error.utils.ts | 111 ++++++ .../task/src/wxapp-task.utils.ts | 68 +--- .../task/tests/CallControl/index.tsx | 3 + .../task/tests/CallControlCAD/index.tsx | 9 + .../task/tests/IncomingTask/index.tsx | 4 + packages/contact-center/task/tests/helper.ts | 323 +++++++++++++++--- .../task/tests/wxapp-error.utils.test.ts | 144 ++++++++ .../task/tests/wxapp-task.utils.test.ts | 101 +----- .../cc/samples-cc-react-app/src/App.tsx | 198 ++++++----- 40 files changed, 1604 insertions(+), 420 deletions(-) create mode 100644 packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss create mode 100644 packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx create mode 100644 packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss create mode 100644 packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx create mode 100644 packages/contact-center/task/src/wxapp-error.utils.ts create mode 100644 packages/contact-center/task/tests/wxapp-error.utils.test.ts diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx index 9785df737..ca0938501 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx @@ -18,6 +18,8 @@ const CallControlConsultComponent: React.FC = controls, toggleConsultMute, conferenceEnabled = true, + enableWxBetterTogether = false, + currentTask = null, }) => { // Use the label and timestamp calculated in helper.ts // Stable key based on timestamp to prevent timer resets @@ -35,7 +37,9 @@ const CallControlConsultComponent: React.FC = consultConference, switchToMainCall, logger, - conferenceEnabled + conferenceEnabled, + enableWxBetterTogether, + currentTask ); // Filter buttons that should be shown, then map them diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts index 1bc2f21f9..daebb7381 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts @@ -1,6 +1,7 @@ -import {BuddyDetails, ContactServiceQueue, ILogger, TaskUIControls} from '@webex/cc-store'; +import {BuddyDetails, ContactServiceQueue, ILogger, ITask, TaskUIControls} from '@webex/cc-store'; import {MUTE_CALL, UNMUTE_CALL} from '../../constants'; import {ButtonConfig} from '../../task.types'; +import {shouldShowWxAppTelephonyControls} from '../call-control.utils'; /** * Interface for list item data @@ -22,7 +23,9 @@ export const createConsultButtons = ( consultConference: () => void, switchToMainCall: () => void, logger?, - conferenceEnabled = true + conferenceEnabled = true, + enableWxBetterTogether = false, + task: ITask | null = null ): ButtonConfig[] => { try { const consultCtrl = controls?.consult; @@ -44,7 +47,9 @@ export const createConsultButtons = ( className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, // Consult mute should only be interactive while consult leg is active. disabled: !isConsultLegActive || !(consultCtrl?.mute?.isEnabled ?? false), - isVisible: consultCtrl?.mute?.isVisible ?? false, + isVisible: shouldShowWxAppTelephonyControls(enableWxBetterTogether, task) + ? false + : (consultCtrl?.mute?.isVisible ?? false), }, { key: 'switchToMainCall', diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx index 57665362f..f99bd791d 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx @@ -10,7 +10,7 @@ export type CallControlDtmfKeypadProps = { /** * In-call DTMF keypad for wxApp telephony sessions (Extension login). - * Each key press sends a single tone via SDK transmitDtmfOnWebex(). + * Each key press sends a single tone via SDK task.transmitDtmf(). */ const CallControlDtmfKeypad: React.FunctionComponent = ({onDigitPress, logger}) => { const handleDigitPress = (digit: string) => { diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index 43d1b388e..beeba7f5a 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -25,6 +25,7 @@ import { filterButtonsForConsultation, getConsultFilterPhase, updateCallStateFromTask, + applyWxAppTelephonyControlVisibility, } from './call-control.utils'; import {withMetrics} from '@webex/cc-ui-logging'; @@ -70,6 +71,8 @@ function CallControlComponent(props: CallControlComponentProps) { getQueuesFetcher, consultTransferOptions, conferenceEnabled = true, + enableWxBetterTogether = false, + agentDeviceType, } = props; useEffect(() => { @@ -147,8 +150,17 @@ function CallControlComponent(props: CallControlComponentProps) { conferenceEnabled ); + const wxAppGatedButtons = applyWxAppTelephonyControlVisibility( + buttons, + currentTask, + controls, + isTelephony, + enableWxBetterTogether, + agentDeviceType + ); + const consultFilterPhase = getConsultFilterPhase(currentTask, controls); - const filteredButtons = filterButtonsForConsultation(buttons, consultFilterPhase, isTelephony, logger); + const filteredButtons = filterButtonsForConsultation(wxAppGatedButtons, consultFilterPhase, isTelephony, logger); if (!currentTask) return null; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts index 24a5a4a01..a3389390b 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts @@ -172,6 +172,67 @@ export const getMediaType = (mediaType: MediaChannelType, mediaChannel: MediaCha } }; +/** Duck-type wxApp engaged call — visibility only; avoid upstream import from task package. */ +type WxAppTelephonyTaskForVisibility = ITask & { + getWebexCallingCallId?: () => string | null | undefined; +}; + +export const isWxAppEngagedCall = (task: ITask | null | undefined): boolean => { + const wxTask = task as WxAppTelephonyTaskForVisibility | null | undefined; + return typeof wxTask?.getWebexCallingCallId === 'function' && !!wxTask.getWebexCallingCallId(); +}; + +/** Thick-client main-bar Mute/Keypad visibility gate — visibility only; not mute API routing. */ +export const shouldShowWxAppTelephonyControls = ( + enableWxBetterTogether: boolean, + task: ITask | null | undefined +): boolean => enableWxBetterTogether === true && isWxAppEngagedCall(task); + +/** + * Thin defense layer for thick-client flag ON: suppresses visible+disabled ghost controls. + * SDK owns isVisible/isEnabled for wxApp engaged calls; widgets pass through when SDK is correct. + */ +export const applyWxAppTelephonyControlVisibility = ( + buttons: CallControlButton[], + task: ITask | null | undefined, + controls: TaskUIControls | undefined, + isTelephony: boolean, + enableWxBetterTogether?: boolean, + agentDeviceType?: string +): CallControlButton[] => { + if (enableWxBetterTogether !== true) { + return buttons; + } + + const mainCtrl = controls?.main as TaskMainControlsWithKeypad | undefined; + const wxAppEngaged = isTelephony && isWxAppEngagedCall(task); + const isExtensionAgent = agentDeviceType != null && agentDeviceType !== 'BROWSER'; + + return buttons.map((button) => { + if (button.id !== 'mute' && button.id !== 'keypad') { + return button; + } + + const ctrl = button.id === 'mute' ? mainCtrl?.mute : mainCtrl?.keypad; + + if (!ctrl?.isVisible || ctrl.isEnabled) { + return button; + } + + // Extension ghost controls when flag ON but wxApp telephony not active. + if (isExtensionAgent && !wxAppEngaged) { + return {...button, isVisible: false}; + } + + // wxApp engaged: hide stale visible+disabled if SDK payload briefly desyncs. + if (wxAppEngaged) { + return {...button, isVisible: false}; + } + + return button; + }); +}; + /** * Checks if the media type is telephony */ diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index a841f5cb1..6e4f2fce9 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx @@ -49,6 +49,7 @@ const CallControlCADComponent: React.FC = (props) => conferenceParticipants, conferenceEnabled = true, isCampaignCall = false, + enableWxBetterTogether = false, } = props; const formatTime = (time: number): string => { @@ -322,6 +323,8 @@ const CallControlCADComponent: React.FC = (props) => controls={controls} toggleConsultMute={toggleMute} conferenceEnabled={conferenceEnabled} + enableWxBetterTogether={enableWxBetterTogether} + currentTask={currentTask} />
)} diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx index bcf4e07fd..1dd8263e2 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx @@ -5,8 +5,17 @@ import {withMetrics} from '@webex/cc-ui-logging'; import {extractIncomingTaskData} from './incoming-task.utils'; const IncomingTaskComponent: React.FunctionComponent = (props) => { - const {incomingTask, accept, reject, logger, acceptControl, declineControl, isDeclineButtonEnabled, isBrowser} = - props; + const { + incomingTask, + accept, + reject, + logger, + acceptControl, + declineControl, + isDeclineButtonEnabled, + isBrowser, + offerActionError, + } = props; if (!incomingTask) { return <>; // hidden component } @@ -46,6 +55,7 @@ const IncomingTaskComponent: React.FunctionComponent styles="task-list-hover" mediaType={taskData.mediaType as MEDIA_CHANNEL} mediaChannel={taskData.mediaChannel as MEDIA_CHANNEL} + actionError={offerActionError} /> ); }; diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx index f747193dc..4b9ab9f5b 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx @@ -63,7 +63,14 @@ export const extractIncomingTaskData = ( // Desktop/WebRTC outdial: accept visible but disabled → show "Accept" (auto-answer handles it) // Desktop/WebRTC inbound: accept visible and enabled → show "Accept" const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); - const acceptText = accept.isVisible ? (showRinging ? 'Ringing...' : 'Accept') : undefined; + const showCalling = isTelephony && isOutdial && accept.isVisible && !accept.isEnabled && decline.isVisible; + const acceptText = accept.isVisible + ? showCalling + ? 'Calling...' + : showRinging + ? 'Ringing...' + : 'Accept' + : undefined; const declineText = decline.isVisible ? 'Decline' : undefined; diff --git a/packages/contact-center/cc-components/src/components/task/Task/index.tsx b/packages/contact-center/cc-components/src/components/task/Task/index.tsx index d7402fae2..37f8f2555 100644 --- a/packages/contact-center/cc-components/src/components/task/Task/index.tsx +++ b/packages/contact-center/cc-components/src/components/task/Task/index.tsx @@ -3,7 +3,8 @@ import {ButtonPill, ListItemBase, ListItemBaseSection, Text} from '@momentum-ui/ import {Avatar, Brandvisual, Tooltip} from '@momentum-design/components/dist/react'; import {PressEvent} from '@react-types/shared'; import TaskTimer from '../TaskTimer'; -import type {MEDIA_CHANNEL as MediaChannelType} from '../task.types'; +import type {MEDIA_CHANNEL as MediaChannelType, WxAppTelephonyErrorDisplay} from '../task.types'; +import WxAppOfferActionError from '../WxAppOfferActionError/wxapp-offer-action-error'; import {extractTaskComponentData, getTaskListItemClasses} from './task.utils'; import './styles.scss'; @@ -26,6 +27,7 @@ export interface TaskProps { styles?: string; mediaType?: MediaChannelType; mediaChannel?: MediaChannelType; + actionError?: WxAppTelephonyErrorDisplay | null; } const Task: React.FC = ({ @@ -47,6 +49,7 @@ const Task: React.FC = ({ declineText, mediaType, mediaChannel, + actionError, }) => { // Extract all computed data using the utility function const taskData = extractTaskComponentData({ @@ -99,94 +102,97 @@ const Task: React.FC = ({ }; return ( - - - {taskData.currentMediaType.isBrandVisual ? ( -
- -
- ) : ( - - )} -
- - -
- {renderTitle()} - {taskData.shouldShowState && ( - - {taskData.capitalizedState} - + <> + + + {taskData.currentMediaType.isBrandVisual ? ( +
+ +
+ ) : ( + )} +
- {taskData.shouldShowQueue && ( - - {taskData.capitalizedQueue} - - )} + +
+ {renderTitle()} + {taskData.shouldShowState && ( + + {taskData.capitalizedState} + + )} - {/* Handle Time should render if it's an incoming call without ronaTimeout OR if it's not an incoming call */} - {taskData.shouldShowHandleTime && ( - - Handle Time: {' '} - - - )} + {taskData.shouldShowQueue && ( + + {taskData.capitalizedQueue} + + )} - {/* Time Left should render if it's an incoming call with ronaTimeout */} - {taskData.shouldShowTimeLeft && ( - - Time Left: {' '} - - - )} -
-
+ {/* Handle Time should render if it's an incoming call without ronaTimeout OR if it's not an incoming call */} + {taskData.shouldShowHandleTime && ( + + Handle Time: {' '} + + + )} - -
- {acceptText ? ( - - {acceptText} - - ) : null} - {declineText ? ( - - {declineText} - - ) : null} -
-
-
+ {/* Time Left should render if it's an incoming call with ronaTimeout */} + {taskData.shouldShowTimeLeft && ( + + Time Left: {' '} + + + )} +
+
+ + +
+ {acceptText ? ( + + {acceptText} + + ) : null} + {declineText ? ( + + {declineText} + + ) : null} +
+
+
+ {actionError ? : null} + ); }; diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx index 08a50d375..333d7b776 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx @@ -30,6 +30,7 @@ const TaskListComponent: React.FunctionComponent = (prop cc, hasCampaignPreviewEnabled = true, acceptedCampaignIds, + taskActionErrors, } = props; // Early return for empty task list @@ -93,9 +94,12 @@ const TaskListComponent: React.FunctionComponent = (prop ); } + const interactionId = task.data.interactionId; + const actionError = taskActionErrors?.[interactionId] ?? null; + return ( = (prop declineText={taskData.declineText} mediaType={taskData.mediaType as MEDIA_CHANNEL} mediaChannel={taskData.mediaChannel as MEDIA_CHANNEL} + actionError={actionError} /> ); })} diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts index abcf39de0..857c8767c 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts @@ -141,7 +141,15 @@ export const extractTaskListItemData = ( // Desktop/WebRTC outdial: accept visible but disabled → show "Accept" (auto-answer handles it) // Desktop/WebRTC inbound: accept visible and enabled → show "Accept" const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); - const acceptText = accept.isVisible && isTaskIncoming ? (showRinging ? 'Ringing...' : 'Accept') : undefined; + const showCalling = isTelephony && isOutdial && accept.isVisible && !accept.isEnabled && decline.isVisible; + const acceptText = + accept.isVisible && isTaskIncoming + ? showCalling + ? 'Calling...' + : showRinging + ? 'Ringing...' + : 'Accept' + : undefined; const declineText = decline.isVisible && isTaskIncoming ? 'Decline' : undefined; diff --git a/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss new file mode 100644 index 000000000..44c4cb462 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss @@ -0,0 +1,27 @@ +.telephony-action-toast-anchor { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + + .telephony-action-toast-icon-wrap { + display: inline-flex; + flex-shrink: 0; + line-height: 0; + } + + .telephony-action-toast-icon { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + background-color: var(--mds-color-theme-button-cancel-normal, #c9190b); + mask-image: var(--telephony-toast-icon-url); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + -webkit-mask-image: var(--telephony-toast-icon-url); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + } +} diff --git a/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx new file mode 100644 index 000000000..c658ea077 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {Text, Toast} from '@momentum-design/components/dist/react'; +import errorLegacyBoldIcon from '@momentum-design/icons/dist/svg/error-legacy-bold.svg'; +import {WxAppTelephonyErrorDisplay} from '../task.types'; +import './telephony-action-toast.style.scss'; + +export type TelephonyActionToastProps = { + error: WxAppTelephonyErrorDisplay; + onDismiss: () => void; +}; + +const TelephonyActionToast: React.FunctionComponent = ({error, onDismiss}) => { + return ( +
+ + +
+ ); +}; + +export default TelephonyActionToast; diff --git a/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss new file mode 100644 index 000000000..1983fead3 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss @@ -0,0 +1,11 @@ +.wxapp-offer-action-error { + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background-color: var(--alertbg-error, #fce8e6); + color: var(--label-error-text, #a12b2f); +} + +.wxapp-offer-action-error-message { + margin: 0; +} diff --git a/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx new file mode 100644 index 000000000..cb004e3b3 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {Text} from '@momentum-design/components/dist/react'; +import {WxAppTelephonyErrorDisplay} from '../task.types'; +import './wxapp-offer-action-error.style.scss'; + +export type WxAppOfferActionErrorProps = { + error: WxAppTelephonyErrorDisplay; +}; + +const WxAppOfferActionError: React.FunctionComponent = ({error}) => { + return ( +
+ + {error.message} + +
+ ); +}; + +export default WxAppOfferActionError; diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index e95b2a165..1defbabfa 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -180,8 +180,17 @@ export type IncomingTaskComponentProps = Pick void; }; +export type WxAppTelephonyErrorDisplay = { + message: string; + trackingId?: string; + status?: number | string; + isWxAppTelephonyError: boolean; +}; + export type TaskListComponentProps = Pick< TaskProps, 'acceptTask' | 'declineTask' | 'onTaskSelect' | 'logger' | 'agentId' | 'cc' @@ -189,6 +198,8 @@ export type TaskListComponentProps = Pick< Partial> & { isDeclineButtonEnabled?: boolean; isBrowser?: boolean; + taskActionErrors?: Record; + clearTaskActionError?: (interactionId: string) => void; }; export interface RealTimeTranscriptEntry { @@ -520,6 +531,16 @@ export interface ControlProps { * Agent ID of the logged-in user */ agentId: string; + + /** + * Host init flag for wxApp thick-client main-bar Mute/Keypad visibility gating only. + */ + enableWxBetterTogether?: boolean; + + /** + * Logged-in agent device type (BROWSER, EXTENSION, AGENT_DN) for Extension-only ghost control suppression. + */ + agentDeviceType?: string; } export type CallControlComponentProps = Pick< @@ -578,6 +599,16 @@ export type CallControlComponentProps = Pick< * "Campaign call" label instead of the standard media type. */ isCampaignCall?: boolean; + + /** + * Host init flag for wxApp thick-client main-bar Mute/Keypad visibility gating only. + */ + enableWxBetterTogether?: boolean; + + /** + * Logged-in agent device type for Extension-only ghost control suppression. + */ + agentDeviceType?: string; }; export type OutdialAniEntry = { @@ -710,6 +741,8 @@ export interface CallControlConsultComponentsProps { controls: TaskUIControls; toggleConsultMute: () => void; conferenceEnabled: boolean; + enableWxBetterTogether?: boolean; + currentTask?: ITask | null; } /** diff --git a/packages/contact-center/cc-components/src/index.ts b/packages/contact-center/cc-components/src/index.ts index 4a38e62a4..e504bec1b 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -11,6 +11,8 @@ import CampaignTaskComponent from './components/task/CampaignTask/campaign-task' import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript'; import E911Modal from './components/StationLogin/E911Modal/e911-modal'; import AIAssistantComponent from './components/AIAssistant/ai-assistant'; +import TelephonyActionToast from './components/task/TelephonyActionToast/telephony-action-toast'; +import WxAppOfferActionError from './components/task/WxAppOfferActionError/wxapp-offer-action-error'; export { UserStateComponent, @@ -26,6 +28,8 @@ export { RealTimeTranscriptComponent, E911Modal, AIAssistantComponent, + TelephonyActionToast, + WxAppOfferActionError, }; export * from './components/StationLogin/constants'; export * from './components/StationLogin/E911Modal/e911-modal.types'; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx index cfd9ec4cc..8da6d7b12 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx @@ -112,6 +112,65 @@ describe('Call Control Custom Utils', () => { expect(muteButton?.tooltip).toBe('Unmute'); }); + it('hides consult mute when wxApp is engaged and enableWxBetterTogether is true', () => { + const wxAppTask = { + getWebexCallingCallId: () => 'call-123', + }; + + const buttons = createConsultButtons( + false, + mockControls, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + loggerMock, + true, + true, + wxAppTask as never + ); + + const muteButton = buttons.find((b) => b.key === 'mute'); + expect(muteButton?.isVisible).toBe(false); + }); + + it('uses SDK consult mute visibility when enableWxBetterTogether is true but wxApp is not engaged', () => { + const buttons = createConsultButtons( + false, + mockControls, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + loggerMock, + true, + true + ); + + const muteButton = buttons.find((b) => b.key === 'mute'); + expect(muteButton?.isVisible).toBe(true); + }); + + it('uses SDK consult mute visibility when enableWxBetterTogether is false', () => { + const buttons = createConsultButtons( + false, + mockControls, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + loggerMock, + true, + false + ); + + const muteButton = buttons.find((b) => b.key === 'mute'); + expect(muteButton?.isVisible).toBe(true); + }); + it('should configure mute button correctly when not muted', () => { const buttons = createConsultButtons( false, // isMuted diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index 1bce6cb97..5f69c2075 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -18,6 +18,7 @@ import { handleAudioRef, onInputDialNumber, handleButtonPress, + applyWxAppTelephonyControlVisibility, } from '../../../../src/components/task/CallControl/call-control.utils'; import * as utils from '../../../../src/utils'; @@ -871,6 +872,196 @@ describe('CallControl Utils', () => { }); }); + describe('applyWxAppTelephonyControlVisibility', () => { + const wxAppTask = { + getWebexCallingCallId: () => 'call-123', + } as ITask; + + const baseButtons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: true}, + keypad: {isVisible: true, isEnabled: true}, + }, + }, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + it('passes through SDK visibility when enableWxBetterTogether is false', () => { + const controlsWithDisabledKeypad = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabledKeypad, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility(buttons, wxAppTask, controlsWithDisabledKeypad, true, false); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('hides mute and keypad when flag is on, wxApp not engaged, and SDK shows disabled controls', () => { + const controlsWithDisabled = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabled, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility( + buttons, + {} as ITask, + controlsWithDisabled, + true, + true, + 'EXTENSION' + ); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(false); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(false); + }); + + it('passes through BROWSER mute when flag is on, wxApp not engaged, and SDK shows disabled controls', () => { + const controlsWithDisabled = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabled, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility( + buttons, + {} as ITask, + controlsWithDisabled, + true, + true, + 'BROWSER' + ); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('passes through SDK visibility when flag is on but wxApp is not engaged and controls are enabled', () => { + const result = applyWxAppTelephonyControlVisibility(baseButtons, {} as ITask, mockControls, true, true); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('passes through SDK visibility when wxApp is engaged and SDK enables controls', () => { + const result = applyWxAppTelephonyControlVisibility(baseButtons, wxAppTask, mockControls, true, true); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('hides mute and keypad when wxApp is engaged but SDK disables controls during consult/hold', () => { + const controlsWithDisabled = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabled, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility(buttons, wxAppTask, controlsWithDisabled, true, true); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(false); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(false); + }); + }); + describe('getConsultFilterPhase', () => { it('returns none when endConsult is not visible', () => { expect(getConsultFilterPhase(null, {main: {}, consult: {}} as never)).toBe('none'); diff --git a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx index dc3e24e3e..8e1bae85d 100644 --- a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx @@ -249,6 +249,21 @@ describe('incoming-task.utils', () => { mockTask.data.interaction.outboundType = originalOutboundType; }); + it('should show Calling... for wxApp outdial while answer is pending', () => { + const originalOutboundType = mockTask.data.interaction.outboundType; + const originalMediaType = mockTask.data.interaction.mediaType; + + mockTask.data.interaction.mediaType = MEDIA_CHANNEL.TELEPHONY; + mockTask.data.interaction.outboundType = OUTBOUND_TYPE.OUTDIAL; + + const result = extractIncomingTaskData(mockTask, logger, visibleDisabledAccept, enabledControl, false, false); + + expect(result.acceptText).toBe('Calling...'); + + mockTask.data.interaction.outboundType = originalOutboundType; + mockTask.data.interaction.mediaType = originalMediaType; + }); + it.skip('should extract correct button states for outdial telephony on non-browser', () => { const originalMediaType = mockTask.data.interaction.mediaType; const originalCallAssociatedDetails = mockTask.data.interaction.callAssociatedDetails; diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 09e7c09ab..47ecc2b00 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -111,6 +111,7 @@ Compatibility notes: | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | | `STORE-R-022` | Per-task listener on **`TASK_WXAPP_MUTE_STATE_UPDATED`** (guarded by `wxAppMuteStateListeners` map) calls **`handleWxAppMuteStateUpdated`** → `setIsMuted(payload.muted)` only when the task matches `currentTask`; detached in **`handleTaskRemove`** | Webex App mute/unmute must sync embed UI without widgets calling Mercury; prevent duplicate listeners | `src/storeEventsWrapper.ts:507-509,942-946,999-1003` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | SDK must emit event; store does not call telephony REST | PRESENT | +| `STORE-R-023` | **`setCurrentTask`** calls **`seedWxAppMuteFromTask`** only when the promoted task **changes** (`!isSameTask`): resolves canonical SDK task from `taskManager.getAllTasks()`, awaits **`syncWxAppMuteFromCallDetails()`**, then applies **`getWxAppMuted()`** to `store.isMuted` when still current (refresh/hydrate backfill; skips re-seed on uiControls-driven list refresh) | Page refresh must restore mute icon from telephony GET even if Mercury event was missed; avoid duplicate GETs on answer | `src/storeEventsWrapper.ts` (`seedWxAppMuteFromTask`, `setCurrentTask`) | `tests/storeEventsWrapper.ts` (`seed isMuted on setCurrentTask`, dedupe tests) | SDK Voice must implement sync + getter + in-flight dedupe | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 2fd5a8b5f..01b34ce05 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -52,6 +52,8 @@ class Store implements IStore { allowConsultToQueue: boolean = false; agentProfile: AgentLoginProfile = {}; isMuted: boolean = false; + /** Host init flag for wxApp thick-client UI gating only — not used for mute API routing. */ + enableWxBetterTogether: boolean = false; isDigitalChannelsInitialized: boolean = false; dataCenter: string = ''; realtimeTranscriptionData: Partial[] = []; @@ -135,6 +137,12 @@ class Store implements IStore { } init(options: InitParams, setupEventListeners): Promise { + if ('webexConfig' in options) { + this.enableWxBetterTogether = options.webexConfig?.cc?.enableWxBetterTogether === true; + } else { + this.enableWxBetterTogether = false; + } + if ('webex' in options) { // If devs decide to go with webex, they will have to listen to the ready event before calling init // This has to be documented diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index c0bf9fc45..116830ba9 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -199,6 +199,7 @@ interface IStore { allowConsultToQueue: boolean; agentProfile: AgentLoginProfile; isMuted: boolean; + enableWxBetterTogether: boolean; isAddressBookEnabled: boolean; isDigitalChannelsInitialized: boolean; dataCenter: string; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 8288b51fa..8fde131cd 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -174,6 +174,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.isEmergencyModalAlreadyDisplayed; } + get enableWxBetterTogether() { + return this.store.enableWxBetterTogether; + } + get realTimeAssist() { return this.store.realTimeAssist; } @@ -264,6 +268,45 @@ class StoreWrapper implements IStoreWrapper { this.store.isAgentLoggedIn = value; }; + private getCanonicalTask(task: ITask): ITask { + const interactionId = task.data?.interactionId; + if (!interactionId) { + return task; + } + + const tasks = this.store.cc?.taskManager?.getAllTasks?.(); + return tasks?.[interactionId] ?? task; + } + + private seedWxAppMuteFromTask(task: ITask): void { + const interactionId = task.data?.interactionId; + if (!interactionId) { + return; + } + + const canonicalTask = this.getCanonicalTask(task) as ITask & { + syncWxAppMuteFromCallDetails?: () => Promise; + getWxAppMuted?: () => boolean; + }; + const sync = canonicalTask.syncWxAppMuteFromCallDetails?.bind(canonicalTask); + if (typeof sync !== 'function') { + return; + } + + void sync() + .then(() => { + if (this.currentTask?.data?.interactionId !== interactionId) { + return; + } + + const muted = canonicalTask.getWxAppMuted?.(); + if (typeof muted === 'boolean') { + this.setIsMuted(muted); + } + }) + .catch(() => undefined); + } + setCurrentTask = (task: ITask | null, isClicked: boolean = false): void => { // Don't assign the task as current task is incoming if (isIncomingTask(task, this.agentId)) return; @@ -297,6 +340,10 @@ class StoreWrapper implements IStoreWrapper { // Update the current task this.store.currentTask = task ? Object.assign(Object.create(Object.getPrototypeOf(task)), task) : null; + if (task && !isSameTask) { + this.seedWxAppMuteFromTask(task); + } + if (this.onTaskSelected && !isSameTask && typeof isClicked !== 'undefined') { this.onTaskSelected(task, isClicked); } diff --git a/packages/contact-center/store/tests/store.ts b/packages/contact-center/store/tests/store.ts index b57924e0c..b6eb24c70 100644 --- a/packages/contact-center/store/tests/store.ts +++ b/packages/contact-center/store/tests/store.ts @@ -188,6 +188,38 @@ describe('Store', () => { expect(storeInstance.registerCC).toHaveBeenCalledWith(mockWebex); }); + it('sets enableWxBetterTogether from webexConfig.cc at init', async () => { + //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 + webexInitSpy = jest.spyOn(Webex, 'init').mockReturnValue(mockWebex); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); + + await storeInstance.init( + { + webexConfig: {cc: {enableWxBetterTogether: true}}, + access_token: 'fake_token', + }, + jest.fn() + ); + + expect(storeInstance.enableWxBetterTogether).toBe(true); + }); + + it('defaults enableWxBetterTogether to false when webexConfig.cc flag is absent', async () => { + //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 + webexInitSpy = jest.spyOn(Webex, 'init').mockReturnValue(mockWebex); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); + + await storeInstance.init( + { + webexConfig: {}, + access_token: 'fake_token', + }, + jest.fn() + ); + + expect(storeInstance.enableWxBetterTogether).toBe(false); + }); + it('should log an error and reject the promise if registerCC fails in init method', async () => { const initParams = { webexConfig: {anyConfig: true}, diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index bd0d461a7..68465c07d 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -603,6 +603,10 @@ describe('storeEventsWrapper', () => { storeWrapper['store'].taskList = {interaction2: mockTaskWithJoined}; storeWrapper.setCurrentTask(mockTaskWithJoined); + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [mockTaskWithJoined.data.interactionId]: mockTaskWithJoined, + [mockTask2.data.interactionId]: mockTask2, + }); // Call the method under test storeWrapper.handleIncomingTask(mockTask2); @@ -1028,6 +1032,108 @@ describe('storeEventsWrapper', () => { expect(task.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, listenerCalls[0][1]); }); + it('should seed isMuted from syncWxAppMuteFromCallDetails on setCurrentTask', async () => { + storeWrapper['store'].agentId = 'mockAgentId'; + const interactionId = 'interaction-wxapp-mute-seed'; + const task = makeMockTask({ + data: { + interactionId, + agentId: 'mockAgentId', + interaction: { + state: 'connected', + participants: { + mockAgentId: {hasJoined: true}, + }, + }, + }, + }) as ITask & { + syncWxAppMuteFromCallDetails: jest.Mock; + getWxAppMuted: jest.Mock; + }; + task.syncWxAppMuteFromCallDetails = jest.fn().mockResolvedValue(true); + task.getWxAppMuted = jest.fn().mockReturnValue(true); + + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [interactionId]: task, + }); + + storeWrapper.setCurrentTask(task); + await waitFor(() => { + expect(task.syncWxAppMuteFromCallDetails).toHaveBeenCalled(); + }); + expect(storeWrapper.isMuted).toBe(true); + }); + + it('should not re-seed isMuted when setCurrentTask is called with the same interactionId', async () => { + const interactionId = 'interaction-wxapp-mute-dedupe'; + storeWrapper['store'].agentId = 'mockAgentId'; + const task = makeMockTask({ + data: { + interactionId, + agentId: 'mockAgentId', + interaction: { + state: 'connected', + participants: { + mockAgentId: {hasJoined: true}, + }, + }, + }, + }) as ITask & { + syncWxAppMuteFromCallDetails: jest.Mock; + getWxAppMuted: jest.Mock; + }; + task.syncWxAppMuteFromCallDetails = jest.fn().mockResolvedValue(true); + task.getWxAppMuted = jest.fn().mockReturnValue(true); + + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [interactionId]: task, + }); + + storeWrapper.setCurrentTask(task); + await waitFor(() => { + expect(task.syncWxAppMuteFromCallDetails).toHaveBeenCalledTimes(1); + }); + + task.syncWxAppMuteFromCallDetails.mockClear(); + storeWrapper.setCurrentTask(task); + expect(task.syncWxAppMuteFromCallDetails).not.toHaveBeenCalled(); + }); + + it('should not trigger additional mute sync when refreshTaskList re-promotes the same current task', async () => { + storeWrapper['store'].agentId = 'mockAgentId'; + const interactionId = 'interaction-wxapp-mute-refresh'; + const task = makeMockTask({ + data: { + interactionId, + agentId: 'mockAgentId', + interaction: { + state: 'connected', + participants: { + mockAgentId: {hasJoined: true}, + }, + }, + }, + }) as ITask & { + syncWxAppMuteFromCallDetails: jest.Mock; + getWxAppMuted: jest.Mock; + }; + task.syncWxAppMuteFromCallDetails = jest.fn().mockResolvedValue(true); + task.getWxAppMuted = jest.fn().mockReturnValue(false); + + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [interactionId]: task, + }); + + storeWrapper.setCurrentTask(task); + await waitFor(() => { + expect(task.syncWxAppMuteFromCallDetails).toHaveBeenCalledTimes(1); + }); + + task.syncWxAppMuteFromCallDetails.mockClear(); + storeWrapper.refreshTaskList(); + expect(task.syncWxAppMuteFromCallDetails).not.toHaveBeenCalled(); + }); + it('should handle task removal', () => { const refreshTaskListSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index b35e57552..9d5f9ae23 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -93,11 +93,13 @@ Canonical spec: [`intake.md`](../../ai-docs/features/thick-client-answer/intake. | Surface | Change | |---|---| -| **Host init** | `webexConfig.cc.enableAnswerOnWebex: boolean` (default `false`) — set **before** `store.init()` | -| **IncomingTask** | When SDK `isWebexAppCallingOffer()` → `acceptOnWebex()` / `rejectOnWebex()` instead of `accept()` / `decline()` | -| **CallControl** | Engaged wxApp → **`toggleMuteForTask(task, intendedMuteState)`** → **`toggleMuteOnWebex({ muted })`**; **`transmitDtmfOnWebex()`** for keypad; visibility from SDK `uiControls` | -| **TaskList** | Inline Accept / Decline on wxApp offers — same branch as IncomingTask | -| **wxapp-task.utils.ts** | Shared wxApp helpers: `isWxAppEngagedCall`, `toggleMuteForTask`, accept/decline routing | +| **Host init** | `webexConfig.cc.enableAnswerOnWebex: boolean` (default `false`) — set **before** `store.init()`; persisted on store for **UI visibility gating only** | +| **IncomingTask** | Calls SDK `task.accept()` / `task.decline()` — wxApp routing is internal to SDK `Voice` | +| **CallControl** | Engaged wxApp → `task.toggleMute({ muted })` / `task.transmitDtmf({ dtmf })`; widget force-visible only when wxApp engaged **and** SDK `isEnabled`; hide SDK visible+disabled ghosts; Desktop WebRTC SDK passthrough; CAD consult sub-bar mute hidden only when wxApp engaged; `toggleMute` guard includes `consult.mute.isVisible` | +| **TaskList** | Inline Accept / Decline — same unified `task.accept()` / `task.decline()` as IncomingTask | +| **wxapp-task.utils.ts** | UI visibility helpers only: `isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` | + +**SDK follow-up (uiControls):** SDK must enable `main.mute/keypad` through consult/hold/conference when wxApp engaged; BROWSER login ignores init flag for uiControls. See [intake.md §7.6–§7.7](../../ai-docs/features/thick-client-answer/intake.md). **SDK scope:** telephony REST, uiControls, usersub publish, **Mercury mute sync** (`TASK_WXAPP_MUTE_STATE_UPDATED`). @@ -120,7 +122,7 @@ Canonical spec: [`intake.md`](../../ai-docs/features/thick-client-answer/intake. | `TASK-R-005` | `useTaskList` wires `store.setTaskAssigned`/`setTaskRejected`/`setTaskSelected` only when the matching consumer callback (`onTaskAccepted`/`onTaskDeclined`/`onTaskSelected`) is provided; each wrapped callback is try/caught. | Avoid registering no-op store callbacks and isolate consumer-thrown errors. | `src/helper.ts` (`useTaskList` `useEffect`) | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided", "should handle errors in taskAssigned callback", "should handle errors in taskSelected callback") | none | PRESENT | | `TASK-R-006` | `CallControl.toggleHold(true/false)` calls `currentTask.hold()`/`currentTask.resume()`; `TASK_HOLD`/`TASK_RESUME` events fire `onHoldResume({isHeld, task})`. | Hold/resume must reflect real SDK state to the consumer. | `src/helper.ts` (`useCallControl.toggleHold`, `holdCallback`, `resumeCallback`) | `tests/helper.ts` ("should call onHoldResume with hold=true and handle success", "...hold=false...", "should log an error if hold fails", "should log an error if resume fails") | none | PRESENT | | `TASK-R-007` | `toggleRecording` calls `pauseRecording()` when `isRecording` else `resumeRecording({autoResumed:false})`; `TASK_RECORDING_PAUSED`/`TASK_RECORDING_RESUMED` callbacks set `isRecording` and fire `onRecordingToggle`. | Recording UI state must track SDK events, not just the click. | `src/helper.ts` (`useCallControl.toggleRecording`, `pauseRecordingCallback`, `resumeRecordingCallback`) | `tests/helper.ts` ("should pause the recording when pauseResume is called with true", "should fail and log error if pause failed", "should resume the recording when pauseResume is called with false") | Subscription uses `TASK_RECORDING_PAUSED/RESUMED`; cleanup removes `CONTACT_RECORDING_PAUSED/RESUMED` (mismatch — see Pitfalls) | PRESENT | -| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; wxApp engaged calls use **`toggleMuteForTask(currentTask, intendedMuteState)`** → **`toggleMuteOnWebex({ muted })`**; WebRTC uses `toggleMute()`; then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure reports prior `isMuted`. | Mute state must reflect SDK/store truth; wxApp must pass UI intent to avoid Mercury desync. | `src/helper.ts` (`useCallControl.toggleMute`), `src/wxapp-task.utils.ts` | `tests/helper.ts` (mute + wxApp routing), `tests/wxapp-task.utils.test.ts` | none | PRESENT | +| `TASK-R-008` | `toggleMute` no-ops with a warning when mute controls are unavailable; wxApp engaged calls use **`currentTask.toggleMute({ muted: intendedMuteState })`**; WebRTC uses parameterless toggle; then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure reports prior `isMuted`. | Mute state must reflect SDK/store truth; wxApp must pass UI intent to avoid Mercury desync. | `src/helper.ts` (`useCallControl.toggleMute`), `src/wxapp-task.utils.ts` | `tests/helper.ts` (mute + wxApp hooks), `tests/wxapp-task.utils.test.ts` | none | PRESENT | | `TASK-R-009` | `wrapupCall(reason, auxCodeId)` calls `currentTask.wrapup(...)`; on resolve it promotes the first remaining task in `store.taskList` to `currentTask` and sets agent state to ENGAGED. | After wrap-up the agent should auto-focus the next task and return to an engaged state. | `src/helper.ts` (`useCallControl.wrapupCall`) | `tests/helper.ts` ("should call wrapupCall", "should log an error if wrapup fails") | ENGAGED label/username are local constants (`ENGAGED_LABEL`, `ENGAGED_USERNAME`) | PRESENT | | `TASK-R-010` | Auto-wrap-up: when `currentTask.autoWrapup` and `controlVisibility.wrapup` are present, a 1s interval counts `secondsUntilAutoWrapup` down from `getTimeLeftSeconds()`; `cancelAutoWrapup` calls `currentTask.cancelAutoWrapupTimer()`. | Show and allow cancellation of the auto-wrap-up countdown. | `src/helper.ts` (`useCallControl` auto-wrapup `useEffect`, `cancelAutoWrapup`) | `tests/helper.ts` ("should initialize secondsUntilAutoWrapup to null when auto wrap-up is not active", "should call cancelAutoWrapup successfully", "should handle cancelAutoWrapup when currentTask is missing") | none | PRESENT | | `TASK-R-011` | `consultCall(dest, type, allowParticipantsToInteract)` sends `holdParticipants: !allowParticipantsToInteract`; for `type==='queue'` it sets/clears `store.isQueueConsultInProgress` + `currentConsultQueueId` around the call, including on error. | Queue consult requires tracking the in-flight queue id so `endConsult` can pass it. | `src/helper.ts` (`useCallControl.consultCall`, `endConsultCall`) | `tests/helper.ts` ("should call consultCall successfully", "should call consultCall with allowParticipantsToInteract set to true", "should call endConsultCall with queue parameters when queue consult is in progress") | none | PRESENT | diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 443072076..d1ad8cbc2 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -5,7 +5,7 @@ import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; -import {CallControlComponent} from '@webex/cc-components'; +import {CallControlComponent, TelephonyActionToast} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; const CallControlInternal: React.FunctionComponent = observer( @@ -20,6 +20,8 @@ const CallControlInternal: React.FunctionComponent = observer( isMuted, agentId, acceptedCampaignIds, + enableWxBetterTogether, + deviceType, } = store; // Hide call control when the current task is a campaign preview that @@ -29,7 +31,7 @@ const CallControlInternal: React.FunctionComponent = observer( return <>; } - const callControlProps = useCallControl({ + const {telephonyToast, dismissTelephonyToast, ...callControlHookProps} = useCallControl({ currentTask, onHoldResume, onEnd, @@ -40,23 +42,33 @@ const CallControlInternal: React.FunctionComponent = observer( isMuted, conferenceEnabled, agentId, + enableWxBetterTogether, }); const result = { - ...callControlProps, + ...callControlHookProps, wrapupCodes, consultStartTimeStamp, callControlAudio, allowConsultToQueue, logger, consultTransferOptions, + enableWxBetterTogether, + agentDeviceType: deviceType, }; if (!currentTask) { return <>; } - return ; + return ( + <> + + {telephonyToast ? ( + + ) : null} + + ); } ); diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index 1426d19da..c50f7bd5b 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -5,7 +5,7 @@ import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; -import {CallControlCADComponent} from '@webex/cc-components'; +import {CallControlCADComponent, TelephonyActionToast} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; const CallControlCADInternal: React.FunctionComponent = observer( @@ -30,13 +30,15 @@ const CallControlCADInternal: React.FunctionComponent = observ isMuted, agentId, acceptedCampaignIds, + enableWxBetterTogether, + deviceType, } = store; if (currentTask && isUnacceptedCampaignPreview(currentTask, acceptedCampaignIds)) { return <>; } - const callControlProps = useCallControl({ + const {telephonyToast, dismissTelephonyToast, ...callControlHookProps} = useCallControl({ currentTask, onHoldResume, onEnd, @@ -47,10 +49,11 @@ const CallControlCADInternal: React.FunctionComponent = observ isMuted, conferenceEnabled, agentId, + enableWxBetterTogether, }); const result = { - ...callControlProps, + ...callControlHookProps, wrapupCodes, consultStartTimeStamp, callControlAudio, @@ -59,13 +62,22 @@ const CallControlCADInternal: React.FunctionComponent = observ allowConsultToQueue, logger, consultTransferOptions, + enableWxBetterTogether, + agentDeviceType: deviceType, }; if (!currentTask) { return <>; } - return ; + return ( + <> + + {telephonyToast ? ( + + ) : null} + + ); } ); diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index d67b456d4..5ec15d861 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -27,13 +27,14 @@ import store, { MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, } from '@webex/cc-store'; +import {shouldShowWxAppTelephonyControls} from './wxapp-task.utils'; import { - acceptTaskForOffer, - getKeypadControl, - rejectTaskForOffer, - toggleMuteForTask, - transmitDtmfForTask, -} from './wxapp-task.utils'; + getTelephonyToastDisplay, + reportWxAppTelephonyFailure, + TelephonyToastAction, + withOfferActionUserMessage, + WxAppTelephonyErrorDisplay, +} from './wxapp-error.utils'; import { TIMER_LABEL_CONSULTING, TIMER_LABEL_CONSULT_REQUESTED, @@ -79,6 +80,24 @@ const mapTranscriptLineToEntry = ( // Hook for managing the task list export const useTaskList = (props: UseTaskListProps) => { const {onTaskAccepted, onTaskDeclined, onTaskSelected, logger, taskList} = props; + const [taskActionErrors, setTaskActionErrors] = useState>({}); + + const clearTaskActionError = useCallback((interactionId: string) => { + setTaskActionErrors((prev) => { + if (!prev[interactionId]) return prev; + const next = {...prev}; + delete next[interactionId]; + return next; + }); + }, []); + + const setTaskActionError = useCallback( + (interactionId: string, error: unknown, action: string) => { + const parsed = reportWxAppTelephonyFailure(error, {widget: 'TaskList', action}, logger, store.onErrorCallback); + setTaskActionErrors((prev) => ({...prev, [interactionId]: withOfferActionUserMessage(parsed, action)})); + }, + [logger] + ); const logError = (message: string, method: string) => { logger.error(message, { @@ -149,7 +168,8 @@ export const useTaskList = (props: UseTaskListProps) => { module: 'useTaskList', method: 'acceptTask', }); - acceptTaskForOffer(task).catch((error) => { + task.accept().catch((error) => { + setTaskActionError(task.data.interactionId, error, 'acceptTask'); logError(`CC-Widgets: Error accepting task: ${error}`, 'acceptTask'); }); } catch (error) { @@ -166,7 +186,8 @@ export const useTaskList = (props: UseTaskListProps) => { module: 'useTaskList', method: 'declineTask', }); - rejectTaskForOffer(task).catch((error) => { + task.decline().catch((error) => { + setTaskActionError(task.data.interactionId, error, 'declineTask'); logError(`CC-Widgets: Error declining task: ${error}`, 'declineTask'); }); logger.log(`CC-Widgets: incoming task declined for ${task.data.interactionId}`, { @@ -191,7 +212,7 @@ export const useTaskList = (props: UseTaskListProps) => { } }; - return {taskList, acceptTask, declineTask, onTaskSelect}; + return {taskList, acceptTask, declineTask, onTaskSelect, taskActionErrors, clearTaskActionError}; }; export const useRealTimeTranscript = (props: UseRealTimeTranscriptInternalProps) => { @@ -215,6 +236,15 @@ export const useRealTimeTranscript = (props: UseRealTimeTranscriptInternalProps) export const useIncomingTask = (props: UseTaskProps) => { const {onAccepted, onRejected, incomingTask, logger} = props; + const [offerActionError, setOfferActionError] = useState(null); + + const clearOfferActionError = useCallback(() => { + setOfferActionError(null); + }, []); + + useEffect(() => { + setOfferActionError(null); + }, [incomingTask?.data?.interactionId]); const acceptControl = incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; const sdkDeclineControl = incomingTask?.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; @@ -308,7 +338,14 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'accept', }); if (!incomingTask?.data.interactionId) return; - acceptTaskForOffer(incomingTask).catch((error) => { + incomingTask.accept().catch((error) => { + const parsed = reportWxAppTelephonyFailure( + error, + {widget: 'IncomingTask', action: 'accept'}, + logger, + store.onErrorCallback + ); + setOfferActionError(withOfferActionUserMessage(parsed, 'accept')); logError(`CC-Widgets: Error accepting incoming task: ${error}`, 'accept'); }); logger.log(`CC-Widgets: incomingTask accepted`, { @@ -330,7 +367,14 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'reject', }); if (!incomingTask?.data.interactionId) return; - rejectTaskForOffer(incomingTask).catch((error) => { + incomingTask.decline().catch((error) => { + const parsed = reportWxAppTelephonyFailure( + error, + {widget: 'IncomingTask', action: 'reject'}, + logger, + store.onErrorCallback + ); + setOfferActionError(withOfferActionUserMessage(parsed, 'reject')); logError(`CC-Widgets: Error rejecting incoming task: ${error}`, 'reject'); }); logger.log(`CC-Widgets: incomingTask rejected`, { @@ -351,6 +395,8 @@ export const useIncomingTask = (props: UseTaskProps) => { reject, acceptControl, declineControl, + offerActionError, + clearOfferActionError, }; }; @@ -366,6 +412,7 @@ export const useCallControl = (props: useCallControlProps) => { isMuted, agentId, conferenceEnabled = true, + enableWxBetterTogether = false, } = props; const [isRecording, setIsRecording] = useState(true); const [controls, setControls] = useState(currentTask?.uiControls ?? getDefaultUIControls()); @@ -375,6 +422,22 @@ export const useCallControl = (props: useCallControlProps) => { const [consultAgentName, setConsultAgentName] = useState('Consult Agent'); const [startTimestamp, setStartTimestamp] = useState(0); const [secondsUntilAutoWrapup, setsecondsUntilAutoWrapup] = useState(null); + const [telephonyToast, setTelephonyToast] = useState<{ + error: WxAppTelephonyErrorDisplay; + action: TelephonyToastAction; + } | null>(null); + + const showTelephonyToast = useCallback( + (error: unknown, action: TelephonyToastAction) => { + const parsed = reportWxAppTelephonyFailure(error, {widget: 'CallControl', action}, logger, store.onErrorCallback); + setTelephonyToast({error: getTelephonyToastDisplay(parsed, action), action}); + }, + [logger] + ); + + const dismissTelephonyToast = useCallback(() => { + setTelephonyToast(null); + }, []); // State timer labels and timestamps const [stateTimerLabel, setStateTimerLabel] = useState(null); @@ -830,7 +893,11 @@ export const useCallControl = (props: useCallControlProps) => { const toggleMute = async () => { try { - if (!controls?.main?.mute?.isVisible) { + if ( + !controls?.main?.mute?.isVisible && + !controls?.consult?.mute?.isVisible && + !shouldShowWxAppTelephonyControls(enableWxBetterTogether, currentTask) + ) { logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); return; } @@ -841,7 +908,7 @@ export const useCallControl = (props: useCallControlProps) => { const intendedMuteState = !isMuted; try { - await toggleMuteForTask(currentTask, intendedMuteState); + await currentTask.toggleMute({muted: intendedMuteState}); // Only update state after successful SDK call store.setIsMuted(intendedMuteState); @@ -856,6 +923,7 @@ export const useCallControl = (props: useCallControlProps) => { logger.info(`Mute state toggled to: ${intendedMuteState}`, {module: 'useCallControl', method: 'toggleMute'}); } catch (error) { logger.error(`toggleMute failed: ${error}`, {module: 'useCallControl', method: 'toggleMute'}); + showTelephonyToast(error, intendedMuteState ? 'mute' : 'unmute'); if (onToggleMute) { onToggleMute({ @@ -874,16 +942,20 @@ export const useCallControl = (props: useCallControlProps) => { const sendDtmf = async (digit: string) => { try { - if (!getKeypadControl(controls)?.isVisible) { + if ( + !controls?.main?.keypad?.isVisible && + !shouldShowWxAppTelephonyControls(enableWxBetterTogether, currentTask) + ) { logger.warn('Keypad control not available', {module: 'useCallControl', method: 'sendDtmf'}); return; } logger.info(`sendDtmf(${digit}) called`, {module: 'useCallControl', method: 'sendDtmf'}); - await transmitDtmfForTask(currentTask, digit); + await currentTask.transmitDtmf({dtmf: digit}); } catch (error) { logger.error(`sendDtmf failed: ${error}`, {module: 'useCallControl', method: 'sendDtmf'}); + showTelephonyToast(error, 'dtmf'); } }; @@ -1296,6 +1368,8 @@ export const useCallControl = (props: useCallControlProps) => { getEntryPoints, getQueuesFetcher, isCampaignCall, + telephonyToast, + dismissTelephonyToast, }; }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 94c1affc6..43bdfbb9f 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -50,7 +50,12 @@ export type useCallControlProps = Pick< ControlProps, 'currentTask' | 'logger' | 'isMuted' | 'conferenceEnabled' | 'agentId' > & - Partial>; + Partial< + Pick< + ControlProps, + 'onHoldResume' | 'onEnd' | 'onWrapUp' | 'onRecordingToggle' | 'onToggleMute' | 'enableWxBetterTogether' + > + >; export type useOutdialCallProps = Pick; diff --git a/packages/contact-center/task/src/wxapp-error.utils.ts b/packages/contact-center/task/src/wxapp-error.utils.ts new file mode 100644 index 000000000..5795dd522 --- /dev/null +++ b/packages/contact-center/task/src/wxapp-error.utils.ts @@ -0,0 +1,111 @@ +import {ILogger} from '@webex/cc-store'; + +export type WxAppTelephonyErrorDisplay = { + message: string; + trackingId?: string; + status?: number | string; + isWxAppTelephonyError: boolean; +}; + +export const OFFER_ACTION_ACCEPT_MESSAGE = 'Unable to answer the Call. Please try again'; +export const OFFER_ACTION_DECLINE_MESSAGE = 'Unable to decline the Call. Please try again'; + +export const getOfferActionUserMessage = (action: string): string => { + if (action === 'accept' || action === 'acceptTask') { + return OFFER_ACTION_ACCEPT_MESSAGE; + } + if (action === 'reject' || action === 'declineTask') { + return OFFER_ACTION_DECLINE_MESSAGE; + } + return OFFER_ACTION_ACCEPT_MESSAGE; +}; + +export const withOfferActionUserMessage = ( + display: WxAppTelephonyErrorDisplay, + action: string +): WxAppTelephonyErrorDisplay => ({ + ...display, + message: getOfferActionUserMessage(action), +}); + +export type TelephonyToastAction = 'mute' | 'unmute' | 'dtmf'; + +export const TELEPHONY_MUTE_MESSAGE = "Couldn't mute call. Please try again."; +export const TELEPHONY_UNMUTE_MESSAGE = "Couldn't unmute call. Please try again."; +export const TELEPHONY_DTMF_MESSAGE = "Action didn't work. Please try again."; + +export const getTelephonyToastUserMessage = (action: TelephonyToastAction): string => { + if (action === 'mute') { + return TELEPHONY_MUTE_MESSAGE; + } + if (action === 'unmute') { + return TELEPHONY_UNMUTE_MESSAGE; + } + return TELEPHONY_DTMF_MESSAGE; +}; + +export const getTelephonyToastDisplay = ( + display: WxAppTelephonyErrorDisplay, + action: TelephonyToastAction +): WxAppTelephonyErrorDisplay => ({ + ...display, + message: getTelephonyToastUserMessage(action), +}); + +type WxAppTelephonyErrorLike = Error & { + isWxAppTelephonyError?: boolean; + trackingId?: string; + status?: number | string; + statusCode?: number; +}; + +export const parseWxAppTelephonyError = (error: unknown): WxAppTelephonyErrorDisplay => { + if (error instanceof Error) { + const wxError = error as WxAppTelephonyErrorLike; + return { + message: error.message || 'Telephony request failed', + trackingId: wxError.trackingId, + status: wxError.status ?? wxError.statusCode, + isWxAppTelephonyError: !!wxError.isWxAppTelephonyError, + }; + } + + return { + message: typeof error === 'string' ? error : 'Telephony request failed', + isWxAppTelephonyError: false, + }; +}; + +export const toWxAppTelephonyError = (display: WxAppTelephonyErrorDisplay): WxAppTelephonyErrorLike => { + const err = new Error(display.message) as WxAppTelephonyErrorLike; + err.isWxAppTelephonyError = display.isWxAppTelephonyError; + if (display.trackingId) { + err.trackingId = display.trackingId; + } + if (display.status !== undefined) { + err.status = display.status; + } + return err; +}; + +export const reportWxAppTelephonyFailure = ( + error: unknown, + context: {widget: string; action: string}, + logger: ILogger, + onErrorCallback?: (widgetName: string, error: Error) => void +): WxAppTelephonyErrorDisplay => { + const parsed = parseWxAppTelephonyError(error); + + logger.error(`CC-Widgets: ${context.action} failed: ${parsed.message}`, { + module: 'wxapp-error.utils', + method: context.action, + trackingId: parsed.trackingId, + status: parsed.status, + }); + + if (onErrorCallback) { + onErrorCallback(context.widget, toWxAppTelephonyError(parsed)); + } + + return parsed; +}; diff --git a/packages/contact-center/task/src/wxapp-task.utils.ts b/packages/contact-center/task/src/wxapp-task.utils.ts index 35ca2a475..2c95d28da 100644 --- a/packages/contact-center/task/src/wxapp-task.utils.ts +++ b/packages/contact-center/task/src/wxapp-task.utils.ts @@ -1,64 +1,16 @@ -import {ITask, TaskUIControls} from '@webex/contact-center'; +import {ITask} from '@webex/contact-center'; /** - * WxApp thick-client telephony methods on ITask (SDK @webex/contact-center — WXCC-6026). - * Duck-typed until SDK types publish acceptOnWebex / transmitDtmfOnWebex on ITask. + * UI visibility helpers for wxApp thick-client telephony (WXCC-6026). + * Telephony routing is owned by the SDK via task.accept/decline/toggleMute/transmitDtmf. */ -export type WxAppTelephonyTask = ITask & { - isWebexAppCallingOffer?: () => boolean; - acceptOnWebex?: () => Promise; - rejectOnWebex?: () => Promise; - toggleMuteOnWebex?: (options?: {lineOwnerId?: string; muted?: boolean}) => Promise; - transmitDtmfOnWebex?: (options: {dtmf: string; lineOwnerId?: string}) => Promise; - getWebexCallingCallId?: () => string | null | undefined; -}; - -/** SDK P0 keypad control — may exist on main leg before InteractionUIControls ships keypad. */ -export type TaskMainControlsWithKeypad = TaskUIControls['main'] & { - keypad?: {isVisible: boolean; isEnabled: boolean}; -}; - -export const getKeypadControl = (controls: TaskUIControls | undefined) => - (controls?.main as TaskMainControlsWithKeypad | undefined)?.keypad; - -export const isWxAppCallingOffer = (task: ITask | null | undefined): boolean => { - const wxTask = task as WxAppTelephonyTask | null | undefined; - return typeof wxTask?.isWebexAppCallingOffer === 'function' && !!wxTask.isWebexAppCallingOffer(); -}; - export const isWxAppEngagedCall = (task: ITask | null | undefined): boolean => { - const wxTask = task as WxAppTelephonyTask | null | undefined; - return typeof wxTask?.getWebexCallingCallId === 'function' && !!wxTask.getWebexCallingCallId(); + const voiceTask = task as ITask & {getWebexCallingCallId?: () => string | null | undefined}; + return typeof voiceTask?.getWebexCallingCallId === 'function' && !!voiceTask.getWebexCallingCallId(); }; -export const acceptTaskForOffer = (task: ITask): Promise => { - const wxTask = task as WxAppTelephonyTask; - if (isWxAppCallingOffer(task) && typeof wxTask.acceptOnWebex === 'function') { - return wxTask.acceptOnWebex(); - } - return task.accept(); -}; - -export const rejectTaskForOffer = (task: ITask): Promise => { - const wxTask = task as WxAppTelephonyTask; - if (isWxAppCallingOffer(task) && typeof wxTask.rejectOnWebex === 'function') { - return wxTask.rejectOnWebex(); - } - return task.decline(); -}; - -export const toggleMuteForTask = (task: ITask, muted: boolean): Promise => { - const wxTask = task as WxAppTelephonyTask; - if (isWxAppEngagedCall(task) && typeof wxTask.toggleMuteOnWebex === 'function') { - return wxTask.toggleMuteOnWebex({muted}); - } - return task.toggleMute(); -}; - -export const transmitDtmfForTask = (task: ITask, dtmf: string): Promise => { - const wxTask = task as WxAppTelephonyTask; - if (isWxAppEngagedCall(task) && typeof wxTask.transmitDtmfOnWebex === 'function') { - return wxTask.transmitDtmfOnWebex({dtmf}); - } - return Promise.resolve(); -}; +/** Thick-client main-bar Mute/Keypad visibility gate — does not affect mute API routing. */ +export const shouldShowWxAppTelephonyControls = ( + enableWxBetterTogether: boolean, + task: ITask | null | undefined +): boolean => enableWxBetterTogether === true && isWxAppEngagedCall(task); diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index ff205bc04..da2461b38 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -66,6 +66,8 @@ describe('CallControl Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render( @@ -89,6 +91,7 @@ describe('CallControl Component', () => { isMuted: false, onToggleMute: undefined, agentId: store.agentId, + enableWxBetterTogether: false, }); }); diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index 6999ccf70..3e28170fa 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -69,6 +69,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render( @@ -95,6 +97,7 @@ describe('CallControlCAD Component', () => { isMuted: false, conferenceEnabled: undefined, agentId: store.agentId, + enableWxBetterTogether: false, }); }); @@ -141,6 +144,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render(); @@ -195,6 +200,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render( @@ -252,6 +259,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); const {container} = render( diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx index c84fe65f0..c4a829609 100644 --- a/packages/contact-center/task/tests/IncomingTask/index.tsx +++ b/packages/contact-center/task/tests/IncomingTask/index.tsx @@ -41,6 +41,8 @@ describe('IncomingTask Component', () => { reject: jest.fn(), acceptControl: {isVisible: true, isEnabled: true}, declineControl: {isVisible: true, isEnabled: true}, + offerActionError: null, + clearOfferActionError: jest.fn(), }); const staleTask = {...mockTask, uiControls: {main: {accept: {isVisible: true, isEnabled: false}}}}; @@ -66,6 +68,8 @@ describe('IncomingTask Component', () => { reject: jest.fn(), acceptControl: {isVisible: true, isEnabled: true}, declineControl: {isVisible: true, isEnabled: true}, + offerActionError: null, + clearOfferActionError: jest.fn(), }); render(); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 6addc3364..a577c7b94 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -7077,20 +7077,17 @@ describe('Task Hook Error Handling and Logging', () => { }); }); -describe('WXCC-6026 wxApp thick-client routing', () => { +describe('WXCC-6026 wxApp thick-client hooks', () => { afterEach(() => { jest.clearAllMocks(); }); - it('useIncomingTask accept routes to acceptOnWebex for wxApp offers', async () => { - const acceptOnWebex = jest.fn().mockResolvedValue(undefined); - const accept = jest.fn(); + it('useIncomingTask accept calls task.accept()', async () => { + const accept = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...taskMock, accept, decline: jest.fn(), - isWebexAppCallingOffer: jest.fn().mockReturnValue(true), - acceptOnWebex, on: jest.fn(), off: jest.fn(), }; @@ -7108,19 +7105,15 @@ describe('WXCC-6026 wxApp thick-client routing', () => { await result.current.accept(); }); - expect(acceptOnWebex).toHaveBeenCalled(); - expect(accept).not.toHaveBeenCalled(); + expect(accept).toHaveBeenCalled(); }); - it('useIncomingTask reject routes to rejectOnWebex for wxApp offers', async () => { - const rejectOnWebex = jest.fn().mockResolvedValue(undefined); - const decline = jest.fn(); + it('useIncomingTask reject calls task.decline()', async () => { + const decline = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...taskMock, accept: jest.fn(), decline, - isWebexAppCallingOffer: jest.fn().mockReturnValue(true), - rejectOnWebex, on: jest.fn(), off: jest.fn(), }; @@ -7137,19 +7130,16 @@ describe('WXCC-6026 wxApp thick-client routing', () => { await result.current.reject(); }); - expect(rejectOnWebex).toHaveBeenCalled(); - expect(decline).not.toHaveBeenCalled(); + expect(decline).toHaveBeenCalled(); }); - it('useCallControl toggleMute routes to toggleMuteOnWebex for engaged wxApp calls', async () => { - const toggleMuteOnWebex = jest.fn().mockResolvedValue(undefined); - const toggleMute = jest.fn(); + it('useCallControl toggleMute calls task.toggleMute with target state', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...mockTask, data: {...mockTask.data, interactionId: 'wxapp-interaction'}, toggleMute, getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), - toggleMuteOnWebex, uiControls: createEnabledMainTaskUIControls(), on: jest.fn(), off: jest.fn(), @@ -7172,17 +7162,16 @@ describe('WXCC-6026 wxApp thick-client routing', () => { await result.current.toggleMute(); }); - expect(toggleMuteOnWebex).toHaveBeenCalledWith({muted: true}); - expect(toggleMute).not.toHaveBeenCalled(); + expect(toggleMute).toHaveBeenCalledWith({muted: true}); }); - it('useCallControl sendDtmf routes to transmitDtmfOnWebex for engaged wxApp calls', async () => { - const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + it('useCallControl sendDtmf calls task.transmitDtmf for engaged wxApp calls', async () => { + const transmitDtmf = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...mockTask, data: {...mockTask.data, interactionId: 'wxapp-interaction'}, getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), - transmitDtmfOnWebex, + transmitDtmf, uiControls: { ...createEnabledMainTaskUIControls(), main: { @@ -7208,18 +7197,15 @@ describe('WXCC-6026 wxApp thick-client routing', () => { await result.current.sendDtmf('5'); }); - expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); + expect(transmitDtmf).toHaveBeenCalledWith({dtmf: '5'}); }); - it('useTaskList acceptTask routes to acceptOnWebex for wxApp offers', async () => { - const acceptOnWebex = jest.fn().mockResolvedValue(undefined); - const accept = jest.fn(); + it('useTaskList acceptTask calls task.accept()', async () => { + const accept = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...taskMock, accept, decline: jest.fn(), - isWebexAppCallingOffer: jest.fn().mockReturnValue(true), - acceptOnWebex, }; const mockTaskList = {mockId1: wxAppTask}; @@ -7230,20 +7216,16 @@ describe('WXCC-6026 wxApp thick-client routing', () => { }); await waitFor(() => { - expect(acceptOnWebex).toHaveBeenCalled(); + expect(accept).toHaveBeenCalled(); }); - expect(accept).not.toHaveBeenCalled(); }); - it('useTaskList declineTask routes to rejectOnWebex for wxApp offers', async () => { - const rejectOnWebex = jest.fn().mockResolvedValue(undefined); - const decline = jest.fn(); + it('useTaskList declineTask calls task.decline()', async () => { + const decline = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...taskMock, accept: jest.fn(), decline, - isWebexAppCallingOffer: jest.fn().mockReturnValue(true), - rejectOnWebex, }; const mockTaskList = {mockId1: wxAppTask}; @@ -7254,20 +7236,16 @@ describe('WXCC-6026 wxApp thick-client routing', () => { }); await waitFor(() => { - expect(rejectOnWebex).toHaveBeenCalled(); + expect(decline).toHaveBeenCalled(); }); - expect(decline).not.toHaveBeenCalled(); }); - it('useIncomingTask accept uses legacy accept for non-wxApp offers', async () => { + it('useIncomingTask accept uses task.accept for non-wxApp offers', async () => { const accept = jest.fn().mockResolvedValue(undefined); - const acceptOnWebex = jest.fn(); const legacyTask = { ...taskMock, accept, decline: jest.fn(), - isWebexAppCallingOffer: jest.fn().mockReturnValue(false), - acceptOnWebex, on: jest.fn(), off: jest.fn(), }; @@ -7286,17 +7264,14 @@ describe('WXCC-6026 wxApp thick-client routing', () => { }); expect(accept).toHaveBeenCalled(); - expect(acceptOnWebex).not.toHaveBeenCalled(); }); - it('useCallControl toggleMute uses legacy toggleMute for non-wxApp calls', async () => { + it('useCallControl toggleMute calls task.toggleMute for non-wxApp calls', async () => { const toggleMute = jest.fn().mockResolvedValue(undefined); - const toggleMuteOnWebex = jest.fn(); const legacyTask = { ...mockTask, toggleMute, getWebexCallingCallId: jest.fn().mockReturnValue(null), - toggleMuteOnWebex, uiControls: createEnabledMainTaskUIControls(), on: jest.fn(), off: jest.fn(), @@ -7319,17 +7294,16 @@ describe('WXCC-6026 wxApp thick-client routing', () => { await result.current.toggleMute(); }); - expect(toggleMute).toHaveBeenCalled(); - expect(toggleMuteOnWebex).not.toHaveBeenCalled(); + expect(toggleMute).toHaveBeenCalledWith({muted: true}); }); it('useCallControl sendDtmf no-ops when keypad control is not visible', async () => { - const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const transmitDtmf = jest.fn().mockResolvedValue(undefined); const wxAppTask = { ...mockTask, data: {...mockTask.data, interactionId: 'wxapp-interaction'}, getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), - transmitDtmfOnWebex, + transmitDtmf, uiControls: { ...createEnabledMainTaskUIControls(), main: { @@ -7355,10 +7329,255 @@ describe('WXCC-6026 wxApp thick-client routing', () => { await result.current.sendDtmf('5'); }); - expect(transmitDtmfOnWebex).not.toHaveBeenCalled(); + expect(transmitDtmf).not.toHaveBeenCalled(); expect(mockCC.LoggerProxy.warn).toHaveBeenCalledWith('Keypad control not available', { module: 'useCallControl', method: 'sendDtmf', }); }); + + it('useIncomingTask accept failure surfaces offerActionError', async () => { + const telephonyError = Object.assign(new Error('Answer failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-accept', + status: 500, + }); + const accept = jest.fn().mockRejectedValue(telephonyError); + const onErrorCallback = jest.fn(); + store.onErrorCallback = onErrorCallback; + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.accept(); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.offerActionError).toMatchObject({ + message: 'Unable to answer the Call. Please try again', + trackingId: 'track-accept', + status: 500, + }); + expect(onErrorCallback).toHaveBeenCalledWith('IncomingTask', expect.objectContaining({message: 'Answer failed'})); + }); + + it('useCallControl toggleMute failure surfaces telephonyToast', async () => { + const telephonyError = Object.assign(new Error('Mute failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-mute', + }); + const toggleMute = jest.fn().mockRejectedValue(telephonyError); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(result.current.telephonyToast).toMatchObject({ + action: 'mute', + error: expect.objectContaining({ + message: "Couldn't mute call. Please try again.", + trackingId: 'track-mute', + }), + }); + }); + + it('useCallControl toggleMute failure while muted surfaces unmute telephonyToast', async () => { + const telephonyError = Object.assign(new Error('Unmute failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-unmute', + }); + const toggleMute = jest.fn().mockRejectedValue(telephonyError); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => true); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: true, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(result.current.telephonyToast).toMatchObject({ + action: 'unmute', + error: expect.objectContaining({ + message: "Couldn't unmute call. Please try again.", + trackingId: 'track-unmute', + }), + }); + }); + + it('useCallControl toggleMute allows wxApp path when SDK hides mute but enableWxBetterTogether is true', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + mute: {isVisible: false, isEnabled: false}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + enableWxBetterTogether: true, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).toHaveBeenCalledWith({muted: true}); + }); + + it('useCallControl toggleMute no-ops when SDK hides mute and enableWxBetterTogether is false despite wxApp call id', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + mute: {isVisible: false, isEnabled: false}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + enableWxBetterTogether: false, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).not.toHaveBeenCalled(); + expect(mockCC.LoggerProxy.warn).toHaveBeenCalledWith('Mute control not available', { + module: 'useCallControl', + method: 'toggleMute', + }); + }); + + it('useCallControl toggleMute routes via task.toggleMute when only consult.mute is visible', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const consultTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'consult-interaction'}, + toggleMute, + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + mute: {isVisible: false, isEnabled: false}, + }, + consult: { + ...createEnabledMainTaskUIControls().consult, + mute: {isVisible: true, isEnabled: true}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + + const {result} = renderHook(() => + useCallControl({ + currentTask: consultTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + enableWxBetterTogether: true, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).toHaveBeenCalled(); + }); }); diff --git a/packages/contact-center/task/tests/wxapp-error.utils.test.ts b/packages/contact-center/task/tests/wxapp-error.utils.test.ts new file mode 100644 index 000000000..1fbfa2470 --- /dev/null +++ b/packages/contact-center/task/tests/wxapp-error.utils.test.ts @@ -0,0 +1,144 @@ +import { + getOfferActionUserMessage, + getTelephonyToastDisplay, + getTelephonyToastUserMessage, + OFFER_ACTION_ACCEPT_MESSAGE, + OFFER_ACTION_DECLINE_MESSAGE, + parseWxAppTelephonyError, + reportWxAppTelephonyFailure, + TELEPHONY_DTMF_MESSAGE, + TELEPHONY_MUTE_MESSAGE, + TELEPHONY_UNMUTE_MESSAGE, + toWxAppTelephonyError, + withOfferActionUserMessage, +} from '../src/wxapp-error.utils'; + +const logger = { + error: jest.fn(), + info: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), +}; + +describe('wxapp-error.utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('parseWxAppTelephonyError', () => { + it('extracts structured wxApp telephony fields from Error', () => { + const error = Object.assign(new Error('TELEPHONY_ERROR'), { + isWxAppTelephonyError: true, + trackingId: 'track-123', + status: 400, + }); + + expect(parseWxAppTelephonyError(error)).toEqual({ + message: 'TELEPHONY_ERROR', + trackingId: 'track-123', + status: 400, + isWxAppTelephonyError: true, + }); + }); + + it('returns generic message for unknown errors', () => { + expect(parseWxAppTelephonyError('network down')).toEqual({ + message: 'network down', + isWxAppTelephonyError: false, + }); + }); + }); + + describe('reportWxAppTelephonyFailure', () => { + it('logs, invokes onErrorCallback, and returns parsed display', () => { + const onErrorCallback = jest.fn(); + const error = Object.assign(new Error('Mute failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-mute', + status: 503, + }); + + const parsed = reportWxAppTelephonyFailure( + error, + {widget: 'CallControl', action: 'Mute'}, + logger, + onErrorCallback + ); + + expect(parsed.trackingId).toBe('track-mute'); + expect(logger.error).toHaveBeenCalled(); + expect(onErrorCallback).toHaveBeenCalledWith('CallControl', expect.objectContaining({message: 'Mute failed'})); + }); + }); + + describe('getOfferActionUserMessage', () => { + it('returns accept message for accept actions', () => { + expect(getOfferActionUserMessage('accept')).toBe(OFFER_ACTION_ACCEPT_MESSAGE); + expect(getOfferActionUserMessage('acceptTask')).toBe(OFFER_ACTION_ACCEPT_MESSAGE); + }); + + it('returns decline message for decline actions', () => { + expect(getOfferActionUserMessage('reject')).toBe(OFFER_ACTION_DECLINE_MESSAGE); + expect(getOfferActionUserMessage('declineTask')).toBe(OFFER_ACTION_DECLINE_MESSAGE); + }); + }); + + describe('withOfferActionUserMessage', () => { + it('replaces SDK message with user-facing offer action text', () => { + const display = withOfferActionUserMessage( + { + message: 'Answer failed', + trackingId: 'track-accept', + status: 500, + isWxAppTelephonyError: true, + }, + 'accept' + ); + + expect(display.message).toBe(OFFER_ACTION_ACCEPT_MESSAGE); + expect(display.trackingId).toBe('track-accept'); + }); + }); + + describe('getTelephonyToastUserMessage', () => { + it('returns user-facing messages for mute, unmute, and dtmf actions', () => { + expect(getTelephonyToastUserMessage('mute')).toBe(TELEPHONY_MUTE_MESSAGE); + expect(getTelephonyToastUserMessage('unmute')).toBe(TELEPHONY_UNMUTE_MESSAGE); + expect(getTelephonyToastUserMessage('dtmf')).toBe(TELEPHONY_DTMF_MESSAGE); + }); + }); + + describe('getTelephonyToastDisplay', () => { + it('replaces SDK message with user-facing telephony toast text', () => { + const display = getTelephonyToastDisplay( + { + message: 'Mute failed', + trackingId: 'track-mute', + status: 503, + isWxAppTelephonyError: true, + }, + 'mute' + ); + + expect(display.message).toBe(TELEPHONY_MUTE_MESSAGE); + expect(display.trackingId).toBe('track-mute'); + }); + }); + + describe('toWxAppTelephonyError', () => { + it('builds Error with wxApp telephony metadata', () => { + const err = toWxAppTelephonyError({ + message: 'Reject failed', + trackingId: 'track-reject', + status: 404, + isWxAppTelephonyError: true, + }); + + expect(err.message).toBe('Reject failed'); + expect(err.trackingId).toBe('track-reject'); + expect(err.status).toBe(404); + expect(err.isWxAppTelephonyError).toBe(true); + }); + }); +}); diff --git a/packages/contact-center/task/tests/wxapp-task.utils.test.ts b/packages/contact-center/task/tests/wxapp-task.utils.test.ts index d51165192..d8b995c77 100644 --- a/packages/contact-center/task/tests/wxapp-task.utils.test.ts +++ b/packages/contact-center/task/tests/wxapp-task.utils.test.ts @@ -1,12 +1,5 @@ import {ITask} from '@webex/contact-center'; -import { - acceptTaskForOffer, - isWxAppCallingOffer, - isWxAppEngagedCall, - rejectTaskForOffer, - toggleMuteForTask, - transmitDtmfForTask, -} from '../src/wxapp-task.utils'; +import {isWxAppEngagedCall, shouldShowWxAppTelephonyControls} from '../src/wxapp-task.utils'; const baseTask = { accept: jest.fn().mockResolvedValue(undefined), @@ -15,24 +8,6 @@ const baseTask = { } as unknown as ITask; describe('wxapp-task.utils', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('isWxAppCallingOffer', () => { - it('returns true when isWebexAppCallingOffer is true', () => { - const task = { - ...baseTask, - isWebexAppCallingOffer: () => true, - } as ITask; - expect(isWxAppCallingOffer(task)).toBe(true); - }); - - it('returns false when helper is missing', () => { - expect(isWxAppCallingOffer(baseTask)).toBe(false); - }); - }); - describe('isWxAppEngagedCall', () => { it('returns true when getWebexCallingCallId returns a call id', () => { const task = { @@ -49,87 +24,31 @@ describe('wxapp-task.utils', () => { } as ITask; expect(isWxAppEngagedCall(task)).toBe(false); }); - }); - - describe('acceptTaskForOffer', () => { - it('calls acceptOnWebex for wxApp offers', async () => { - const acceptOnWebex = jest.fn().mockResolvedValue(undefined); - const task = { - ...baseTask, - isWebexAppCallingOffer: () => true, - acceptOnWebex, - } as ITask; - - await acceptTaskForOffer(task); - - expect(acceptOnWebex).toHaveBeenCalled(); - expect(baseTask.accept).not.toHaveBeenCalled(); - }); - - it('calls accept for non-wxApp offers', async () => { - await acceptTaskForOffer(baseTask); - expect(baseTask.accept).toHaveBeenCalled(); - }); - }); - - describe('rejectTaskForOffer', () => { - it('calls rejectOnWebex for wxApp offers', async () => { - const rejectOnWebex = jest.fn().mockResolvedValue(undefined); - const task = { - ...baseTask, - isWebexAppCallingOffer: () => true, - rejectOnWebex, - } as ITask; - - await rejectTaskForOffer(task); - expect(rejectOnWebex).toHaveBeenCalled(); - expect(baseTask.decline).not.toHaveBeenCalled(); + it('returns false when helper is missing', () => { + expect(isWxAppEngagedCall(baseTask)).toBe(false); }); }); - describe('toggleMuteForTask', () => { - it('calls toggleMuteOnWebex with muted target for engaged wxApp calls', async () => { - const toggleMuteOnWebex = jest.fn().mockResolvedValue(undefined); + describe('shouldShowWxAppTelephonyControls', () => { + it('returns true when flag is on and wxApp call id is set', () => { const task = { ...baseTask, getWebexCallingCallId: () => 'call-123', - toggleMuteOnWebex, } as ITask; - - await toggleMuteForTask(task, true); - - expect(toggleMuteOnWebex).toHaveBeenCalledWith({muted: true}); - expect(baseTask.toggleMute).not.toHaveBeenCalled(); + expect(shouldShowWxAppTelephonyControls(true, task)).toBe(true); }); - it('calls toggleMute for non-wxApp engaged calls', async () => { - await toggleMuteForTask(baseTask, true); - expect(baseTask.toggleMute).toHaveBeenCalled(); - }); - }); - - describe('transmitDtmfForTask', () => { - it('calls transmitDtmfOnWebex for engaged wxApp calls', async () => { - const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + it('returns false when flag is off even with wxApp call id', () => { const task = { ...baseTask, getWebexCallingCallId: () => 'call-123', - transmitDtmfOnWebex, } as ITask; - - await transmitDtmfForTask(task, '5'); - - expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); + expect(shouldShowWxAppTelephonyControls(false, task)).toBe(false); }); - it('no-ops when transmitDtmfOnWebex is unavailable', async () => { - const task = { - ...baseTask, - getWebexCallingCallId: () => 'call-123', - } as ITask; - - await expect(transmitDtmfForTask(task, '1')).resolves.toBeUndefined(); + it('returns false when flag is on but wxApp is not engaged', () => { + expect(shouldShowWxAppTelephonyControls(true, baseTask)).toBe(false); }); }); }); diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index f4cf566a9..765d1999e 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useRef} from 'react'; +import React, {useState, useEffect} from 'react'; import { StationLogin, UserState, @@ -74,6 +74,11 @@ function App() { const [collapsedTasks, setCollapsedTasks] = React.useState([]); const [showLoader, setShowLoader] = useState(false); const [toast, setToast] = useState<{type: 'success' | 'error'} | null>(null); + const [telephonyError, setTelephonyError] = useState<{ + widgetName: string; + message: string; + trackingId?: string; + } | null>(null); const [integrationEnv, setintegrationEnv] = useState(() => { const savedintegrationEnv = window.localStorage.getItem('integrationEnv'); return savedintegrationEnv === 'true'; @@ -94,8 +99,9 @@ function App() { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; }); - const [enableAnswerOnWebex, setEnableAnswerOnWebex] = useState(false); - const wxAppPreferenceAppliedRef = useRef(false); + const [enableWxBetterTogether, setEnableWxBetterTogether] = useState(() => { + return window.localStorage.getItem('enableWxBetterTogether') === 'true'; + }); const [isWebRTCWidgetSelectionLocked, setIsWebRTCWidgetSelectionLocked] = useState(() => { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; @@ -153,7 +159,7 @@ function App() { cc: { allowMultiLogin: isMultiLoginEnabled, disableWebRTCRegistration, - enableAnswerOnWebex: false, + enableWxBetterTogether, }, ...(integrationEnv && { services: { @@ -238,23 +244,10 @@ function App() { } }; - const handleEnableAnswerOnWebexChange = async () => { - const next = !enableAnswerOnWebex; - - setEnableAnswerOnWebex(next); - window.localStorage.setItem('enableAnswerOnWebex', next ? 'true' : 'false'); - - if (!store.isAgentLoggedIn) { - return; - } - - try { - await store.cc.setManageWebexCallingInWxcc(next); - } catch (error) { - console.error('setManageWebexCallingInWxcc failed:', error); - setEnableAnswerOnWebex(!next); - window.localStorage.setItem('enableAnswerOnWebex', !next ? 'true' : 'false'); - } + const handleEnableWxBetterTogetherChange = () => { + const next = !enableWxBetterTogether; + setEnableWxBetterTogether(next); + window.localStorage.setItem('enableWxBetterTogether', next ? 'true' : 'false'); }; const toggleDisableWebRTCRegistration = () => { @@ -383,6 +376,7 @@ function App() { }, cc: { disableWebRTCRegistration, + enableWxBetterTogether, }, }, }; @@ -419,32 +413,8 @@ function App() { }, [disableWebRTCRegistration]); useEffect(() => { - if (!isSdkReady || !store.isAgentLoggedIn) { - if (!store.isAgentLoggedIn) { - wxAppPreferenceAppliedRef.current = false; - setEnableAnswerOnWebex(false); - } - - return; - } - - if (wxAppPreferenceAppliedRef.current) { - return; - } - - wxAppPreferenceAppliedRef.current = true; - - const savedPreference = window.localStorage.getItem('enableAnswerOnWebex') === 'true'; - if (!savedPreference) { - return; - } - - setEnableAnswerOnWebex(true); - store.cc.setManageWebexCallingInWxcc(true).catch((error) => { - console.error('setManageWebexCallingInWxcc failed:', error); - setEnableAnswerOnWebex(false); - }); - }, [isSdkReady, store.isAgentLoggedIn]); + window.localStorage.setItem('enableWxBetterTogether', enableWxBetterTogether ? 'true' : 'false'); + }, [enableWxBetterTogether]); useEffect(() => { if (!disableWebRTCRegistration) { @@ -486,6 +456,21 @@ function App() { }; }, []); + useEffect(() => { + const handlePageHide = () => { + if (!store.isAgentLoggedIn && !isLoggedIn) { + return; + } + + void store.cc + ?.stationLogout({logoutReason: 'Page unload'}) + .catch(() => undefined); + }; + + window.addEventListener('pagehide', handlePageHide); + return () => window.removeEventListener('pagehide', handlePageHide); + }, [isLoggedIn]); + useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && showOutdialFailedModal) { @@ -503,6 +488,20 @@ function App() { }, [showOutdialFailedModal]); const onError = (widgetName: string, error: Error) => { + const wxError = error as Error & {isWxAppTelephonyError?: boolean; trackingId?: string}; + if (wxError.isWxAppTelephonyError) { + // CallControl / CallControlCAD show TelephonyActionToast — avoid duplicate host toast. + if (widgetName === 'CallControl' || widgetName === 'CallControlCAD') { + console.log('WxApp telephony error (widget toast):', widgetName, error.message, wxError.trackingId); + return; + } + setTelephonyError({ + widgetName, + message: error.message, + trackingId: wxError.trackingId, + }); + return; + } console.log('Error in widgets:', widgetName, error); }; @@ -581,6 +580,28 @@ function App() { )} + {telephonyError && ( +
+
+
Telephony error ({telephonyError.widgetName})
+
{telephonyError.message}
+ {telephonyError.trackingId ?
Tracking ID: {telephonyError.trackingId}
: null} +
+
+ )} +
@@ -786,6 +807,42 @@ function App() { +
@@ -1000,53 +1057,6 @@ function App() { )} {(store.isAgentLoggedIn || isLoggedIn) && ( <> - {store.isAgentLoggedIn && ( -
-
-
-  Webex Calling (WxCC)  - -
-
-
- )} - {selectedWidgets.userState && (