From 2227d693dcb8883fba90078e19f84129b841f156 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Tue, 7 Apr 2026 21:46:05 +0530 Subject: [PATCH] fix(task-refactor)migrate widgets to SDK state-machine-driven uiControls --- .../migration/call-control-hook-migration.md | 70 +- .../migration/component-layer-migration.md | 53 +- .../migration/incoming-task-migration.md | 16 + .../ai-docs/migration/migration-overview.md | 51 ++ .../migration/store-task-utils-migration.md | 6 +- .../call-control-consult.tsx | 8 +- .../call-control-custom.utils.ts | 33 +- .../task/CallControl/call-control.tsx | 27 +- .../task/CallControl/call-control.utils.ts | 74 +- .../task/CallControlCAD/call-control-cad.tsx | 49 +- .../task/IncomingTask/incoming-task.tsx | 4 +- .../task/IncomingTask/incoming-task.utils.tsx | 29 +- .../components/task/TaskList/task-list.tsx | 4 +- .../task/TaskList/task-list.utils.ts | 31 +- .../src/components/task/task.types.ts | 48 +- .../contact-center/cc-components/src/wc.ts | 2 - packages/contact-center/cc-widgets/src/wc.ts | 2 + .../contact-center/store/src/constants.ts | 16 - packages/contact-center/store/src/store.ts | 1 + .../contact-center/store/src/store.types.ts | 62 +- .../store/src/storeEventsWrapper.ts | 88 ++- .../contact-center/store/src/task-utils.ts | 222 +----- .../task/src/CallControl/index.tsx | 8 +- .../task/src/CallControlCAD/index.tsx | 10 +- .../task/src/IncomingTask/index.tsx | 5 +- .../task/src/TaskList/index.tsx | 5 +- .../task/src/Utils/task-util.ts | 650 +----------------- .../task/src/Utils/timer-utils.ts | 37 +- packages/contact-center/task/src/helper.ts | 186 ++--- .../contact-center/task/src/task.types.ts | 15 +- .../test-fixtures/src/fixtures.ts | 8 +- 31 files changed, 554 insertions(+), 1266 deletions(-) diff --git a/packages/contact-center/ai-docs/migration/call-control-hook-migration.md b/packages/contact-center/ai-docs/migration/call-control-hook-migration.md index 7df987410..896a9b9ea 100644 --- a/packages/contact-center/ai-docs/migration/call-control-hook-migration.md +++ b/packages/contact-center/ai-docs/migration/call-control-hook-migration.md @@ -20,7 +20,7 @@ The following functions are deleted — their only consumer (`getControlsVisibil |----------|-------------| | `deviceType` | SDK handles via `UIControlConfig` | | `featureFlags` | SDK handles via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled` | -| `conferenceEnabled` | SDK computes conference/mergeToConference/exitConference visibility based on task state and config | +| ~~`conferenceEnabled`~~ | **RESTORED** — This is an application-level config (not a feature flag). See [Fix Log: Restore conferenceEnabled](#fix-restore-conferenceenabled-prop--application-level-conference-gating) below | ### Props retained @@ -415,7 +415,7 @@ export function calculateStateTimerData( ## Migration Gotchas -1. **`UIControlConfig` is built by SDK:** Widgets do NOT provide it. The SDK handles feature-flag gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled`. Widget props `deviceType`, `featureFlags`, and `conferenceEnabled` can be **removed**. There is no `applyFeatureGates` function. **Retain `agentId`** — timer utils need it for participant lookup. +1. **`UIControlConfig` is built by SDK:** Widgets do NOT provide it. The SDK handles feature-flag gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled`. Widget props `deviceType` and `featureFlags` can be **removed**. **`conferenceEnabled` is RETAINED** — it is an application-level config (not a feature flag) that gates conference UI at the consumer level. There is no `applyFeatureGates` function. **Retain `agentId`** — timer utils need it for participant lookup. 2. **`isHeld` derivation:** Hold control can be `VISIBLE_DISABLED` in conference/consulting states without meaning the call is held. Do NOT derive from `controls.hold.isEnabled` — it is an action flag (button clickability), not hold state. Get hold state from the task object (SDK tracks hold state internally). `findHoldStatus()` is dead code and will be removed (see [store-task-utils-migration.md](./store-task-utils-migration.md)). @@ -456,3 +456,69 @@ export function calculateStateTimerData( --- _Parent: [migration-overview.md](./migration-overview.md)_ + +--- + +## Migration Fix Log + +### Fix: `isHeld` Reactivity — Hold Button State and Multi-Login Sync + +- **Issue**: After migration, the hold button icon/tooltip did not toggle on click, and multi-login hold/resume did not sync across systems. +- **Root Cause**: The old `controlVisibility.isHeld` was removed. `controls.hold.isEnabled` is an action flag, not state. `task.data.isOnHold` is not populated by SDK at runtime. The SDK state machine also lacked `HOLD_SUCCESS`/`UNHOLD_SUCCESS` transitions for multi-login scenarios. +- **SDK Source of Truth**: `uiControlsComputer.ts` derives `isHeld` from `serverHold ?? state === TaskState.HELD`. `controls.hold` is `VISIBLE_ENABLED` in both `CONNECTED` and `HELD` states — it's an action flag, not a state indicator. +- **Fix Pattern** (in `useCallControl` hook — `helper.ts`): + ```typescript + import { isInteractionOnHold } from '@webex/cc-store'; + + const [isHeld, setIsHeld] = useState(() => + currentTask ? isInteractionOnHold(currentTask) : false + ); + + useEffect(() => { + setIsHeld(currentTask ? isInteractionOnHold(currentTask) : false); + }, [currentTask]); + + // In holdCallback: setIsHeld(true); + // In resumeCallback: setIsHeld(false); + // Return isHeld from hook + ``` +- **SDK Fix**: Added `HOLD_SUCCESS` handler to `CONNECTED` state and `UNHOLD_SUCCESS` handler to `HELD` state in `TaskStateMachine.ts` for multi-login sync. + +### Fix: Restore `conferenceEnabled` Prop — Application-Level Conference Gating + +- **Issue**: During the task-refactor migration, the `conferenceEnabled` prop was removed from the widget APIs. This prop is **not a feature flag** — it is an application-level configuration passed from `App.tsx` that controls whether conference-related UI controls should be available to the agent. Without it, applications cannot disable conference features regardless of SDK `uiControls`. +- **Root Cause**: The migration assumed all UI visibility is driven exclusively by `task.uiControls` from the SDK state machine. However, `conferenceEnabled` is an application-level override that gates conference availability at the consumer level, independent of the SDK's computed state. +- **Design Decision (Option A — Widget-Side Override at Button Level)**: `conferenceEnabled` is applied directly in the button builder functions (`buildCallControlButtons` and `createConsultButtons`) where conference-related buttons are defined. When `false`, the `isVisible` property of conference buttons (`conference`, `exitConference`, `merge`) is forced to `false` regardless of SDK `uiControls`. When `true` (default), SDK controls pass through unchanged. +- **Gating Pattern** (in button builder functions): + ```typescript + // call-control.utils.ts — buildCallControlButtons + // conferenceEnabled param defaults to true + { + id: 'conference', + isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false) && !!handleConsultConferencePress, + }, + { + id: 'exitConference', + isVisible: conferenceEnabled && (controls?.exitConference?.isVisible ?? false), + }, + + // call-control-custom.utils.ts — createConsultButtons + { + key: 'conference', + isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false), + }, + ``` +- **Prop Flow**: `App.tsx` → `CallControl`/`CallControlCAD` → `useCallControl` hook → returned as prop → `CallControlComponent` → `buildCallControlButtons()` / `CallControlConsultComponent` → `createConsultButtons()` +- **Files Changed**: + - `cc-components/…/task.types.ts`: Added `conferenceEnabled: boolean` to `ControlProps`, `CallControlComponentProps`, `CallControlConsultComponentsProps` + - `cc-components/…/call-control.utils.ts`: Added `conferenceEnabled` param to `buildCallControlButtons`, gated `conference` and `exitConference` buttons + - `cc-components/…/call-control-custom.utils.ts`: Added `conferenceEnabled` param to `createConsultButtons`, gated `conference` (merge) button + - `cc-components/…/call-control.tsx`: Destructured `conferenceEnabled`, passed to `buildCallControlButtons` + - `cc-components/…/call-control-consult.tsx`: Destructured `conferenceEnabled`, passed to `createConsultButtons` + - `cc-components/…/call-control-cad.tsx`: Destructured `conferenceEnabled`, passed to `CallControlConsultComponent` + - `task/src/task.types.ts`: Added `conferenceEnabled` to `CallControlProps` and `useCallControlProps` + - `task/src/helper.ts`: Destructured `conferenceEnabled` (default `true`), returned from hook + - `task/src/CallControl/index.tsx` and `CallControlCAD/index.tsx`: Pass `conferenceEnabled` to `useCallControl` + - `cc-widgets/src/wc.ts`: Exposed `conferenceEnabled` as r2wc `boolean` prop on `WebCallControl` and `WebCallControlCAD` +- **Consumer Usage**: Apps pass `conferenceEnabled={true|false}` as a prop to `` or ``. Web component consumers set the `conference-enabled` attribute. Defaults to `true` if not provided. +- **Result**: Conference buttons (merge, exit conference) are hidden when `conferenceEnabled` is `false`, while all other SDK-driven controls remain unaffected. diff --git a/packages/contact-center/ai-docs/migration/component-layer-migration.md b/packages/contact-center/ai-docs/migration/component-layer-migration.md index 5083a2313..266cd516b 100644 --- a/packages/contact-center/ai-docs/migration/component-layer-migration.md +++ b/packages/contact-center/ai-docs/migration/component-layer-migration.md @@ -370,11 +370,11 @@ This function builds the main call control button array. It references 12 old co - `isHeld: boolean` → get from the task object (SDK provides hold state); remove `findHoldStatus` derivation - `deviceType: string` → REMOVE (SDK handles) - `featureFlags: {[key: string]: boolean}` → REMOVE (SDK handles) -- `conferenceEnabled: boolean` → REMOVE (SDK handles) +- ~~`conferenceEnabled: boolean` → REMOVE~~ **RESTORED** — application-level config (not a feature flag), applied at button builder level - `agentId: string` → RETAIN (needed for timer participant lookup) ### `CallControlCAD` — task package and cc-components view -- **task/src/CallControlCAD/index.tsx:** `deviceType`, `featureFlags`, `conferenceEnabled` are used today in `getControlsVisibility` (task-util.ts lines 421–525). The **SDK** handles feature-flag-like gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled` from agent profile and `callProcessingDetails`. Since widgets will read `task.uiControls` instead of calling `getControlsVisibility`, these props can be **removed** — the SDK has already computed them. **Retain `agentId`** for timer participant lookup. +- **task/src/CallControlCAD/index.tsx:** `deviceType` and `featureFlags` are used today in `getControlsVisibility` (task-util.ts lines 421–525). The **SDK** handles feature-flag-like gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled` from agent profile and `callProcessingDetails`. Since widgets will read `task.uiControls` instead of calling `getControlsVisibility`, `deviceType` and `featureFlags` can be **removed** — the SDK has already computed them. **`conferenceEnabled` is RETAINED** — it is an application-level configuration (not a feature flag) passed from the consumer app. **Retain `agentId`** for timer participant lookup. - **cc-components/.../CallControlCAD/call-control-cad.tsx:** This view consumes `controlVisibility` (and related state flags such as `isConferenceInProgress`, `isHeld`, `isConsultReceived`, `recordingIndicator`, `isConsultInitiatedOrAccepted`). It must be updated to use `TaskUIControls` and the new prop shape when replacing `ControlVisibility`; otherwise migration will leave stale references and break at compile or runtime. ### Files NOT Impacted (Confirmed) @@ -581,7 +581,7 @@ const WebTaskList = r2wc(TaskListComponent, { | `cc-components/.../TaskList/task-list.utils.ts` | Update `extractTaskListItemData()`: remove `isBrowser` param and `store.isDeclineButtonEnabled` usage; use `task.uiControls?.accept` / `task.uiControls?.decline` for button text and disable state | **MEDIUM** | | `cc-components/.../CallControlCAD/call-control-cad.tsx` | Replace `ControlVisibility` / legacy control-shape usage with `TaskUIControls`; update props (`controlVisibility.isConferenceInProgress`, `isHeld`, `isConsultReceived`, `recordingIndicator`, `isConsultInitiatedOrAccepted`, etc.) | **MEDIUM** | | `cc-components/src/wc.ts` | Update Web Component prop definitions: remove `isBrowser` from `WebIncomingTask` and `WebTaskList` r2wc props when migrating to per-task uiControls; align with React prop changes so WC consumers do not pass obsolete attributes | **LOW** | -| `task/src/CallControlCAD/index.tsx` | **Remove** `deviceType`, `featureFlags`, `conferenceEnabled` (SDK handles via `task.uiControls`); retain `agentId` for timer participant lookup | **MEDIUM** | +| `task/src/CallControlCAD/index.tsx` | **Remove** `deviceType`, `featureFlags` (SDK handles via `task.uiControls`); **retain** `conferenceEnabled` (app-level config) and `agentId` (timer participant lookup) | **MEDIUM** | | All test files for above | Update mocks and assertions | **HIGH** | --- @@ -603,3 +603,50 @@ const WebTaskList = r2wc(TaskListComponent, { --- _Part of the task refactor migration doc set (overview in PR 1/4)._ + +--- + +## Migration Fix Log + +### Fix: Duplicate Transfer Button — Wrong `uiControls` Field Mapping + +- **Issue**: After accepting a call, both "Transfer" and "Transfer Call" buttons appeared simultaneously. The `transferConsult` button and the consult strip `transfer` button were both reading `controls.transfer` instead of `controls.consultTransfer`. +- **Root Cause**: Three button definitions all mapped to `controls.transfer`: + - `call-control.utils.ts` — `transferConsult` button used `controls.transfer` (should be `controls.consultTransfer`) + - `call-control-custom.utils.ts` — consult strip `transfer` button used `controls.transfer` (should be `controls.consultTransfer`) + - `call-control.utils.ts` — main `transfer` button correctly used `controls.transfer` +- **SDK Source of Truth**: `uiControlsComputer.ts` computes `consultTransfer: DISABLED` for `CONNECTED` state and only enables it during active consultation. The main `transfer` control handles the primary transfer action. +- **Fix**: + - `call-control.utils.ts` L252-258: Changed `transferConsult` button's `disabled` and `isVisible` from `controls?.transfer` to `controls?.consultTransfer` + - `call-control-custom.utils.ts` L46-54: Changed consult strip `transfer` button's `disabled` and `isVisible` from `controls?.transfer` to `controls?.consultTransfer` +- **Result**: Only the main "Transfer Call" button shows in `CONNECTED` state. The `transferConsult` button only appears when `consultTransfer` is explicitly enabled by the SDK during active consultation. + +### Fix: Hold Button Icon/Tooltip Not Toggling & Multi-Login Hold State Not Syncing + +- **Issue**: (1) After clicking Hold, the button icon stayed as pause and tooltip stayed as "Hold the call" instead of changing to play/"Resume the call". (2) In multi-login scenarios, holding/resuming on one system did not reflect on the other system. +- **Root Cause**: + - The old `controlVisibility.isHeld` was removed during migration. The replacement `controls.hold.isEnabled` is an **action flag** (can the user click hold?), not the current hold state. `task.data.isOnHold` exists in SDK types but is not populated at runtime. + - For multi-login: The SDK's `TaskStateMachine.ts` `CONNECTED` state had no handler for `HOLD_SUCCESS` (another system held), and `HELD` state had no handler for `UNHOLD_SUCCESS` (another system resumed). These events were silently dropped. +- **Fix (Widgets)**: + - `helper.ts` (`useCallControl` hook): Added `useState(isHeld)` initialized from `isInteractionOnHold(currentTask)`. Updated `holdCallback` to `setIsHeld(true)` and `resumeCallback` to `setIsHeld(false)`. Added `useEffect([currentTask])` to re-sync from `isInteractionOnHold` on task reference changes (covers multi-login `refreshTaskList`). + - `call-control.utils.ts`: Added `isHeld: boolean` parameter to `buildCallControlButtons()`. Hold button uses `isHeld ? 'play-bold' : 'pause-bold'` for icon and `isHeld ? RESUME_CALL : HOLD_CALL` for tooltip. + - `call-control.tsx`: Destructured `isHeld` from props, passed to `buildCallControlButtons()` and `handleToggleHoldUtil()`. + - `task.types.ts`: Added `'isHeld'` to `CallControlComponentProps` pick list. +- **Fix (SDK)**: Added `HOLD_SUCCESS` transition in `CONNECTED` state and `UNHOLD_SUCCESS` transition in `HELD` state of `TaskStateMachine.ts`, both with actions `['updateTaskData', 'setHoldState', 'emitTaskHold'/'emitTaskResume']`. +- **Result**: Hold button icon/tooltip toggles correctly on click. Multi-login hold/resume state syncs across systems via SDK state machine transitions. + +### Fix: Restore `conferenceEnabled` Prop — Application-Level Conference Gating + +- **Issue**: The `conferenceEnabled` prop was removed from widget APIs during migration. This is an application-level configuration (not a feature flag) passed from the consumer app that controls whether conference-related UI controls are available to the agent. +- **Root Cause**: The migration assumed all UI visibility is exclusively SDK-driven. However, `conferenceEnabled` is a consumer-level override independent of SDK state. +- **Design Decision**: Option A — widget-side override applied directly at the button builder level. When `conferenceEnabled` is `false`, the `isVisible` property of conference-related buttons (`conference`, `exitConference`, `merge`) is forced to `false` in `buildCallControlButtons()` and `createConsultButtons()`. Defaults to `true`. +- **Component-Layer Changes**: + - `task.types.ts`: Added `conferenceEnabled: boolean` to `ControlProps`, `CallControlComponentProps`, `CallControlConsultComponentsProps` + - `call-control.utils.ts`: Added `conferenceEnabled` param to `buildCallControlButtons()`, gated `conference` and `exitConference` buttons via `conferenceEnabled && (controls?.…isVisible)` + - `call-control-custom.utils.ts`: Added `conferenceEnabled` param to `createConsultButtons()`, gated `conference` (merge) button + - `call-control.tsx`: Destructured `conferenceEnabled` from props, passed to `buildCallControlButtons()` + - `call-control-consult.tsx`: Destructured `conferenceEnabled`, passed to `createConsultButtons()` + - `call-control-cad.tsx`: Destructured `conferenceEnabled`, passed to `CallControlConsultComponent` + - `cc-widgets/src/wc.ts`: Exposed `conferenceEnabled` as r2wc `boolean` prop on `WebCallControl` and `WebCallControlCAD` +- **No SDK changes required**: Gating is applied at the widget component layer directly on button definitions. +- **Result**: Conference merge and exit buttons are hidden when `conferenceEnabled={false}`. All other SDK-driven controls remain unaffected. diff --git a/packages/contact-center/ai-docs/migration/incoming-task-migration.md b/packages/contact-center/ai-docs/migration/incoming-task-migration.md index 00ac01662..c0ae60d13 100644 --- a/packages/contact-center/ai-docs/migration/incoming-task-migration.md +++ b/packages/contact-center/ai-docs/migration/incoming-task-migration.md @@ -246,3 +246,19 @@ const IncomingTaskComponent = ({ acceptControl, declineControl, onAccept, onReje --- _Parent: [migration-overview.md](./migration-overview.md)_ + +--- + +## Migration Fix Log + +### Fix: Restore `isDeclineButtonEnabled` from Store to Component Level + +- **Issue**: During the task-refactor migration, `store.isDeclineButtonEnabled` was removed from the IncomingTask and TaskList component layers. The migration docs instructed replacing it with `task.uiControls.decline.isEnabled`. However, the store property is still set by `handleAutoAnswer` in `storeEventsWrapper.ts` and needs to be kept as an additional override for the decline button enabled state. +- **Root Cause**: The migration assumed `task.uiControls.decline.isEnabled` fully replaces `store.isDeclineButtonEnabled`, but the store property provides an additional auto-answer override that the SDK state machine may not account for in all scenarios. +- **Fix**: + - `task/src/helper.ts` (`useIncomingTask`): Reads `store.isDeclineButtonEnabled` and merges it with the SDK's `declineControl` — if either the SDK or the store says decline is enabled, the button is enabled: `isEnabled: sdkDeclineControl.isEnabled || store.isDeclineButtonEnabled`. + - `task/src/TaskList/index.tsx`: Reads `store.isDeclineButtonEnabled` and passes it as a prop to `TaskListComponent`. + - `cc-components/.../task.types.ts`: Added `isDeclineButtonEnabled?: boolean` to `TaskListComponentProps`. + - `cc-components/.../task-list.tsx`: Destructures `isDeclineButtonEnabled` and passes it to `extractTaskListItemData`. + - `cc-components/.../task-list.utils.ts`: `extractTaskListItemData` accepts `isDeclineButtonEnabled` param and merges it with `task.uiControls.decline.isEnabled`: `isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled`. +- **Result**: The decline button is enabled when either the SDK's `task.uiControls.decline.isEnabled` is `true` OR `store.isDeclineButtonEnabled` is `true` (set by auto-answer handler). Both IncomingTask and TaskList components respect this combined logic. diff --git a/packages/contact-center/ai-docs/migration/migration-overview.md b/packages/contact-center/ai-docs/migration/migration-overview.md index 5b6097f4f..612077df7 100644 --- a/packages/contact-center/ai-docs/migration/migration-overview.md +++ b/packages/contact-center/ai-docs/migration/migration-overview.md @@ -173,5 +173,56 @@ Widgets no longer compute control visibility — `task.uiControls` is the single --- +## Migration Fix Log + +### 2026-03-30 - Dial Number Transfer Wrapup Visibility (Complete Fix) + +**Issue**: After dial number consult transfers, wrapup button not appearing. Tests in SET_6 failing with `findFirstVisibleWrapupIndex` returning -1 (timeout after 15 seconds). + +**Root Cause (Deeper Analysis)**: +1. Initial hypothesis: `shouldWrapUpOrIsInitiator` guard relied on backend `wrapUpRequired` flag which wasn't set for dial number transfers. +2. **Actual root cause**: Backend sends `AgentConsultEnded` **before** `AgentConsultTransferred` for dial number transfers. +3. Event ordering issue: CONSULT_END (clears `consultInitiator`) → TRANSFER_SUCCESS (checks `consultInitiator`, now false) → transitions to CONNECTED instead of WRAPPING_UP. + +**Fix Location**: SDK `/packages/@webex/contact-center/src/services/task/state-machine/` + +**Changes Made**: +1. **TaskStateMachine.ts** - Updated TRANSFER_SUCCESS guards (lines 256-267, 336-347, 489-505): + - Changed to directly check `consultInitiator` instead of using `guards.shouldWrapUpOrIsInitiator` + - Ensures consult initiators always wrap up regardless of backend flags + +2. **Added `transferRequested` flag** to track transfer initiation: + - **types.ts**: Added `transferRequested: boolean` to TaskContext + - **constants.ts**: Added `TRANSFER` event + - **actions.ts**: + - Initialize `transferRequested: false` in `createInitialContext` + - Added `setTransferRequested` and `clearTransferRequested` actions + - Added `clearConsultStatePreservingTransfer` action that preserves `consultInitiator` if `transferRequested` is true + - **TaskStateMachine.ts**: + - CONNECTED, HELD, CONSULTING states: Added TRANSFER event handler that sets `transferRequested` flag + - CONSULT_END in CONSULTING state: Changed to use `clearConsultStatePreservingTransfer` instead of `clearConsultState` + - TRANSFER_SUCCESS in all states (CONNECTED, HELD, CONSULTING): Added `clearTransferRequested` to ALL branches (wrapup and fallback) + - TRANSFER_FAILED in all states: Added `clearTransferRequested` action + - **Voice.ts**: `transfer()` method now dispatches TRANSFER event before API call + +**Why**: For dial number transfers, backend event ordering can vary - CONSULT_END may arrive before TRANSFER_SUCCESS. The `transferRequested` flag tracks that a transfer is in progress, preventing CONSULT_END from clearing `consultInitiator` prematurely. This ensures TRANSFER_SUCCESS can properly check `consultInitiator` for wrapup transition. + +**Impact on Widgets**: No widget changes needed. Pure SDK state machine fix. Widgets already consume `task.uiControls.wrapup.isVisible`. + +**Tests Fixed**: SET_6 Tests 1, 2, 4, 9 (all dial number transfer wrapup visibility failures) + +**Fix Iterations**: +- Iteration 1-3: Implemented transferRequested flag and preservation logic, but only added clearTransferRequested to CONSULTING state +- Iteration 4 (2026-03-31): Discovered CONNECTED and HELD states' TRANSFER_SUCCESS handlers were missing clearTransferRequested. This was critical because when CONSULT_END arrives during transfer, state transitions CONSULTING → HELD, and TRANSFER_SUCCESS is then handled in HELD state. Without cleanup in HELD state, the flag would leak. Fixed by adding clearTransferRequested to ALL TRANSFER_SUCCESS branches in ALL states +- **CRITICAL DISCOVERY (2026-03-31)**: SDK was on WRONG BRANCH (`ADD_MISSING_EVENT_EMITTER_TYPES` instead of `task-refactor`). This meant: + - stateMachineService was not initialized + - All previous fix iterations were applied to wrong branch + - Widgets were NOT using state machine at all + - All test failures were due to missing state machine, not implementation bugs + - **Resolution**: Switched SDK to `task-refactor` branch and re-applied all fixes. Tests must be re-run to validate fixes work on correct branch. + +--- + _Created: 2026-03-09_ _Updated: 2026-03-24 (added dead code removal and task-object source of truth sections; aligned with PR #648 decisions)_ +_Updated: 2026-03-30 (added dial number transfer wrapup fix log)_ diff --git a/packages/contact-center/ai-docs/migration/store-task-utils-migration.md b/packages/contact-center/ai-docs/migration/store-task-utils-migration.md index a5266735c..89a145598 100644 --- a/packages/contact-center/ai-docs/migration/store-task-utils-migration.md +++ b/packages/contact-center/ai-docs/migration/store-task-utils-migration.md @@ -152,9 +152,9 @@ The old `getControlsVisibility` applied integrator-provided widget props (`featu | `featureFlags.isEndCallEnabled` | `config.isEndTaskEnabled` | | `featureFlags.isEndConsultEnabled` | `config.isEndConsultEnabled` | | `featureFlags.webRtcEnabled` (recording gate) | `config.isRecordingEnabled` | -| `conferenceEnabled` | SDK computes conference/mergeToConference/exitConference visibility based on task state and config | +| ~~`conferenceEnabled`~~ | **RESTORED** — This is an application-level config (not a feature flag). Applied at button builder level to gate conference button visibility. See call-control-hook-migration.md and component-layer-migration.md fix logs | -Since `task.uiControls` already reflects these gates, the widget layer can **remove** the `featureFlags`, `conferenceEnabled`, and `deviceType` props — no widget-side overlay is needed. +Since `task.uiControls` already reflects these gates, the widget layer can **remove** the `featureFlags` and `deviceType` props. **`conferenceEnabled` is RETAINED** — it is an application-level configuration passed from the consumer app that controls conference UI availability independently of SDK state. ```typescript const controls = currentTask?.uiControls ?? getDefaultUIControls(); @@ -170,7 +170,7 @@ const controls = currentTask?.uiControls ?? getDefaultUIControls(); - [ ] 12 state constants deleted; 7 participant/media constants kept - [ ] `getControlsVisibility` + 22 visibility functions deleted from `task-util.ts` - [ ] `findHoldTimestamp` dual-signature (task vs interaction) not confused -- [ ] Widget props `featureFlags`, `conferenceEnabled`, `deviceType` removed (SDK handles via `UIControlConfig`) +- [ ] Widget props `featureFlags`, `deviceType` removed (SDK handles via `UIControlConfig`); `conferenceEnabled` **retained** (application-level config) - [ ] No regression in conference participant display, hold timers, or switch-call actions - [ ] Downstream (Epic) confirmed unused before removing barrel exports 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 65df644b3..9785df737 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 @@ -15,8 +15,9 @@ const CallControlConsultComponent: React.FC = switchToMainCall, logger, isMuted, - controlVisibility, + controls, toggleConsultMute, + conferenceEnabled = true, }) => { // Use the label and timestamp calculated in helper.ts // Stable key based on timestamp to prevent timer resets @@ -27,13 +28,14 @@ const CallControlConsultComponent: React.FC = const buttons = createConsultButtons( isMuted, - controlVisibility, + controls, consultTransfer, toggleConsultMute, endConsultCall, consultConference, switchToMainCall, - logger + logger, + conferenceEnabled ); // 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 3180414db..7f1cd9bc3 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,6 @@ -import {BuddyDetails, ContactServiceQueue, ILogger} from '@webex/cc-store'; +import {BuddyDetails, ContactServiceQueue, ILogger, TaskUIControls} from '@webex/cc-store'; import {MUTE_CALL, UNMUTE_CALL} from '../../constants'; -import {ButtonConfig, ControlVisibility} from '../../task.types'; +import {ButtonConfig} from '../../task.types'; /** * Interface for list item data @@ -15,13 +15,14 @@ export interface ListItemData { */ export const createConsultButtons = ( isMuted: boolean, - controlVisibility: ControlVisibility, + controls: TaskUIControls, consultTransfer: () => void, toggleConsultMute: () => void, endConsultCall: () => void, consultConference: () => void, switchToMainCall: () => void, - logger? + logger?, + conferenceEnabled = true ): ButtonConfig[] => { try { return [ @@ -31,26 +32,26 @@ export const createConsultButtons = ( onClick: toggleConsultMute, tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, - disabled: !controlVisibility.muteUnmuteConsult.isEnabled, - isVisible: controlVisibility.muteUnmuteConsult.isVisible, + disabled: !(controls?.mute?.isEnabled ?? false), + isVisible: controls?.mute?.isVisible ?? false, }, { key: 'switchToMainCall', icon: 'call-swap-bold', - tooltip: controlVisibility.isConferenceInProgress ? 'Switch to Conference Call' : 'Switch to Call', + tooltip: 'Switch to Call', onClick: switchToMainCall, className: 'call-control-button', - disabled: !controlVisibility.switchToMainCall.isEnabled, - isVisible: controlVisibility.switchToMainCall.isVisible, + disabled: !(controls?.switchToMainCall?.isEnabled ?? false), + isVisible: controls?.switchToMainCall?.isVisible ?? false, }, { key: 'transfer', icon: 'next-bold', - tooltip: controlVisibility.isConferenceInProgress ? 'Transfer Conference' : 'Transfer', + tooltip: 'Transfer', onClick: consultTransfer, className: 'call-control-button', - disabled: !controlVisibility.consultTransferConsult.isEnabled, - isVisible: controlVisibility.consultTransferConsult.isVisible, + disabled: !(controls?.consultTransfer?.isEnabled ?? false), + isVisible: controls?.consultTransfer?.isVisible ?? false, }, { key: 'conference', @@ -58,8 +59,8 @@ export const createConsultButtons = ( tooltip: 'Merge', onClick: consultConference, className: 'call-control-button', - disabled: !controlVisibility.mergeConferenceConsult.isEnabled, - isVisible: controlVisibility.mergeConferenceConsult.isVisible, + disabled: !(controls?.mergeToConference?.isEnabled ?? false), + isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false), }, { key: 'cancel', @@ -67,7 +68,7 @@ export const createConsultButtons = ( tooltip: 'End Consult', onClick: endConsultCall, className: 'call-control-consult-button-cancel', - isVisible: controlVisibility.endConsult.isVisible, + isVisible: controls?.endConsult?.isVisible ?? false, }, ]; } catch (error) { @@ -595,7 +596,7 @@ export const debounce = unknown>( logger? ): ((...args: Parameters) => void) => { try { - let timeout: NodeJS.Timeout; + let timeout: ReturnType; return (...args: Parameters) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); 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 7dfb1b137..bddf3e334 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 @@ -35,6 +35,7 @@ function CallControlComponent(props: CallControlComponentProps) { const { currentTask, + isHeld, toggleHold, toggleRecording, toggleMute, @@ -57,7 +58,7 @@ function CallControlComponent(props: CallControlComponentProps) { setConsultAgentName, allowConsultToQueue, setLastTargetType, - controlVisibility, + controls, logger, secondsUntilAutoWrapup, cancelAutoWrapup, @@ -65,6 +66,7 @@ function CallControlComponent(props: CallControlComponentProps) { getEntryPoints, getQueuesFetcher, consultTransferOptions, + conferenceEnabled = true, } = props; useEffect(() => { @@ -72,7 +74,7 @@ function CallControlComponent(props: CallControlComponentProps) { }, [currentTask, logger]); const handletoggleHold = () => { - handleToggleHoldUtil(controlVisibility.isHeld, toggleHold, logger); + handleToggleHoldUtil(isHeld, toggleHold, logger); }; const handleMuteToggle = () => { @@ -128,7 +130,8 @@ function CallControlComponent(props: CallControlComponentProps) { isRecording, isMuteButtonDisabled, currentMediaType, - controlVisibility, + controls, + isHeld, handleMuteToggle, handletoggleHold, toggleRecording, @@ -136,15 +139,13 @@ function CallControlComponent(props: CallControlComponentProps) { exitConference, switchToConsult, consultTransfer, - consultConference + consultConference, + logger, + conferenceEnabled ); - const filteredButtons = filterButtonsForConsultation( - buttons, - controlVisibility.isConsultInitiatedOrAccepted, - isTelephony, - logger - ); + const isConsulting = controls?.endConsult?.isVisible ?? false; + const filteredButtons = filterButtonsForConsultation(buttons, isConsulting, isTelephony, logger); if (!currentTask) return null; @@ -156,7 +157,7 @@ function CallControlComponent(props: CallControlComponentProps) { autoPlay >
- {!controlVisibility.isConsultReceived && !controlVisibility.wrapup.isVisible && ( + {!controls?.wrapup?.isVisible && (
{filteredButtons.map((button, index) => { if (!button.isVisible) return null; @@ -249,7 +250,7 @@ function CallControlComponent(props: CallControlComponentProps) { showEntryPointTab: false, } } - isConferenceInProgress={controlVisibility.isConferenceInProgress} + isConferenceInProgress={currentTask?.data?.isConferenceInProgress ?? false} logger={logger} /> ) : null} @@ -283,7 +284,7 @@ function CallControlComponent(props: CallControlComponentProps) { })}
)} - {controlVisibility.wrapup.isVisible && ( + {controls?.wrapup?.isVisible && (
void, handleToggleHoldFunc: () => void, toggleRecording: () => void, @@ -203,7 +200,8 @@ export const buildCallControlButtons = ( switchToConsult: () => void, onTransferConsult: () => void, handleConsultConferencePress: () => void, - logger?: ILogger + logger?: ILogger, + conferenceEnabled = true ): CallControlButton[] => { try { return [ @@ -214,7 +212,7 @@ export const buildCallControlButtons = ( tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, disabled: isMuteButtonDisabled, - isVisible: controlVisibility.muteUnmute.isVisible, + isVisible: controls?.mute?.isVisible ?? false, dataTestId: 'call-control:mute-toggle', }, { @@ -223,19 +221,18 @@ export const buildCallControlButtons = ( tooltip: 'Switch to Consult Call', className: 'call-control-button', onClick: switchToConsult, - disabled: !controlVisibility.switchToConsult.isEnabled, - isVisible: controlVisibility.switchToConsult.isVisible, + disabled: !(controls?.switchToConsult?.isEnabled ?? false), + isVisible: controls?.switchToConsult?.isVisible ?? false, dataTestId: 'call-control:switch-to-consult', }, - { id: 'hold', - icon: controlVisibility.isHeld ? 'play-bold' : 'pause-bold', + icon: isHeld ? 'play-bold' : 'pause-bold', onClick: handleToggleHoldFunc, - tooltip: controlVisibility.isHeld ? RESUME_CALL : HOLD_CALL, + tooltip: isHeld ? RESUME_CALL : HOLD_CALL, className: 'call-control-button', - disabled: !controlVisibility.holdResume.isEnabled, - isVisible: controlVisibility.holdResume.isVisible, + disabled: !(controls?.hold?.isEnabled ?? false), + isVisible: controls?.hold?.isVisible ?? false, dataTestId: 'call-control:hold-toggle', }, { @@ -243,19 +240,19 @@ export const buildCallControlButtons = ( icon: 'headset-bold', tooltip: CONSULT_AGENT, className: 'call-control-button', - disabled: !controlVisibility.consult.isEnabled, + disabled: !(controls?.consult?.isEnabled ?? false), menuType: 'Consult', - isVisible: controlVisibility.consult.isVisible, + isVisible: controls?.consult?.isVisible ?? false, dataTestId: 'call-control:consult', }, { id: 'transferConsult', icon: 'next-bold', - tooltip: controlVisibility.isConferenceInProgress ? 'Transfer Conference' : 'Transfer', + tooltip: 'Transfer', onClick: onTransferConsult || (() => {}), className: 'call-control-button', - disabled: !controlVisibility.consultTransfer.isEnabled, - isVisible: controlVisibility.consultTransfer.isVisible && !!onTransferConsult, + disabled: !(controls?.consultTransfer?.isEnabled ?? false), + isVisible: (controls?.consultTransfer?.isVisible ?? false) && !!onTransferConsult, }, { id: 'conference', @@ -263,17 +260,17 @@ export const buildCallControlButtons = ( tooltip: 'conference', onClick: handleConsultConferencePress || (() => {}), className: 'call-control-button', - disabled: !controlVisibility.mergeConference.isEnabled, - isVisible: controlVisibility.mergeConference.isVisible && !!handleConsultConferencePress, + disabled: !(controls?.mergeToConference?.isEnabled ?? false), + isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false) && !!handleConsultConferencePress, }, { id: 'transfer', icon: 'next-bold', tooltip: `${TRANSFER} ${currentMediaType.labelName}`, className: 'call-control-button', - disabled: !controlVisibility.transfer.isEnabled, + disabled: !(controls?.transfer?.isEnabled ?? false), menuType: 'Transfer', - isVisible: controlVisibility.transfer.isVisible, + isVisible: controls?.transfer?.isVisible ?? false, dataTestId: 'call-control:transfer', }, { @@ -282,8 +279,8 @@ export const buildCallControlButtons = ( onClick: toggleRecording, tooltip: isRecording ? PAUSE_RECORDING : RESUME_RECORDING, className: 'call-control-button', - disabled: !controlVisibility.pauseResumeRecording.isEnabled, - isVisible: controlVisibility.pauseResumeRecording.isVisible, + disabled: !(controls?.recording?.isEnabled ?? false), + isVisible: controls?.recording?.isVisible ?? false, dataTestId: 'call-control:recording-toggle', }, { @@ -292,8 +289,8 @@ export const buildCallControlButtons = ( tooltip: 'Exit Conference', className: 'call-control-button-muted', onClick: exitConference, - disabled: !controlVisibility.exitConference.isEnabled, - isVisible: controlVisibility.exitConference.isVisible, + disabled: !(controls?.exitConference?.isEnabled ?? false), + isVisible: conferenceEnabled && (controls?.exitConference?.isVisible ?? false), dataTestId: 'call-control:exit-conference', }, { @@ -302,8 +299,8 @@ export const buildCallControlButtons = ( onClick: endCall, tooltip: `${END} ${currentMediaType.labelName}`, className: 'call-control-button-cancel', - disabled: !controlVisibility.end.isEnabled, - isVisible: controlVisibility.end.isVisible, + disabled: !(controls?.end?.isEnabled ?? false), + isVisible: controls?.end?.isVisible ?? false, dataTestId: 'call-control:end-call', }, ]; @@ -320,6 +317,11 @@ export const buildCallControlButtons = ( /** * Filters buttons based on consultation state + * During consulting: + * - Hide: hold, consult, and blind transfer buttons + * - Respect SDK enabled/disabled state for consulting buttons (transferConsult, conference) + * They will be enabled when on main call, disabled when on consult call + * - Show as-is: mute, switchToConsult, recording, exitConference, end */ export const filterButtonsForConsultation = ( buttons: CallControlButton[], @@ -328,9 +330,13 @@ export const filterButtonsForConsultation = ( logger? ): CallControlButton[] => { try { - return consultInitiated && isTelephony - ? buttons.filter((button) => !['hold', 'consult'].includes(button.id)) - : buttons; + if (!consultInitiated || !isTelephony) { + return buttons; + } + + // Filter out buttons that shouldn't be visible during consulting + // SDK now properly controls enable/disable state based on consultCallHeld + return buttons.filter((button) => !['hold', 'consult', 'transfer'].includes(button.id)); } catch (error) { logger?.error('CC-Widgets: CallControl: Error in filterButtonsForConsultation', { module: 'cc-components#call-control.utils.ts', 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 b2f31072a..1621708ad 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 @@ -19,6 +19,7 @@ import { CUSTOMER_NAME, } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; +import {isInteractionOnHold} from '@webex/cc-store'; const CallControlCADComponent: React.FC = (props) => { const { @@ -37,11 +38,12 @@ const CallControlCADComponent: React.FC = (props) => startTimestamp, stateTimerLabel, stateTimerTimestamp, - controlVisibility, + controls, logger, isMuted, toggleMute, conferenceParticipants, + conferenceEnabled = true, } = props; const formatTime = (time: number): string => { @@ -61,12 +63,9 @@ const CallControlCADComponent: React.FC = (props) => const participantsCount = conferenceParticipants?.length || 1; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const ani = currentTask?.data?.interaction?.callAssociatedDetails?.ani; - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const dn = currentTask?.data?.interaction?.callAssociatedDetails?.dn; // Create unique IDs for tooltips @@ -178,7 +177,7 @@ const CallControlCADComponent: React.FC = (props) => )} - {controlVisibility.isConferenceInProgress && !controlVisibility.wrapup.isVisible && ( + {currentTask?.data?.isConferenceInProgress && !controls?.wrapup?.isVisible && ( <>
@@ -228,25 +227,22 @@ const CallControlCADComponent: React.FC = (props) => )}
- {!controlVisibility.wrapup.isVisible && - controlVisibility.isHeld && - !controlVisibility.isConsultReceived && - !controlVisibility.consultCallHeld && ( - <> - -
- - - {ON_HOLD} {formatTime(holdTime)} - -
- - )} + {!controls?.wrapup?.isVisible && isInteractionOnHold(currentTask) && ( + <> + +
+ + + {ON_HOLD} {formatTime(holdTime)} + +
+ + )}
- {!controlVisibility.wrapup.isVisible && controlVisibility.recordingIndicator.isVisible && ( + {!controls?.wrapup?.isVisible && controls?.recording?.isVisible && (
@@ -255,18 +251,12 @@ const CallControlCADComponent: React.FC = (props) =>
{QUEUE}{' '} - - { - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - - currentTask?.data?.interaction?.callAssociatedDetails?.virtualTeamName || NO_TEAM_NAME - } - + {currentTask?.data?.interaction?.callAssociatedDetails?.virtualTeamName || NO_TEAM_NAME} {renderPhoneNumber()}
- {controlVisibility.isConsultInitiatedOrAccepted && !controlVisibility.wrapup.isVisible && ( + {controls?.endConsult?.isVisible && !controls?.wrapup?.isVisible && (
= (props) => switchToMainCall={switchToMainCall} logger={logger} isMuted={isMuted} - controlVisibility={controlVisibility} + controls={controls} toggleConsultMute={toggleMute} + conferenceEnabled={conferenceEnabled} />
)} 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 3b46d68c9..ec7025ab4 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,13 +5,13 @@ import {withMetrics} from '@webex/cc-ui-logging'; import {extractIncomingTaskData} from './incoming-task.utils'; const IncomingTaskComponent: React.FunctionComponent = (props) => { - const {incomingTask, isBrowser, accept, reject, logger, isDeclineButtonEnabled} = props; + const {incomingTask, accept, reject, logger, acceptControl, declineControl, isDeclineButtonEnabled} = props; if (!incomingTask) { return <>; // hidden component } // Extract all task data using the utility function - const taskData = extractIncomingTaskData(incomingTask, isBrowser, logger, isDeclineButtonEnabled); + const taskData = extractIncomingTaskData(incomingTask, logger, acceptControl, declineControl, isDeclineButtonEnabled); return ( { try { + const accept = acceptControl ?? incomingTask?.uiControls?.accept ?? {isVisible: false, isEnabled: false}; + const sdkDecline = declineControl ?? incomingTask?.uiControls?.decline ?? {isVisible: false, isEnabled: false}; + const decline = { + ...sdkDecline, + isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled, + }; + // Extract basic data from task - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const callAssociationDetails = incomingTask?.data?.interaction?.callAssociatedDetails; const ani = callAssociationDetails?.ani; const customerName = callAssociationDetails?.customerName; @@ -46,24 +53,14 @@ export const extractIncomingTaskData = ( const isTelephony = mediaType === MEDIA_CHANNEL.TELEPHONY; const isSocial = mediaType === MEDIA_CHANNEL.SOCIAL; - // Compute button text based on conditions - const acceptText = !incomingTask.data.wrapUpRequired - ? isTelephony && !isBrowser - ? 'Ringing...' - : 'Accept' - : undefined; - - const declineText = !incomingTask.data.wrapUpRequired && isTelephony && isBrowser ? 'Decline' : undefined; + const acceptText = accept.isVisible ? 'Accept' : undefined; + const declineText = decline.isVisible ? 'Decline' : undefined; // Compute title based on media type const title = isSocial ? customerName : ani; - // Compute disable state for accept button when auto-answering - const isAutoAnswering = incomingTask.data.isAutoAnswering || false; - // Compute disable state for accept button - const disableAccept = (isTelephony && !isBrowser) || isAutoAnswering; - - const disableDecline = (isTelephony && !isBrowser) || (isAutoAnswering && !isDeclineButtonEnabled); + const disableAccept = !accept.isEnabled; + const disableDecline = !decline.isEnabled; return { ani, 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 73da269c6..cbe0faeb4 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 @@ -12,7 +12,7 @@ import './styles.scss'; import {withMetrics} from '@webex/cc-ui-logging'; const TaskListComponent: React.FunctionComponent = (props) => { - const {currentTask, taskList, acceptTask, declineTask, isBrowser, onTaskSelect, logger, agentId} = props; + const {currentTask, taskList, acceptTask, declineTask, onTaskSelect, logger, agentId, isDeclineButtonEnabled} = props; // Early return for empty task list if (isTaskListEmpty(taskList)) { @@ -25,7 +25,7 @@ const TaskListComponent: React.FunctionComponent = (prop
    {tasks.map((task, index) => { // Extract all task data using the utility function - const taskData = extractTaskListItemData(task, isBrowser, agentId, logger); + const taskData = extractTaskListItemData(task, agentId, logger, isDeclineButtonEnabled); // Log task rendering logger.info('CC-Widgets: TaskList: rendering task list', { 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 aa1591ffc..c4ac7db86 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 @@ -1,5 +1,5 @@ import {MEDIA_CHANNEL, TaskListItemData} from '../task.types'; -import store, {isIncomingTask, ILogger, ITask} from '@webex/cc-store'; +import {isIncomingTask, ILogger, ITask} from '@webex/cc-store'; /** * Extracts and processes data from a task for rendering in the task list * @param task - The task object @@ -8,13 +8,19 @@ import store, {isIncomingTask, ILogger, ITask} from '@webex/cc-store'; */ export const extractTaskListItemData = ( task: ITask, - isBrowser: boolean, agentId: string, - logger?: ILogger + logger?: ILogger, + isDeclineButtonEnabled?: boolean ): TaskListItemData => { try { + const accept = task.uiControls?.accept ?? {isVisible: false, isEnabled: false}; + const sdkDecline = task.uiControls?.decline ?? {isVisible: false, isEnabled: false}; + const decline = { + ...sdkDecline, + isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled, + }; + // Extract basic data from task - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const callAssociationDetails = task?.data?.interaction?.callAssociatedDetails; const ani = callAssociationDetails?.ani; const customerName = callAssociationDetails?.customerName; @@ -33,21 +39,14 @@ export const extractTaskListItemData = ( const isTelephony = mediaType === MEDIA_CHANNEL.TELEPHONY; const isSocial = mediaType === MEDIA_CHANNEL.SOCIAL; - // Compute button text based on conditions - const acceptText = isTaskIncoming ? (isTelephony && !isBrowser ? 'Ringing...' : 'Accept') : undefined; - - const declineText = isTaskIncoming && isTelephony && isBrowser ? 'Decline' : undefined; + const acceptText = accept.isVisible && isTaskIncoming ? 'Accept' : undefined; + const declineText = decline.isVisible && isTaskIncoming ? 'Decline' : undefined; // Compute title based on media type const title = isSocial ? customerName : ani; - const isAutoAnswering = task.data.isAutoAnswering || false; - - // Compute disable state for accept button - const disableAccept = (isTaskIncoming && isTelephony && !isBrowser) || isAutoAnswering; - - const disableDecline = - (isTaskIncoming && isTelephony && !isBrowser) || (isAutoAnswering && !store.isDeclineButtonEnabled); + const disableAccept = !accept.isEnabled; + const disableDecline = !decline.isEnabled; const ronaTimeout = isTaskIncoming ? rawRonaTimeout : null; @@ -210,7 +209,7 @@ export const createTaskSelectHandler = ( return () => { try { // Logging moved to helper.ts - const taskData = extractTaskListItemData(task, true, agentId, logger); // Use browser=true for selection logic + const taskData = extractTaskListItemData(task, agentId, logger); if (isTaskSelectable(task, currentTask, taskData, logger)) { onTaskSelect(task); 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 aac63d7ca..4a74429d8 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 @@ -12,6 +12,7 @@ import { Participant, AddressBookEntrySearchParams, AddressBookEntriesResponse, + TaskUIControls, } from '@webex/cc-store'; type Enum> = T[keyof T]; @@ -104,11 +105,6 @@ export interface TaskProps { * Function to handle task selection */ onTaskSelect: (task: ITask) => void; - /** - * Flag to determine if the user is logged in with a browser option - */ - isBrowser: boolean; - /** * Flag to determine if the task is answered */ @@ -119,11 +115,6 @@ export interface TaskProps { */ isEnded: boolean; - /** - * Selected login option - */ - deviceType: string; - /** * List of tasks */ @@ -138,20 +129,22 @@ export interface TaskProps { * Agent ID of the logged-in user */ agentId: string; - /** - * Flag to enable decline button on incoming task component - */ - isDeclineButtonEnabled?: boolean; } -export type IncomingTaskComponentProps = Pick & - Partial>; +export type IncomingTaskComponentProps = Pick & + Partial> & { + acceptControl?: {isVisible: boolean; isEnabled: boolean}; + declineControl?: {isVisible: boolean; isEnabled: boolean}; + isDeclineButtonEnabled?: boolean; + }; export type TaskListComponentProps = Pick< TaskProps, - 'isBrowser' | 'acceptTask' | 'declineTask' | 'onTaskSelect' | 'logger' | 'agentId' + 'acceptTask' | 'declineTask' | 'onTaskSelect' | 'logger' | 'agentId' > & - Partial>; + Partial> & { + isDeclineButtonEnabled?: boolean; + }; /** * Interface representing the properties for control actions on a task. @@ -245,11 +238,6 @@ export interface ControlProps { */ wrapupCall: (wrapupReason: string, wrapupId: string) => void; - /** - * Selected login option - */ - deviceType: string; - /** * Flag to determine if the task is held */ @@ -383,11 +371,6 @@ export interface ControlProps { */ holdTime: number; - /** - * Feature flags for the task. - */ - featureFlags: {[key: string]: boolean}; - /** * Custom CSS ClassName for CallControlCAD component. */ @@ -438,7 +421,7 @@ export interface ControlProps { */ setLastTargetType: (targetType: TargetType) => void; - controlVisibility: ControlVisibility; + controls: TaskUIControls; secondsUntilAutoWrapup?: number; @@ -475,6 +458,7 @@ export interface ControlProps { export type CallControlComponentProps = Pick< ControlProps, | 'currentTask' + | 'isHeld' | 'wrapupCodes' | 'toggleHold' | 'toggleRecording' @@ -509,7 +493,7 @@ export type CallControlComponentProps = Pick< | 'allowConsultToQueue' | 'lastTargetType' | 'setLastTargetType' - | 'controlVisibility' + | 'controls' | 'logger' | 'secondsUntilAutoWrapup' | 'cancelAutoWrapup' @@ -518,6 +502,7 @@ export type CallControlComponentProps = Pick< | 'getEntryPoints' | 'getQueuesFetcher' | 'consultTransferOptions' + | 'conferenceEnabled' >; export type OutdialAniEntry = { @@ -647,8 +632,9 @@ export interface CallControlConsultComponentsProps { switchToMainCall: () => void; logger: ILogger; isMuted: boolean; - controlVisibility: ControlVisibility; + controls: TaskUIControls; toggleConsultMute: () => void; + conferenceEnabled: boolean; } /** diff --git a/packages/contact-center/cc-components/src/wc.ts b/packages/contact-center/cc-components/src/wc.ts index 1553aab77..3f3d81b2a 100644 --- a/packages/contact-center/cc-components/src/wc.ts +++ b/packages/contact-center/cc-components/src/wc.ts @@ -79,7 +79,6 @@ if (!customElements.get('component-cc-call-control')) { const WebIncomingTask = r2wc(IncomingTaskComponent, { props: { incomingTask: 'json', - isBrowser: 'boolean', accept: 'function', reject: 'function', }, @@ -92,7 +91,6 @@ const WebTaskList = r2wc(TaskListComponent, { props: { currentTask: 'json', taskList: 'json', - isBrowser: 'boolean', acceptTask: 'function', declineTask: 'function', logger: 'function', diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index 5fd608367..e1fde50ab 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -40,6 +40,7 @@ const WebCallControl = r2wc(CallControl, { onEnd: 'function', onWrapUp: 'function', onRecordingToggle: 'function', + conferenceEnabled: 'boolean', }, }); @@ -49,6 +50,7 @@ const WebCallControlCAD = r2wc(CallControlCAD, { onEnd: 'function', onWrapUp: 'function', onRecordingToggle: 'function', + conferenceEnabled: 'boolean', }, }); diff --git a/packages/contact-center/store/src/constants.ts b/packages/contact-center/store/src/constants.ts index 09ac07ed6..91b05d475 100644 --- a/packages/contact-center/store/src/constants.ts +++ b/packages/contact-center/store/src/constants.ts @@ -1,19 +1,3 @@ -// Task States -export const TASK_STATE_CONSULT = 'consult'; -export const TASK_STATE_CONSULTING = 'consulting'; -export const TASK_STATE_CONSULT_COMPLETED = 'consultCompleted'; - -// Interaction States -export const INTERACTION_STATE_WRAPUP = 'wrapUp'; -export const INTERACTION_STATE_POST_CALL = 'post_call'; -export const INTERACTION_STATE_CONNECTED = 'connected'; -export const INTERACTION_STATE_CONFERENCE = 'conference'; - -// Consult States (participant.consultState) -export const CONSULT_STATE_INITIATED = 'consultInitiated'; -export const CONSULT_STATE_COMPLETED = 'consultCompleted'; -export const CONSULT_STATE_CONFERENCING = 'conferencing'; - // Relationship Types export const RELATIONSHIP_TYPE_CONSULT = 'consult'; diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 5ade1368c..e87e1a2dc 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -38,6 +38,7 @@ class Store implements IStore { isQueueConsultInProgress = false; isDeclineButtonEnabled = false; currentConsultQueueId: string = ''; + lastConsultDestination: {to: string; destinationType: string} | null = null; consultStartTimeStamp = undefined; lastStateChangeTimestamp?: number; lastIdleCodeChangeTimestamp?: number; diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 0d3d6a2ae..e2bcb1acb 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -17,6 +17,10 @@ import { ContactServiceQueuesResponse, ContactServiceQueueSearchParams, AddressBook, + TASK_EVENTS, + TaskUIControls, + TaskUIControlState, + getDefaultUIControls, } from '@webex/contact-center'; import { OutdialAniEntriesResponse, @@ -119,6 +123,7 @@ interface IStore { isQueueConsultInProgress: boolean; isDeclineButtonEnabled: boolean; currentConsultQueueId: string; + lastConsultDestination: {to: string; destinationType: DestinationType} | null; consultStartTimeStamp?: number; callControlAudio: MediaStream | null; isEndConsultEnabled: boolean; @@ -167,47 +172,7 @@ interface IWrapupCode { name: string; } -enum TASK_EVENTS { - TASK_INCOMING = 'task:incoming', - TASK_ASSIGNED = 'task:assigned', - TASK_MEDIA = 'task:media', - TASK_HOLD = 'task:hold', - TASK_UNHOLD = 'task:unhold', - TASK_CONSULT = 'task:consult', - TASK_CONSULT_END = 'task:consultEnd', - TASK_CONSULT_ACCEPTED = 'task:consultAccepted', - TASK_PAUSE = 'task:pause', - TASK_RESUME = 'task:resume', - TASK_END = 'task:end', - TASK_WRAPUP = 'task:wrapup', - TASK_REJECT = 'task:rejected', - TASK_HYDRATE = 'task:hydrate', - TASK_CONSULTING = 'task:consulting', - TASK_CONSULT_QUEUE_CANCELLED = 'task:consultQueueCancelled', - AGENT_CONTACT_ASSIGNED = 'AgentContactAssigned', - CONTACT_RECORDING_PAUSED = 'ContactRecordingPaused', - CONTACT_RECORDING_RESUMED = 'ContactRecordingResumed', - AGENT_WRAPPEDUP = 'AgentWrappedUp', - AGENT_OFFER_CONTACT = 'AgentOfferContact', - AGENT_CONSULT_CREATED = 'AgentConsultCreated', - TASK_RECORDING_PAUSED = 'task:recordingPaused', - TASK_RECORDING_RESUMED = 'task:recordingResumed', - TASK_OFFER_CONSULT = 'task:offerConsult', - TASK_AUTO_ANSWERED = 'task:autoAnswered', - TASK_CONFERENCE_ESTABLISHING = 'task:conferenceEstablishing', - TASK_CONFERENCE_STARTED = 'task:conferenceStarted', - TASK_CONFERENCE_FAILED = 'task:conferenceFailed', - TASK_CONFERENCE_ENDED = 'task:conferenceEnded', - TASK_PARTICIPANT_JOINED = 'task:participantJoined', - TASK_PARTICIPANT_LEFT = 'task:participantLeft', - TASK_CONFERENCE_TRANSFERRED = 'task:conferenceTransferred', - TASK_CONFERENCE_TRANSFER_FAILED = 'task:conferenceTransferFailed', - TASK_CONFERENCE_END_FAILED = 'task:conferenceEndFailed', - TASK_PARTICIPANT_LEFT_FAILED = 'task:participantLeftFailed', - TASK_MERGED = 'task:merged', - TASK_POST_CALL_ACTIVITY = 'task:postCallActivity', - TASK_OUTDIAL_FAILED = 'task:outdialFailed', -} // TODO: remove this once cc sdk exports this enum +// TASK_EVENTS is now imported from @webex/contact-center SDK // Events that are received on the contact center SDK // TODO: Export & Import these constants from SDK @@ -246,6 +211,7 @@ type AgentLoginProfile = { social: number; telephony: number; }; + agentProfileID?: string; }; // Generic pagination params for list-fetching APIs @@ -319,6 +285,8 @@ export type { PaginatedListParams, FetchPaginatedList, TransformPaginatedData, + TaskUIControls, + TaskUIControlState, }; export { @@ -335,18 +303,10 @@ export { AGENT_STATE_AVAILABLE, LoginOptions, ERROR_TRIGGERING_IDLE_CODES, + getDefaultUIControls, }; -export enum ConsultStatus { - NO_CONSULTATION_IN_PROGRESS = 'No consultation in progress', - BEING_CONSULTED = 'beingConsulted', - CONSULT_INITIATED = 'consultInitiated', - BEING_CONSULTED_ACCEPTED = 'beingConsultedAccepted', - CONSULT_ACCEPTED = 'consultAccepted', - CONNECTED = 'connected', - CONFERENCE = 'conference', - CONSULT_COMPLETED = 'consultCompleted', -} +// ConsultStatus enum removed — use task.data.consultStatus from SDK instead export type Participant = { id: string; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 87aeb93f4..90a68858f 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -152,6 +152,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.currentConsultQueueId; } + get lastConsultDestination() { + return this.store.lastConsultDestination; + } + get isEndConsultEnabled() { return this.store.isEndConsultEnabled; } @@ -275,6 +279,9 @@ class StoreWrapper implements IStoreWrapper { } this.setCurrentTask(null); this.setState({reset: true}); + // Ensure agent state is set to Available (auxCodeId '0') when no tasks remain + // The backend should send AGENT_STATE_CHANGE, but in test environments it may not + this.setCurrentState('0'); } else if (this.currentTask && this.store.taskList[this.currentTask.data.interactionId]) { this.setCurrentTask(this.store.taskList[this.currentTask?.data?.interactionId]); } else if (taskListKeys.length > 0) { @@ -316,6 +323,12 @@ class StoreWrapper implements IStoreWrapper { }); }; + setLastConsultDestination = (destination: {to: string; destinationType: string} | null): void => { + runInAction(() => { + this.store.lastConsultDestination = destination; + }); + }; + setState = (state: ICustomState | IdleCode): void => { if ('reset' in state) { runInAction(() => { @@ -382,6 +395,7 @@ class StoreWrapper implements IStoreWrapper { orgId: profile.orgId || undefined, roles: profile.roles || undefined, deviceType: profile.deviceType || undefined, + agentProfileID: profile.agentProfileID || undefined, }; }); }; @@ -423,15 +437,18 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_END, this.handleTaskEnd); 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.AGENT_WRAPPEDUP, this.refreshTaskList); + taskToRemove.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, this.handleUIControlsUpdated); + 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); + taskToRemove.off(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); + taskToRemove.off(TASK_EVENTS.TASK_RECORDING_PAUSED, this.refreshTaskList); + taskToRemove.off(TASK_EVENTS.TASK_RECORDING_RESUMED, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); taskToRemove.off(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); taskToRemove.off(TASK_EVENTS.TASK_AUTO_ANSWERED, this.handleAutoAnswer); - taskToRemove.off(TASK_EVENTS.TASK_CONSULT_END, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONSULT_ACCEPTED, this.handleConsultAccepted); - taskToRemove.off(TASK_EVENTS.AGENT_CONSULT_CREATED, this.handleConsultCreated); taskToRemove.off(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); - taskToRemove.off(TASK_EVENTS.AGENT_OFFER_CONTACT, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_HOLD, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_RESUME, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_ENDED, this.handleConferenceEnded); @@ -442,7 +459,7 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_PARTICIPANT_LEFT, this.handleConferenceEnded); taskToRemove.off(TASK_EVENTS.TASK_PARTICIPANT_LEFT_FAILED, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_STARTED, this.handleConferenceStarted); - taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.handleConferenceEnded); + taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); if (this.deviceType === DEVICE_TYPE_BROWSER) { @@ -510,6 +527,7 @@ class StoreWrapper implements IStoreWrapper { handleConsultEnd = () => { this.setIsQueueConsultInProgress(false); this.setCurrentConsultQueueId(null); + this.setLastConsultDestination(null); this.refreshTaskList(); this.setConsultStartTimeStamp(null); }; @@ -541,6 +559,7 @@ class StoreWrapper implements IStoreWrapper { handleConsultQueueCancelled = () => { this.setIsQueueConsultInProgress(false); this.setCurrentConsultQueueId(null); + this.setLastConsultDestination(null); this.setConsultStartTimeStamp(null); this.refreshTaskList(); }; @@ -549,6 +568,7 @@ class StoreWrapper implements IStoreWrapper { runInAction(() => { this.setIsQueueConsultInProgress(false); this.setCurrentConsultQueueId(null); + this.setLastConsultDestination(null); this.setConsultStartTimeStamp(null); }); this.refreshTaskList(); @@ -562,45 +582,51 @@ class StoreWrapper implements IStoreWrapper { * Register all task event listeners * @param task - The task to register event listeners for */ + handleUIControlsUpdated = () => { + this.refreshTaskList(); + }; + private registerTaskEventListeners = (task: ITask): void => { - // Attach event listeners to the task task.on(TASK_EVENTS.TASK_END, this.handleTaskEnd); - - // When we receive TASK_ASSIGNED the task was accepted by the agent and we need wrap up task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); - task.on(TASK_EVENTS.AGENT_OFFER_CONTACT, this.refreshTaskList); - task.on(TASK_EVENTS.AGENT_CONSULT_CREATED, this.handleConsultCreated); - task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); - - // When we receive TASK_REJECT sdk changes the agent status - // When we receive TASK_REJECT that means the task was not accepted by the agent and we wont need wrap up task.on(TASK_EVENTS.TASK_REJECT, (reason) => this.handleTaskReject(task, reason)); - - // When we receive TASK_OUTDIAL_FAILED the outdial call failed task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, (reason) => this.handleOutdialFailed(reason)); - task.on(TASK_EVENTS.AGENT_WRAPPEDUP, this.refreshTaskList); + // SDK-computed UI control updates + task.on(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, this.handleUIControlsUpdated); + + // Renamed events (SDK names) + task.on(TASK_EVENTS.TASK_WRAPPEDUP, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONSULT_CREATED, this.handleConsultCreated); + task.on(TASK_EVENTS.TASK_OFFER_CONTACT, this.refreshTaskList); + // Fix: wire handleConsultEnd (was dead code — previously wired to refreshTaskList) + task.on(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); + + // Fix: correct event names + task.on(TASK_EVENTS.TASK_RECORDING_PAUSED, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_RECORDING_RESUMED, this.refreshTaskList); + + task.on(TASK_EVENTS.TASK_AUTO_ANSWERED, this.handleAutoAnswer); task.on(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); task.on(TASK_EVENTS.TASK_CONSULT_ACCEPTED, this.handleConsultAccepted); + task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); + task.on(TASK_EVENTS.TASK_PARTICIPANT_JOINED, this.handleConferenceStarted); + task.on(TASK_EVENTS.TASK_CONFERENCE_STARTED, this.handleConferenceStarted); + task.on(TASK_EVENTS.TASK_CONFERENCE_ENDED, this.handleConferenceEnded); + task.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT, this.handleConferenceEnded); task.on(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); - task.on(TASK_EVENTS.TASK_AUTO_ANSWERED, this.handleAutoAnswer); - task.on(TASK_EVENTS.TASK_CONSULT_END, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_HOLD, this.refreshTaskList); task.on(TASK_EVENTS.TASK_RESUME, this.refreshTaskList); - task.on(TASK_EVENTS.TASK_CONFERENCE_ENDED, this.handleConferenceEnded); - task.on(TASK_EVENTS.TASK_CONFERENCE_END_FAILED, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); task.on(TASK_EVENTS.TASK_CONFERENCE_ESTABLISHING, this.refreshTaskList); task.on(TASK_EVENTS.TASK_CONFERENCE_FAILED, this.refreshTaskList); - task.on(TASK_EVENTS.TASK_PARTICIPANT_JOINED, this.handleConferenceStarted); - task.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT, this.handleConferenceEnded); + task.on(TASK_EVENTS.TASK_CONFERENCE_END_FAILED, this.refreshTaskList); task.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT_FAILED, this.refreshTaskList); - task.on(TASK_EVENTS.TASK_CONFERENCE_STARTED, this.handleConferenceStarted); task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.refreshTaskList); task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, this.refreshTaskList); - task.on(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); - // Register media event listener for browser devices if (this.deviceType === DEVICE_TYPE_BROWSER) { task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); } @@ -643,6 +669,15 @@ class StoreWrapper implements IStoreWrapper { method: 'handleMultiLoginCloseSession', }); if (data && typeof data === 'object' && data.type === 'AgentMultiLoginCloseSession') { + // Don't show the multi-login modal if there's an active task + // The modal blocks UI interactions and should not interfere with task handling + if (this.currentTask) { + this.store.logger.info('CC-Widgets: handleMultiLoginCloseSession(): skipping alert due to active task', { + module: 'storeEventsWrapper.ts', + method: 'handleMultiLoginCloseSession', + }); + return; + } this.setShowMultipleLoginAlert(true); } }; @@ -801,6 +836,7 @@ class StoreWrapper implements IStoreWrapper { this.setConsultStartTimeStamp(undefined); this.setTeamId(''); this.setDigitalChannelsInitialized(false); + this.setLastConsultDestination(null); }); }; diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index 0f7cf870b..880bfa295 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -1,22 +1,5 @@ -import { - CONSULT_STATE_COMPLETED, - CONSULT_STATE_CONFERENCING, - CONSULT_STATE_INITIATED, - CUSTOMER, - EXCLUDED_PARTICIPANT_TYPES, - INTERACTION_STATE_CONFERENCE, - INTERACTION_STATE_CONNECTED, - INTERACTION_STATE_POST_CALL, - INTERACTION_STATE_WRAPUP, - MEDIA_TYPE_CONSULT, - RELATIONSHIP_TYPE_CONSULT, - SUPERVISOR, - TASK_STATE_CONSULT, - TASK_STATE_CONSULT_COMPLETED, - TASK_STATE_CONSULTING, - VVA, -} from './constants'; -import {ConsultStatus, ITask, MEDIA_TYPE_TELEPHONY_LOWER, Participant} from './store.types'; +import {EXCLUDED_PARTICIPANT_TYPES, MEDIA_TYPE_CONSULT, RELATIONSHIP_TYPE_CONSULT} from './constants'; +import {ITask, MEDIA_TYPE_TELEPHONY_LOWER, Participant} from './store.types'; /** * Determines if a task is an incoming task @@ -36,36 +19,6 @@ export const isIncomingTask = (task: ITask, agentId: string): boolean => { ); }; -export function getConsultMPCState(task: ITask, agentId: string): string { - const consultMediaResourceId = findMediaResourceId(task, 'consult'); - - const interaction = task.data.interaction; - if ( - (!!consultMediaResourceId && - !!interaction.participants[agentId]?.consultState && - task.data.interaction.state !== INTERACTION_STATE_WRAPUP) || - (!consultMediaResourceId && interaction.participants[agentId]?.consultState === CONSULT_STATE_COMPLETED) - // revisit below condition if needed for post_call scenarios in future - //&& task.data.interaction.state !== INTERACTION_STATE_POST_CALL // If interaction.state is post_call, we want to return post_call. - ) { - // interaction state for all agents when consult is going on - switch (interaction.participants[agentId]?.consultState) { - case CONSULT_STATE_INITIATED: - return TASK_STATE_CONSULT; - case CONSULT_STATE_COMPLETED: - return interaction.state === INTERACTION_STATE_CONNECTED - ? INTERACTION_STATE_CONNECTED - : TASK_STATE_CONSULT_COMPLETED; - case CONSULT_STATE_CONFERENCING: - return INTERACTION_STATE_CONFERENCE; - default: - return TASK_STATE_CONSULTING; - } - } - - return interaction?.state; -} - /** * Checks if the current agent is a secondary agent in a consultation scenario. * Secondary agents are those who were consulted (not the original call owner). @@ -86,88 +39,11 @@ export function isSecondaryAgent(task: ITask): boolean { /** * Checks if the current agent is a secondary EP-DN (Entry Point Dial Number) agent. * This is specifically for telephony consultations to external numbers/entry points. - * @param {Object} task - The task object containing interaction details - * @returns {boolean} True if this is a secondary EP-DN agent in telephony consultation */ export function isSecondaryEpDnAgent(task: ITask): boolean { return task.data.interaction.mediaType === MEDIA_TYPE_TELEPHONY_LOWER && isSecondaryAgent(task); } -export function getTaskStatus(task: ITask, agentId: string): string { - const interaction = task.data.interaction; - if (isSecondaryEpDnAgent(task)) { - if (interaction.state === INTERACTION_STATE_CONFERENCE) { - return INTERACTION_STATE_CONFERENCE; - } - return TASK_STATE_CONSULTING; // handle state of child agent case as we cant rely on interaction state. - } - if ( - (task.data.interaction.state === INTERACTION_STATE_WRAPUP || - task.data.interaction.state === INTERACTION_STATE_POST_CALL) && - interaction.participants[agentId]?.consultState === CONSULT_STATE_COMPLETED - ) { - return TASK_STATE_CONSULT_COMPLETED; - } - - return getConsultMPCState(task, agentId); -} - -export function getConsultStatus(task: ITask, agentId: string): string { - if (!task || !task.data) { - return ConsultStatus.NO_CONSULTATION_IN_PROGRESS; - } - - const state = getTaskStatus(task, agentId); - - const {interaction} = task.data; - const participants = interaction?.participants || {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const participant: any = Object.values(participants).find((p: any) => p.pType === 'Agent' && p.id === agentId); - - if (state === TASK_STATE_CONSULT) { - if ((participant && participant.isConsulted) || isSecondaryEpDnAgent(task)) { - return ConsultStatus.BEING_CONSULTED; - } - return ConsultStatus.CONSULT_INITIATED; - } else if (state === TASK_STATE_CONSULTING) { - if ((participant && participant.isConsulted) || isSecondaryEpDnAgent(task)) { - return ConsultStatus.BEING_CONSULTED_ACCEPTED; - } - return ConsultStatus.CONSULT_ACCEPTED; - } else if (state === INTERACTION_STATE_CONNECTED) { - return ConsultStatus.CONNECTED; - } else if (state === INTERACTION_STATE_CONFERENCE) { - return ConsultStatus.CONFERENCE; - } else if (state === TASK_STATE_CONSULT_COMPLETED) { - return ConsultStatus.CONSULT_COMPLETED; - } - // Default return for states that don't match any condition (e.g., chat, email initial states) - return state || ConsultStatus.NO_CONSULTATION_IN_PROGRESS; -} - -export function getIsConferenceInProgress(task: ITask): boolean { - // Early return if required data is missing - if (!task?.data?.interaction?.media || !task?.data?.interactionId) { - return false; - } - - const mediaMainCall = task.data.interaction.media[task.data.interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants); - const participants = task?.data?.interaction?.participants; - - const agentParticipants = new Set(); - if (participantsInMainCall.size > 0 && participants) { - participantsInMainCall.forEach((participantId: string) => { - const participant = participants[participantId]; - if (participant && ![CUSTOMER, SUPERVISOR, VVA].includes(participant.pType) && !participant.hasLeft) { - agentParticipants.add(participantId); - } - }); - } - - return agentParticipants.size >= 2; -} - /** * Retrieves the list of active conference participants excluding the current agent * Filters out customers, supervisors, VVAs, and participants who have left @@ -210,67 +86,6 @@ export const getConferenceParticipants = (task: ITask, agentId: string): Partici return participantsList; }; -/** - * Counts the number of active agent participants in the conference - * Excludes customers, supervisors, VVAs, and participants who have left - * - * @param task - The task object containing interaction data - * @returns Count of active agent participants - */ -export function getConferenceParticipantsCount(task: ITask): number { - const participantsList: Participant[] = []; - - // Early return if required data is missing - if (!task?.data?.interaction?.media || !task?.data?.interactionId) { - return 0; - } - - const mediaMainCall = task.data.interaction.media?.[task.data.interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants ?? []); - const participants = task.data.interaction.participants ?? {}; - - if (participantsInMainCall.size > 0 && participants) { - participantsInMainCall.forEach((participantId: string) => { - const participant = participants[participantId]; - // Count only active agent participants (excluding customers, supervisors, and VVAs) - if (participant && !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && !participant.hasLeft) { - participantsList.push({ - id: participant.id, - pType: participant.pType, - name: participant.name, - }); - } - }); - } - - return participantsList.length; -} - -export function getIsCustomerInCall(task: ITask): boolean { - // Early return if required data is missing - if (!task?.data?.interaction?.media || !task?.data?.interactionId) { - return false; - } - - const mediaMainCall = task.data.interaction.media[task.data.interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants); - const participants = task?.data?.interaction?.participants; - - if (participantsInMainCall.size > 0 && participants) { - return Array.from(participantsInMainCall).some((participantId: string) => { - const participant = participants[participantId]; - return participant && participant.pType === CUSTOMER && !participant.hasLeft; - }); - } - - return false; -} - -export function getIsConsultInProgress(task: ITask): boolean { - const mediaObject = task.data.interaction.media; - return Object.values(mediaObject).some((media) => media.mType === MEDIA_TYPE_CONSULT); -} - export function isInteractionOnHold(task: ITask): boolean { if (!task || !task.data || !task.data.interaction) { return false; @@ -299,39 +114,6 @@ export const findMediaResourceId = (task: ITask, mType: string) => { return ''; }; -const isConsultOnHoldMPC = (task: ITask, agentId: string): boolean => { - const isInConsultState = [TASK_STATE_CONSULT, TASK_STATE_CONSULTING].includes(getConsultMPCState(task, agentId)); - const consultMediaResourceId = task.data.consultMediaResourceId; - const isConsultHold = consultMediaResourceId && task.data.interaction.media[consultMediaResourceId]?.isHold; - - return isInConsultState && !isConsultHold; -}; - -export const findHoldStatus = (task: ITask, mType: string, agentId: string): boolean => { - const interaction = task.data.interaction; - if (!interaction) { - return false; - } - mType = setmTypeForEPDN(task, mType); // set mType if agent is secondary EPDN agent - const mediaId = findMediaResourceId(task, mType); - // custom mainCall hold status for agent who initiated the consult. - if ( - mType === 'mainCall' && - interaction.media[mediaId]?.participants.includes(agentId) && - (isConsultOnHoldMPC(task, agentId) || [TASK_STATE_CONSULT_COMPLETED].includes(getConsultMPCState(task, agentId))) - ) { - return true; - } - - // hold status for agents who are in consulting call(consulting agent | consulted agent) - - return mType === TASK_STATE_CONSULT && interaction.media[mediaId] - ? interaction.media[mediaId].participants.includes(agentId) - ? interaction.media[mediaId].isHold - : false - : (interaction.media[mediaId] && interaction.media[mediaId].isHold) || false; // For all the other agent for main whatever is the status of main call hold -}; - /** * Finds the hold timestamp for a specific media type (mainCall, consult, etc.) * Used for timer alignment in Consult & Conference scenarios to match Agent Desktop behavior. diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 022629749..90c62c93f 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -15,8 +15,6 @@ const CallControlInternal: React.FunctionComponent = observer( wrapupCodes, consultStartTimeStamp, callControlAudio, - deviceType, - featureFlags, allowConsultToQueue, isMuted, agentId, @@ -31,11 +29,9 @@ const CallControlInternal: React.FunctionComponent = observer( onRecordingToggle, onToggleMute, logger, - deviceType, - featureFlags, isMuted, - conferenceEnabled, agentId, + conferenceEnabled, }), wrapupCodes, consultStartTimeStamp, @@ -57,7 +53,7 @@ const CallControl: React.FunctionComponent = (props) => { if (store.onErrorCallback) store.onErrorCallback('CallControl', error); }} > - + ); }; diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index df353bd8b..4a9e67f3b 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -16,8 +16,8 @@ const CallControlCADInternal: React.FunctionComponent = observ onToggleMute, callControlClassName, callControlConsultClassName, - conferenceEnabled, consultTransferOptions, + conferenceEnabled, }) => { const { logger, @@ -26,8 +26,6 @@ const CallControlCADInternal: React.FunctionComponent = observ consultStartTimeStamp, callControlAudio, allowConsultToQueue, - featureFlags, - deviceType, isMuted, agentId, } = store; @@ -40,11 +38,9 @@ const CallControlCADInternal: React.FunctionComponent = observ onRecordingToggle, onToggleMute, logger, - deviceType, - featureFlags, isMuted, - conferenceEnabled, agentId, + conferenceEnabled, }), wrapupCodes, consultStartTimeStamp, @@ -68,7 +64,7 @@ const CallControlCAD: React.FunctionComponent = (props) => { if (store.onErrorCallback) store.onErrorCallback('CallControlCAD', error); }} > - + ); }; diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index 146686e71..496a257d0 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -9,12 +9,13 @@ import {IncomingTaskProps} from '../task.types'; const IncomingTaskInternal: React.FunctionComponent = observer( ({incomingTask, onAccepted, onRejected}) => { - const {deviceType, logger} = store; - const result = useIncomingTask({incomingTask, onAccepted, onRejected, deviceType, logger}); + const {logger, isDeclineButtonEnabled} = store; + const result = useIncomingTask({incomingTask, onAccepted, onRejected, logger}); const props = { ...result, logger, + isDeclineButtonEnabled, }; return ; diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx index 3cd675c52..8393ce914 100644 --- a/packages/contact-center/task/src/TaskList/index.tsx +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -9,14 +9,15 @@ import {TaskListProps} from '../task.types'; const TaskListInternal: React.FunctionComponent = observer( ({onTaskAccepted, onTaskDeclined, onTaskSelected}) => { - const {cc, taskList, currentTask, deviceType, logger, agentId} = store; + const {cc, taskList, currentTask, logger, agentId, isDeclineButtonEnabled} = store; - const result = useTaskList({cc, deviceType, logger, taskList, onTaskAccepted, onTaskDeclined, onTaskSelected}); + const result = useTaskList({cc, logger, taskList, onTaskAccepted, onTaskDeclined, onTaskSelected}); const props = { ...result, currentTask, logger, agentId, + isDeclineButtonEnabled, }; return ; diff --git a/packages/contact-center/task/src/Utils/task-util.ts b/packages/contact-center/task/src/Utils/task-util.ts index b5b3dea5c..5c869a286 100644 --- a/packages/contact-center/task/src/Utils/task-util.ts +++ b/packages/contact-center/task/src/Utils/task-util.ts @@ -1,79 +1,12 @@ -import { - ILogger, - DIAL_NUMBER, - EXTENSION, - DESKTOP, - ConsultStatus, - getConsultStatus, - getIsConsultInProgress, - getIsCustomerInCall, - getConferenceParticipantsCount, - findHoldStatus, -} from '@webex/cc-store'; -import {ITask, Interaction} from '@webex/contact-center'; -import {Visibility} from '@webex/cc-components'; -import { - MEDIA_TYPE_TELEPHONY, - MEDIA_TYPE_CHAT, - MEDIA_TYPE_EMAIL, - MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE, - DestinationAgentType, -} from './constants'; -import {DeviceTypeFlags} from '../task.types'; - -// ==================== UTILITY FUNCTIONS ==================== - -/** - * Helper function to get device type flags to avoid repetition - */ -function getDeviceTypeFlags(deviceType: string): DeviceTypeFlags { - return { - isBrowser: deviceType === DESKTOP, - isAgentDN: deviceType === DIAL_NUMBER, - isExtension: deviceType === EXTENSION, - }; -} +import {Interaction} from '@webex/contact-center'; /** - * Helper function to check if telephony is supported for the device + * Finds the hold timestamp for a specific media type from an interaction. + * Used by useHoldTimer for hold duration display. + * + * Note: There is a separate findHoldTimestamp in @webex/cc-store that takes ITask. + * This one takes Interaction directly. */ -function isTelephonySupported(deviceType: string, webRtcEnabled: boolean): boolean { - const {isBrowser, isAgentDN, isExtension} = getDeviceTypeFlags(deviceType); - return (isBrowser && webRtcEnabled) || isAgentDN || isExtension; -} - -/** - * Check if consulting with an EP_DN agent (Entry Point Dial Number) - * This function looks for EP-DN participants in the consult media - */ -function isConsultingWithEpDnAgent(task: ITask): boolean { - if (!task?.data?.interaction?.media || !task?.data?.interaction?.participants) { - return false; - } - - // Find the consult media - const consultMedia = Object.values(task.data.interaction.media).find((media) => media.mType === 'consult'); - - if (!consultMedia || !consultMedia.participants) { - return false; - } - - // Check if any participant in the consult media is an EP-DN - const participants = task.data.interaction.participants; - return consultMedia.participants.some((participantId: string) => { - const participant = participants[participantId]; - if (!participant) return false; - - // Check for EP-DN participant types using the type field - return ( - participant.type === DestinationAgentType.EP_DN || - participant.type === DestinationAgentType.EPDN || - participant.type === DestinationAgentType.ENTRY_POINT || - participant.type === DestinationAgentType.EP - ); - }); -} - export function findHoldTimestamp(interaction: Interaction, mType = 'mainCall'): number | null { if (interaction?.media) { const media = Object.values(interaction.media).find((m) => m.mType === mType); @@ -81,574 +14,3 @@ export function findHoldTimestamp(interaction: Interaction, mType = 'mainCall'): } return null; } - -// ==================== CALL CONTROL BUTTON VISIBILITY FUNCTIONS ==================== - -/** - * Get visibility for Accept button - */ -export function getAcceptButtonVisibility( - isBrowser: boolean, - isPhoneDevice: boolean, - webRtcEnabled: boolean, - isCall: boolean, - isDigitalChannel: boolean -): Visibility { - const isVisible = - (isBrowser && ((webRtcEnabled && isCall) || isDigitalChannel)) || (isPhoneDevice && isDigitalChannel); - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for Decline button - */ -export function getDeclineButtonVisibility(isBrowser: boolean, webRtcEnabled: boolean, isCall: boolean): Visibility { - const isVisible = isBrowser && webRtcEnabled && isCall; - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for End button (matches Agent Desktop behavior) - */ -export function getEndButtonVisibility( - isBrowser: boolean, - isEndCallEnabled: boolean, - isCall: boolean, - isConsultInitiatedOrAcceptedOrBeingConsulted: boolean, - isConferenceInProgress: boolean, - isConsultCompleted: boolean, - isHeld: boolean, - consultCallHeld: boolean, - task?: ITask, - agentId?: string -): Visibility { - const isVisible = isBrowser || (isEndCallEnabled && isCall) || !isCall; - const isEpDnConsult = task && agentId ? isConsultingWithEpDnAgent(task) : false; - - if (isConsultInitiatedOrAcceptedOrBeingConsulted) { - let isEnabled = false; - if (isEpDnConsult) { - // EP-DN consult: enabled when on main call OR during conference when main not held - isEnabled = consultCallHeld || (!isHeld && isConferenceInProgress && !isConsultCompleted); - } - return {isVisible, isEnabled}; - } - - // Default logic for other states - const isEnabled = - (!isHeld || (isConferenceInProgress && !isConsultCompleted)) && - (!isConsultInitiatedOrAcceptedOrBeingConsulted || consultCallHeld); - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for Mute/Unmute button - */ -export function getMuteUnmuteButtonVisibility( - isBrowser: boolean, - webRtcEnabled: boolean, - isCall: boolean, - isBeingConsulted: boolean -): Visibility { - const isVisible = isBrowser && webRtcEnabled && isCall && !isBeingConsulted; - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for Hold/Resume button - */ -export function getHoldResumeButtonVisibility( - isTelephonySupported: boolean, - isCall: boolean, - isConferenceInProgress: boolean, - isConsultInProgress: boolean, - isHeld: boolean, - isBeingConsulted: boolean, - isConsultCompleted: boolean -): Visibility { - const isVisible = isCall && isTelephonySupported && !isBeingConsulted; - // Enable if: (NOT in conference AND NOT in consult) OR (in conference AND consult completed AND held) - const isEnabled = - (!isConferenceInProgress && !isConsultInProgress) || (isConferenceInProgress && isConsultCompleted && isHeld); - - return {isVisible, isEnabled}; -} - -// ==================== RECORDING FUNCTIONS ==================== - -/** - * Get visibility for Pause/Resume Recording button - */ -export function getPauseResumeRecordingButtonVisibility( - isTelephonySupported: boolean, - isCall: boolean, - isConferenceInProgress: boolean, - isConsultInitiatedOrAccepted: boolean -): Visibility { - const isVisible = isCall && isTelephonySupported && !isConferenceInProgress && !isConsultInitiatedOrAccepted; - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for Recording Indicator - */ -export function getRecordingIndicatorVisibility(isCall: boolean): Visibility { - return {isVisible: isCall, isEnabled: true}; -} - -// ==================== TRANSFER AND CONFERENCE FUNCTIONS ==================== - -/** - * Get visibility for Transfer button - */ -export function getTransferButtonVisibility( - isTransferVisibility: boolean, - isConferenceInProgress: boolean, - isConsultInitiatedOrAccepted: boolean -): Visibility { - const isVisible = isTransferVisibility && !isConferenceInProgress && !isConsultInitiatedOrAccepted; - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for Conference button - */ -export function getConferenceButtonVisibility( - isBrowser: boolean, - webRtcEnabled: boolean, - isCall: boolean, - isChat: boolean, - isBeingConsulted: boolean, - conferenceEnabled: boolean -): Visibility { - const isVisible = ((isBrowser && isCall && webRtcEnabled) || isChat) && !isBeingConsulted && conferenceEnabled; - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for Exit Conference button - */ -export function getExitConferenceButtonVisibility( - isConferenceInProgress: boolean, - isConsultInitiatedOrAccepted: boolean, - consultCallHeld: boolean, - isHeld: boolean, - isConsultCompleted: boolean, - conferenceEnabled: boolean -): Visibility { - const isVisible = isConferenceInProgress && !isConsultInitiatedOrAccepted && conferenceEnabled; - const isConferenceWithConsultNotHeld = isConferenceInProgress && isConsultInitiatedOrAccepted && !consultCallHeld; - // Disable if: conference with consult not held OR (held AND in conference AND consult completed) - const isEnabled = !isConferenceWithConsultNotHeld && !(isHeld && isConferenceInProgress && isConsultCompleted); - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for Merge Conference button - */ -export function getMergeConferenceButtonVisibility( - isConsultInitiatedOrAccepted: boolean, - isConsultAccepted: boolean, - consultCallHeld: boolean, - isConferenceInProgress: boolean, - isCustomerInCall: boolean, - conferenceEnabled: boolean -): Visibility { - const isVisible = isConsultInitiatedOrAccepted && isCustomerInCall && conferenceEnabled; - const isConferenceWithConsultNotHeld = isConferenceInProgress && isConsultInitiatedOrAccepted && !consultCallHeld; - const isEnabled = isConsultAccepted && consultCallHeld && !isConferenceWithConsultNotHeld; - - return {isVisible, isEnabled}; -} - -// ==================== CONSULT FUNCTIONS ==================== - -/** - * Get visibility for Consult button - */ -export function getConsultButtonVisibility( - isTelephonySupported: boolean, - isCall: boolean, - isConsultInProgress: boolean, - isCustomerInCall: boolean, - conferenceParticipantsCount: number, - maxParticipantsInConference: number, - isBeingConsulted: boolean, - isHeld: boolean, - isConsultCompleted: boolean, - isConferenceInProgress: boolean -): Visibility { - const isVisible = isCall && isTelephonySupported && !isBeingConsulted; - const isEnabled = - conferenceParticipantsCount < maxParticipantsInConference && - !isConsultInProgress && - isCustomerInCall && - !(isHeld && isConferenceInProgress && !isConsultCompleted); - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for End Consult button - */ -export function getEndConsultButtonVisibility( - isEndConsultEnabled: boolean, - isTelephonySupported: boolean, - isCall: boolean, - isConsultInitiatedOrAccepted: boolean -): Visibility { - const isVisible = isEndConsultEnabled && isCall && isTelephonySupported && isConsultInitiatedOrAccepted; - - return {isVisible, isEnabled: true}; -} - -/** - * Get visibility for Consult Transfer button - */ -export function getConsultTransferButtonVisibility( - isConsultInitiatedOrAccepted: boolean, - isConsultAccepted: boolean, - consultCallHeld: boolean, - isConferenceInProgress: boolean, - isCustomerInCall: boolean -): Visibility { - const isVisible = isConsultInitiatedOrAccepted && isCustomerInCall; - const isConferenceWithConsultNotHeld = isConferenceInProgress && isConsultInitiatedOrAccepted && !consultCallHeld; - const isEnabled = isConsultAccepted && consultCallHeld && !isConferenceWithConsultNotHeld; - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for Merge Conference Consult button - */ -export function getMergeConferenceConsultButtonVisibility( - isConsultAccepted: boolean, - isConsultInitiated: boolean, - consultCallHeld: boolean, - isCustomerInCall: boolean, - conferenceEnabled: boolean -): Visibility { - const isVisible = (isConsultAccepted || isConsultInitiated) && conferenceEnabled; - const isEnabled = !consultCallHeld && isConsultAccepted && isCustomerInCall; - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for Consult Transfer Consult button - */ -export function getConsultTransferConsultButtonVisibility( - isConsultAccepted: boolean, - isConsultInitiated: boolean, - consultCallHeld: boolean, - isCustomerInCall: boolean -): Visibility { - const isVisible = isConsultAccepted || isConsultInitiated; - const isEnabled = !consultCallHeld && isConsultAccepted && isCustomerInCall; - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for Mute/Unmute Consult button - */ -export function getMuteUnmuteConsultButtonVisibility( - isBrowser: boolean, - webRtcEnabled: boolean, - isCall: boolean, - isConsultInitiated: boolean, - isBeingConsulted: boolean -): Visibility { - const isVisible = isBrowser && webRtcEnabled && isCall && (isConsultInitiated || isBeingConsulted); - - return {isVisible, isEnabled: true}; -} - -// ==================== SWITCH CALL FUNCTIONS ==================== - -/** - * Get visibility for Switch to Main Call button - */ -export function getSwitchToMainCallButtonVisibility( - isBeingConsulted: boolean, - isConsultAccepted: boolean, - isConsultInitiated: boolean, - consultCallHeld: boolean, - isCustomerInCall: boolean, - isConferenceInProgress: boolean -): Visibility { - const isVisible = !isBeingConsulted && (isConsultAccepted || isConsultInitiated) && !consultCallHeld; - const isEnabled = isConsultAccepted && (isCustomerInCall || (!isCustomerInCall && isConferenceInProgress)); - - return {isVisible, isEnabled}; -} - -/** - * Get visibility for Switch to Consult button - */ -export function getSwitchToConsultButtonVisibility(isBeingConsulted: boolean, consultCallHeld: boolean): Visibility { - const isVisible = !isBeingConsulted && consultCallHeld; - // const isConferenceWithConsultNotHeld = isConferenceInProgress && isConsultAccepted && !consultCallHeld; - const isEnabled = true; - - return {isVisible, isEnabled}; -} - -// ==================== OTHER FUNCTIONS ==================== - -/** - * Get visibility for Wrapup button - */ -export function getWrapupButtonVisibility(task: ITask): Visibility { - const isVisible = task?.data?.wrapUpRequired ?? false; - - return {isVisible, isEnabled: true}; -} -// ==================== MAIN AGGREGATOR FUNCTION ==================== - -/** - * This function determines the visibility of various controls based on the task's data. - * @param deviceType The device type (Browser, Extension, AgentDN) - * @param featureFlags Feature flags configuration object - * @param task The task object - * @param agentId The agent ID - * @param conferenceEnabled Whether conference is enabled - * @param logger Optional logger instance - * @returns An object containing the visibility and state of various controls - */ -export function getControlsVisibility( - deviceType: string, - featureFlags: {[key: string]: boolean}, - task: ITask, - agentId: string, - conferenceEnabled: boolean, - logger?: ILogger -) { - try { - // Extract media type and related flags - const {mediaType} = task?.data?.interaction || {}; - const isCall = mediaType === MEDIA_TYPE_TELEPHONY; - const isChat = mediaType === MEDIA_TYPE_CHAT; - const isEmail = mediaType === MEDIA_TYPE_EMAIL; - const isDigitalChannel = isChat || isEmail; - - // Extract device type flags - const {isBrowser, isAgentDN, isExtension} = getDeviceTypeFlags(deviceType); - const isPhoneDevice = isAgentDN || isExtension; - - // Extract feature flags - const {isEndCallEnabled, isEndConsultEnabled, webRtcEnabled} = featureFlags; - - // Calculate telephony support - const telephonySupported = isTelephonySupported(deviceType, webRtcEnabled); - - // Calculate task state flags - const isTransferVisibility = isBrowser ? webRtcEnabled : true; - const isConferenceInProgress = (task?.data?.isConferenceInProgress && conferenceEnabled) ?? false; - const isConsultInProgress = getIsConsultInProgress(task); - const isHeld = findHoldStatus(task, 'mainCall', agentId); - const isCustomerInCall = getIsCustomerInCall(task); - // const mainCallHeld = findHoldStatus(task, 'mainCall', agentId); - const consultCallHeld = findHoldStatus(task, 'consult', agentId); - const taskConsultStatus = getConsultStatus(task, agentId); - - // Calculate conference participants count - const conferenceParticipantsCount = getConferenceParticipantsCount(task); - - // Calculate consult status flags (REUSED CONDITIONS) - const isConsultInitiated = taskConsultStatus === ConsultStatus.CONSULT_INITIATED; - const isConsultAccepted = taskConsultStatus === ConsultStatus.CONSULT_ACCEPTED; - const isBeingConsulted = taskConsultStatus === ConsultStatus.BEING_CONSULTED_ACCEPTED; - const isConsultCompleted = taskConsultStatus === ConsultStatus.CONSULT_COMPLETED; - const isConsultInitiatedOrAccepted = isConsultInitiated || isConsultAccepted || isBeingConsulted; - const isConsultInitiatedOrAcceptedOnly = isConsultInitiated || isConsultAccepted; - const isConsultInitiatedOrAcceptedOrBeingConsulted = - isConsultInitiated || - isConsultAccepted || - taskConsultStatus === ConsultStatus.BEING_CONSULTED || - isBeingConsulted; - - // Build controls visibility object - const controls = { - // Basic call controls - accept: getAcceptButtonVisibility(isBrowser, isPhoneDevice, webRtcEnabled, isCall, isDigitalChannel), - decline: getDeclineButtonVisibility(isBrowser, webRtcEnabled, isCall), - end: getEndButtonVisibility( - isBrowser, - isEndCallEnabled, - isCall, - isConsultInitiatedOrAcceptedOrBeingConsulted, - isConferenceInProgress, - isConsultCompleted, - isHeld, - consultCallHeld, - task, - agentId - ), - muteUnmute: getMuteUnmuteButtonVisibility(isBrowser, webRtcEnabled, isCall, isBeingConsulted), - holdResume: getHoldResumeButtonVisibility( - telephonySupported, - isCall, - isConferenceInProgress, - isConsultInProgress, - isHeld, - isBeingConsulted, - isConsultCompleted - ), - - // Recording controls - pauseResumeRecording: getPauseResumeRecordingButtonVisibility( - telephonySupported, - isCall, - isConferenceInProgress, - isConsultInitiatedOrAccepted - ), - recordingIndicator: getRecordingIndicatorVisibility(isCall), - - // Transfer and conference controls - transfer: getTransferButtonVisibility(isTransferVisibility, isConferenceInProgress, isConsultInitiatedOrAccepted), - conference: getConferenceButtonVisibility( - isBrowser, - webRtcEnabled, - isCall, - isChat, - isBeingConsulted, - conferenceEnabled - ), - exitConference: getExitConferenceButtonVisibility( - isConferenceInProgress, - isConsultInitiatedOrAccepted, - consultCallHeld, - isHeld, - isConsultCompleted, - conferenceEnabled - ), - mergeConference: getMergeConferenceButtonVisibility( - isConsultInitiatedOrAcceptedOnly, - isConsultAccepted, - consultCallHeld, - isConferenceInProgress, - isCustomerInCall, - conferenceEnabled - ), - - // Consult controls - consult: getConsultButtonVisibility( - telephonySupported, - isCall, - isConsultInProgress, - isCustomerInCall, - conferenceParticipantsCount, - MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE, - isBeingConsulted, - isHeld, - isConsultCompleted, - isConferenceInProgress - ), - endConsult: getEndConsultButtonVisibility( - isEndConsultEnabled, - telephonySupported, - isCall, - isConsultInitiatedOrAccepted - ), - consultTransfer: getConsultTransferButtonVisibility( - isConsultInitiatedOrAcceptedOnly, - isConsultAccepted, - consultCallHeld, - isConferenceInProgress, - isCustomerInCall - ), - consultTransferConsult: getConsultTransferConsultButtonVisibility( - isConsultAccepted, - isConsultInitiated, - consultCallHeld, - isCustomerInCall - ), - mergeConferenceConsult: getMergeConferenceConsultButtonVisibility( - isConsultAccepted, - isConsultInitiated, - consultCallHeld, - isCustomerInCall, - conferenceEnabled - ), - muteUnmuteConsult: getMuteUnmuteConsultButtonVisibility( - isBrowser, - webRtcEnabled, - isCall, - isConsultInitiated, - isBeingConsulted - ), - - // Switch call controls - switchToMainCall: getSwitchToMainCallButtonVisibility( - isBeingConsulted, - isConsultAccepted, - isConsultInitiated, - consultCallHeld, - isCustomerInCall, - isConferenceInProgress - ), - switchToConsult: getSwitchToConsultButtonVisibility(isBeingConsulted, consultCallHeld), - - // Other controls - wrapup: getWrapupButtonVisibility(task), - - // State flags - isConferenceInProgress, - isConsultInitiated, - isConsultInitiatedAndAccepted: isConsultAccepted, - isConsultReceived: isBeingConsulted, - isConsultInitiatedOrAccepted: isConsultInitiatedOrAccepted, - isHeld, - consultCallHeld, - }; - - return controls; - } catch (error) { - logger?.error(`CC-Widgets: Task: Error in getControlsVisibility - ${error.message}`, { - module: 'task-util', - method: 'getControlsVisibility', - }); - - // Return safe default controls - const defaultVisibility: Visibility = {isVisible: false, isEnabled: false}; - return { - accept: defaultVisibility, - decline: defaultVisibility, - end: defaultVisibility, - muteUnmute: defaultVisibility, - holdResume: defaultVisibility, - pauseResumeRecording: defaultVisibility, - recordingIndicator: defaultVisibility, - transfer: defaultVisibility, - conference: defaultVisibility, - exitConference: defaultVisibility, - mergeConference: defaultVisibility, - consult: defaultVisibility, - endConsult: defaultVisibility, - consultTransfer: defaultVisibility, - consultTransferConsult: defaultVisibility, - mergeConferenceConsult: defaultVisibility, - muteUnmuteConsult: defaultVisibility, - switchToMainCall: defaultVisibility, - switchToConsult: defaultVisibility, - wrapup: {isVisible: false, isEnabled: true}, - isConferenceInProgress: false, - isConsultInitiated: false, - isConsultInitiatedAndAccepted: false, - isConsultReceived: false, - isConsultInitiatedOrAccepted: false, - isHeld: false, - consultCallHeld: false, - }; - } -} diff --git a/packages/contact-center/task/src/Utils/timer-utils.ts b/packages/contact-center/task/src/Utils/timer-utils.ts index 9e3cf15cf..5710064fb 100644 --- a/packages/contact-center/task/src/Utils/timer-utils.ts +++ b/packages/contact-center/task/src/Utils/timer-utils.ts @@ -1,5 +1,4 @@ -import {ITask, findHoldTimestamp} from '@webex/cc-store'; -import {ControlVisibility} from '@webex/cc-components'; +import {ITask, findHoldTimestamp, TaskUIControls} from '@webex/cc-store'; import { TIMER_LABEL_WRAP_UP, TIMER_LABEL_POST_CALL, @@ -19,21 +18,16 @@ export interface TimerData { /** * Calculate state timer label and timestamp based on task state. * Priority: Wrap Up > Post Call - * - * @param currentTask - The current task object - * @param controlVisibility - Control visibility flags - * @param agentId - The current agent ID - * @returns TimerData object with label and timestamp */ export function calculateStateTimerData( currentTask: ITask | null, - controlVisibility: ControlVisibility | null, + controls: TaskUIControls | null, agentId: string ): TimerData { // Default return value const defaultTimer: TimerData = {label: null, timestamp: 0}; - if (!currentTask || !controlVisibility) { + if (!currentTask || !controls) { return defaultTimer; } @@ -59,7 +53,7 @@ export function calculateStateTimerData( postCallTimestamp = participant.currentStateTimestamp || 0; // Priority 1: Wrap-up state (highest priority) - if (controlVisibility.wrapup?.isVisible && wrapUpTimestamp) { + if (controls.wrapup?.isVisible && wrapUpTimestamp) { return { label: TIMER_LABEL_WRAP_UP, timestamp: wrapUpTimestamp, @@ -81,21 +75,15 @@ export function calculateStateTimerData( /** * Calculate consult timer label and timestamp based on consult state. * Handles consult on hold vs active consulting states. - * - * @param currentTask - The current task object - * @param controlVisibility - Control visibility flags - * @param agentId - The current agent ID - * @returns TimerData object with label and timestamp */ export function calculateConsultTimerData( currentTask: ITask | null, - controlVisibility: ControlVisibility | null, + controls: TaskUIControls | null, agentId: string ): TimerData { - // Default return value const defaultTimer: TimerData = {label: TIMER_LABEL_CONSULTING, timestamp: 0}; - if (!currentTask || !controlVisibility) { + if (!currentTask || !controls) { return defaultTimer; } @@ -119,20 +107,21 @@ export function calculateConsultTimerData( return defaultTimer; } - // Check if consult call is on hold - if (controlVisibility.consultCallHeld) { - // Extract consult hold timestamp + // Derive consultCallHeld from controls: switchToConsult.isVisible means consult call is held + const consultCallHeld = controls.switchToConsult?.isVisible ?? false; + + if (consultCallHeld) { const consultHoldTimestamp = findHoldTimestamp(currentTask, 'consult'); return { label: TIMER_LABEL_CONSULT_ON_HOLD, - // Use consultHoldTimestamp when on hold, fallback to consult start time timestamp: consultHoldTimestamp && consultHoldTimestamp > 0 ? consultHoldTimestamp : consultStartTimeStamp, }; } - // Active consulting - determine label based on consult state - const label = controlVisibility.isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING; + // Use task.data.consultStatus for consult phase distinction + const isConsultInitiated = currentTask.data?.consultStatus === 'consultInitiated'; + const label = isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING; return { label, diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index c3e2b6129..1092218c2 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -1,5 +1,11 @@ -import {useEffect, useCallback, useState, useMemo} from 'react'; -import {AddressBookEntriesResponse, AddressBookEntrySearchParams, ITask} from '@webex/contact-center'; +import {useEffect, useCallback, useState, useMemo, useRef} from 'react'; +import { + AddressBookEntriesResponse, + AddressBookEntrySearchParams, + ITask, + TaskUIControls, + getDefaultUIControls, +} from '@webex/contact-center'; import { useCallControlProps, UseTaskListProps, @@ -16,9 +22,9 @@ import store, { getConferenceParticipants, Participant, findMediaResourceId, + isInteractionOnHold, MEDIA_TYPE_TELEPHONY_LOWER, } from '@webex/cc-store'; -import {getControlsVisibility} from './Utils/task-util'; import {TIMER_LABEL_CONSULTING} from './Utils/constants'; import {calculateStateTimerData, calculateConsultTimerData} from './Utils/timer-utils'; import {useHoldTimer} from './Utils/useHoldTimer'; @@ -29,8 +35,7 @@ const ENGAGED_USERNAME = 'Engaged'; // Hook for managing the task list export const useTaskList = (props: UseTaskListProps) => { - const {deviceType, onTaskAccepted, onTaskDeclined, onTaskSelected, logger, taskList} = props; - const isBrowser = deviceType === 'BROWSER'; + const {onTaskAccepted, onTaskDeclined, onTaskSelected, logger, taskList} = props; const logError = (message: string, method: string) => { logger.error(message, { @@ -143,15 +148,20 @@ export const useTaskList = (props: UseTaskListProps) => { } }; - return {taskList, acceptTask, declineTask, onTaskSelect, isBrowser}; + return {taskList, acceptTask, declineTask, onTaskSelect}; }; export const useIncomingTask = (props: UseTaskProps) => { - const {onAccepted, onRejected, deviceType, incomingTask, logger} = props; - const isBrowser = deviceType === 'BROWSER'; - const isDeclineButtonEnabled = store.isDeclineButtonEnabled; + const {onAccepted, onRejected, incomingTask, logger} = props; + + const acceptControl = incomingTask?.uiControls?.accept ?? {isVisible: false, isEnabled: false}; + const sdkDeclineControl = incomingTask?.uiControls?.decline ?? {isVisible: false, isEnabled: false}; + const declineControl = { + ...sdkDeclineControl, + isEnabled: sdkDeclineControl.isEnabled || store.isDeclineButtonEnabled, + }; - const taskAssignCallback = () => { + const taskAssignCallback = useCallback(() => { try { if (onAccepted) onAccepted({task: incomingTask}); } catch (error) { @@ -160,9 +170,9 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'taskAssignCallback', }); } - }; + }, [onAccepted, incomingTask, logger]); - const taskRejectCallback = () => { + const taskRejectCallback = useCallback(() => { try { if (onRejected) onRejected({task: incomingTask}); } catch (error) { @@ -171,25 +181,12 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'taskRejectCallback', }); } - }; + }, [onRejected, incomingTask, logger]); useEffect(() => { try { if (!incomingTask) return; - store.setTaskCallback( - TASK_EVENTS.TASK_ASSIGNED, - () => { - try { - if (onAccepted) onAccepted({task: incomingTask}); - } catch (error) { - logger?.error(`CC-Widgets: Task: Error in TASK_ASSIGNED callback - ${error.message}`, { - module: 'useIncomingTask', - method: 'TASK_ASSIGNED_callback', - }); - } - }, - incomingTask.data.interactionId - ); + store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); @@ -219,7 +216,7 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'useEffect', }); } - }, [incomingTask]); + }, [incomingTask, taskAssignCallback, taskRejectCallback]); const logError = (message: string, method: string) => { logger.error(message, { @@ -276,27 +273,16 @@ export const useIncomingTask = (props: UseTaskProps) => { incomingTask, accept, reject, - isBrowser, - isDeclineButtonEnabled, + acceptControl, + declineControl, }; }; export const useCallControl = (props: useCallControlProps) => { - const { - currentTask, - onHoldResume, - onEnd, - onWrapUp, - onRecordingToggle, - onToggleMute, - logger, - deviceType, - featureFlags, - isMuted, - conferenceEnabled, - agentId, - } = props; + const {currentTask, onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute, logger, isMuted, agentId, conferenceEnabled = true} = props; const [isRecording, setIsRecording] = useState(true); + const [controls, setControls] = useState(currentTask?.uiControls ?? getDefaultUIControls()); + const [isHeld, setIsHeld] = useState(() => (currentTask ? isInteractionOnHold(currentTask) : false)); const [buddyAgents, setBuddyAgents] = useState([]); const [loadingBuddyAgents, setLoadingBuddyAgents] = useState(false); const [consultAgentName, setConsultAgentName] = useState('Consult Agent'); @@ -312,6 +298,27 @@ export const useCallControl = (props: useCallControlProps) => { const [consultTimerTimestamp, setConsultTimerTimestamp] = useState(0); const [lastTargetType, setLastTargetType] = useState(TARGET_TYPE.AGENT); const [conferenceParticipants, setConferenceParticipants] = useState([]); + const lastWrapupAuxCodeIdRef = useRef(null); + + // Subscribe to SDK-computed UI control updates + useEffect(() => { + if (!currentTask) { + setControls(getDefaultUIControls()); + return; + } + setControls(currentTask.uiControls ?? getDefaultUIControls()); + const onControlsUpdated = (updatedControls: TaskUIControls) => { + setControls(updatedControls); + }; + currentTask.on(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated); + return () => { + currentTask.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated); + }; + }, [currentTask]); + + useEffect(() => { + setIsHeld(currentTask ? isInteractionOnHold(currentTask) : false); + }, [currentTask]); // Use custom hook for hold timer management const holdTime = useHoldTimer(currentTask); @@ -393,8 +400,7 @@ export const useCallControl = (props: useCallControlProps) => { } else { // Fallback: Use old logic if consult media not found const otherAgents = Object.values(interaction.participants || {}).filter( - (participant): participant is Participant => - (participant as Participant).pType === 'Agent' && (participant as Participant).id !== myAgentId + (participant) => participant.pType === 'Agent' && participant.id !== myAgentId ); // In a conference with multiple agents, find the agent currently being consulted @@ -530,6 +536,7 @@ export const useCallControl = (props: useCallControlProps) => { const holdCallback = () => { try { + setIsHeld(true); if (onHoldResume) { onHoldResume({ isHeld: true, @@ -546,6 +553,7 @@ export const useCallControl = (props: useCallControlProps) => { const resumeCallback = () => { try { + setIsHeld(false); if (onHoldResume) { onHoldResume({ isHeld: false, @@ -575,14 +583,16 @@ export const useCallControl = (props: useCallControlProps) => { } }; - const wrapupCallCallback = ({wrapUpAuxCodeId}) => { + const wrapupCallCallback = () => { try { - const wrapUpReason = store.wrapupCodes.find((code) => code.id === wrapUpAuxCodeId)?.name; - if (onWrapUp) { - onWrapUp({ - task: currentTask, - wrapUpReason: wrapUpReason, - }); + if (lastWrapupAuxCodeIdRef.current) { + const wrapUpReason = store.wrapupCodes.find((code) => code.id === lastWrapupAuxCodeIdRef.current)?.name; + if (onWrapUp) { + onWrapUp({ + task: currentTask, + wrapUpReason: wrapUpReason, + }); + } } } catch (error) { logger?.error(`CC-Widgets: Task: Error in wrapupCallCallback - ${error.message}`, { @@ -639,7 +649,8 @@ export const useCallControl = (props: useCallControlProps) => { ); store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId); store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); - store.setTaskCallback(TASK_EVENTS.AGENT_WRAPPEDUP, wrapupCallCallback, interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, interactionId); // Also call onEnd when entering wrapup + store.setTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, interactionId); store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId); store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId); @@ -647,9 +658,10 @@ export const useCallControl = (props: useCallControlProps) => { store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId); store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.AGENT_WRAPPEDUP, wrapupCallCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.CONTACT_RECORDING_PAUSED, pauseRecordingCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.CONTACT_RECORDING_RESUMED, resumeRecordingCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId); }; }, [currentTask]); @@ -701,8 +713,7 @@ export const useCallControl = (props: useCallControlProps) => { const toggleMute = async () => { try { - console.log('Mute control not available', controlVisibility); - if (!controlVisibility?.muteUnmute) { + if (!controls?.mute?.isVisible) { logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); return; } @@ -760,6 +771,9 @@ export const useCallControl = (props: useCallControlProps) => { const wrapupCall = (wrapUpReason: string, auxCodeId: string) => { try { + // Store auxCodeId for use in wrapupCallCallback + lastWrapupAuxCodeIdRef.current = auxCodeId; + currentTask .wrapup({wrapUpReason: wrapUpReason, auxCodeId: auxCodeId}) .then(() => { @@ -808,7 +822,7 @@ export const useCallControl = (props: useCallControlProps) => { const switchToMainCall = async () => { try { - await currentTask.resume(findMediaResourceId(currentTask, 'consult')); + await currentTask.switchCall(); logger.info('switchToMainCall success', {module: 'useCallControl', method: 'switchToMainCall'}); } catch (error) { logger.error(`Error switchToMainCall: ${error}`, {module: 'useCallControl', method: 'switchToMainCall'}); @@ -818,7 +832,7 @@ export const useCallControl = (props: useCallControlProps) => { const switchToConsult = async () => { try { - await currentTask.hold(findMediaResourceId(currentTask, 'mainCall')); + await currentTask.switchCall(); logger.info('switchToConsult success', {module: 'useCallControl', method: 'switchToConsult'}); } catch (error) { logger.error(`Error switching to consult: ${error}`, {module: 'useCallControl', method: 'switchToConsult'}); @@ -847,6 +861,8 @@ export const useCallControl = (props: useCallControlProps) => { holdParticipants: !allowParticipantsToInteract, }; + store.setLastConsultDestination({to: consultDestination, destinationType}); + if (destinationType === 'queue') { store.setIsQueueConsultInProgress(true); store.setCurrentConsultQueueId(consultDestination); @@ -883,8 +899,10 @@ export const useCallControl = (props: useCallControlProps) => { try { await currentTask.endConsult(consultEndPayload); } catch (error) { - logError(`Error ending consult call: ${error}`, 'endConsultCall'); - throw error; + // Log error but don't throw - SDK retry mechanism will handle timing issues + // If endConsult fails due to backend timing (called before CONSULTING_ACTIVE), + // the SDK's requestEndConsultRetry will automatically retry when ready + logError(`Error ending consult call (will retry automatically): ${error}`, 'endConsultCall'); } }; @@ -895,15 +913,27 @@ export const useCallControl = (props: useCallControlProps) => { } try { - if (currentTask.data.isConferenceInProgress) { + // When consulting (even from within a conference), use regular transfer. + // transferConference() is only for transferring the entire conference ownership, + // not for transferring a consult to join the conference. + // Check state machine: CONSULTING state means we should use transfer(), not transferConference() + const currentState = currentTask.state?.value; + const isCurrentlyConsulting = currentState === 'CONSULTING'; + + if (!isCurrentlyConsulting && currentTask.data.isConferenceInProgress) { logger.info('Conference in progress, using transferConference', { module: 'useCallControl', - method: 'transferCall', + method: 'consultTransfer', }); await currentTask.transferConference(); } else { logger.info('Consult transfer initiated', {module: 'useCallControl', method: 'consultTransfer'}); - await currentTask.consultTransfer(); + await currentTask.transfer( + store.lastConsultDestination ?? { + to: currentTask.data.destAgentId, + destinationType: 'agent' as DestinationType, + } + ); } } catch (error) { logError(`Error transferring consult call: ${error}`, 'consultTransfer'); @@ -927,16 +957,11 @@ export const useCallControl = (props: useCallControlProps) => { currentTask.cancelAutoWrapupTimer(); }; - const controlVisibility = useMemo( - () => getControlsVisibility(deviceType, featureFlags, currentTask, agentId, conferenceEnabled, logger), - [deviceType, featureFlags, currentTask, agentId, conferenceEnabled, logger] - ); - // Add useEffect for auto wrap-up timer useEffect(() => { - let timerId: NodeJS.Timeout; + let timerId: ReturnType; - if (currentTask?.autoWrapup && controlVisibility?.wrapup) { + if (currentTask?.autoWrapup && controls?.wrapup) { try { // Initialize time left from the autoWrapup object const initialTimeLeft = currentTask.autoWrapup.getTimeLeftSeconds(); @@ -966,25 +991,25 @@ export const useCallControl = (props: useCallControlProps) => { clearInterval(timerId); } }; - }, [currentTask?.autoWrapup, controlVisibility?.wrapup]); + }, [currentTask?.autoWrapup, controls?.wrapup]); // Calculate state timer label and timestamp using utils - // Priority: Wrap Up > Post Call useEffect(() => { - const stateTimerData = calculateStateTimerData(currentTask, controlVisibility, agentId); + const stateTimerData = calculateStateTimerData(currentTask, controls, agentId); setStateTimerLabel(stateTimerData.label); setStateTimerTimestamp(stateTimerData.timestamp); - }, [currentTask, controlVisibility, agentId]); + }, [currentTask, controls, agentId]); // Calculate consult timer label and timestamp using utils useEffect(() => { - const consultTimerData = calculateConsultTimerData(currentTask, controlVisibility, agentId); + const consultTimerData = calculateConsultTimerData(currentTask, controls, agentId); setConsultTimerLabel(consultTimerData.label); setConsultTimerTimestamp(consultTimerData.timestamp); - }, [currentTask, controlVisibility, agentId]); + }, [currentTask, controls, agentId]); return { currentTask, + isHeld, endCall, toggleHold, toggleRecording, @@ -1014,7 +1039,8 @@ export const useCallControl = (props: useCallControlProps) => { consultTimerTimestamp, lastTargetType, setLastTargetType, - controlVisibility, + controls, + conferenceEnabled, secondsUntilAutoWrapup, cancelAutoWrapup, conferenceParticipants, diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index c0c759382..446b9d965 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -1,9 +1,9 @@ import {TaskProps, ControlProps, OutdialCallProps} from '@webex/cc-components'; -export type UseTaskProps = Pick & +export type UseTaskProps = Pick & Partial>; -export type UseTaskListProps = Pick & +export type UseTaskListProps = Pick & Partial>; export type IncomingTaskProps = Pick & Partial>; @@ -27,7 +27,7 @@ export type CallControlProps = Partial< export type useCallControlProps = Pick< ControlProps, - 'currentTask' | 'logger' | 'deviceType' | 'featureFlags' | 'isMuted' | 'conferenceEnabled' | 'agentId' + 'currentTask' | 'logger' | 'isMuted' | 'conferenceEnabled' | 'agentId' > & Partial>; @@ -40,15 +40,6 @@ export interface OutdialProps { isAddressBookEnabled?: boolean; } -/** - * Helper interface for device type checks - */ -export interface DeviceTypeFlags { - isBrowser: boolean; - isAgentDN: boolean; - isExtension: boolean; -} - /** * Target types for consult/transfer operations */ diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index 643eddf9c..70eebbd2d 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -54,7 +54,7 @@ const mockProfile: Profile = { isAgentAvailableAfterOutdial: false, isCampaignManagementEnabled: true, outDialEp: '', - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, agentDbId: 'agentDb123', allowConsultToQueue: true, @@ -71,7 +71,6 @@ const mockProfile: Profile = { lastStateAuxCodeId: 'auxCodeId', lastStateChangeTimestamp: 123456789, lastIdleCodeChangeTimestamp: 123456789, - environment: 'produs1', }; const mockEntryPointsResponse: EntryPointListResponse = { @@ -111,7 +110,7 @@ const makeMockAddressBook = (getEntriesMock?: AddressBook['getEntries']): Addres const mockAddressBook = makeMockAddressBook(); -const mockTask: ITask = { +const mockTask = { data: { interaction: { mediaType: 'telephony', @@ -120,6 +119,7 @@ const mockTask: ITask = { callProcessingDetails: { relationshipType: 'primary', parentInteractionId: null, + pauseResumeEnabled: true, }, participants: { agent1: { @@ -198,7 +198,7 @@ const mockTask: ITask = { transferConference: jest.fn(), exitConference: jest.fn(), toggleMute: jest.fn(), -}; +} as unknown as ITask; const mockQueueDetails = [ {