From 13b15ed84fcc0853d8f84729ec634a10e571c745 Mon Sep 17 00:00:00 2001 From: ajitku3 Date: Tue, 18 Aug 2026 15:22:27 +0530 Subject: [PATCH 1/6] Implement contact center widget enhancements --- .sdd/manifest.json | 8 +- ai-docs/CONTRACTS.md | 6 +- ai-docs/SECURITY.md | 3 +- ai-docs/SPEC_INDEX.md | 9 +- ai-docs/features/participant-drop-intake.md | 73 +++ .../ai-docs/cc-components-spec.md | 6 +- .../call-control-cad.styles.scss | 134 ++++- .../task/CallControlCAD/call-control-cad.tsx | 235 ++++++++- .../src/components/task/task.types.ts | 44 +- .../task/CallControl/call-control.tsx | 12 + .../task/CallControlCAD/call-control-cad.tsx | 285 ++++++++++- .../cc-widgets/ai-docs/cc-widgets-spec.md | 4 +- .../store/ai-docs/store-spec.md | 13 +- .../contact-center/store/src/store.types.ts | 26 + .../store/src/storeEventsWrapper.ts | 18 +- .../contact-center/store/src/task-utils.ts | 395 ++++++++++++++- .../store/tests/storeEventsWrapper.ts | 52 +- .../contact-center/store/tests/task-utils.ts | 460 +++++++++++++++++- .../contact-center/task/ai-docs/task-spec.md | 10 +- packages/contact-center/task/src/helper.ts | 138 +++++- .../contact-center/task/src/task.types.ts | 3 +- .../task/tests/CallControl/index.tsx | 4 + .../task/tests/CallControlCAD/index.tsx | 16 + .../task/tests/call-control-recording.tsx | 5 +- packages/contact-center/task/tests/helper.ts | 406 +++++++++++++++- 25 files changed, 2259 insertions(+), 106 deletions(-) create mode 100644 ai-docs/features/participant-drop-intake.md diff --git a/.sdd/manifest.json b/.sdd/manifest.json index 17249044a..407b6681e 100644 --- a/.sdd/manifest.json +++ b/.sdd/manifest.json @@ -32,7 +32,7 @@ "package": "@webex/cc-store", "path": "packages/contact-center/store", "spec": "packages/contact-center/store/ai-docs/store-spec.md", - "responsibility": "MobX singleton holding global CC state; proxies SDK events; sole SDK access point.", + "responsibility": "MobX singleton holding global CC state; proxies SDK events; sole SDK access point; owns pure conference Drop roster derivation.", "coverageState": "DRAFT", "tier": 1 }, @@ -41,7 +41,7 @@ "package": "@webex/cc-components", "path": "packages/contact-center/cc-components", "spec": "packages/contact-center/cc-components/ai-docs/cc-components-spec.md", - "responsibility": "Shared presentational React UI primitives consumed by widget packages.", + "responsibility": "Shared presentational React UI primitives, including the CallControlCAD participant Drop experience.", "coverageState": "DRAFT", "tier": 1 }, @@ -50,7 +50,7 @@ "package": "@webex/cc-widgets", "path": "packages/contact-center/cc-widgets", "spec": "packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md", - "responsibility": "r2wc Web Component wrappers aggregating widget packages for framework-agnostic consumption.", + "responsibility": "r2wc Web Component wrappers aggregating widget packages; existing CallControlCAD wrapper inherits participant Drop without new properties.", "coverageState": "DRAFT", "tier": 1 }, @@ -86,7 +86,7 @@ "package": "@webex/cc-task", "path": "packages/contact-center/task", "spec": "packages/contact-center/task/ai-docs/task-spec.md", - "responsibility": "Task widget bundle: CallControl, CallControlCAD, IncomingTask, OutdialCall, TaskList.", + "responsibility": "Task widget bundle: CallControl, CallControlCAD with participant Drop orchestration, IncomingTask, OutdialCall, TaskList.", "coverageState": "DRAFT", "tier": 1, "subWidgets": ["CallControl", "CallControlCAD", "IncomingTask", "OutdialCall", "TaskList"] diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 11862cc93..9f70fe7f5 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -17,7 +17,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.UserState | `@webex/cc-user-state` | `UserState` | React component; custom element `widget-cc-user-state`; prop `onStateChange` (function) | stable semver | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | `packages/contact-center/user-state/src/index.ts` | | cc-widgets.IncomingTask | `@webex/cc-task` | `IncomingTask` | React component; custom element `widget-cc-incoming-task`; props `incomingTask` (json), `onAccepted`, `onRejected` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.CallControl | `@webex/cc-task` | `CallControl` | React component; custom element `widget-cc-call-control`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | -| cc-widgets.CallControlCAD | `@webex/cc-task` | `CallControlCAD` | React component; custom element `widget-cc-call-control-cad`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | +| cc-widgets.CallControlCAD | `@webex/cc-task` | `CallControlCAD` | React component; custom element `widget-cc-call-control-cad`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions); owner-aware participant Drop is store-driven and adds no prop/attribute | stable semver | `packages/contact-center/task/ai-docs/task-spec.md`; `ai-docs/features/participant-drop-intake.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.TaskList | `@webex/cc-task` | `TaskList` | React component; custom element `widget-cc-task-list`; props `onTaskAccepted`, `onTaskDeclined`, `onTaskSelected` (functions), `hasCampaignPreviewEnabled` (boolean) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.OutdialCall | `@webex/cc-task` | `OutdialCall` | React component; custom element `widget-cc-outdial-call`; no declared props | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | @@ -25,14 +25,14 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | | store.types | `@webex/cc-store` | Type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `AgentLogin`, `IStore`, `ILogger`, `InitParams`, `IWebex`, `RealTimeTranscriptionData`, plus ~20 more) | TypeScript `type`/`interface` exports describing the SDK-backed domain surface | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:334-366` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | -| store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | +| store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantDropRoster`, `isInteractionOnHold`, `findHoldStatus`, etc.) | Read-only selectors over SDK task objects; the Drop selector returns an owner-aware main-leg roster plus the current Entry Point/EP-DN consult destination (dialed number while ringing, answering Agent name before merge), or `null` for Customer-only/terminal calls | stable semver | `packages/contact-center/store/ai-docs/store-spec.md`; `ai-docs/features/participant-drop-intake.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | | ui-logging.WidgetMetrics | `@webex/cc-ui-logging` | `WidgetMetrics` (type) | `{ widgetName; event: 'WIDGET_MOUNTED' \| 'ERROR' \| 'WIDGET_UNMOUNTED' \| 'PROPS_UPDATED'; props?; timestamp; additionalContext? }` | stable semver; narrowing the `event` union or removing a field is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store (`packages/contact-center/store/src/storeEventsWrapper.ts`) | Host establishes the authenticated Webex session; SDK assumed reachable | `Store.init()` rejects after a 6s init timeout; widgets stay inert and surface error UI (`packages/contact-center/store/src/store.ts:140-142`) | Pinned by the SDK dependency in each package's `package.json` | +| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, `ITask.dropConferenceParticipant({participantId})`, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store/task object | Host establishes the authenticated Webex session; SDK and routing backend are assumed reachable and authoritative | `Store.init()` rejects after a 6s init timeout; participant Drop rejects and `CallControlCAD` shows generic feedback | Pinned by the SDK dependency in each package's `package.json`; Participant Drop requires the published SDK version recorded in the feature intake | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/SECURITY.md b/ai-docs/SECURITY.md index bf164274d..0b1dd325c 100644 --- a/ai-docs/SECURITY.md +++ b/ai-docs/SECURITY.md @@ -18,7 +18,7 @@ This repo is a client-side React/Web-Component widget library. It hosts no netwo ## Authentication & Authorization Model - **Authentication:** Owned by the host app and the Webex SDK, not this repo. The host supplies either a live `webex` instance or `{webexConfig, access_token}`; the store passes the token to `Webex.init({credentials: {access_token}})` and otherwise treats identity as opaque (`packages/contact-center/store/src/store.ts:144-151`). Token retrieval for downstream SDK features delegates to the SDK: `getAccessToken()` calls `webex.credentials.getUserToken()` (`packages/contact-center/store/src/storeEventsWrapper.ts:988-998`). -- **Authorization:** Owned by the SDK / back end. The store surfaces the agent's capabilities as read-only feature flags derived from the SDK-provided `Profile` (`packages/contact-center/store/src/util.ts:3-36`); widgets use these only to show/hide UI. There is no access-decision logic enforced in this repo. +- **Authorization:** Owned by the SDK / back end. The store surfaces the agent's capabilities as read-only feature flags derived from the SDK-provided `Profile` (`packages/contact-center/store/src/util.ts:3-36`); widgets use these only to show/hide UI. Participant Drop additionally hides actions unless `interaction.owner === currentAgentId`, but that client rule is not an authorization boundary; the backend must authorize every request. - **Default posture:** Widgets are inert until the host completes `Store.init()`; with no valid host-supplied session the SDK never initializes (`Webex.init` rejects after a 6s timeout — `packages/contact-center/store/src/store.ts:140-142`), so no agent data flows. ## Secret & Credential Handling @@ -42,6 +42,7 @@ This repo is a client-side React/Web-Component widget library. It hosts no netwo |---|---|---|---| | `ui-logging` metrics props | Widget props are logged without sanitization — acknowledged in the `havePropsChanged` JSDoc `@remarks` (`metricsLogger.ts:73-76`) | Callers must not pass PII-bearing objects as metrics props; sanitization is noted as a future enhancement | cc-ui-logging maintainers | | `getAccessToken()` SDK gap | `webex.credentials.getUserToken()` is `@ts-expect-error`-typed (SDK API not yet typed) (`storeEventsWrapper.ts:990-992`) | Token value is returned to the caller and never logged; failures log only an error message | cc-store maintainers | +| Participant Drop identity | Target IDs can be agent identifiers or customer ANI/DNIS and therefore may be sensitive | Derive in memory, pass only to `task.dropConferenceParticipant`, render through React text escaping, and log only static context plus generic feedback | cc-store / cc-task maintainers | ## Reporting & Review - Security-relevant changes (anything touching the store init/credential path, the `@webex/contact-center` SDK boundary, logging, or the public export/custom-element surface) require review by the package CODEOWNERS on the `next`-targeted PR, following `.github/PULL_REQUEST_TEMPLATE.md` (FedRAMP/GAI sections). Suspected vulnerabilities: report through the Webex internal security channel, not a public issue. diff --git a/ai-docs/SPEC_INDEX.md b/ai-docs/SPEC_INDEX.md index e8124b429..a7bfdb460 100644 --- a/ai-docs/SPEC_INDEX.md +++ b/ai-docs/SPEC_INDEX.md @@ -9,13 +9,13 @@ ## Module Registry | Module | Responsibility | Manifest coverage state | Start here | |---|---|---|---| -| `store/` | MobX singleton; global CC state; proxies SDK events; sole SDK access point | DRAFT | `packages/contact-center/store/ai-docs/store-spec.md` | -| `cc-components/` | Shared presentational React UI primitives | DRAFT | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | -| `cc-widgets/` | r2wc Web Component wrappers (aggregator) | DRAFT | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | +| `store/` | MobX singleton; global CC state; SDK event proxy; pure conference Drop roster derivation | DRAFT | `packages/contact-center/store/ai-docs/store-spec.md` | +| `cc-components/` | Shared presentational React UI primitives, including the CallControlCAD Drop experience | DRAFT | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | +| `cc-widgets/` | r2wc Web Component wrappers; existing CallControlCAD wrapper inherits Drop behavior | DRAFT | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | | `cc-digital-channels/` | Digital channels (chat/email/social) widget | DRAFT | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | | `station-login/` | Agent login: team + device selection | DRAFT | `packages/contact-center/station-login/ai-docs/station-login-spec.md` | | `user-state/` | Agent state: state, idle codes, timer | DRAFT | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | -| `task/` | Task widget bundle: CallControl, IncomingTask, OutdialCall, TaskList, CallControlCAD | DRAFT | `packages/contact-center/task/ai-docs/task-spec.md` | +| `task/` | Task widget bundle; CallControlCAD owns participant Drop orchestration | DRAFT | `packages/contact-center/task/ai-docs/task-spec.md` | | `ai-assistant/` | AI Assistant widget: chrome + Real-time Assist requests, transcript, feedback | DRAFT | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | | `ui-logging/` | Metrics/telemetry: `withMetrics`, `metricsLogger` | DRAFT | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | | `test-fixtures/` | Shared test mocks/helpers | DRAFT | `packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md` | @@ -67,5 +67,6 @@ The intake record confirms scope/modules **against the code** and sets the chang | Decision records | `ai-docs/adr/` | Standing ADRs — why the architecture is the way it is | | Review catalog | `ai-docs/REVIEW_CHECKLIST.md` | 6-core + 4-coverage + 3-cross-cutting review checks | | SDK reference | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | installed SDK `.d.ts` surface — verify every SDK call | +| Participant Drop intake | `ai-docs/features/participant-drop-intake.md` | Cross-repository SDK/widget contract, behavior, delivery gates, and verification | _No `DATA_MODEL.md`: this repo owns no persistent datastore (all domain data comes from the SDK at runtime)._ diff --git a/ai-docs/features/participant-drop-intake.md b/ai-docs/features/participant-drop-intake.md new file mode 100644 index 000000000..f3a4b48d3 --- /dev/null +++ b/ai-docs/features/participant-drop-intake.md @@ -0,0 +1,73 @@ +# Participant Drop Cross-Repository Intake + +Status: Implemented and verified locally on `feature/participant-drop`; live widget validation and SDK publication remain release gates. + +## Goal and delivery order + +Add owner-controlled participant removal to the Contact Center conference roster. Delivery is intentionally split into two sequential changes based on `next`: + +1. `webex-js-sdk` adds the SDK task API, AQM correlation, tests, and Contact Center sample behavior. +2. `widgets-1` consumes the published SDK API and adds Drop only to `CallControlCAD`. + +The remote routing/media backend and Agent Desktop are outside these repositories. No SDK initialization flag, widget flag, or new public failure event is introduced. + +## SDK contract + +```ts +export type DropConferenceParticipantPayload = { + participantId: string; +}; + +task.dropConferenceParticipant( + payload: DropConferenceParticipantPayload +): Promise; +``` + +The voice task resolves the latest main interaction ID and delegates to the existing AQM contact service: + +```http +POST /v1/tasks/{interactionId}/conference/participants/{encodeURIComponent(participantId)}/drop +Content-Type: application/json + +{} +``` + +Completion is event-correlated: `ParticipantLeftConference` resolves, `ParticipantDropConferenceFailed` rejects, and the existing 20-second AQM timeout applies. Existing participant-left task handling updates conference state and the participant roster; clients do not remove rows optimistically. Non-voice tasks reject as unsupported. + +For EP-DN cross-channel lifecycle ordering, `ContactMerged` replaces the child task with the main-interaction task and publishes `task:merged`. `ParticipantLeftConference` and `AgentConsultEnded` first use exact task correlation, then one unique `mainInteractionId`/`parentInteractionId` relationship. Updated `mainCall` membership is authoritative for whether the current Agent ended, independent of consult state or initiator role. Widgets consume the existing terminal events and defer their task-list read by one microtask so SDK final cleanup is visible; they never delete or terminate an SDK task locally. + +## Widget behavior + +`@webex/cc-store` derives a Drop-specific roster from the current main-call media leg without changing `getConferenceParticipants` for its existing consumers. The viewing agent must remain active, and any supported non-customer row keeps the roster visible. Main-leg membership remains authoritative when conference state, flags, controls, or wrap-up signals lag behind participant updates. + +- Exclude the viewing agent, departed/not-yet-joined participants, VVA, unsupported types, and consult-only participants. +- Include Agent and joined EP-DN rows; include Supervisor rows as read-only. +- Synthesize Customer only while an active main-leg Customer exists, using inbound ANI or outbound DNIS. +- After Customer leaves, retain Participants while at least one eligible Agent, EP-DN, or Supervisor remains with the viewing agent, even if backend conference state, flags, controls, or wrap-up signals downgrade. +- Customer-only calls use the original 1-to-1 UI. Dropping the final non-customer participant while Customer remains returns to that UI; Agent-to-Agent remains visible so the primary owner can Drop the other Agent. +- Merge only the current active Entry Point/EP-DN consult leg. Classify the destination from both SDK `pType` and `type` because Entry Point IDs can occupy `pType` while `type` carries `EpDn`. While ringing, display `dn` with participant/media ID fallback; once answered, replace the dialed number with the answering Agent name even before merge. Exclude stale legs and ordinary consult-only Agents, deduplicate after main-leg join, and keep the owner action disabled until merge. +- Show Drop only when `interaction.owner === currentAgentId`; Supervisor always remains read-only. +- Disable every Drop action during an active, non-held consult. +- Agent and joined EP-DN drop immediately; a ringing Entry Point/EP-DN or its pre-merge answering Agent is visible but disabled; Customer requires confirmation. +- Permit one request at a time, display `Dropping…` on only the selected row, and wait for SDK task hydration before a row disappears. +- On success announce `Participant removed from the conference.` +- On failure announce `Unable to drop participant from the call. Try again.` and invoke the existing `CallControlCAD` host error callback with a sanitized error. + +Backend authorization remains mandatory. Owner-based UI visibility is not an authorization boundary. + +## Public surfaces and compatibility + +Only the existing React `CallControlCAD` export and `widget-cc-call-control-cad` custom element gain behavior. Standard `CallControl` remains visually unchanged. No widget prop, custom-element attribute/property, initialization option, or callback is added. + +The widget change must use a local, untracked SDK link during development. Before PR readiness, replace `@webex/contact-center@3.12.0-next.96` in `@webex/cc-store` with the exact published SDK `next` version, update `yarn.lock`, remove the local link, and verify from a clean install. Absolute paths, portals, tarballs, and temporary resolutions must never be committed. + +## Verification + +- SDK: build, unit, and style tests plus the Contact Center sample on port 8001. +- Widgets: build, store selector tests, task hook/shell tests, component tests, cc-widgets tests, and style checks. +- Local samples: React on port 3000 and Web Component on port 4000. +- Manual coverage: owner/non-owner, Customer/Agent/EP-DN/Supervisor, confirmation and cancellation, consult gating, global pending lock, failure recovery, owner transfer, and final conference downgrade. + +## Security and privacy + +Participant IDs, names, ANI/DNIS, raw task/routing payloads, request URLs, credentials, and authorization headers must not enter widget logs or metrics. React text rendering is used for participant-derived display values. Failure reporting carries only the generic message and static module/method context. diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 7f8cc7078..ee8b52af3 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -102,7 +102,7 @@ Consumed as an imported SDK/code API. The React barrel (`src/index.ts`) is the p | `cc-components.StationLoginComponent` | SDK | `StationLoginComponent` (`StationLoginComponentProps`) | Agent login: device/team selection, login/logout, multiple-login alert, profile mode | semver; props are `Pick`ed — adding optional props = minor, removing/renaming a picked prop = major | `src/components/StationLogin/station-login.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.UserStateComponent` | SDK | `UserStateComponent` (`UserStateComponentsProps`) | Agent state dropdown + idle codes + state timer | semver as above | `src/components/UserState/user-state.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlComponent` | SDK | `CallControlComponent` (`CallControlComponentProps`) | Call control buttons: hold/resume, mute, record, end, wrapup, consult/transfer/conference | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header, CAD global variables, and owner-aware conference participant Drop | semver as above; internal Drop props add no widget or Web Component surface | `src/components/task/task.types.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.OutdialCallComponent` | SDK | `OutdialCallComponent` (`OutdialCallComponentProps`) | Outbound dialpad, ANI selection, address-book search | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -149,6 +149,7 @@ Compatibility notes: | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | | `CC-COMPONENTS-R-016` | `E911Modal` gates `Save & Continue` on the acknowledgment checkbox, disables both `Save & Continue` and `Cancel` while `onSaveAndContinue` is in flight (guarding against a double-click firing concurrent saves), shows a user-facing error and re-enables the buttons if the save rejects, and only `Cancel` (not the Dialog's built-in close button or Escape) dismisses the modal; checkbox/saving/error state resets when the modal closes. | An emergency-notification acknowledgment must not be skippable, must not double-submit against the preference API, and must give the agent visible recourse on failure. | `src/components/StationLogin/E911Modal/e911-modal.tsx` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (checkbox gating, save-in-flight button disabling, save-error display, close-only-via-Cancel) | None | PRESENT | +| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. Agent/merged EP-DN Drop immediately, Customer uses a native confirmation dialog, pending/consult state globally protects requests, focus is restored, and exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | ## Design Overview @@ -156,7 +157,7 @@ Every component follows the same shape: a typed function component destructures Logic that is non-trivial or shared is pulled out of the JSX: per-component utils (`station-login.utils.tsx`, `call-control.utils.ts`, `task-list.utils.ts`, etc.) and library-wide utils (`src/utils/index.ts`: `formatTime`, `getMediaTypeInfo`). `task.types.ts` is the shared type hub for the task family — the larger `ControlProps`/`TaskProps` interfaces describe the full data set, and each component's public prop type is a `Pick` of the keys it actually uses, which is why the public surface is intentionally narrower than the interfaces. -Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header + `GlobalVariablesPanel`; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes the same components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual registration into a host app happens in `cc-widgets`. +Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header, `GlobalVariablesPanel`, and the prop-driven participant Drop roster/dialog; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual widget registration into a host app happens in `cc-widgets`. ## Data Flow @@ -342,6 +343,7 @@ Each component is tested in isolation with React Testing Library: render from a | `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | | `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | | `CC-COMPONENTS-R-016` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` | None | +| `CC-COMPONENTS-R-017` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (sections, immediate/confirmed Drop, pending/consult disabled state, read-only rows, focus, live regions) | Native browser dialog behavior receives final manual coverage | ## Traceability diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index 19ec06a16..e39b5e9bb 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -317,42 +317,148 @@ } .participants-menu { - padding: 0.125rem; - max-height: 12.5rem; + padding: 0.5rem; + max-height: 18rem; min-width: 16.125rem; max-width: 16.125rem; overflow-y: auto; overflow-x: hidden; } + .participant-roster-section + .participant-roster-section { + border-top: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); + margin-top: 0.5rem; + padding-top: 0.5rem; + } + + .participant-roster-heading { + margin: 0 0 0.25rem; + padding: 0 0.25rem; + font-weight: 700; + } + + .participant-roster-list { + list-style: none; + margin: 0; + padding: 0; + } + .participant-menu-item { display: flex; align-items: center; gap: 0.5rem; - padding: 0.4rem 0.6rem; + padding: 0.5rem 0.25rem; font-size: 0.875rem; - cursor: pointer; color: var(--mds-color-theme-text-primary-normal, #000000f2); border-radius: 0.5rem; min-width: 0; max-width: 100%; + } + + .participant-menu-identity { + flex: 1 1 auto; + min-width: 0; + } + + .participant-menu-text { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + } - &:hover { - background-color: var(--mds-color-theme-background-secondary-hover); + .participant-drop-button { + background: transparent; + border: 0.0625rem solid var(--mds-color-theme-outline-button-normal); + border-radius: 1rem; + color: var(--mds-color-theme-text-error-normal); + cursor: pointer; + flex: 0 0 auto; + font: inherit; + padding: 0.25rem 0.625rem; + + &:hover:not(:disabled) { + background: var(--mds-color-theme-background-alert-error-normal); } &:focus-visible { - background-color: var(--mds-color-theme-background-secondary-normal); - outline: 0.0625rem solid var(--mds-color-theme-outline-input-focus); - outline-offset: -0.0625rem; + outline: 0.125rem solid var(--mds-color-theme-outline-input-focus); + outline-offset: 0.125rem; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; } } +} - .participant-menu-text { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; +.participant-drop-dialog { + background: var(--mds-color-theme-background-solid-primary-normal); + border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); + border-radius: 0.75rem; + color: var(--mds-color-theme-text-primary-normal); + max-width: min(25rem, calc(100vw - 2rem)); + padding: 1.5rem; + + &::backdrop { + background: var(--mds-color-theme-background-overlay, rgb(0 0 0 / 45%)); + } + + h2, + p { + margin: 0; + } + + p { + margin-top: 0.75rem; + } +} + +.participant-drop-dialog-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1.5rem; + + button { + border: 0.0625rem solid var(--mds-color-theme-outline-button-normal); + border-radius: 1rem; + cursor: pointer; + font: inherit; + padding: 0.5rem 1rem; + + &:focus-visible { + outline: 0.125rem solid var(--mds-color-theme-outline-input-focus); + outline-offset: 0.125rem; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + } +} + +.participant-drop-dialog-cancel { + background: var(--mds-color-theme-background-solid-primary-normal); + color: var(--mds-color-theme-text-primary-normal); +} + +.participant-drop-dialog-confirm { + background: var(--mds-color-theme-background-alert-error-normal); + color: var(--mds-color-theme-text-error-normal); +} + +.participant-drop-feedback { + p { + margin: 0.5rem 0 0; + } + + [role='status'] { + color: var(--mds-color-theme-text-success-normal); + } + + [role='alert'] { + color: var(--mds-color-theme-text-error-normal); } } diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index a841f5cb1..70a53971d 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 @@ -1,4 +1,4 @@ -import React, {useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import CallControlComponent from '../CallControl/call-control'; import {Text, PopoverNext} from '@momentum-ui/react-collaboration'; import {Avatar, Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; @@ -6,6 +6,7 @@ import './call-control-cad.styles.scss'; import TaskTimer from '../TaskTimer/index'; import CallControlConsultComponent from '../CallControl/CallControlCustom/call-control-consult'; import {MEDIA_CHANNEL as MediaChannelType, CallControlComponentProps, CallAssociatedDataMap} from '../task.types'; +import {ConferenceParticipantDropTarget} from '@webex/cc-store'; import {getAgentViewableGlobalVariables} from '../Task/task.utils'; import GlobalVariablesPanel from '../GlobalVariablesPanel/global-variables-panel'; @@ -22,7 +23,6 @@ import { CAMPAIGN_CALL, } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; -import {isSecondaryAgent} from '@webex/cc-store'; const CallControlCADComponent: React.FC = (props) => { const { @@ -46,7 +46,10 @@ const CallControlCADComponent: React.FC = (props) => logger, isMuted, toggleMute, - conferenceParticipants, + conferenceParticipantDropRoster = null, + pendingParticipantDropId = null, + participantDropAnnouncement = null, + dropConferenceParticipant = async () => undefined, conferenceEnabled = true, isCampaignCall = false, } = props; @@ -65,16 +68,17 @@ const CallControlCADComponent: React.FC = (props) => const mediaChannel = currentTask.data.interaction.mediaType as MediaChannelType; const isSocial = mediaChannel === MediaChannelType.SOCIAL; const isTelephony = mediaChannel === MediaChannelType.TELEPHONY; - const participantsCount = conferenceParticipants?.length || 1; + const participantsCount = conferenceParticipantDropRoster?.participants.length ?? 0; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; - const interactionState = currentTask?.data?.interaction?.state; - const isConferenceActive = - controls?.main?.exitConference?.isVisible || - currentTask?.data?.isConferenceInProgress === true || - interactionState === 'conference'; - const isConsultOnlyAgent = isSecondaryAgent(currentTask); - const shouldShowParticipantsList = - isConferenceActive && !isConsultOnlyAgent && (conferenceParticipants?.length ?? 0) > 0; + const shouldShowParticipantsList = Boolean(conferenceParticipantDropRoster); + const [customerDropTarget, setCustomerDropTarget] = useState(null); + const customerDropDialogRef = useRef(null); + const customerDropTriggerRef = useRef(null); + const participantMenuTriggerRef = useRef(null); + const customerDropConfirmRef = useRef(null); + const participantDropIsPending = pendingParticipantDropId !== null; + const interactionId = currentTask.data.interaction.interactionId; + const customerDropDialogTitleId = `participant-drop-dialog-title-${interactionId}`; const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; @@ -96,7 +100,6 @@ const CallControlCADComponent: React.FC = (props) => // variables. Variables are never cleared mid-call by the backend. // Reset when the interaction changes so stale CAD from a previous task // is never shown on a new call. - const interactionId = currentTask.data.interaction.interactionId; const globalVariablesRef = useRef(latestGlobalVariables); const prevInteractionIdRef = useRef(interactionId); if (prevInteractionIdRef.current !== interactionId) { @@ -107,6 +110,146 @@ const CallControlCADComponent: React.FC = (props) => } const globalVariables = globalVariablesRef.current; + const latestCustomerDropTarget = + customerDropTarget && conferenceParticipantDropRoster?.customer?.dropTargetId === customerDropTarget.dropTargetId + ? conferenceParticipantDropRoster.customer + : null; + const customerDropConfirmationDisabled = Boolean( + participantDropIsPending || + conferenceParticipantDropRoster?.isDropDisabled || + !latestCustomerDropTarget || + latestCustomerDropTarget.isReadOnly || + latestCustomerDropTarget.isDropDisabled + ); + + const restoreCustomerDropFocus = () => { + const focusTarget = customerDropTriggerRef.current?.isConnected + ? customerDropTriggerRef.current + : participantMenuTriggerRef.current; + + focusTarget?.focus(); + customerDropTriggerRef.current = null; + }; + + const closeCustomerDropDialog = (restoreFocus = true) => { + const dialog = customerDropDialogRef.current; + + if (dialog?.open && typeof dialog.close === 'function') { + dialog.close(); + } else { + dialog?.removeAttribute('open'); + } + + setCustomerDropTarget(null); + + if (restoreFocus) { + restoreCustomerDropFocus(); + } + }; + + useEffect(() => { + const dialog = customerDropDialogRef.current; + + if (!customerDropTarget || !dialog) { + return; + } + + if (!dialog.open) { + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', ''); + } + } + + customerDropConfirmRef.current?.focus(); + }, [customerDropTarget]); + + useEffect(() => { + closeCustomerDropDialog(false); + customerDropTriggerRef.current = null; + }, [interactionId]); + + useEffect(() => { + if (customerDropTarget && !latestCustomerDropTarget) { + closeCustomerDropDialog(); + } + }, [customerDropTarget, latestCustomerDropTarget]); + + const handleParticipantDrop = (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => { + if ( + target.isReadOnly || + target.isDropDisabled || + participantDropIsPending || + conferenceParticipantDropRoster?.isDropDisabled + ) { + return; + } + + if (target.requiresConfirmation) { + customerDropTriggerRef.current = trigger; + setCustomerDropTarget(target); + return; + } + + void dropConferenceParticipant(target); + }; + + const confirmCustomerDrop = () => { + const target = latestCustomerDropTarget; + + if (!target || customerDropConfirmationDisabled) { + return; + } + + closeCustomerDropDialog(); + void dropConferenceParticipant(target); + }; + + const renderParticipantSection = ( + heading: 'Customer' | 'Participants', + targets: ConferenceParticipantDropTarget[] + ) => ( +

+

+ {heading} +

+
    + {targets.map((target) => { + const isSelectedPending = pendingParticipantDropId === target.dropTargetId; + const isDropDisabled = + target.isDropDisabled || + participantDropIsPending || + Boolean(conferenceParticipantDropRoster?.isDropDisabled); + + return ( +
  • + + + + {target.displayName} + {target.isPrimary ? ' (Primary)' : ''} + + + {!target.isReadOnly && ( + + )} +
  • + ); + })} +
+
+ ); + // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; const customerNameTooltipId = `customer-name-tooltip-${currentTask.data.interaction.interactionId}`; @@ -226,7 +369,7 @@ const CallControlCADComponent: React.FC = (props) => )} - {shouldShowParticipantsList && !controls?.main?.wrapup?.isVisible && ( + {shouldShowParticipantsList && ( <>
@@ -243,11 +386,14 @@ const CallControlCADComponent: React.FC = (props) => triggerComponent={
@@ -306,6 +445,48 @@ const CallControlCADComponent: React.FC = (props) => + {customerDropTarget && ( + { + event.preventDefault(); + closeCustomerDropDialog(); + }} + > +

Drop customer from conference?

+

The customer will be removed from this conference. The remaining participants can continue the call.

+
+ + +
+
+ )} + {participantDropAnnouncement && ( +
+ {participantDropAnnouncement.type === 'success' ? ( +

+ {participantDropAnnouncement.message} +

+ ) : ( +

+ {participantDropAnnouncement.message} +

+ )} +
+ )} {(controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) && !controls?.main?.wrapup?.isVisible && (
diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 6192837be..0ef4f4a29 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 @@ -10,6 +10,8 @@ import { EntryPointRecord, FetchPaginatedList, Participant, + ConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, AddressBookEntrySearchParams, AddressBookEntriesResponse, TaskUIControls, @@ -53,6 +55,11 @@ export const TARGET_TYPE = { export type TargetType = (typeof TARGET_TYPE)[keyof typeof TARGET_TYPE]; +export type ParticipantDropAnnouncement = { + type: 'success' | 'error'; + message: string; +}; + /** * Interface representing the TaskProps of a user. */ @@ -497,6 +504,18 @@ export interface ControlProps { */ conferenceParticipants: Participant[]; + /** Owner-aware conference roster used by the CallControlCAD Drop menu. */ + conferenceParticipantDropRoster: ConferenceParticipantDropRoster | null; + + /** Drop target currently waiting for routing-event completion. */ + pendingParticipantDropId: string | null; + + /** Generic screen-reader and visible feedback for the most recent Drop request. */ + participantDropAnnouncement: ParticipantDropAnnouncement | null; + + /** Requests removal of one currently eligible conference participant. */ + dropConferenceParticipant: (target: ConferenceParticipantDropTarget) => Promise; + /** Fetch paginated address book entries for dial numbers */ getAddressBookEntries?: FetchPaginatedList; @@ -565,14 +584,23 @@ export type CallControlComponentProps = Pick< | 'getQueuesFetcher' | 'consultTransferOptions' | 'conferenceEnabled' -> & { - /** - * Whether the current task is an accepted campaign preview call. - * When `true`, the header renders the campaign icon and - * "Campaign call" label instead of the standard media type. - */ - isCampaignCall?: boolean; -}; +> & + Partial< + Pick< + ControlProps, + | 'conferenceParticipantDropRoster' + | 'pendingParticipantDropId' + | 'participantDropAnnouncement' + | 'dropConferenceParticipant' + > + > & { + /** + * Whether the current task is an accepted campaign preview call. + * When `true`, the header renders the campaign icon and + * "Campaign call" label instead of the standard media type. + */ + isCampaignCall?: boolean; + }; export type OutdialAniEntry = { /** Unique identifier for the ANI entry */ diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8363fb2ec..0713ffbe0 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -161,6 +161,18 @@ describe('CallControlComponent', () => { jest.restoreAllMocks(); }); describe('Rendering', () => { + it('does not render the CallControlCAD participant roster surface', () => { + const screen = render( + + ); + + expect(screen.queryByTestId('call-control:participants-trigger')).not.toBeInTheDocument(); + }); + it('renders mute and hold buttons and responds to user interactions', async () => { const modifiedProps = { ...defaultProps, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index dd4888597..13f0c7920 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {render} from '@testing-library/react'; +import {fireEvent, render, waitFor} from '@testing-library/react'; import CallControlCADComponent from '../../../../src/components/task/CallControlCAD/call-control-cad'; import {CallControlComponentProps, TARGET_TYPE, OUTBOUND_TYPE} from '../../../../src/components/task/task.types'; import { @@ -8,9 +8,21 @@ import { createMockTaskUIControls, enabledControl, } from '@webex/test-fixtures'; -import {BuddyDetails} from '@webex/cc-store'; +import {BuddyDetails, ConferenceParticipantDropRoster} from '@webex/cc-store'; import '@testing-library/jest-dom'; +jest.mock('@momentum-ui/react-collaboration', () => { + const actual = jest.requireActual('@momentum-ui/react-collaboration'); + const react = jest.requireActual('react'); + + const popover = ({triggerComponent, children}: {triggerComponent: React.ReactNode; children: React.ReactNode}) => + react.createElement(react.Fragment, null, triggerComponent, children); + + return new Proxy(actual, { + get: (target, property) => (property === 'PopoverNext' ? popover : Reflect.get(target, property)), + }); +}); + // Mock MediaStream for testing Object.defineProperty(window, 'MediaStream', { writable: true, @@ -89,6 +101,29 @@ describe('CallControlCADComponent', () => { ]; const mockControls = createEnabledMainTaskUIControls(); + const ownerDropRoster: ConferenceParticipantDropRoster = { + customer: { + participantType: 'Customer', + displayName: '+15551234567', + dropTargetId: '+15551234567', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: true, + }, + participants: [ + { + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent-2', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }; const defaultProps: CallControlComponentProps = { currentTask: mockCurrentTask, @@ -137,6 +172,18 @@ describe('CallControlCADComponent', () => { beforeEach(() => { jest.clearAllMocks(); + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value: function showModal() { + this.setAttribute('open', ''); + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, 'close', { + configurable: true, + value: function close() { + this.removeAttribute('open'); + }, + }); }); it('should render telephony call control with all basic information', () => { @@ -415,6 +462,235 @@ describe('CallControlCADComponent', () => { }); }); + describe('conference participant Drop', () => { + const renderRoster = (overrides: Partial = {}) => { + const dropConferenceParticipant = jest.fn().mockResolvedValue(undefined); + const screen = render( + + ); + + fireEvent.click(screen.getByTestId('call-control:participants-trigger')); + + return {screen, dropConferenceParticipant}; + }; + + it('renders Customer and Participants sections and immediately drops an Agent target', () => { + const {screen, dropConferenceParticipant} = renderRoster(); + + expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(screen.getByText('+15551234567')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'Drop agent Agent Two'})); + + expect(dropConferenceParticipant).toHaveBeenCalledWith(ownerDropRoster.participants[0]); + }); + + it('keeps the Participants section when the Customer row is removed', () => { + const {screen} = renderRoster({ + conferenceParticipantDropRoster: { + ...ownerDropRoster, + customer: null, + participants: [ + ...ownerDropRoster.participants, + { + participantType: 'Agent', + displayName: 'Agent Three', + dropTargetId: 'agent-3', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + }, + }); + + expect(screen.queryByRole('heading', {name: 'Customer'})).not.toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + expect(screen.getByText('Agent Three')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeEnabled(); + }); + + it('keeps a valid multiparty roster visible when wrap-up controls appear', () => { + const {screen} = renderRoster({ + controls: createEnabledMainTaskUIControls({wrapup: {isVisible: true, isEnabled: true}}), + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + }); + + it('requires confirmation for Customer Drop and cancel restores focus without invoking the SDK callback', async () => { + const {screen, dropConferenceParticipant} = renderRoster(); + const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); + + fireEvent.click(customerDropButton); + + const dialog = screen.getByTestId('call-control:customer-drop-dialog'); + expect(dialog).toHaveAttribute('open'); + expect(screen.getByRole('heading', {name: 'Drop customer from conference?'})).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + await waitFor(() => expect(customerDropButton).toHaveFocus()); + }); + + it('confirms Customer Drop and supports Escape cancellation', () => { + const {screen, dropConferenceParticipant} = renderRoster(); + const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); + + fireEvent.click(customerDropButton); + fireEvent.click(screen.getByRole('button', {name: 'Drop'})); + expect(dropConferenceParticipant).toHaveBeenCalledWith(ownerDropRoster.customer); + + dropConferenceParticipant.mockClear(); + fireEvent.click(customerDropButton); + const dialog = screen.getByTestId('call-control:customer-drop-dialog'); + fireEvent(dialog, new Event('cancel', {cancelable: true})); + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + }); + + it('globally disables Drop controls and shows loading only on the selected row', () => { + const {screen} = renderRoster({pendingParticipantDropId: 'agent-2'}); + + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toHaveTextContent('Dropping…'); + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Drop customer +15551234567'})).toBeDisabled(); + }); + + it('disables all owner Drop controls during an active non-held consult', () => { + const {screen} = renderRoster({ + conferenceParticipantDropRoster: {...ownerDropRoster, isDropDisabled: true}, + }); + + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Drop customer +15551234567'})).toBeDisabled(); + }); + + it('renders non-owner and Supervisor rows without Drop actions or read-only labels', () => { + const readOnlyRoster: ConferenceParticipantDropRoster = { + customer: ownerDropRoster.customer ? {...ownerDropRoster.customer, isReadOnly: true} : null, + participants: [ + {...ownerDropRoster.participants[0], isPrimary: true, isReadOnly: true}, + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor-1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }; + const {screen} = renderRoster({conferenceParticipantDropRoster: readOnlyRoster}); + + expect(screen.getByText('Agent Two (Primary)')).toBeInTheDocument(); + expect(screen.getByText('Supervisor One')).toBeInTheDocument(); + expect(screen.queryByText('Read only')).not.toBeInTheDocument(); + expect(document.querySelector('.participant-menu-type')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /Drop (agent|customer|supervisor)/})).not.toBeInTheDocument(); + }); + + it('counts visible non-customer rows and disables a pending EP-DN action', () => { + const pendingEpDn = { + participantType: 'EP-DN' as const, + displayName: '+15551230000', + dropTargetId: 'pending-epdn', + isPrimary: false, + isReadOnly: false, + isDropDisabled: true, + requiresConfirmation: false, + }; + const supervisor = { + participantType: 'Supervisor' as const, + displayName: 'Supervisor One', + dropTargetId: 'supervisor-1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }; + const {screen, dropConferenceParticipant} = renderRoster({ + conferenceParticipantDropRoster: { + ...ownerDropRoster, + participants: [...ownerDropRoster.participants, pendingEpDn, supervisor], + }, + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+3 Participants'); + const pendingDrop = screen.getByRole('button', {name: 'Drop ep-dn +15551230000'}); + expect(pendingDrop).toBeDisabled(); + fireEvent.click(pendingDrop); + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + }); + + it('renders an answered Entry Point agent as disabled until conference merge', () => { + const answeredEntryPointAgent = { + participantType: 'Agent' as const, + displayName: 'Support Agent', + dropTargetId: 'agent-3', + isPrimary: false, + isReadOnly: false, + isDropDisabled: true, + requiresConfirmation: false, + }; + const {screen, dropConferenceParticipant} = renderRoster({ + conferenceParticipantDropRoster: { + customer: null, + participants: [answeredEntryPointAgent], + isDropDisabled: false, + }, + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+1 Participant'); + expect(screen.getByText('Support Agent')).toBeInTheDocument(); + const answeredAgentDrop = screen.getByRole('button', {name: 'Drop agent Support Agent'}); + expect(answeredAgentDrop).toBeDisabled(); + fireEvent.click(answeredAgentDrop); + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + }); + + it('keeps generic live feedback mounted when the roster disappears', () => { + const screen = render( + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Participant removed from the conference.'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + screen.rerender( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to drop participant from the call. Try again.'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + }); + describe('conference participants list visibility', () => { it('shows participants list when conference is active and other agents are present', () => { const screen = render( @@ -422,13 +698,14 @@ describe('CallControlCADComponent', () => { {...defaultProps} controls={createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}})} conferenceParticipants={[{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}]} + conferenceParticipantDropRoster={ownerDropRoster} /> ); expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); }); - it('hides participants list when exitConference is not visible and conference is not in progress', () => { + it('hides participants list when the validated Drop roster is null', () => { const screen = render( { {id: 'agent-2', name: 'Agent Two', pType: 'Agent'}, {id: 'agent-3', name: 'Agent Three', pType: 'Agent'}, ]} + conferenceParticipantDropRoster={null} /> ); @@ -462,6 +740,7 @@ describe('CallControlCADComponent', () => { currentTask={conferenceTask} controls={createEnabledMainTaskUIControls({exitConference: {isVisible: false, isEnabled: false}})} conferenceParticipants={[{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}]} + conferenceParticipantDropRoster={ownerDropRoster} /> ); diff --git a/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md b/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md index 983dbce96..b0c4c4dbd 100644 --- a/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md +++ b/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md @@ -83,7 +83,7 @@ boundary (see `src/wc.ts`). | `cc-widgets.UserState` | SDK | React export `UserState`; tag `widget-cc-user-state` (prop `onStateChange`) | Agent state management UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.IncomingTask` | SDK | React export `IncomingTask`; tag `widget-cc-incoming-task` (props `incomingTask:json`, `onAccepted`, `onRejected`) | Incoming task notification UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.CallControl` | SDK | React export `CallControl`; tag `widget-cc-call-control` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`) | Active-call control buttons | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK | React export `CallControlCAD`; tag `widget-cc-call-control-cad` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`) | CAD-enabled call control | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK | React export `CallControlCAD`; tag `widget-cc-call-control-cad` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`); participant Drop is inherited from the task widget | CAD-enabled call control with owner-aware conference participant removal | stable; no new property or attribute | `src/index.ts`, `src/wc.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.TaskList` | SDK | React export `TaskList`; tag `widget-cc-task-list` (props `onTaskAccepted`, `onTaskDeclined`, `onTaskSelected`, `hasCampaignPreviewEnabled:boolean`) | Active tasks list UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.OutdialCall` | SDK | React export `OutdialCall`; tag `widget-cc-outdial-call` (no mapped props; store-driven) | Outbound dialing UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.RealTimeTranscript` | SDK | React export `RealTimeTranscript`; tag `widget-cc-realtime-transcript` (props `liveTranscriptEntries:json`, `className:string`) | Live transcript UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -117,6 +117,7 @@ Compatibility notes: | `cc-widgets-R-004` | Map complex/callback props across the WC boundary with explicit r2wc prop types: `function` callbacks, `json` for object props (`incomingTask`, `liveTranscriptEntries`), `boolean` (`hasCampaignPreviewEnabled`), and `string` (`className`). | HTML attributes are strings only; functions and objects must be set as element properties with the right r2wc coercion or the widget won't receive them. | `packages/contact-center/cc-widgets/src/wc.ts` | None found | Prop maps are the WC public contract; no test asserts the type map. | WEAK | | `cc-widgets-R-005` | Import Momentum UI base CSS in the React entry so React consumers get widget styling without a separate import. | Avoids unstyled widgets in React hosts (a documented prior support issue). | `packages/contact-center/cc-widgets/src/index.ts` | None found | CSS side-effect import is untested. | WEAK | | `cc-widgets-R-006` | Treat React/ReactDOM as peer dependencies (`>=18.3.1`) rather than bundled runtime deps for the React export. | A single host React instance prevents "Invalid hook call" / duplicate-React failures. | `packages/contact-center/cc-widgets/package.json` | None found | Peer-dep enforcement is by package manager, not tested here. | WEAK | +| `cc-widgets-R-007` | The existing React `CallControlCAD` export and `widget-cc-call-control-cad` wrapper inherit participant Drop through `@webex/cc-task` without adding an r2wc prop mapping, attribute, property, event, or feature flag; `CallControl` remains unchanged. | Both consumption modes receive the same store-driven behavior without expanding or breaking the host contract. | `src/index.ts`, `src/wc.ts`, `packages/contact-center/task/src/CallControlCAD/index.tsx` | `@webex/cc-task` and `@webex/cc-components` tests; this package currently has no tests | Wrapper registration itself is unchanged. | PRESENT | ## Design Overview The module is a pure composition/distribution layer with two entry points and no internal state. The React @@ -354,6 +355,7 @@ tested by the upstream widget packages, not here. | `cc-widgets-R-004` (r2wc prop-type map) | None found | No test asserts function/json/boolean/string prop mapping. | | `cc-widgets-R-005` (Momentum CSS imported) | None found | No test for the CSS side-effect import. | | `cc-widgets-R-006` (React/ReactDOM peers) | None found | Enforced by package manager only; no automated check here. | +| `cc-widgets-R-007` (CallControlCAD inherits Drop without a new WC surface) | Covered by upstream task/component tests | No package-local test; the wrapper and prop map are unchanged. | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 75a882569..cf9fa1a06 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -76,7 +76,7 @@ This module is consumed as an imported SDK/code API (the `@webex/cc-store` packa | `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.types` | SDK | type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `IStore`, `IStoreWrapper`, `InitParams`, `RealTimeTranscriptionData`, ~20 more) | Typed domain surface for widget code | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts:334-366`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantDropRoster`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask`; the Drop roster is main-leg, owner-aware, and may add the current Entry Point/EP-DN consult destination by number while ringing or answering Agent name before merge | stable semver | `packages/contact-center/store/src/task-utils.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: - Adding a new observable getter or mutator is additive (minor). Removing/renaming an observable, mutator, or changing the `CC_EVENTS`/`TASK_EVENTS` enum values is breaking (major) — widgets and the SDK event stream depend on the exact string values. @@ -111,13 +111,15 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | +| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | +| `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`). With a host-supplied `webex`, the wrapper wires event listeners and registers synchronously. Without one, the store calls `Webex.init()`, arms a 6000ms timeout, and waits for the `ready` event before wiring listeners and registering; the timeout guards against an SDK that never becomes ready. Registration maps the agent `Profile` into observables once. -Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. +Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Terminal `task:end` and `task:consultEnd` signals coalesce that read into the next microtask because SDK final-state cleanup completes after event emission; consult flags still clear synchronously, and participant-left remains synchronous for surviving-call roster updates. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`. @@ -233,8 +235,10 @@ sequenceDiagram else W->>S: setCurrentTask(task); setState(ENGAGED) end - SDK-->>W: task:end - W->>S: setIsDeclineButtonEnabled(false); refreshTaskList() + SDK-->>W: task:end / task:consultEnd + W->>S: clear immediate button/consult state + W->>W: coalesce one microtask refresh + W->>S: refreshTaskList() after SDK final cleanup Note over W,S: handleTaskRemove detaches all task listeners,
clears transcription, drops accepted-campaign id,
resets state, refreshTaskList() ``` @@ -344,6 +348,7 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | | `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | | `STORE-R-021` | None found | `handleRealtimeTranscription` untested | +| `STORE-R-022` | `tests/task-utils.ts` (participant Drop roster, owner transfer, ANI/DNIS, filtering, consult gating, conference eligibility) | none | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index c0bf9fc45..30fce3470 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -411,6 +411,32 @@ export type Participant = { name?: string; }; +export type ConferenceParticipantDropType = 'Customer' | 'Agent' | 'EP-DN' | 'Supervisor'; + +/** + * Display and authorization data for one participant-drop roster row. + * `dropTargetId` is passed to the SDK only when `isReadOnly` is false. + */ +export type ConferenceParticipantDropTarget = { + participantType: ConferenceParticipantDropType; + displayName: string; + dropTargetId: string; + isPrimary: boolean; + isReadOnly: boolean; + /** Target is visible with an action, but the conference Drop request is not valid yet. */ + isDropDisabled: boolean; + requiresConfirmation: boolean; +}; + +/** + * Participant-drop roster derived from the authoritative main-call media leg. + */ +export type ConferenceParticipantDropRoster = { + customer: ConferenceParticipantDropTarget | null; + participants: ConferenceParticipantDropTarget[]; + isDropDisabled: boolean; +}; + /** * Desktop preference data structure containing E911 modal acknowledgment. * @public diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4f1f35f60..2cb5f04d1 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -50,6 +50,7 @@ class StoreWrapper implements IStoreWrapper { onTaskSelected?: (task: ITask, isClicked: boolean) => void; onErrorCallback?: (widgetName: string, error: Error) => void; private realtimeTranscriptionListeners: Record void> = {}; + private taskListRefreshScheduled = false; // Keyed by interactionId; the task is tracked alongside the listener so a // replacement task object (task:hydrate / task:merged) gets rebound. private realTimeAssistListeners: Record void}> = @@ -345,6 +346,18 @@ class StoreWrapper implements IStoreWrapper { }); }; + private scheduleTaskListRefresh = (): void => { + if (this.taskListRefreshScheduled) { + return; + } + + this.taskListRefreshScheduled = true; + queueMicrotask(() => { + this.taskListRefreshScheduled = false; + this.refreshTaskList(); + }); + }; + setWrapupCodes = (wrapupCodes: IWrapupCode[]): void => { this.store.wrapupCodes = wrapupCodes; }; @@ -799,8 +812,7 @@ class StoreWrapper implements IStoreWrapper { handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); - - this.refreshTaskList(); + this.scheduleTaskListRefresh(); }; handleTaskAssigned = (event) => { @@ -873,8 +885,8 @@ class StoreWrapper implements IStoreWrapper { this.setIsQueueConsultInProgress(false); this.setCurrentConsultQueueId(null); this.setLastConsultDestination(null); - this.refreshTaskList(); this.setConsultStartTimeStamp(null); + this.scheduleTaskListRefresh(); }; handleConsultOffer = () => { diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index f3dd96ca6..6e44088f4 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -1,5 +1,12 @@ import {EXCLUDED_PARTICIPANT_TYPES, RELATIONSHIP_TYPE_CONSULT} from './constants'; -import {ITask, MEDIA_TYPE_TELEPHONY_LOWER, Participant} from './store.types'; +import { + ConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, + ConferenceParticipantDropType, + ITask, + MEDIA_TYPE_TELEPHONY_LOWER, + Participant, +} from './store.types'; /** * Determines if a task is an incoming task @@ -133,6 +140,392 @@ export const getConferenceParticipants = (task: ITask, agentId: string): Partici return participantsList; }; +const TERMINAL_CONFERENCE_STATES = new Set(['ended', 'disconnected', 'terminated']); + +type RosterTaskData = ITask['data']; +type RosterInteraction = RosterTaskData['interaction']; +type RosterParticipant = NonNullable[string]; + +type TaskWithStateSnapshot = ITask & { + state?: { + context?: { + taskData?: RosterTaskData; + }; + }; +}; + +const normalizeParticipantType = (participantType?: string): string => + String(participantType || '') + .trim() + .toUpperCase() + .replaceAll('_', '-'); + +const getParticipantTypeTokens = (participant: RosterParticipant): string[] => + [participant.pType, participant.type] + .map((participantType) => normalizeParticipantType(participantType)) + .filter(Boolean); + +const participantHasType = (participant: RosterParticipant, participantType: string): boolean => + getParticipantTypeTokens(participant).includes(participantType); + +const hasVisibleControls = (controls: ITask['uiControls']['consult'] | undefined): boolean => + Boolean(controls && Object.values(controls).some((control) => control?.isVisible)); + +const isViewingAgentActiveOnMainLeg = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { + const participants = task?.data?.interaction?.participants ?? {}; + return mainParticipantIds.some((participantId) => { + const participant = participants[participantId]; + + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return false; + } + + return participantId === agentId || participant.id === agentId; + }); +}; + +const isEligibleConference = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { + const interaction = task?.data?.interaction; + const interactionState = interaction?.state?.toLowerCase(); + const isTerminated = Boolean(interaction?.isTerminated || TERMINAL_CONFERENCE_STATES.has(interactionState)); + + return Boolean( + interaction?.mediaType?.toLowerCase() === MEDIA_TYPE_TELEPHONY_LOWER && + mainParticipantIds.length > 0 && + !isTerminated && + isViewingAgentActiveOnMainLeg(task, mainParticipantIds, agentId) + ); +}; + +const getMediaRecencyScore = (media: Record, fallbackIndex: number): number => { + const candidateTimestamps = [ + media.lastUpdated, + media.joinTimestamp, + media.consultTimestamp, + media.holdTimestamp, + media.eventTime, + media.createdAt, + ]; + + for (const value of candidateTimestamps) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + + return fallbackIndex; +}; + +const getConsultMediaEntry = (taskData: RosterTaskData, fallbackConsultMediaId?: string) => { + const interaction = taskData?.interaction; + const configuredConsultMediaId = taskData?.consultMediaResourceId || fallbackConsultMediaId; + const configuredConsultMedia = configuredConsultMediaId ? interaction?.media?.[configuredConsultMediaId] : undefined; + + if (configuredConsultMedia?.mType === 'consult') { + return configuredConsultMedia; + } + + const matchingMedia = Object.values(interaction?.media ?? {}).filter((media) => media.mType === 'consult'); + + if (matchingMedia.length === 0) { + return undefined; + } + + return matchingMedia.reduce((latest, media, index) => { + const latestScore = getMediaRecencyScore(latest as Record, index - 1); + const currentScore = getMediaRecencyScore(media as Record, index); + return currentScore >= latestScore ? media : latest; + }); +}; + +const getCurrentConsultMediaEntry = (task: ITask) => { + const taskWithSnapshot = task as TaskWithStateSnapshot; + const snapshotTaskData = taskWithSnapshot.state?.context?.taskData; + const interactionState = task?.data?.interaction?.state?.toLowerCase(); + const consultIsActive = + hasVisibleControls(task?.uiControls?.consult) || ['consult', 'consulting'].includes(interactionState); + + if (!consultIsActive) { + return undefined; + } + + // Prefer the observable task data when it already contains the current leg; + // fall back to the state-machine snapshot during the brief hydration gap. + const taskDataCandidates = [task.data, snapshotTaskData].filter((taskData): taskData is RosterTaskData => + Boolean(taskData?.interaction) + ); + + for (const taskData of taskDataCandidates) { + const media = getConsultMediaEntry(taskData, task.data.consultMediaResourceId); + + if (media) { + return {media, interaction: taskData.interaction, taskData}; + } + } + + return undefined; +}; + +const hasActiveNonHeldConsult = (task: ITask): boolean => { + const interaction = task?.data?.interaction; + const consultMediaResourceId = findMediaResourceId(task, 'consult'); + const consultMedia = consultMediaResourceId ? interaction?.media?.[consultMediaResourceId] : undefined; + const interactionState = interaction?.state?.toLowerCase(); + const consultIsActive = + hasVisibleControls(task?.uiControls?.consult) || ['consult', 'consulting'].includes(interactionState); + + // Treat an active consult without a hydrated media leg conservatively as non-held. + // Drop becomes available again once the SDK explicitly reports that consult leg held. + return Boolean(consultIsActive && consultMedia?.isHold !== true); +}; + +const getCustomerDropTargetId = (task: ITask): string => { + const interaction = task?.data?.interaction; + const direction = interaction?.contactDirection?.type?.toLowerCase(); + const callDetails = interaction?.callAssociatedDetails; + const processingDetails = interaction?.callProcessingDetails; + + if (direction === 'inbound') { + return callDetails?.ani || processingDetails?.ani || ''; + } + + if (direction === 'outbound') { + return callDetails?.dnis || processingDetails?.dnis || ''; + } + + return ''; +}; + +const getParticipantType = ( + normalizedType: string +): {participantType: ConferenceParticipantDropType; isSupervisor: boolean} | null => { + if (normalizedType === 'AGENT') { + return {participantType: 'Agent', isSupervisor: false}; + } + + if (['EP-DN', 'EPDN', 'DN'].includes(normalizedType)) { + return {participantType: 'EP-DN', isSupervisor: false}; + } + + if (normalizedType === 'SUPERVISOR') { + return {participantType: 'Supervisor', isSupervisor: true}; + } + + return null; +}; + +const getParticipantTypeDetails = ( + participant: RosterParticipant +): {participantType: ConferenceParticipantDropType; isSupervisor: boolean} | null => { + for (const participantType of getParticipantTypeTokens(participant)) { + const typeDetails = getParticipantType(participantType); + + if (typeDetails) { + return typeDetails; + } + } + + return null; +}; + +const isEpDnConsultDestination = (destinationType?: string): boolean => + ['ENTRYPOINT', 'ENTRY-POINT', 'DIALNUMBER', 'DIAL-NUMBER', 'EP-DN', 'EPDN', 'DN'].includes( + normalizeParticipantType(destinationType) + ); + +const isTrueLike = (value: boolean | string | undefined): boolean => + value === true || String(value).toLowerCase() === 'true'; + +/** + * Derives the owner-aware participant Drop roster from the active main-call media leg. + * The viewing agent is never returned as a target and backend authorization remains authoritative. + */ +export const getConferenceParticipantDropRoster = ( + task: ITask, + agentId: string +): ConferenceParticipantDropRoster | null => { + const interaction = task?.data?.interaction; + const mainCallMedia = getMainCallMediaEntry(task); + const mainParticipantIds = mainCallMedia?.participants ?? []; + + if (!interaction || !isEligibleConference(task, mainParticipantIds, agentId) || isSecondaryAgent(task)) { + return null; + } + + const participants = interaction.participants ?? {}; + const mainParticipantIdSet = new Set(mainParticipantIds); + + const isOwner = interaction.owner === agentId; + const participantRows: ConferenceParticipantDropTarget[] = []; + const includedParticipantIds = new Set(); + const currentConsult = getCurrentConsultMediaEntry(task); + let hasActiveCustomer = false; + + mainParticipantIdSet.forEach((participantId) => { + const participant = participants[participantId]; + + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return; + } + + const resolvedParticipantId = participant.id || participantId; + if (participantHasType(participant, 'CUSTOMER')) { + hasActiveCustomer = true; + return; + } + + if (participantId === agentId || resolvedParticipantId === agentId || participantHasType(participant, 'VVA')) { + return; + } + + const typeDetails = getParticipantTypeDetails(participant); + + if (!typeDetails) { + return; + } + + const {participantType, isSupervisor} = typeDetails; + const displayName = + participantType === 'EP-DN' + ? participant.dn || resolvedParticipantId || participantId + : participant.name || resolvedParticipantId || participantType; + + participantRows.push({ + participantType, + displayName: String(displayName), + dropTargetId: resolvedParticipantId, + isPrimary: interaction.owner === participantId || interaction.owner === resolvedParticipantId, + isReadOnly: !isOwner || isSupervisor, + isDropDisabled: false, + requiresConfirmation: false, + }); + includedParticipantIds.add(participantId); + includedParticipantIds.add(resolvedParticipantId); + }); + + const consultParticipantIds = currentConsult?.media.participants ?? []; + const consultParticipants = currentConsult?.interaction.participants ?? participants; + const pendingEpDnEntry = consultParticipantIds + .map((participantId) => ({participantId, participant: consultParticipants[participantId]})) + .find(({participantId, participant}) => { + if (!participant || participant.hasLeft) { + return false; + } + + const resolvedParticipantId = participant.id || participantId; + return ( + getParticipantTypeDetails(participant)?.participantType === 'EP-DN' && + participantId !== agentId && + resolvedParticipantId !== agentId + ); + }); + const consultProcessingDetails = currentConsult?.interaction.callProcessingDetails; + const rawDestinationAgentName = consultProcessingDetails?.consultDestinationAgentName; + const destinationAgentJoined = + isTrueLike(consultProcessingDetails?.consultDestinationAgentJoined) || Boolean(rawDestinationAgentName); + const destinationAgentName = destinationAgentJoined ? rawDestinationAgentName : ''; + const answeredAgentEntries = consultParticipantIds + .map((participantId) => ({participantId, participant: consultParticipants[participantId]})) + .filter(({participantId, participant}) => { + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return false; + } + + const resolvedParticipantId = participant.id || participantId; + return ( + getParticipantTypeDetails(participant)?.participantType === 'Agent' && + participantId !== agentId && + resolvedParticipantId !== agentId + ); + }); + // A nested Entry Point consult can contain both the consulting conference + // Agent and the newly answering Agent. Prefer the explicit destination, then + // a consulted non-main participant, so the row never regresses to the caller. + const answeredAgentEntry = + answeredAgentEntries.find(({participant}) => + Boolean(destinationAgentName && participant?.name === destinationAgentName) + ) || + answeredAgentEntries.find(({participantId, participant}) => { + const resolvedParticipantId = participant?.id || participantId; + return participant?.isConsulted === true && !mainParticipantIdSet.has(resolvedParticipantId); + }) || + answeredAgentEntries.find(({participantId, participant}) => { + const resolvedParticipantId = participant?.id || participantId; + return !mainParticipantIdSet.has(participantId) && !mainParticipantIdSet.has(resolvedParticipantId); + }); + const epDnConsultIsActive = Boolean( + currentConsult && + (pendingEpDnEntry || + isEpDnConsultDestination(currentConsult.taskData.destinationType) || + isEpDnConsultDestination(task.data.destinationType)) + ); + + if (epDnConsultIsActive) { + const answeredAgent = answeredAgentEntry?.participant; + const pendingEpDn = pendingEpDnEntry?.participant; + const visibleParticipant = answeredAgent || pendingEpDn; + const visibleParticipantId = answeredAgentEntry?.participantId || pendingEpDnEntry?.participantId; + + if (visibleParticipant && visibleParticipantId) { + const resolvedParticipantId = visibleParticipant.id || visibleParticipantId; + const answeredAgentAlreadyVisible = participantRows.some( + (target) => + target.participantType === 'Agent' && + Boolean(destinationAgentName) && + target.displayName === destinationAgentName + ); + + if ( + !mainParticipantIdSet.has(visibleParticipantId) && + !includedParticipantIds.has(resolvedParticipantId) && + !answeredAgentAlreadyVisible + ) { + const participantType = answeredAgent ? 'Agent' : 'EP-DN'; + const displayName = answeredAgent + ? answeredAgent.name || destinationAgentName || resolvedParticipantId + : destinationAgentName || pendingEpDn?.dn || resolvedParticipantId || currentConsult.media.mediaResourceId; + + participantRows.push({ + participantType, + displayName: String(displayName), + dropTargetId: resolvedParticipantId, + isPrimary: false, + isReadOnly: !isOwner, + isDropDisabled: true, + requiresConfirmation: false, + }); + includedParticipantIds.add(visibleParticipantId); + includedParticipantIds.add(resolvedParticipantId); + } + } + } + + const customerDropTargetId = hasActiveCustomer ? getCustomerDropTargetId(task) : ''; + const customer: ConferenceParticipantDropTarget | null = customerDropTargetId + ? { + participantType: 'Customer', + displayName: customerDropTargetId, + dropTargetId: customerDropTargetId, + isPrimary: false, + isReadOnly: !isOwner, + isDropDisabled: false, + requiresConfirmation: true, + } + : null; + + // Customer-only calls retain the original 1-to-1 UI. Any supported + // non-customer row keeps the participant roster visible. + if (participantRows.length === 0) { + return null; + } + + return { + customer, + participants: participantRows, + isDropDisabled: hasActiveNonHeldConsult(task), + }; +}; + export function isInteractionOnHold(task: ITask): boolean { if (!task || !task.data || !task.data.interaction) { return false; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index e41c51e94..a0b648a18 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -2392,7 +2392,7 @@ describe('storeEventsWrapper', () => { }); describe('handleTaskEnd — campaign preview (unaccepted)', () => { - it('should call refreshTaskList and let the backend drive task removal', () => { + it('should defer refreshTaskList so SDK cleanup completes first', async () => { const task = createCampaignPreviewTask('campaign-1'); storeWrapper['store'].taskList = {'campaign-1': task}; storeWrapper['store'].currentTask = task; @@ -2403,13 +2403,14 @@ describe('storeEventsWrapper', () => { storeWrapper.handleTaskEnd(); - // refreshTaskList should be called (normal path, no force cleanup) - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); describe('handleTaskEnd — accepted campaign preview', () => { - it('should call refreshTaskList for accepted campaign', () => { + it('should defer refreshTaskList for accepted campaign', async () => { const task = createCampaignPreviewTask('campaign-accepted'); storeWrapper['store'].acceptedCampaignIds = new Set(['campaign-accepted']); storeWrapper['store'].taskList = {'campaign-accepted': task}; @@ -2422,8 +2423,9 @@ describe('storeEventsWrapper', () => { // acceptedCampaignIds should NOT be cleaned up here (deferred to handleTaskRemove) expect(storeWrapper['store'].acceptedCampaignIds.has('campaign-accepted')).toBe(true); - // refreshTaskList SHOULD be called (normal path) - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); @@ -2455,7 +2457,7 @@ describe('storeEventsWrapper', () => { }); describe('handleTaskEnd — non-campaign tasks', () => { - it('should call refreshTaskList for a regular (non-campaign) task', () => { + it('should refresh a regular task after SDK terminal cleanup', async () => { const regularTask = makeMockTask({ data: { interactionId: 'regular-1', @@ -2474,11 +2476,43 @@ describe('storeEventsWrapper', () => { storeWrapper.handleTaskEnd(); - // Should call refreshTaskList normally - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); // taskList should still contain the task (SDK still returns it) expect(storeWrapper['store'].taskList['regular-1']).toBeDefined(); }); + + it('coalesces consult-end and task-end into one deferred refresh', async () => { + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + const setQueueProgressSpy = jest.spyOn(storeWrapper, 'setIsQueueConsultInProgress'); + + storeWrapper.handleConsultEnd(); + storeWrapper.handleTaskEnd(); + + expect(setQueueProgressSpy).toHaveBeenCalledWith(false); + expect(storeWrapper.consultStartTimeStamp).toBeNull(); + expect(refreshSpy).not.toHaveBeenCalled(); + + await Promise.resolve(); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('clears the ended current task after SDK cleanup removes it', async () => { + const regularTask = makeMockTask({ + data: {interactionId: 'ended-task', interaction: {state: 'connected'}}, + }); + storeWrapper['store'].taskList = {'ended-task': regularTask}; + storeWrapper['store'].currentTask = regularTask; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({}); + + storeWrapper.handleTaskEnd(); + + expect(storeWrapper.currentTask).toBe(regularTask); + await Promise.resolve(); + expect(storeWrapper.currentTask).toBeNull(); + }); }); describe('handleIncomingCampaignPreview — campaign type branching', () => { diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index 557448c68..d178f9fdd 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -1,5 +1,11 @@ -import {isIncomingTask, getConferenceParticipants, findHoldTimestamp} from '../src/task-utils'; +import { + isIncomingTask, + getConferenceParticipants, + getConferenceParticipantDropRoster, + findHoldTimestamp, +} from '../src/task-utils'; import {mockTask} from '../../test-fixtures/src/fixtures'; +import {createEnabledMainTaskUIControls} from '../../test-fixtures/src/taskUIControlsFixtures'; import {ITask} from '../src/store.types'; const participant = (hasJoined: boolean) => @@ -601,6 +607,458 @@ describe('getConferenceParticipants', () => { }); }); +describe('getConferenceParticipantDropRoster', () => { + const currentAgentId = 'agent1'; + + const activeParticipant = (id: string, pType: string, name?: string) => ({ + id, + pType, + type: pType, + name, + hasJoined: true, + hasLeft: false, + isInPredial: false, + }); + + const createDropRosterTask = ({ + owner = currentAgentId, + direction = 'inbound', + state = 'conference', + wrapUpRequired = false, + consultHold, + }: { + owner?: string; + direction?: string; + state?: string; + wrapUpRequired?: boolean; + consultHold?: boolean; + } = {}): ITask => { + const controls = createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}); + const media = { + main: { + mediaResourceId: 'main', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'agent2', 'epdn1', 'supervisor1', 'customer1', 'vva1', 'unsupported1'], + }, + ...(consultHold === undefined + ? {} + : { + consult: { + mediaResourceId: 'consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: consultHold, + holdTimestamp: consultHold ? Date.now() : null, + participants: ['agent1', 'consult-agent'], + }, + }), + }; + + if (consultHold !== undefined) { + controls.consult.endConsult = {isVisible: true, isEnabled: true}; + } + + return { + ...mockTask, + uiControls: controls, + data: { + ...mockTask.data, + interactionId: 'main', + wrapUpRequired, + isConferenceInProgress: true, + interaction: createPartialInteraction({ + ...mockTask.data.interaction, + interactionId: 'main', + mediaType: 'telephony', + state, + owner, + contactDirection: {type: direction}, + callAssociatedDetails: {ani: '+15550000001', dnis: '+15550000002'}, + callProcessingDetails: { + ...mockTask.data.interaction.callProcessingDetails, + ani: '+15550000001', + dnis: '+15550000002', + }, + media, + participants: { + agent1: activeParticipant('agent1', 'Agent', 'Current Agent'), + agent2: activeParticipant('agent2', 'Agent', 'Agent Two'), + epdn1: {...activeParticipant('epdn1', 'EP_DN', 'EP-DN'), dn: '+15550000003'}, + supervisor1: activeParticipant('supervisor1', 'Supervisor', 'Supervisor One'), + customer1: activeParticipant('customer1', 'Customer', 'Customer'), + vva1: activeParticipant('vva1', 'VVA', 'Virtual Agent'), + unsupported1: activeParticipant('unsupported1', 'Queue', 'Queue'), + 'consult-agent': activeParticipant('consult-agent', 'Agent', 'Consult Agent'), + }, + }), + }, + } as ITask; + }; + + it('derives Customer, Agent, EP-DN, and read-only Supervisor rows from the main leg', () => { + const task = createDropRosterTask(); + const roster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(roster).toEqual({ + customer: { + participantType: 'Customer', + displayName: '+15550000001', + dropTargetId: '+15550000001', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: true, + }, + participants: [ + { + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent2', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + { + participantType: 'EP-DN', + displayName: '+15550000003', + dropTargetId: 'epdn1', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }); + expect(roster?.participants).not.toEqual(expect.arrayContaining([expect.objectContaining({dropTargetId: 'vva1'})])); + expect(roster?.participants).not.toEqual( + expect.arrayContaining([expect.objectContaining({dropTargetId: 'consult-agent'})]) + ); + expect(getConferenceParticipants(task, currentAgentId)).toHaveLength(3); + }); + + it('uses outbound DNIS for the synthetic Customer target', () => { + const roster = getConferenceParticipantDropRoster(createDropRosterTask({direction: 'outbound'}), currentAgentId); + + expect(roster?.customer?.dropTargetId).toBe('+15550000002'); + }); + + it('omits departed, not-yet-joined, and customer rows without a valid direction number', () => { + const task = createDropRosterTask(); + task.data.interaction.participants.agent2.hasLeft = true; + task.data.interaction.participants.epdn1.hasJoined = false; + task.data.interaction.callAssociatedDetails.ani = ''; + task.data.interaction.callProcessingDetails.ani = ''; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toEqual({ + customer: null, + participants: [ + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }); + }); + + it('makes every row read-only for a non-owner and marks the owner as Primary', () => { + const roster = getConferenceParticipantDropRoster(createDropRosterTask({owner: 'agent2'}), currentAgentId); + + expect(roster?.customer?.isReadOnly).toBe(true); + expect(roster?.participants.every((target) => target.isReadOnly)).toBe(true); + expect(roster?.participants.find((target) => target.dropTargetId === 'agent2')?.isPrimary).toBe(true); + }); + + it('disables Drop only while an active consult is not held', () => { + expect( + getConferenceParticipantDropRoster(createDropRosterTask({consultHold: false}), currentAgentId)?.isDropDisabled + ).toBe(true); + expect( + getConferenceParticipantDropRoster(createDropRosterTask({consultHold: true}), currentAgentId)?.isDropDisabled + ).toBe(false); + }); + + it('keeps Drop disabled while active consult controls precede consult media hydration', () => { + const task = createDropRosterTask({consultHold: false}); + delete task.data.interaction.media.consult; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.isDropDisabled).toBe(true); + }); + + it('returns null outside an eligible telephony main call', () => { + const task = createDropRosterTask(); + task.data.interaction.mediaType = 'chat'; + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('keeps a valid multiparty roster through post-call and wrap-up signal downgrades', () => { + const task = createDropRosterTask({state: 'post_call', wrapUpRequired: true}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'epdn1']; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({dropTargetId: 'agent2'}), + expect.objectContaining({dropTargetId: 'epdn1'}), + ]); + }); + + it('uses main-leg membership for a Customer-plus-Agent multiparty call', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).not.toBeNull(); + }); + + it('keeps Participants after Customer leaves and conference signals downgrade', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.data.interaction.participants.customer1.hasLeft = true; + + const roster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(roster?.customer).toBeNull(); + expect(roster?.participants).toEqual([ + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'}), + expect.objectContaining({participantType: 'EP-DN', dropTargetId: 'epdn1'}), + expect.objectContaining({participantType: 'Supervisor', dropTargetId: 'supervisor1'}), + ]); + }); + + it('does not treat an initial one-agent/one-customer call as a conference roster', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('keeps the roster when Customer leaves one other Agent', () => { + const task = createDropRosterTask({state: 'post_call', wrapUpRequired: true}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + task.data.interaction.media.main.participants = ['agent1', 'agent2']; + task.data.interaction.participants.customer1.hasLeft = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toEqual({ + customer: null, + participants: [expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'})], + isDropDisabled: false, + }); + }); + + it('returns to the 1-to-1 UI when the final Agent leaves while Customer remains', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'customer1']; + task.data.interaction.participants.agent2.hasLeft = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('shows an Entry Point number while ringing, replaces it with the answering agent, and enables it after merge', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'EP-DN', + displayName: '+15550000009', + dropTargetId: '+15550000009', + isReadOnly: false, + isDropDisabled: true, + }), + ]); + + task.data.interaction.participants['answering-agent'] = activeParticipant( + 'answering-agent', + 'Agent', + 'Support Agent' + ); + task.data.interaction.media.consult.participants.push('answering-agent'); + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: true, + }), + ]); + + task.data.interaction.media.main.participants.push('answering-agent'); + task.data.interaction.state = 'conference'; + task.data.interaction.media.consult.isHold = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: false, + }), + ]); + }); + + it('keeps the consulting conference Agent and identifies the Entry Point answering Agent', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'customer1']; + task.data.interaction.media.consult.participants = ['agent2', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({displayName: 'Agent Two', dropTargetId: 'agent2'}), + expect.objectContaining({displayName: '+15550000009', isDropDisabled: true}), + ]); + + task.data.interaction.participants['answering-agent'] = { + ...activeParticipant('answering-agent', 'Agent', 'Support Agent'), + isConsulted: true, + }; + task.data.interaction.media.consult.participants.push('answering-agent'); + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({displayName: 'Agent Two', dropTargetId: 'agent2'}), + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: true, + }), + ]); + }); + + it('uses the latest task snapshot when the Entry Point consult leg has not reached task.data yet', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + delete task.data.interaction.media.consult; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'snapshot-consult', + destinationType: 'entryPoint', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'snapshot-consult': { + mediaResourceId: 'snapshot-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'snapshot-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'snapshot-entry-point': { + ...activeParticipant('+15550000010', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'EP-DN', + displayName: '+15550000010', + dropTargetId: '+15550000010', + isDropDisabled: true, + }), + ]); + }); + + it('does not revive a stale pending EP-DN consult leg', () => { + const task = createDropRosterTask({state: 'connected', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'pending-epdn']; + task.data.interaction.participants['pending-epdn'] = { + ...activeParticipant('pending-epdn', 'EP_DN', 'EP-DN'), + dn: '+15550000009', + hasJoined: false, + }; + task.uiControls.consult = createEnabledMainTaskUIControls().consult; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('returns null when the viewing agent has departed or the task is terminated', () => { + const departedTask = createDropRosterTask(); + departedTask.data.interaction.participants.agent1.hasLeft = true; + expect(getConferenceParticipantDropRoster(departedTask, currentAgentId)).toBeNull(); + + const terminatedTask = createDropRosterTask({state: 'terminated'}); + terminatedTask.data.interaction.isTerminated = true; + expect(getConferenceParticipantDropRoster(terminatedTask, currentAgentId)).toBeNull(); + }); +}); + describe('findHoldTimestamp', () => { it('should return null when task data is missing or undefined', () => { const task = {} as Partial as ITask; diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..4d5795f9c 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -79,7 +79,7 @@ packages/contact-center/task/src/ | `cc-widgets.IncomingTask` | SDK (React component / Web Component) | `IncomingTask` — props: `incomingTask`; callbacks: `onAccepted({task})`, `onRejected({task})` | Render an offered task with accept/decline; notify consumer on accept/reject/RONA | Stable; adding optional props/callbacks = minor | `src/task.types.ts` (`IncomingTaskProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.TaskList` | SDK (React component / Web Component) | `TaskList` — props: `hasCampaignPreviewEnabled?`; callbacks: `onTaskAccepted(task)`, `onTaskDeclined(task, reason)`, `onTaskSelected({task, isClicked})` | List concurrent tasks; accept/decline/select | Stable; `hasCampaignPreviewEnabled` defaults true | `src/task.types.ts` (`TaskListProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.CallControl` | SDK (React component / Web Component) | `CallControl` — callbacks: `onHoldResume({isHeld,task})`, `onEnd({task})`, `onWrapUp({task,wrapUpReason})`, `onRecordingToggle({isRecording,task})`, `onToggleMute({isMuted,task})`; props: `conferenceEnabled?`, `consultTransferOptions?`, `callControlClassName?`, `callControlConsultClassName?` | Active-call controls for `store.currentTask` | Stable; `conferenceEnabled` defaults `true` | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName` | CallControl variant styled for a customer-data layout | Stable; same surface as CallControl | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName`; participant Drop is store-driven | CallControl variant styled for a customer-data layout with owner-aware conference participant removal | Stable; no new React prop or Web Component property/attribute | `src/task.types.ts` (`CallControlProps`); [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.OutdialCall` | SDK (React component / Web Component) | `OutdialCall` — props: `isAddressBookEnabled?` (default `true`); no consumer callbacks | Outbound dialpad + ANI selection; disabled when a telephony task is active | Stable | `src/task.types.ts` (`OutdialProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.RealTimeTranscript` | SDK (React component / Web Component) | `RealTimeTranscript` — props: `liveTranscriptEntries?`, `className?` | Render live transcript for `store.currentTask` | Stable | `src/task.types.ts` (`RealTimeTranscriptProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -120,11 +120,12 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | +| `TASK-R-024` | `useCallControl.dropConferenceParticipant(target)` revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous request token; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; ignores stale completions; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. -`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, additionally maintains a dozen `useState` values (recording, buddy agents, consult agent name, target type, timers, conference participants) and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. +`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, maintains transient UI state (recording, buddy agents, consult agent name, target type, timers, participant Drop request/announcement), derives both conference rosters directly from the current observable task, and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. `Utils/` is pure logic split out for testability: `task-util.ts` decides control visibility/enablement from device type, feature flags, and a large set of derived task-state booleans (consult status, hold status, conference progress, customer-in-call, participant counts); `timer-utils.ts` and `useHoldTimer.ts` compute timer labels/elapsed times. Keeping these pure means the visibility matrix and timer math are unit-tested without rendering. @@ -436,7 +437,7 @@ stateDiagram-v2 ## UI Flow - **IncomingTask:** task card with caller/queue/media info, RONA countdown badge, Accept/Decline buttons. Empty state = no card when `incomingTask` is null. Error state = empty fragment via ErrorBoundary. - **TaskList:** list of task cards; selected task highlighted (mirrors `currentTask`); per-task Accept/Decline; empty list renders nothing. Campaign-preview tasks render a `CampaignTask` when `hasCampaignPreviewEnabled` (default true). -- **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks. Disabled/enabled state of every button comes from `getControlsVisibility`. +- **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. The CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks plus owner-aware conference participant Drop; standard `CallControl` has no participant Drop UI. Disabled/enabled state of the established call-control buttons comes from `getControlsVisibility`. - **OutdialCall:** numeric dialpad with E.164/special-char validation, ANI selector dropdown, optional address book (`isAddressBookEnabled`), dial button disabled on invalid/empty input or while a telephony task is active. - **RealTimeTranscript:** scrolling transcript with normalized speaker ("You"/"Customer") and `HH:MM` display time; renders supplied `liveTranscriptEntries` when no live data for the current task. @@ -474,7 +475,7 @@ stateDiagram-v2 - DON'T add new task-event subscriptions without matching the exact event name in both `setTaskCallback` and the cleanup `removeTaskCallback`. ## Host Integration & Theming -These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides. The host supplies `store.onErrorCallback` to receive widget-crash notifications. +These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides and inherits participant Drop in both React and Web Component modes without new public inputs. The host supplies `store.onErrorCallback` to receive widget-crash and sanitized participant-Drop failure notifications. ## Test-Case Strategy (module) Tests are split between widget-shell render tests (each `tests//index.tsx` asserts the hook is called with the right props, the presentational component receives merged output, and the ErrorBoundary renders empty + invokes/handles-missing `onErrorCallback`) and exhaustive hook/util logic tests. `tests/helper.ts` is the large behavioral suite covering accept/decline, hold/resume, end, recording pause/resume (positive + SDK-failure negative cases), mute (including rapid toggles and failure revert), wrap-up + auto-wrap-up cancel, consult/transfer/conference, queue-consult flags, buddy-agent loading, and consulting-agent extraction. `tests/utils/task-util.ts` matrices `getControlsVisibility` across device types (BROWSER/AGENT_DN/EXTENSION) and media types (telephony/chat/email) plus EP-DN end-button rules and the error→safe-defaults path. `tests/utils/timer-utils.test.ts` and `tests/utils/useHoldTimer.test.ts` cover label priority, consult-on-hold, null-task defaults, and consult-vs-main hold prioritization. Edge cases asserted: missing interaction/participants, missing currentTask, error logging in every callback. Gaps: no unit test for the OutdialCall empty-destination alert, the `getOutdialANIEntries` missing-ANI-id throw, or the CallControl unaccepted-campaign-preview early return. @@ -504,6 +505,7 @@ Tests are split between widget-shell render tests (each `tests//index.ts | `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | | `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | | `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | +| `TASK-R-024` participant Drop orchestration | `tests/helper.ts` (exact payload, duplicate prevention, success/failure cleanup, sanitized callback/logging, stale completion, roster re-derivation) | Live routing-event behavior is covered by SDK/manual integration tests | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..0b15e8f2b 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -15,6 +15,7 @@ import { useOutdialCallProps, TargetType, TARGET_TYPE, + ParticipantDropAnnouncement, } from './task.types'; import store, { TASK_EVENTS, @@ -22,7 +23,8 @@ import store, { DestinationType, PaginatedListParams, getConferenceParticipants, - Participant, + getConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, findMediaResourceId, MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, @@ -42,6 +44,15 @@ import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/servic const ENGAGED_LABEL = 'ENGAGED'; const ENGAGED_USERNAME = 'Engaged'; +const PARTICIPANT_DROP_SUCCESS_MESSAGE = 'Participant removed from the conference.'; +const PARTICIPANT_DROP_FAILURE_MESSAGE = 'Unable to drop participant from the call. Try again.'; + +type PendingParticipantDropRequest = { + token: symbol; + taskId: string; + task: ITask; + dropTargetId: string; +}; const getTranscriptSpeaker = (role?: string): string => { const normalizedRole = role?.toUpperCase(); @@ -376,8 +387,22 @@ export const useCallControl = (props: useCallControlProps) => { !!(initialControls?.consult?.endConsult?.isVisible || initialControls?.main?.endConsult?.isVisible) ); const [lastTargetType, setLastTargetType] = useState(TARGET_TYPE.AGENT); - const [conferenceParticipants, setConferenceParticipants] = useState([]); const lastWrapupAuxCodeIdRef = useRef(null); + const [pendingParticipantDropId, setPendingParticipantDropId] = useState(null); + const [participantDropAnnouncement, setParticipantDropAnnouncement] = useState( + null + ); + const pendingParticipantDropRef = useRef(null); + const participantDropTaskRef = useRef(currentTask); + const participantDropAgentIdRef = useRef(agentId); + participantDropTaskRef.current = currentTask; + participantDropAgentIdRef.current = agentId; + + // Derive conference state during render so MobX tracks nested participant and owner changes. + const conferenceParticipants = currentTask && agentId ? getConferenceParticipants(currentTask, agentId) : []; + const conferenceParticipantDropRoster = + currentTask && agentId ? getConferenceParticipantDropRoster(currentTask, agentId) : null; + const participantDropTaskId = currentTask?.data?.interactionId; // Subscribe to SDK-computed UI control updates useEffect(() => { @@ -447,11 +472,15 @@ export const useCallControl = (props: useCallControlProps) => { }, [controls?.consult?.endConsult?.isVisible, controls?.main?.endConsult?.isVisible]); useEffect(() => { - if (currentTask && store?.cc?.agentConfig?.agentId) { - const participants = getConferenceParticipants(currentTask, store.cc.agentConfig.agentId); - setConferenceParticipants(participants); + const pendingRequest = pendingParticipantDropRef.current; + + if (pendingRequest && (pendingRequest.taskId !== participantDropTaskId || pendingRequest.task !== currentTask)) { + pendingParticipantDropRef.current = null; } - }, [currentTask, controls]); + + setPendingParticipantDropId(null); + setParticipantDropAnnouncement(null); + }, [currentTask, participantDropTaskId]); // Function to extract consulting agent information const extractConsultingAgent = useCallback(() => { try { @@ -813,6 +842,84 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const dropConferenceParticipant = useCallback( + async (target: ConferenceParticipantDropTarget): Promise => { + const task = participantDropTaskRef.current; + const currentAgentId = participantDropAgentIdRef.current; + + if (!task || !currentAgentId || pendingParticipantDropRef.current) { + return; + } + + const latestRoster = getConferenceParticipantDropRoster(task, currentAgentId); + const latestTarget = [latestRoster?.customer, ...(latestRoster?.participants ?? [])].find( + (candidate) => + candidate?.dropTargetId === target.dropTargetId && candidate.participantType === target.participantType + ); + + if ( + !latestRoster || + !latestTarget || + latestTarget.isReadOnly || + latestTarget.isDropDisabled || + latestRoster.isDropDisabled + ) { + return; + } + + const taskId = task.data.interactionId; + const request: PendingParticipantDropRequest = { + token: Symbol('participant-drop-request'), + taskId, + task, + dropTargetId: latestTarget.dropTargetId, + }; + + pendingParticipantDropRef.current = request; + setPendingParticipantDropId(latestTarget.dropTargetId); + setParticipantDropAnnouncement(null); + + try { + await task.dropConferenceParticipant({participantId: latestTarget.dropTargetId}); + + if ( + pendingParticipantDropRef.current === request && + participantDropTaskRef.current === request.task && + participantDropTaskRef.current?.data?.interactionId === request.taskId + ) { + setParticipantDropAnnouncement({type: 'success', message: PARTICIPANT_DROP_SUCCESS_MESSAGE}); + } + } catch { + if ( + pendingParticipantDropRef.current === request && + participantDropTaskRef.current === request.task && + participantDropTaskRef.current?.data?.interactionId === request.taskId + ) { + const sanitizedError = new Error(PARTICIPANT_DROP_FAILURE_MESSAGE); + + setParticipantDropAnnouncement({type: 'error', message: PARTICIPANT_DROP_FAILURE_MESSAGE}); + logger.error('CC-Widgets: Conference participant Drop failed', { + module: 'useCallControl', + method: 'dropConferenceParticipant', + }); + store.onErrorCallback?.('CallControlCAD', sanitizedError); + } + } finally { + if (pendingParticipantDropRef.current === request) { + pendingParticipantDropRef.current = null; + + if ( + participantDropTaskRef.current === request.task && + participantDropTaskRef.current?.data?.interactionId === request.taskId + ) { + setPendingParticipantDropId(null); + } + } + } + }, + [logger] + ); + const toggleMute = async () => { try { if (!controls?.main?.mute?.isVisible) { @@ -1261,6 +1368,10 @@ export const useCallControl = (props: useCallControlProps) => { secondsUntilAutoWrapup, cancelAutoWrapup, conferenceParticipants, + conferenceParticipantDropRoster, + pendingParticipantDropId, + participantDropAnnouncement, + dropConferenceParticipant, getAddressBookEntries, getEntryPoints, getQueuesFetcher, @@ -1304,12 +1415,17 @@ export const useOutdialCall = (props: useOutdialCallProps) => { return; } - // Only pass origin if it's defined and not empty - const outdialArgs = origin ? [destination, origin] : [destination]; + const outdialClient = cc as typeof cc & { + startOutdial(destination: string, origin?: string): Promise; + }; + + const outdialRequest = origin + ? outdialClient.startOutdial(destination, origin) + : outdialClient.startOutdial(destination); - cc.startOutdial(...outdialArgs) - .then((response) => { - logger.info('Outdial call started', response); + outdialRequest + .then(() => { + logger.info('Outdial call started'); }) .catch((error: Error) => { logger.error(`${error}`, { diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 94c1affc6..ac6f961c6 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -4,6 +4,7 @@ import { OutdialCallProps, RealTimeTranscriptComponentProps, RealTimeTranscriptEntry, + ParticipantDropAnnouncement, } from '@webex/cc-components'; import {RealTimeTranscriptionData} from '@webex/cc-store'; @@ -57,7 +58,7 @@ export type useOutdialCallProps = Pick; // Re-exported from store — single source of truth. export {CAMPAIGN_PREVIEW_OUTBOUND_TYPES, CAMPAIGN_PREVIEW_CAMPAIGN_TYPES} from '@webex/cc-store'; -export type {RealTimeTranscriptEntry}; +export type {RealTimeTranscriptEntry, ParticipantDropAnnouncement}; export interface OutdialProps { /** * Flag to determine if the address book is enabled. diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 3e7bae023..6965e79b3 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -57,6 +57,10 @@ describe('CallControl Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getEntryPoints: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getQueuesFetcher: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index b30905ba9..2dff4d1a6 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -60,6 +60,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -131,6 +135,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -184,6 +192,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -240,6 +252,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx index 9b5ea11ee..659325f2e 100644 --- a/packages/contact-center/task/tests/call-control-recording.tsx +++ b/packages/contact-center/task/tests/call-control-recording.tsx @@ -76,8 +76,8 @@ const promoteTask = (task: FakeTask) => { store.store.agentId = AGENT_ID; // Registers the store's own task listeners (refreshTaskList on recording // pause/resume, etc.) exactly as production does. - store.handleIncomingTask(task); - store.setCurrentTask(task); + store.handleIncomingTask(task as unknown as Parameters[0]); + store.setCurrentTask(task as unknown as Parameters[0]); }; /** What the SDK does when the ContactRecordingPaused websocket event arrives. */ @@ -104,7 +104,6 @@ describe('CallControl recording pause/resume state', () => { info: jest.fn(), warn: jest.fn(), error: jest.fn(), - debug: jest.fn(), trace: jest.fn(), }; }); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..cdabf044f 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -206,6 +206,28 @@ describe('useIncomingTask Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + it('should reject an unaccepted consult exactly once on consult-end', async () => { + renderHook(() => + useIncomingTask({ + incomingTask: taskMock, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + const consultEndCallback = taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_CONSULT_END)?.[1]; + + act(() => { + consultEndCallback?.(); + }); + + await waitFor(() => { + expect(onTaskDeclined).toHaveBeenCalledTimes(1); + expect(onTaskDeclined).toHaveBeenCalledWith({task: taskMock}); + }); + }); + it('should return if there is no taskId for incoming task', async () => { // Reset the mock first onTaskDeclined.mockClear(); @@ -746,6 +768,7 @@ describe('useCallControl', () => { toggleMute: jest.fn(() => Promise.resolve()), switchCall: jest.fn(() => Promise.resolve()), transferConference: jest.fn(() => Promise.resolve()), + dropConferenceParticipant: jest.fn(() => Promise.resolve()), }; const mockLogger = mockCC.LoggerProxy; @@ -754,6 +777,69 @@ describe('useCallControl', () => { const mockOnEnd = jest.fn(); const mockOnWrapUp = jest.fn(); + const createParticipantDropTask = (dropRequest = jest.fn().mockResolvedValue(undefined)): ITask => + ({ + ...mockCurrentTask, + dropConferenceParticipant: dropRequest, + uiControls: createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}), + data: { + ...mockCurrentTask.data, + interactionId: 'participant-drop-task', + isConferenceInProgress: true, + interaction: { + ...mockCurrentTask.data.interaction, + interactionId: 'participant-drop-task', + mediaType: 'telephony', + mediaChannel: 'telephony', + state: 'conference', + owner: 'agent1', + contactDirection: {type: 'inbound'}, + callAssociatedDetails: {ani: '+15550000001', dnis: '+15550000002'}, + callProcessingDetails: {...mockCurrentTask.data.interaction.callProcessingDetails}, + media: { + main: { + mediaResourceId: 'main', + mediaType: 'telephony', + mediaMgr: 'aqm', + participants: ['agent1', 'agent2', 'customer1'], + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + }, + }, + participants: { + agent1: { + id: 'agent1', + pType: 'Agent', + type: 'Agent', + name: 'Current Agent', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + agent2: { + id: 'agent2', + pType: 'Agent', + type: 'Agent', + name: 'Agent Two', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + customer1: { + id: 'customer1', + pType: 'Customer', + type: 'Customer', + name: 'Customer', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + }, + }, + }, + }) as ITask; + beforeEach(() => { store.refreshTaskList(); // Mock the MediaStreamTrack and MediaStream classes for the test environment @@ -799,6 +885,7 @@ describe('useCallControl', () => { // Restore the original Worker class and URL.createObjectURL global.Worker = originalWorker; delete global.URL.createObjectURL; + store.onErrorCallback = undefined; jest.clearAllMocks(); }); @@ -837,6 +924,321 @@ describe('useCallControl', () => { setTaskCallbackSpy.mockRestore(); }); + describe('conference participant Drop', () => { + it('retains surviving Participants when Customer leaves and conference signals downgrade', () => { + const task = createParticipantDropTask(); + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'agent3', 'customer1']; + task.data.interaction.participants.agent3 = { + id: 'agent3', + pType: 'Agent', + type: 'Agent', + name: 'Agent Three', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + expect(result.current.conferenceParticipantDropRoster?.customer).not.toBeNull(); + + task.data.interaction.participants.customer1.hasLeft = true; + task.data.interaction.state = 'connected'; + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.customer).toBeNull(); + expect(result.current.conferenceParticipantDropRoster?.participants).toEqual([ + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'}), + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent3'}), + ]); + }); + + it('keeps the roster when Customer leaves only one other Agent', () => { + const task = createParticipantDropTask(); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + expect(result.current.conferenceParticipantDropRoster).not.toBeNull(); + + task.data.interaction.participants.customer1.hasLeft = true; + task.data.interaction.state = 'post_call'; + task.data.wrapUpRequired = true; + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + rerender(); + + expect(result.current.conferenceParticipantDropRoster).toEqual({ + customer: null, + participants: [expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'})], + isDropDisabled: false, + }); + }); + + it('shows the Entry Point number while ringing, then replaces it with the answering agent', async () => { + const dropRequest = jest.fn().mockResolvedValue(undefined); + const task = createParticipantDropTask(dropRequest); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.state = 'consulting'; + task.data.interaction.media.consult = { + mediaResourceId: 'consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + participants: ['agent1', 'entry-point-route'], + mType: 'consult', + isHold: true, + holdTimestamp: Date.now(), + }; + task.data.interaction.participants['entry-point-route'] = { + id: '+15550000009', + pType: 'entry-point-id', + type: 'EpDn', + name: 'EP-DN', + dn: '+15550000009', + hasJoined: false, + hasLeft: false, + isInPredial: false, + }; + task.uiControls.consult.endConsult = {isVisible: true, isEnabled: true}; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const pendingTarget = result.current.conferenceParticipantDropRoster?.participants.find( + (target) => target.dropTargetId === '+15550000009' + ); + + expect(pendingTarget).toEqual(expect.objectContaining({displayName: '+15550000009', isDropDisabled: true})); + if (!pendingTarget) throw new Error('Expected pending EP-DN target'); + + await act(async () => { + await result.current.dropConferenceParticipant(pendingTarget); + }); + + expect(dropRequest).not.toHaveBeenCalled(); + + task.data.interaction.media.consult.participants.push('agent2', 'agent3'); + task.data.interaction.participants.agent3 = { + id: 'agent3', + pType: 'Agent', + type: 'Agent', + name: 'Support Agent', + hasJoined: true, + hasLeft: false, + isInPredial: false, + isConsulted: true, + }; + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent2', + isDropDisabled: false, + }), + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'agent3', + isDropDisabled: true, + }), + ]); + }); + + it('delegates the exact target and publishes success only after the SDK promise resolves', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + act(() => { + requestPromise = result.current.dropConferenceParticipant(target); + }); + + expect(dropRequest).toHaveBeenCalledWith({participantId: 'agent2'}); + expect(result.current.pendingParticipantDropId).toBe('agent2'); + expect(result.current.participantDropAnnouncement).toBeNull(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'success', + message: 'Participant removed from the conference.', + }); + }); + + it('globally serializes same-target and cross-target requests', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const agentTarget = result.current.conferenceParticipantDropRoster?.participants[0]; + const customerTarget = result.current.conferenceParticipantDropRoster?.customer; + if (!agentTarget || !customerTarget) throw new Error('Expected Agent and Customer Drop targets'); + let firstRequest!: Promise; + + act(() => { + firstRequest = result.current.dropConferenceParticipant(agentTarget); + void result.current.dropConferenceParticipant(agentTarget); + void result.current.dropConferenceParticipant(customerTarget); + }); + + expect(dropRequest).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDrop(); + await firstRequest; + }); + }); + + it('reports a sanitized failure without logging participant data', async () => { + const sensitiveParticipantId = 'sensitive-participant-id'; + const dropRequest = jest.fn().mockRejectedValue(new Error(`Failed URL contained ${sensitiveParticipantId}`)); + const task = createParticipantDropTask(dropRequest); + task.data.interaction.media.main.participants[1] = sensitiveParticipantId; + task.data.interaction.participants[sensitiveParticipantId] = { + ...task.data.interaction.participants.agent2, + id: sensitiveParticipantId, + }; + delete task.data.interaction.participants.agent2; + const hostErrorCallback = jest.fn(); + store.onErrorCallback = hostErrorCallback; + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + + await act(async () => { + await result.current.dropConferenceParticipant(target); + }); + + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'error', + message: 'Unable to drop participant from the call. Try again.', + }); + expect(hostErrorCallback).toHaveBeenCalledWith( + 'CallControlCAD', + Error('Unable to drop participant from the call. Try again.') + ); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(sensitiveParticipantId); + expect(JSON.stringify(hostErrorCallback.mock.calls)).not.toContain(sensitiveParticipantId); + }); + + it('ignores stale completion and re-derives roster changes from the current task', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const firstTask = createParticipantDropTask(dropRequest); + const nextTask = createParticipantDropTask(); + nextTask.data.interaction.owner = 'agent2'; + let activeTask = firstTask; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.dropConferenceParticipant(target); + }); + activeTask = nextTask; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.participants[0].isReadOnly).toBe(true); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.participantDropAnnouncement).toBeNull(); + + nextTask.data.interaction.participants.agent2.hasLeft = true; + rerender(); + expect(result.current.conferenceParticipantDropRoster).toBeNull(); + }); + }); + it('should not call any call backs if callbacks are not provided', async () => { mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); @@ -5696,7 +6098,7 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); + expect(logger.info).toHaveBeenCalledWith('Outdial call started'); }); it('should successfully start an outdial call with origin', async () => { @@ -5713,7 +6115,7 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination, origin); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); + expect(logger.info).toHaveBeenCalledWith('Outdial call started'); }); it('should show alert when destination is empty or only contains spaces', async () => { From 8f10ce0e50d217ae7c52b674f5baf90251996358 Mon Sep 17 00:00:00 2001 From: ajitku3 Date: Tue, 18 Aug 2026 16:17:41 +0530 Subject: [PATCH 2/6] Keep participant trigger and chevron on one line --- .../cc-components/ai-docs/cc-components-spec.md | 2 +- .../CallControlCAD/call-control-cad.styles.scss | 16 +++++++++++++--- .../task/CallControlCAD/call-control-cad.tsx | 2 +- .../task/CallControlCAD/call-control-cad.tsx | 2 ++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index ee8b52af3..60c784de6 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -149,7 +149,7 @@ Compatibility notes: | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | | `CC-COMPONENTS-R-016` | `E911Modal` gates `Save & Continue` on the acknowledgment checkbox, disables both `Save & Continue` and `Cancel` while `onSaveAndContinue` is in flight (guarding against a double-click firing concurrent saves), shows a user-facing error and re-enables the buttons if the save rejects, and only `Cancel` (not the Dialog's built-in close button or Escape) dismisses the modal; checkbox/saving/error state resets when the modal closes. | An emergency-notification acknowledgment must not be skippable, must not double-submit against the preference API, and must give the agent visible recourse on failure. | `src/components/StationLogin/E911Modal/e911-modal.tsx` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (checkbox gating, save-in-flight button disabling, save-error display, close-only-via-Cancel) | None | PRESENT | -| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. Agent/merged EP-DN Drop immediately, Customer uses a native confirmation dialog, pending/consult state globally protects requests, focus is restored, and exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | +| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. Agent/merged EP-DN Drop immediately, Customer uses a native confirmation dialog, pending/consult state globally protects requests, focus is restored, and exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | ## Design Overview diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index e39b5e9bb..4d05db570 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -122,6 +122,7 @@ .participants-section { display: flex; align-items: center; + flex: 0 0 auto; gap: 0.25rem; } @@ -139,9 +140,12 @@ .participants-select-button { display: inline-flex; align-items: center; + flex: 0 0 auto; gap: 0.3rem; padding: 0 0.4rem; min-height: 1.5rem; + width: max-content; + white-space: nowrap; border-radius: 999px; background: transparent !important; border: 1px solid var(--mds-color-theme-outline-secondary-normal) !important; @@ -153,15 +157,21 @@ } } -.dropdown-arrow { +.participants-select-button::part(button-text) { + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +.participants-select-button::part(postfix-icon) { width: 0.75rem; height: 0.75rem; color: var(--mds-color-theme-text-secondary-normal); - margin-top: 0.0625rem; + flex: 0 0 auto; transition: transform 0.15s ease; } -.participants-select-button[aria-expanded='true'] .dropdown-arrow { +.participants-select-button[aria-expanded='true']::part(postfix-icon) { transform: rotate(180deg); } 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 70a53971d..481b4b1ce 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 @@ -391,6 +391,7 @@ const CallControlCADComponent: React.FC = (props) => className="participants-select-button" color="default" variant="tertiary" + postfix-icon="arrow-down-bold" onClick={(event) => { participantMenuTriggerRef.current = event.currentTarget as HTMLElement; }} @@ -398,7 +399,6 @@ const CallControlCADComponent: React.FC = (props) => +{participantsCount} {participantsLabel} - } > diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index 13f0c7920..d14b12677 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -485,7 +485,9 @@ describe('CallControlCADComponent', () => { it('renders Customer and Participants sections and immediately drops an Agent target', () => { const {screen, dropConferenceParticipant} = renderRoster(); + const participantsTrigger = screen.getByTestId('call-control:participants-trigger'); + expect(participantsTrigger).toHaveAttribute('postfix-icon', 'arrow-down-bold'); expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); expect(screen.getByText('+15551234567')).toBeInTheDocument(); From 053e6f46f701acd2129165c581dfc2816dd4a581 Mon Sep 17 00:00:00 2001 From: ajitku3 Date: Wed, 19 Aug 2026 12:22:56 +0530 Subject: [PATCH 3/6] Refine consult media selection using state snapshots --- .../store/ai-docs/store-spec.md | 2 +- .../contact-center/store/src/task-utils.ts | 111 +++++++++--- .../contact-center/store/tests/task-utils.ts | 169 ++++++++++++++++++ 3 files changed, 252 insertions(+), 30 deletions(-) diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index cf9fa1a06..4967db0c0 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -111,7 +111,7 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | -| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | +| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | | `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | ## Design Overview diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index 6e44088f4..046bfc7b2 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -145,6 +145,7 @@ const TERMINAL_CONFERENCE_STATES = new Set(['ended', 'disconnected', 'terminated type RosterTaskData = ITask['data']; type RosterInteraction = RosterTaskData['interaction']; type RosterParticipant = NonNullable[string]; +type RosterMedia = NonNullable[string]; type TaskWithStateSnapshot = ITask & { state?: { @@ -197,7 +198,7 @@ const isEligibleConference = (task: ITask, mainParticipantIds: string[], agentId ); }; -const getMediaRecencyScore = (media: Record, fallbackIndex: number): number => { +const getMediaRecencyTimestamp = (media: Record): number | undefined => { const candidateTimestamps = [ media.lastUpdated, media.joinTimestamp, @@ -213,29 +214,90 @@ const getMediaRecencyScore = (media: Record, fallbackIndex: num } } - return fallbackIndex; + return undefined; +}; + +const getMediaRecencyScore = (media: Record, fallbackIndex: number): number => + getMediaRecencyTimestamp(media) ?? fallbackIndex; + +type ConsultMediaEntry = { + mediaId: string; + media: RosterMedia; + interaction: RosterInteraction; + taskData: RosterTaskData; + isConfiguredMedia: boolean; + source: 'observable' | 'snapshot'; }; -const getConsultMediaEntry = (taskData: RosterTaskData, fallbackConsultMediaId?: string) => { +const getConsultMediaEntry = ( + taskData: RosterTaskData, + source: ConsultMediaEntry['source'] +): ConsultMediaEntry | undefined => { const interaction = taskData?.interaction; - const configuredConsultMediaId = taskData?.consultMediaResourceId || fallbackConsultMediaId; - const configuredConsultMedia = configuredConsultMediaId ? interaction?.media?.[configuredConsultMediaId] : undefined; + const configuredConsultMediaId = taskData?.consultMediaResourceId; + const matchingMedia = Object.entries(interaction?.media ?? {}).filter(([, media]) => media.mType === 'consult'); - if (configuredConsultMedia?.mType === 'consult') { - return configuredConsultMedia; + if (!interaction || matchingMedia.length === 0) { + return undefined; } - const matchingMedia = Object.values(interaction?.media ?? {}).filter((media) => media.mType === 'consult'); + const configuredEntry = configuredConsultMediaId + ? matchingMedia.find( + ([mediaId, media]) => mediaId === configuredConsultMediaId || media.mediaResourceId === configuredConsultMediaId + ) + : undefined; + const [mediaId, media] = + configuredEntry ?? + matchingMedia.reduce((latest, current, index) => { + const latestScore = getMediaRecencyScore(latest[1] as Record, index - 1); + const currentScore = getMediaRecencyScore(current[1] as Record, index); + return currentScore >= latestScore ? current : latest; + }); - if (matchingMedia.length === 0) { - return undefined; + return { + mediaId: media.mediaResourceId || mediaId, + media, + interaction, + taskData, + isConfiguredMedia: Boolean(configuredEntry), + source, + }; +}; + +const selectCurrentConsultMediaEntry = ( + current: ConsultMediaEntry, + candidate: ConsultMediaEntry +): ConsultMediaEntry => { + const currentTimestamp = getMediaRecencyTimestamp(current.media as Record); + const candidateTimestamp = getMediaRecencyTimestamp(candidate.media as Record); + + if (current.mediaId === candidate.mediaId) { + if (currentTimestamp !== undefined && candidateTimestamp !== undefined && candidateTimestamp > currentTimestamp) { + return candidate; + } + + // Preserve the existing observable-data behavior when both sources describe + // the same leg and there is no positive evidence that the snapshot is newer. + return current; } - return matchingMedia.reduce((latest, media, index) => { - const latestScore = getMediaRecencyScore(latest as Record, index - 1); - const currentScore = getMediaRecencyScore(media as Record, index); - return currentScore >= latestScore ? media : latest; - }); + if (currentTimestamp !== candidateTimestamp) { + if (currentTimestamp !== undefined && candidateTimestamp !== undefined) { + return candidateTimestamp > currentTimestamp ? candidate : current; + } + + if (current.isConfiguredMedia === candidate.isConfiguredMedia) { + return candidateTimestamp !== undefined ? candidate : current; + } + } + + if (current.isConfiguredMedia !== candidate.isConfiguredMedia) { + return candidate.isConfiguredMedia ? candidate : current; + } + + // The state-machine context can advance before observable task hydration. + // Prefer it when recency and current-media signals cannot distinguish the legs. + return candidate.source === 'snapshot' ? candidate : current; }; const getCurrentConsultMediaEntry = (task: ITask) => { @@ -249,21 +311,12 @@ const getCurrentConsultMediaEntry = (task: ITask) => { return undefined; } - // Prefer the observable task data when it already contains the current leg; - // fall back to the state-machine snapshot during the brief hydration gap. - const taskDataCandidates = [task.data, snapshotTaskData].filter((taskData): taskData is RosterTaskData => - Boolean(taskData?.interaction) - ); - - for (const taskData of taskDataCandidates) { - const media = getConsultMediaEntry(taskData, task.data.consultMediaResourceId); + const candidates = [ + getConsultMediaEntry(task.data, 'observable'), + snapshotTaskData ? getConsultMediaEntry(snapshotTaskData, 'snapshot') : undefined, + ].filter((candidate): candidate is ConsultMediaEntry => Boolean(candidate)); - if (media) { - return {media, interaction: taskData.interaction, taskData}; - } - } - - return undefined; + return candidates.length > 0 ? candidates.reduce(selectCurrentConsultMediaEntry) : undefined; }; const hasActiveNonHeldConsult = (task: ITask): boolean => { diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index d178f9fdd..3d1f8a7be 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -1033,6 +1033,175 @@ describe('getConferenceParticipantDropRoster', () => { ]); }); + it('prefers a newer configured consult leg from the state snapshot over retained observable media', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'old-entry-point']; + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 1000; + task.data.interaction.participants['old-entry-point'] = { + ...activeParticipant('+15550000011', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'new-consult', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'new-consult': { + mediaResourceId: 'new-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + lastUpdated: 2000, + participants: ['agent1', 'new-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'new-entry-point': { + ...activeParticipant('+15550000012', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000012', + dropTargetId: '+15550000012', + }), + ]); + }); + + it('keeps a newer observable consult leg when the state snapshot is stale', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'current-entry-point']; + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 2000; + task.data.interaction.participants['current-entry-point'] = { + ...activeParticipant('+15550000013', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'old-consult', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'old-consult': { + mediaResourceId: 'old-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'old-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'old-entry-point': { + ...activeParticipant('+15550000014', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000013', + dropTargetId: '+15550000013', + }), + ]); + }); + + it('preserves observable data for the same leg unless the snapshot is demonstrably newer', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'observable-entry-point']; + task.data.interaction.participants['observable-entry-point'] = { + ...activeParticipant('+15550000015', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + consult: { + ...task.data.interaction.media.consult, + participants: ['agent1', 'snapshot-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'snapshot-entry-point': { + ...activeParticipant('+15550000016', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000015', + dropTargetId: '+15550000015', + }), + ]); + + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 1000; + (snapshotTaskData.interaction.media.consult as unknown as Record).lastUpdated = 2000; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000016', + dropTargetId: '+15550000016', + }), + ]); + }); + it('does not revive a stale pending EP-DN consult leg', () => { const task = createDropRosterTask({state: 'connected', consultHold: false}); task.data.consultMediaResourceId = 'consult'; From f49f429ddc16b56608334b1c4a3cb6585e089c75 Mon Sep 17 00:00:00 2001 From: ajitku3 Date: Mon, 24 Aug 2026 11:20:35 +0530 Subject: [PATCH 4/6] Update contact center widget behavior --- .sdd/manifest.json | 8 +- ai-docs/SPEC_INDEX.md | 8 +- .../ai-docs/cc-components-spec.md | 2 +- .../call-control-cad.styles.scss | 14 +- .../task/CallControlCAD/call-control-cad.tsx | 239 +++++++------ .../CallControlCAD/call-control-cad.types.ts | 11 + .../src/components/task/task.types.ts | 22 +- .../task/CallControl/call-control.tsx | 2 +- .../task/CallControlCAD/call-control-cad.tsx | 97 +++-- .../store/ai-docs/store-spec.md | 2 +- packages/contact-center/store/package.json | 2 +- .../store/src/storeEventsWrapper.ts | 4 + .../contact-center/store/src/task-utils.ts | 24 +- .../contact-center/store/tests/task-utils.ts | 29 ++ .../contact-center/task/ai-docs/task-spec.md | 4 +- packages/contact-center/task/src/helper.ts | 156 ++++++-- .../contact-center/task/src/task.types.ts | 7 + .../task/tests/CallControl/index.tsx | 6 +- .../task/tests/CallControlCAD/index.tsx | 24 +- .../task/tests/call-control-recording.tsx | 5 +- packages/contact-center/task/tests/helper.ts | 230 +++++++++++- yarn.lock | 337 ++++++++++-------- 22 files changed, 863 insertions(+), 370 deletions(-) create mode 100644 packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts diff --git a/.sdd/manifest.json b/.sdd/manifest.json index 407b6681e..17249044a 100644 --- a/.sdd/manifest.json +++ b/.sdd/manifest.json @@ -32,7 +32,7 @@ "package": "@webex/cc-store", "path": "packages/contact-center/store", "spec": "packages/contact-center/store/ai-docs/store-spec.md", - "responsibility": "MobX singleton holding global CC state; proxies SDK events; sole SDK access point; owns pure conference Drop roster derivation.", + "responsibility": "MobX singleton holding global CC state; proxies SDK events; sole SDK access point.", "coverageState": "DRAFT", "tier": 1 }, @@ -41,7 +41,7 @@ "package": "@webex/cc-components", "path": "packages/contact-center/cc-components", "spec": "packages/contact-center/cc-components/ai-docs/cc-components-spec.md", - "responsibility": "Shared presentational React UI primitives, including the CallControlCAD participant Drop experience.", + "responsibility": "Shared presentational React UI primitives consumed by widget packages.", "coverageState": "DRAFT", "tier": 1 }, @@ -50,7 +50,7 @@ "package": "@webex/cc-widgets", "path": "packages/contact-center/cc-widgets", "spec": "packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md", - "responsibility": "r2wc Web Component wrappers aggregating widget packages; existing CallControlCAD wrapper inherits participant Drop without new properties.", + "responsibility": "r2wc Web Component wrappers aggregating widget packages for framework-agnostic consumption.", "coverageState": "DRAFT", "tier": 1 }, @@ -86,7 +86,7 @@ "package": "@webex/cc-task", "path": "packages/contact-center/task", "spec": "packages/contact-center/task/ai-docs/task-spec.md", - "responsibility": "Task widget bundle: CallControl, CallControlCAD with participant Drop orchestration, IncomingTask, OutdialCall, TaskList.", + "responsibility": "Task widget bundle: CallControl, CallControlCAD, IncomingTask, OutdialCall, TaskList.", "coverageState": "DRAFT", "tier": 1, "subWidgets": ["CallControl", "CallControlCAD", "IncomingTask", "OutdialCall", "TaskList"] diff --git a/ai-docs/SPEC_INDEX.md b/ai-docs/SPEC_INDEX.md index a7bfdb460..681e8f99a 100644 --- a/ai-docs/SPEC_INDEX.md +++ b/ai-docs/SPEC_INDEX.md @@ -9,13 +9,13 @@ ## Module Registry | Module | Responsibility | Manifest coverage state | Start here | |---|---|---|---| -| `store/` | MobX singleton; global CC state; SDK event proxy; pure conference Drop roster derivation | DRAFT | `packages/contact-center/store/ai-docs/store-spec.md` | -| `cc-components/` | Shared presentational React UI primitives, including the CallControlCAD Drop experience | DRAFT | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | -| `cc-widgets/` | r2wc Web Component wrappers; existing CallControlCAD wrapper inherits Drop behavior | DRAFT | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | +| `store/` | MobX singleton; global CC state; proxies SDK events; sole SDK access point | DRAFT | `packages/contact-center/store/ai-docs/store-spec.md` | +| `cc-components/` | Shared presentational React UI primitives | DRAFT | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | +| `cc-widgets/` | r2wc Web Component wrappers (aggregator) | DRAFT | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | | `cc-digital-channels/` | Digital channels (chat/email/social) widget | DRAFT | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | | `station-login/` | Agent login: team + device selection | DRAFT | `packages/contact-center/station-login/ai-docs/station-login-spec.md` | | `user-state/` | Agent state: state, idle codes, timer | DRAFT | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | -| `task/` | Task widget bundle; CallControlCAD owns participant Drop orchestration | DRAFT | `packages/contact-center/task/ai-docs/task-spec.md` | +| `task/` | Task widget bundle: CallControl, IncomingTask, OutdialCall, TaskList, CallControlCAD | DRAFT | `packages/contact-center/task/ai-docs/task-spec.md` | | `ai-assistant/` | AI Assistant widget: chrome + Real-time Assist requests, transcript, feedback | DRAFT | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | | `ui-logging/` | Metrics/telemetry: `withMetrics`, `metricsLogger` | DRAFT | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | | `test-fixtures/` | Shared test mocks/helpers | DRAFT | `packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md` | diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 60c784de6..f9c1d354a 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -149,7 +149,7 @@ Compatibility notes: | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | | `CC-COMPONENTS-R-016` | `E911Modal` gates `Save & Continue` on the acknowledgment checkbox, disables both `Save & Continue` and `Cancel` while `onSaveAndContinue` is in flight (guarding against a double-click firing concurrent saves), shows a user-facing error and re-enables the buttons if the save rejects, and only `Cancel` (not the Dialog's built-in close button or Escape) dismisses the modal; checkbox/saving/error state resets when the modal closes. | An emergency-notification acknowledgment must not be skippable, must not double-submit against the preference API, and must give the agent visible recourse on failure. | `src/components/StationLogin/E911Modal/e911-modal.tsx` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (checkbox gating, save-in-flight button disabling, save-error display, close-only-via-Cancel) | None | PRESENT | -| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. Agent/merged EP-DN Drop immediately, Customer uses a native confirmation dialog, pending/consult state globally protects requests, focus is restored, and exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | +| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. The component receives request/confirmation state and handlers from `useCallControl`; it owns only roster rendering plus native dialog open/close, Escape, and focus restoration. Exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/CallControlCAD/call-control-cad.types.ts`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | ## Design Overview diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index 4d05db570..c9cac45c8 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -146,7 +146,7 @@ min-height: 1.5rem; width: max-content; white-space: nowrap; - border-radius: 999px; + border-radius: 62.4375rem; background: transparent !important; border: 1px solid var(--mds-color-theme-outline-secondary-normal) !important; color: var(--mds-color-theme-text-secondary-normal); @@ -310,17 +310,17 @@ } .participants-popover { - background: var(--mds-color-theme-background-solid-primary-normal, #ffffff) !important; + background: var(--mds-color-theme-background-solid-primary-normal) !important; align-items: normal; width: 100%; padding: 0 !important; - border: var(--mds-color-theme-outline-secondary-normal); + border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); &::part(popover-content) { - background: var(--mds-color-theme-background-solid-primary-normal, #ffffff); + background: var(--mds-color-theme-background-solid-primary-normal); border-radius: 0.75rem; border: 1px solid var(--mds-color-theme-outline-secondary-normal); - box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08); + box-shadow: var(--md-globals-elevation-2); padding: 0; width: 16.125rem; /* 258px */ overflow: hidden; @@ -359,7 +359,7 @@ gap: 0.5rem; padding: 0.5rem 0.25rem; font-size: 0.875rem; - color: var(--mds-color-theme-text-primary-normal, #000000f2); + color: var(--mds-color-theme-text-primary-normal); border-radius: 0.5rem; min-width: 0; max-width: 100%; @@ -411,7 +411,7 @@ padding: 1.5rem; &::backdrop { - background: var(--mds-color-theme-background-overlay, rgb(0 0 0 / 45%)); + background: var(--mds-color-theme-background-overlay); } h2, 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 481b4b1ce..581734c4d 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 @@ -1,6 +1,6 @@ import React, {useEffect, useRef, useState} from 'react'; import CallControlComponent from '../CallControl/call-control'; -import {Text, PopoverNext} from '@momentum-ui/react-collaboration'; +import {Text, PopoverNext, PopoverInstance} from '@momentum-ui/react-collaboration'; import {Avatar, Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; import './call-control-cad.styles.scss'; import TaskTimer from '../TaskTimer/index'; @@ -9,6 +9,7 @@ import {MEDIA_CHANNEL as MediaChannelType, CallControlComponentProps, CallAssoci import {ConferenceParticipantDropTarget} from '@webex/cc-store'; import {getAgentViewableGlobalVariables} from '../Task/task.utils'; import GlobalVariablesPanel from '../GlobalVariablesPanel/global-variables-panel'; +import {ParticipantRosterSectionProps} from './call-control-cad.types'; import {getMediaTypeInfo} from '../../../utils'; import { @@ -24,6 +25,47 @@ import { } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; +const ParticipantRosterSection: React.FC = ({ + heading, + headingId, + targets, + pendingParticipantDropId, + participantDropIsPending, + rosterDropDisabled, + onParticipantDropRequest, +}) => ( +
+

+ {heading} +

+
    + {targets.map((target) => ( +
  • + + + + {target.displayName} + {target.isPrimary ? ' (Primary)' : ''} + + + {!target.isReadOnly && ( + + )} +
  • + ))} +
+
+); + const CallControlCADComponent: React.FC = (props) => { const { currentTask, @@ -49,7 +91,11 @@ const CallControlCADComponent: React.FC = (props) => conferenceParticipantDropRoster = null, pendingParticipantDropId = null, participantDropAnnouncement = null, - dropConferenceParticipant = async () => undefined, + participantDropConfirmationTarget = null, + participantDropConfirmationDisabled = true, + requestParticipantDrop = async () => undefined, + confirmParticipantDrop = async () => undefined, + cancelParticipantDropConfirmation = () => undefined, conferenceEnabled = true, isCampaignCall = false, } = props; @@ -71,11 +117,13 @@ const CallControlCADComponent: React.FC = (props) => const participantsCount = conferenceParticipantDropRoster?.participants.length ?? 0; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; const shouldShowParticipantsList = Boolean(conferenceParticipantDropRoster); - const [customerDropTarget, setCustomerDropTarget] = useState(null); const customerDropDialogRef = useRef(null); const customerDropTriggerRef = useRef(null); const participantMenuTriggerRef = useRef(null); + const callControlContainerRef = useRef(null); const customerDropConfirmRef = useRef(null); + const customerDropDialogWasOpenRef = useRef(false); + const [participantPopoverInstance, setParticipantPopoverInstance] = useState(); const participantDropIsPending = pendingParticipantDropId !== null; const interactionId = currentTask.data.interaction.interactionId; const customerDropDialogTitleId = `participant-drop-dialog-title-${interactionId}`; @@ -110,25 +158,35 @@ const CallControlCADComponent: React.FC = (props) => } const globalVariables = globalVariablesRef.current; - const latestCustomerDropTarget = - customerDropTarget && conferenceParticipantDropRoster?.customer?.dropTargetId === customerDropTarget.dropTargetId - ? conferenceParticipantDropRoster.customer - : null; - const customerDropConfirmationDisabled = Boolean( - participantDropIsPending || - conferenceParticipantDropRoster?.isDropDisabled || - !latestCustomerDropTarget || - latestCustomerDropTarget.isReadOnly || - latestCustomerDropTarget.isDropDisabled - ); - const restoreCustomerDropFocus = () => { - const focusTarget = customerDropTriggerRef.current?.isConnected - ? customerDropTriggerRef.current - : participantMenuTriggerRef.current; + const customerTriggerIsAvailable = Boolean( + conferenceParticipantDropRoster?.customer && customerDropTriggerRef.current?.isConnected + ); + const focusCurrentControl = () => { + const participantMenuTrigger = participantMenuTriggerRef.current; + const stableCallControl = callControlContainerRef.current?.querySelector( + '[data-testid="call-control:end-call"]' + ); + const focusTarget = customerTriggerIsAvailable + ? customerDropTriggerRef.current + : participantMenuTrigger?.isConnected + ? participantMenuTrigger + : stableCallControl; + + focusTarget?.focus(); + customerDropTriggerRef.current = null; + }; + + if (!customerTriggerIsAvailable) { + // The participant popover's focus lock otherwise keeps focus inside content whose + // Customer row has just disappeared. Close it before focusing its trigger (or a + // stable call control when the roster itself has also disappeared). + participantPopoverInstance?.hide(); + globalThis.setTimeout(focusCurrentControl, 0); + return; + } - focusTarget?.focus(); - customerDropTriggerRef.current = null; + focusCurrentControl(); }; const closeCustomerDropDialog = (restoreFocus = true) => { @@ -140,7 +198,7 @@ const CallControlCADComponent: React.FC = (props) => dialog?.removeAttribute('open'); } - setCustomerDropTarget(null); + customerDropDialogWasOpenRef.current = false; if (restoreFocus) { restoreCustomerDropFocus(); @@ -150,7 +208,14 @@ const CallControlCADComponent: React.FC = (props) => useEffect(() => { const dialog = customerDropDialogRef.current; - if (!customerDropTarget || !dialog) { + if (!participantDropConfirmationTarget) { + if (customerDropDialogWasOpenRef.current) { + closeCustomerDropDialog(); + } + return; + } + + if (!dialog) { return; } @@ -162,94 +227,28 @@ const CallControlCADComponent: React.FC = (props) => } } + customerDropDialogWasOpenRef.current = true; customerDropConfirmRef.current?.focus(); - }, [customerDropTarget]); + }, [participantDropConfirmationTarget]); useEffect(() => { closeCustomerDropDialog(false); customerDropTriggerRef.current = null; }, [interactionId]); - useEffect(() => { - if (customerDropTarget && !latestCustomerDropTarget) { - closeCustomerDropDialog(); - } - }, [customerDropTarget, latestCustomerDropTarget]); - - const handleParticipantDrop = (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => { - if ( - target.isReadOnly || - target.isDropDisabled || - participantDropIsPending || - conferenceParticipantDropRoster?.isDropDisabled - ) { - return; - } - + const handleParticipantDropRequest = (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => { if (target.requiresConfirmation) { customerDropTriggerRef.current = trigger; - setCustomerDropTarget(target); - return; } - void dropConferenceParticipant(target); + void requestParticipantDrop(target); }; - const confirmCustomerDrop = () => { - const target = latestCustomerDropTarget; - - if (!target || customerDropConfirmationDisabled) { - return; - } - + const handleConfirmCustomerDrop = () => { closeCustomerDropDialog(); - void dropConferenceParticipant(target); + void confirmParticipantDrop(); }; - const renderParticipantSection = ( - heading: 'Customer' | 'Participants', - targets: ConferenceParticipantDropTarget[] - ) => ( -
-

- {heading} -

-
    - {targets.map((target) => { - const isSelectedPending = pendingParticipantDropId === target.dropTargetId; - const isDropDisabled = - target.isDropDisabled || - participantDropIsPending || - Boolean(conferenceParticipantDropRoster?.isDropDisabled); - - return ( -
  • - - - - {target.displayName} - {target.isPrimary ? ' (Primary)' : ''} - - - {!target.isReadOnly && ( - - )} -
  • - ); - })} -
-
- ); - // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; const customerNameTooltipId = `customer-name-tooltip-${currentTask.data.interaction.interactionId}`; @@ -342,7 +341,7 @@ const CallControlCADComponent: React.FC = (props) => return ( <> -
+
{/* Caller Information */}
@@ -383,8 +382,12 @@ const CallControlCADComponent: React.FC = (props) => interactive offsetDistance={6} className="participants-popover" + setInstance={setParticipantPopoverInstance} triggerComponent={
@@ -445,7 +462,7 @@ const CallControlCADComponent: React.FC = (props) =>
- {customerDropTarget && ( + {participantDropConfirmationTarget && ( = (props) => onCancel={(event) => { event.preventDefault(); closeCustomerDropDialog(); + cancelParticipantDropConfirmation(); }} >

Drop customer from conference?

The customer will be removed from this conference. The remaining participants can continue the call.

- diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts new file mode 100644 index 000000000..e844324d0 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts @@ -0,0 +1,11 @@ +import {ConferenceParticipantDropTarget} from '@webex/cc-store'; + +export type ParticipantRosterSectionProps = { + heading: 'Customer' | 'Participants'; + headingId: string; + targets: ConferenceParticipantDropTarget[]; + pendingParticipantDropId: string | null; + participantDropIsPending: boolean; + rosterDropDisabled: boolean; + onParticipantDropRequest: (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => void; +}; 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 0ef4f4a29..9b87476cc 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 @@ -513,8 +513,20 @@ export interface ControlProps { /** Generic screen-reader and visible feedback for the most recent Drop request. */ participantDropAnnouncement: ParticipantDropAnnouncement | null; - /** Requests removal of one currently eligible conference participant. */ - dropConferenceParticipant: (target: ConferenceParticipantDropTarget) => Promise; + /** Customer target currently awaiting explicit confirmation. */ + participantDropConfirmationTarget: ConferenceParticipantDropTarget | null; + + /** Whether the current Customer confirmation action is unavailable. */ + participantDropConfirmationDisabled: boolean; + + /** Validates a Drop target, opening confirmation for Customer or invoking Drop immediately. */ + requestParticipantDrop: (target: ConferenceParticipantDropTarget) => Promise; + + /** Confirms and invokes the currently selected Customer Drop. */ + confirmParticipantDrop: () => Promise; + + /** Cancels the currently selected Customer Drop. */ + cancelParticipantDropConfirmation: () => void; /** Fetch paginated address book entries for dial numbers */ getAddressBookEntries?: FetchPaginatedList; @@ -591,7 +603,11 @@ export type CallControlComponentProps = Pick< | 'conferenceParticipantDropRoster' | 'pendingParticipantDropId' | 'participantDropAnnouncement' - | 'dropConferenceParticipant' + | 'participantDropConfirmationTarget' + | 'participantDropConfirmationDisabled' + | 'requestParticipantDrop' + | 'confirmParticipantDrop' + | 'cancelParticipantDropConfirmation' > > & { /** diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 0713ffbe0..fe4bfcf57 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -166,7 +166,7 @@ describe('CallControlComponent', () => { ); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index d14b12677..d6d246572 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -464,27 +464,40 @@ describe('CallControlCADComponent', () => { describe('conference participant Drop', () => { const renderRoster = (overrides: Partial = {}) => { - const dropConferenceParticipant = jest.fn().mockResolvedValue(undefined); - const screen = render( - - ); + const requestParticipantDrop = jest.fn().mockResolvedValue(undefined); + const confirmParticipantDrop = jest.fn().mockResolvedValue(undefined); + const cancelParticipantDropConfirmation = jest.fn(); + const renderProps = (nextOverrides: Partial = {}) => ({ + ...defaultProps, + controls: createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}), + conferenceParticipants: [{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}], + conferenceParticipantDropRoster: ownerDropRoster, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: false, + requestParticipantDrop, + confirmParticipantDrop, + cancelParticipantDropConfirmation, + ...overrides, + ...nextOverrides, + }); + const screen = render(); fireEvent.click(screen.getByTestId('call-control:participants-trigger')); - return {screen, dropConferenceParticipant}; + return { + screen, + requestParticipantDrop, + confirmParticipantDrop, + cancelParticipantDropConfirmation, + rerenderRoster: (nextOverrides: Partial) => + screen.rerender(), + }; }; it('renders Customer and Participants sections and immediately drops an Agent target', () => { - const {screen, dropConferenceParticipant} = renderRoster(); + const {screen, requestParticipantDrop} = renderRoster(); const participantsTrigger = screen.getByTestId('call-control:participants-trigger'); expect(participantsTrigger).toHaveAttribute('postfix-icon', 'arrow-down-bold'); @@ -495,7 +508,7 @@ describe('CallControlCADComponent', () => { fireEvent.click(screen.getByRole('button', {name: 'Drop agent Agent Two'})); - expect(dropConferenceParticipant).toHaveBeenCalledWith(ownerDropRoster.participants[0]); + expect(requestParticipantDrop).toHaveBeenCalledWith(ownerDropRoster.participants[0]); }); it('keeps the Participants section when the Customer row is removed', () => { @@ -535,11 +548,13 @@ describe('CallControlCADComponent', () => { expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); }); - it('requires confirmation for Customer Drop and cancel restores focus without invoking the SDK callback', async () => { - const {screen, dropConferenceParticipant} = renderRoster(); + it('requires confirmation for Customer Drop and cancel restores focus without confirming', async () => { + const {screen, requestParticipantDrop, cancelParticipantDropConfirmation, rerenderRoster} = renderRoster(); const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); fireEvent.click(customerDropButton); + expect(requestParticipantDrop).toHaveBeenCalledWith(ownerDropRoster.customer); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); const dialog = screen.getByTestId('call-control:customer-drop-dialog'); expect(dialog).toHaveAttribute('open'); @@ -547,23 +562,51 @@ describe('CallControlCADComponent', () => { fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); - expect(dropConferenceParticipant).not.toHaveBeenCalled(); + expect(cancelParticipantDropConfirmation).toHaveBeenCalledTimes(1); await waitFor(() => expect(customerDropButton).toHaveFocus()); }); + it('restores focus to the roster trigger when the confirmed Customer row disappears', async () => { + const {screen, rerenderRoster} = renderRoster(); + + fireEvent.click(screen.getByRole('button', {name: 'Drop customer +15551234567'})); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + expect(screen.getByTestId('call-control:customer-drop-dialog')).toHaveAttribute('open'); + + rerenderRoster({ + conferenceParticipantDropRoster: {...ownerDropRoster, customer: null}, + participantDropConfirmationTarget: null, + }); + + await waitFor(() => expect(screen.getByTestId('call-control:participants-trigger')).toHaveFocus()); + }); + + it('restores focus to a stable call control when the roster disappears after confirmation', async () => { + const {screen, rerenderRoster} = renderRoster(); + + fireEvent.click(screen.getByRole('button', {name: 'Drop customer +15551234567'})); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + expect(screen.getByTestId('call-control:customer-drop-dialog')).toHaveAttribute('open'); + + rerenderRoster({conferenceParticipantDropRoster: null, participantDropConfirmationTarget: null}); + + await waitFor(() => expect(screen.getByTestId('call-control:end-call')).toHaveFocus()); + }); + it('confirms Customer Drop and supports Escape cancellation', () => { - const {screen, dropConferenceParticipant} = renderRoster(); + const {screen, confirmParticipantDrop, cancelParticipantDropConfirmation, rerenderRoster} = renderRoster(); const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); fireEvent.click(customerDropButton); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); fireEvent.click(screen.getByRole('button', {name: 'Drop'})); - expect(dropConferenceParticipant).toHaveBeenCalledWith(ownerDropRoster.customer); + expect(confirmParticipantDrop).toHaveBeenCalledTimes(1); - dropConferenceParticipant.mockClear(); fireEvent.click(customerDropButton); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); const dialog = screen.getByTestId('call-control:customer-drop-dialog'); fireEvent(dialog, new Event('cancel', {cancelable: true})); - expect(dropConferenceParticipant).not.toHaveBeenCalled(); + expect(cancelParticipantDropConfirmation).toHaveBeenCalledTimes(1); }); it('globally disables Drop controls and shows loading only on the selected row', () => { @@ -628,7 +671,7 @@ describe('CallControlCADComponent', () => { isDropDisabled: false, requiresConfirmation: false, }; - const {screen, dropConferenceParticipant} = renderRoster({ + const {screen, requestParticipantDrop} = renderRoster({ conferenceParticipantDropRoster: { ...ownerDropRoster, participants: [...ownerDropRoster.participants, pendingEpDn, supervisor], @@ -639,7 +682,7 @@ describe('CallControlCADComponent', () => { const pendingDrop = screen.getByRole('button', {name: 'Drop ep-dn +15551230000'}); expect(pendingDrop).toBeDisabled(); fireEvent.click(pendingDrop); - expect(dropConferenceParticipant).not.toHaveBeenCalled(); + expect(requestParticipantDrop).not.toHaveBeenCalled(); }); it('renders an answered Entry Point agent as disabled until conference merge', () => { @@ -652,7 +695,7 @@ describe('CallControlCADComponent', () => { isDropDisabled: true, requiresConfirmation: false, }; - const {screen, dropConferenceParticipant} = renderRoster({ + const {screen, requestParticipantDrop} = renderRoster({ conferenceParticipantDropRoster: { customer: null, participants: [answeredEntryPointAgent], @@ -665,7 +708,7 @@ describe('CallControlCADComponent', () => { const answeredAgentDrop = screen.getByRole('button', {name: 'Drop agent Support Agent'}); expect(answeredAgentDrop).toBeDisabled(); fireEvent.click(answeredAgentDrop); - expect(dropConferenceParticipant).not.toHaveBeenCalled(); + expect(requestParticipantDrop).not.toHaveBeenCalled(); }); it('keeps generic live feedback mounted when the roster disappears', () => { diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 4967db0c0..b0a0b7cc3 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -111,7 +111,7 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | -| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | +| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. Consult activity recognizes visible consult controls, `uiControls.main.endConsult`, and consult interaction states. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | | `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | ## Design Overview diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index bd02e216d..82361e22f 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -23,7 +23,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/contact-center": "3.12.0-next.96", + "@webex/contact-center": "3.12.0-next.106", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 2cb5f04d1..00de424b2 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -346,6 +346,10 @@ class StoreWrapper implements IStoreWrapper { }); }; + /** + * Terminal task events are emitted before the SDK removes the task from its collection. + * Defer and coalesce that authoritative read so ended calls disappear without a page refresh. + */ private scheduleTaskListRefresh = (): void => { if (this.taskListRefreshScheduled) { return; diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index 046bfc7b2..72be302a1 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -172,6 +172,16 @@ const participantHasType = (participant: RosterParticipant, participantType: str const hasVisibleControls = (controls: ITask['uiControls']['consult'] | undefined): boolean => Boolean(controls && Object.values(controls).some((control) => control?.isVisible)); +const hasActiveConsultSignal = (task: ITask): boolean => { + const interactionState = task?.data?.interaction?.state?.toLowerCase(); + + return Boolean( + hasVisibleControls(task?.uiControls?.consult) || + task?.uiControls?.main?.endConsult?.isVisible || + ['consult', 'consulting'].includes(interactionState) + ); +}; + const isViewingAgentActiveOnMainLeg = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { const participants = task?.data?.interaction?.participants ?? {}; return mainParticipantIds.some((participantId) => { @@ -303,11 +313,8 @@ const selectCurrentConsultMediaEntry = ( const getCurrentConsultMediaEntry = (task: ITask) => { const taskWithSnapshot = task as TaskWithStateSnapshot; const snapshotTaskData = taskWithSnapshot.state?.context?.taskData; - const interactionState = task?.data?.interaction?.state?.toLowerCase(); - const consultIsActive = - hasVisibleControls(task?.uiControls?.consult) || ['consult', 'consulting'].includes(interactionState); - if (!consultIsActive) { + if (!hasActiveConsultSignal(task)) { return undefined; } @@ -320,16 +327,11 @@ const getCurrentConsultMediaEntry = (task: ITask) => { }; const hasActiveNonHeldConsult = (task: ITask): boolean => { - const interaction = task?.data?.interaction; - const consultMediaResourceId = findMediaResourceId(task, 'consult'); - const consultMedia = consultMediaResourceId ? interaction?.media?.[consultMediaResourceId] : undefined; - const interactionState = interaction?.state?.toLowerCase(); - const consultIsActive = - hasVisibleControls(task?.uiControls?.consult) || ['consult', 'consulting'].includes(interactionState); + const consultMedia = getCurrentConsultMediaEntry(task)?.media; // Treat an active consult without a hydrated media leg conservatively as non-held. // Drop becomes available again once the SDK explicitly reports that consult leg held. - return Boolean(consultIsActive && consultMedia?.isHold !== true); + return Boolean(hasActiveConsultSignal(task) && consultMedia?.isHold !== true); }; const getCustomerDropTargetId = (task: ITask): string => { diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index 3d1f8a7be..3ed3dee47 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -806,6 +806,35 @@ describe('getConferenceParticipantDropRoster', () => { expect(getConferenceParticipantDropRoster(task, currentAgentId)?.isDropDisabled).toBe(true); }); + it('uses main.endConsult to expose the current EP-DN consult and gate Drop until it is held', () => { + const task = createDropRosterTask({state: 'conference', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.consult.participants = ['agent1', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + task.uiControls.consult.endConsult = {isVisible: false, isEnabled: false}; + task.uiControls.main.endConsult = {isVisible: true, isEnabled: true}; + + const activeConsultRoster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(activeConsultRoster?.participants).toEqual( + expect.arrayContaining([ + expect.objectContaining({dropTargetId: 'agent2', isDropDisabled: false}), + expect.objectContaining({dropTargetId: '+15550000009', isDropDisabled: true}), + ]) + ); + expect(activeConsultRoster?.isDropDisabled).toBe(true); + + task.data.interaction.media.consult.isHold = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.isDropDisabled).toBe(false); + }); + it('returns null outside an eligible telephony main call', () => { const task = createDropRosterTask(); task.data.interaction.mediaType = 'chat'; diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 4d5795f9c..deaf89d67 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -120,12 +120,12 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | -| `TASK-R-024` | `useCallControl.dropConferenceParticipant(target)` revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous request token; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; ignores stale completions; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | +| `TASK-R-024` | `useCallControl` owns Customer confirmation plus `requestParticipantDrop`/confirm/cancel orchestration. It revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous token that survives same-interaction task clones; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; suppresses stale completions after owner/agent/interaction/terminal changes; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts`, `src/task.types.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. -`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, maintains transient UI state (recording, buddy agents, consult agent name, target type, timers, participant Drop request/announcement), derives both conference rosters directly from the current observable task, and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. +`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, maintains transient UI state (recording, buddy agents, consult agent name, target type, timers, participant Drop request/confirmation/announcement), derives both conference rosters directly from the current observable task, and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. `Utils/` is pure logic split out for testability: `task-util.ts` decides control visibility/enablement from device type, feature flags, and a large set of derived task-state booleans (consult status, hold status, conference progress, customer-in-call, participant counts); `timer-utils.ts` and `useHoldTimer.ts` compute timer labels/elapsed times. Keeping these pure means the visibility matrix and timer math are unit-tested without rendering. diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 0b15e8f2b..fc306b179 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -16,6 +16,7 @@ import { TargetType, TARGET_TYPE, ParticipantDropAnnouncement, + PendingParticipantDropRequest, } from './task.types'; import store, { TASK_EVENTS, @@ -47,13 +48,44 @@ const ENGAGED_USERNAME = 'Engaged'; const PARTICIPANT_DROP_SUCCESS_MESSAGE = 'Participant removed from the conference.'; const PARTICIPANT_DROP_FAILURE_MESSAGE = 'Unable to drop participant from the call. Try again.'; -type PendingParticipantDropRequest = { - token: symbol; - taskId: string; - task: ITask; - dropTargetId: string; +const getLatestEligibleParticipantDropTarget = ( + task: ITask, + agentId: string, + target: ConferenceParticipantDropTarget +): ConferenceParticipantDropTarget | null => { + const latestRoster = getConferenceParticipantDropRoster(task, agentId); + const latestTarget = [latestRoster?.customer, ...(latestRoster?.participants ?? [])].find( + (candidate) => + candidate?.dropTargetId === target.dropTargetId && candidate.participantType === target.participantType + ); + + if ( + !latestRoster || + !latestTarget || + latestTarget.isReadOnly || + latestTarget.isDropDisabled || + latestRoster.isDropDisabled + ) { + return null; + } + + return latestTarget; }; +const isCurrentParticipantDropRequest = ( + request: PendingParticipantDropRequest, + pendingRequest: PendingParticipantDropRequest | null, + task: ITask | undefined, + agentId: string | undefined +): boolean => + Boolean( + pendingRequest?.token === request.token && + task?.data?.interactionId === request.taskId && + agentId === request.agentId && + task.data.interaction.owner === request.agentId && + !task.data.interaction.isTerminated + ); + const getTranscriptSpeaker = (role?: string): string => { const normalizedRole = role?.toUpperCase(); if (normalizedRole === 'AGENT') return 'You'; @@ -392,6 +424,8 @@ export const useCallControl = (props: useCallControlProps) => { const [participantDropAnnouncement, setParticipantDropAnnouncement] = useState( null ); + const [participantDropConfirmationTarget, setParticipantDropConfirmationTarget] = + useState(null); const pendingParticipantDropRef = useRef(null); const participantDropTaskRef = useRef(currentTask); const participantDropAgentIdRef = useRef(agentId); @@ -403,6 +437,18 @@ export const useCallControl = (props: useCallControlProps) => { const conferenceParticipantDropRoster = currentTask && agentId ? getConferenceParticipantDropRoster(currentTask, agentId) : null; const participantDropTaskId = currentTask?.data?.interactionId; + const latestParticipantDropConfirmationTarget = + participantDropConfirmationTarget && + conferenceParticipantDropRoster?.customer?.dropTargetId === participantDropConfirmationTarget.dropTargetId + ? conferenceParticipantDropRoster.customer + : null; + const participantDropConfirmationDisabled = Boolean( + pendingParticipantDropId || + conferenceParticipantDropRoster?.isDropDisabled || + !latestParticipantDropConfirmationTarget || + latestParticipantDropConfirmationTarget.isReadOnly || + latestParticipantDropConfirmationTarget.isDropDisabled + ); // Subscribe to SDK-computed UI control updates useEffect(() => { @@ -474,13 +520,20 @@ export const useCallControl = (props: useCallControlProps) => { useEffect(() => { const pendingRequest = pendingParticipantDropRef.current; - if (pendingRequest && (pendingRequest.taskId !== participantDropTaskId || pendingRequest.task !== currentTask)) { + if (pendingRequest && (pendingRequest.taskId !== participantDropTaskId || pendingRequest.agentId !== agentId)) { pendingParticipantDropRef.current = null; } setPendingParticipantDropId(null); setParticipantDropAnnouncement(null); - }, [currentTask, participantDropTaskId]); + setParticipantDropConfirmationTarget(null); + }, [participantDropTaskId, agentId]); + + useEffect(() => { + if (participantDropConfirmationTarget && !latestParticipantDropConfirmationTarget) { + setParticipantDropConfirmationTarget(null); + } + }, [participantDropConfirmationTarget, latestParticipantDropConfirmationTarget]); // Function to extract consulting agent information const extractConsultingAgent = useCallback(() => { try { @@ -842,7 +895,7 @@ export const useCallControl = (props: useCallControlProps) => { } }; - const dropConferenceParticipant = useCallback( + const executeParticipantDrop = useCallback( async (target: ConferenceParticipantDropTarget): Promise => { const task = participantDropTaskRef.current; const currentAgentId = participantDropAgentIdRef.current; @@ -851,19 +904,9 @@ export const useCallControl = (props: useCallControlProps) => { return; } - const latestRoster = getConferenceParticipantDropRoster(task, currentAgentId); - const latestTarget = [latestRoster?.customer, ...(latestRoster?.participants ?? [])].find( - (candidate) => - candidate?.dropTargetId === target.dropTargetId && candidate.participantType === target.participantType - ); + const latestTarget = getLatestEligibleParticipantDropTarget(task, currentAgentId, target); - if ( - !latestRoster || - !latestTarget || - latestTarget.isReadOnly || - latestTarget.isDropDisabled || - latestRoster.isDropDisabled - ) { + if (!latestTarget) { return; } @@ -871,7 +914,7 @@ export const useCallControl = (props: useCallControlProps) => { const request: PendingParticipantDropRequest = { token: Symbol('participant-drop-request'), taskId, - task, + agentId: currentAgentId, dropTargetId: latestTarget.dropTargetId, }; @@ -882,18 +925,26 @@ export const useCallControl = (props: useCallControlProps) => { try { await task.dropConferenceParticipant({participantId: latestTarget.dropTargetId}); + const latestTask = participantDropTaskRef.current; if ( - pendingParticipantDropRef.current === request && - participantDropTaskRef.current === request.task && - participantDropTaskRef.current?.data?.interactionId === request.taskId + isCurrentParticipantDropRequest( + request, + pendingParticipantDropRef.current, + latestTask, + participantDropAgentIdRef.current + ) ) { setParticipantDropAnnouncement({type: 'success', message: PARTICIPANT_DROP_SUCCESS_MESSAGE}); } } catch { + const latestTask = participantDropTaskRef.current; if ( - pendingParticipantDropRef.current === request && - participantDropTaskRef.current === request.task && - participantDropTaskRef.current?.data?.interactionId === request.taskId + isCurrentParticipantDropRequest( + request, + pendingParticipantDropRef.current, + latestTask, + participantDropAgentIdRef.current + ) ) { const sanitizedError = new Error(PARTICIPANT_DROP_FAILURE_MESSAGE); @@ -905,12 +956,12 @@ export const useCallControl = (props: useCallControlProps) => { store.onErrorCallback?.('CallControlCAD', sanitizedError); } } finally { - if (pendingParticipantDropRef.current === request) { + if (pendingParticipantDropRef.current?.token === request.token) { pendingParticipantDropRef.current = null; if ( - participantDropTaskRef.current === request.task && - participantDropTaskRef.current?.data?.interactionId === request.taskId + participantDropTaskRef.current?.data?.interactionId === request.taskId && + participantDropAgentIdRef.current === request.agentId ) { setPendingParticipantDropId(null); } @@ -920,6 +971,45 @@ export const useCallControl = (props: useCallControlProps) => { [logger] ); + const requestParticipantDrop = useCallback( + async (target: ConferenceParticipantDropTarget): Promise => { + const task = participantDropTaskRef.current; + const currentAgentId = participantDropAgentIdRef.current; + + if (!task || !currentAgentId || pendingParticipantDropRef.current) { + return; + } + + const latestTarget = getLatestEligibleParticipantDropTarget(task, currentAgentId, target); + if (!latestTarget) { + return; + } + + if (latestTarget.requiresConfirmation) { + setParticipantDropConfirmationTarget(latestTarget); + return; + } + + await executeParticipantDrop(latestTarget); + }, + [executeParticipantDrop] + ); + + const confirmParticipantDrop = useCallback(async (): Promise => { + const target = latestParticipantDropConfirmationTarget; + + if (!target || participantDropConfirmationDisabled) { + return; + } + + setParticipantDropConfirmationTarget(null); + await executeParticipantDrop(target); + }, [executeParticipantDrop, latestParticipantDropConfirmationTarget, participantDropConfirmationDisabled]); + + const cancelParticipantDropConfirmation = useCallback((): void => { + setParticipantDropConfirmationTarget(null); + }, []); + const toggleMute = async () => { try { if (!controls?.main?.mute?.isVisible) { @@ -1371,7 +1461,11 @@ export const useCallControl = (props: useCallControlProps) => { conferenceParticipantDropRoster, pendingParticipantDropId, participantDropAnnouncement, - dropConferenceParticipant, + participantDropConfirmationTarget: latestParticipantDropConfirmationTarget, + participantDropConfirmationDisabled, + requestParticipantDrop, + confirmParticipantDrop, + cancelParticipantDropConfirmation, getAddressBookEntries, getEntryPoints, getQueuesFetcher, diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index ac6f961c6..ccf6d8aee 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -32,6 +32,13 @@ export type UseRealTimeTranscriptInternalProps = RealTimeTranscriptProps & { realtimeTranscriptionData?: Partial[]; }; +export type PendingParticipantDropRequest = { + token: symbol; + taskId: string; + agentId: string; + dropTargetId: string; +}; + export type CallControlProps = Partial< Pick< ControlProps, diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 6965e79b3..81cddc1eb 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -60,7 +60,11 @@ describe('CallControl Component', () => { conferenceParticipantDropRoster: null, pendingParticipantDropId: null, participantDropAnnouncement: null, - dropConferenceParticipant: jest.fn(), + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getEntryPoints: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getQueuesFetcher: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index 2dff4d1a6..65b0f6d4e 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -63,7 +63,11 @@ describe('CallControlCAD Component', () => { conferenceParticipantDropRoster: null, pendingParticipantDropId: null, participantDropAnnouncement: null, - dropConferenceParticipant: jest.fn(), + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -138,7 +142,11 @@ describe('CallControlCAD Component', () => { conferenceParticipantDropRoster: null, pendingParticipantDropId: null, participantDropAnnouncement: null, - dropConferenceParticipant: jest.fn(), + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -195,7 +203,11 @@ describe('CallControlCAD Component', () => { conferenceParticipantDropRoster: null, pendingParticipantDropId: null, participantDropAnnouncement: null, - dropConferenceParticipant: jest.fn(), + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -255,7 +267,11 @@ describe('CallControlCAD Component', () => { conferenceParticipantDropRoster: null, pendingParticipantDropId: null, participantDropAnnouncement: null, - dropConferenceParticipant: jest.fn(), + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx index 659325f2e..9b5ea11ee 100644 --- a/packages/contact-center/task/tests/call-control-recording.tsx +++ b/packages/contact-center/task/tests/call-control-recording.tsx @@ -76,8 +76,8 @@ const promoteTask = (task: FakeTask) => { store.store.agentId = AGENT_ID; // Registers the store's own task listeners (refreshTaskList on recording // pause/resume, etc.) exactly as production does. - store.handleIncomingTask(task as unknown as Parameters[0]); - store.setCurrentTask(task as unknown as Parameters[0]); + store.handleIncomingTask(task); + store.setCurrentTask(task); }; /** What the SDK does when the ContactRecordingPaused websocket event arrives. */ @@ -104,6 +104,7 @@ describe('CallControl recording pause/resume state', () => { info: jest.fn(), warn: jest.fn(), error: jest.fn(), + debug: jest.fn(), trace: jest.fn(), }; }); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index cdabf044f..6a8099adc 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1038,7 +1038,7 @@ describe('useCallControl', () => { if (!pendingTarget) throw new Error('Expected pending EP-DN target'); await act(async () => { - await result.current.dropConferenceParticipant(pendingTarget); + await result.current.requestParticipantDrop(pendingTarget); }); expect(dropRequest).not.toHaveBeenCalled(); @@ -1097,7 +1097,7 @@ describe('useCallControl', () => { if (!target) throw new Error('Expected Agent Drop target'); let requestPromise!: Promise; act(() => { - requestPromise = result.current.dropConferenceParticipant(target); + requestPromise = result.current.requestParticipantDrop(target); }); expect(dropRequest).toHaveBeenCalledWith({participantId: 'agent2'}); @@ -1116,6 +1116,142 @@ describe('useCallControl', () => { }); }); + it('preserves the pending lock and feedback across a same-interaction task clone', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const clonedDropRequest = jest.fn().mockResolvedValue(undefined); + let activeTask = createParticipantDropTask(dropRequest); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + activeTask = createParticipantDropTask(clonedDropRequest); + rerender(); + + expect(result.current.pendingParticipantDropId).toBe('agent2'); + const clonedTarget = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!clonedTarget) throw new Error('Expected cloned Agent Drop target'); + + await act(async () => { + await result.current.requestParticipantDrop(clonedTarget); + }); + + expect(dropRequest).toHaveBeenCalledTimes(1); + expect(clonedDropRequest).not.toHaveBeenCalled(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'success', + message: 'Participant removed from the conference.', + }); + }); + + it('publishes a failure from the original request after a same-interaction task clone', async () => { + let rejectDrop!: (error: Error) => void; + const dropRequest = jest.fn( + () => + new Promise((_resolve, reject) => { + rejectDrop = reject; + }) + ); + const clonedDropRequest = jest.fn().mockResolvedValue(undefined); + let activeTask = createParticipantDropTask(dropRequest); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + activeTask = createParticipantDropTask(clonedDropRequest); + rerender(); + + await act(async () => { + rejectDrop(new Error('routing failure')); + await requestPromise; + }); + + expect(clonedDropRequest).not.toHaveBeenCalled(); + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'error', + message: 'Unable to drop participant from the call. Try again.', + }); + }); + + it('owns Customer confirmation state and invokes Drop only after confirmation', async () => { + const dropRequest = jest.fn().mockResolvedValue(undefined); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const customerTarget = result.current.conferenceParticipantDropRoster?.customer; + if (!customerTarget) throw new Error('Expected Customer Drop target'); + + await act(async () => { + await result.current.requestParticipantDrop(customerTarget); + }); + + expect(dropRequest).not.toHaveBeenCalled(); + expect(result.current.participantDropConfirmationTarget).toEqual(customerTarget); + expect(result.current.participantDropConfirmationDisabled).toBe(false); + + act(() => { + result.current.cancelParticipantDropConfirmation(); + }); + expect(result.current.participantDropConfirmationTarget).toBeNull(); + + await act(async () => { + await result.current.requestParticipantDrop(customerTarget); + }); + + await act(async () => { + await result.current.confirmParticipantDrop(); + }); + + expect(dropRequest).toHaveBeenCalledWith({participantId: customerTarget.dropTargetId}); + expect(result.current.participantDropConfirmationTarget).toBeNull(); + }); + it('globally serializes same-target and cross-target requests', async () => { let resolveDrop!: () => void; const dropRequest = jest.fn( @@ -1140,9 +1276,9 @@ describe('useCallControl', () => { let firstRequest!: Promise; act(() => { - firstRequest = result.current.dropConferenceParticipant(agentTarget); - void result.current.dropConferenceParticipant(agentTarget); - void result.current.dropConferenceParticipant(customerTarget); + firstRequest = result.current.requestParticipantDrop(agentTarget); + void result.current.requestParticipantDrop(agentTarget); + void result.current.requestParticipantDrop(customerTarget); }); expect(dropRequest).toHaveBeenCalledTimes(1); @@ -1178,7 +1314,7 @@ describe('useCallControl', () => { if (!target) throw new Error('Expected Agent Drop target'); await act(async () => { - await result.current.dropConferenceParticipant(target); + await result.current.requestParticipantDrop(target); }); expect(result.current.participantDropAnnouncement).toEqual({ @@ -1219,7 +1355,7 @@ describe('useCallControl', () => { let requestPromise!: Promise; act(() => { - requestPromise = result.current.dropConferenceParticipant(target); + requestPromise = result.current.requestParticipantDrop(target); }); activeTask = nextTask; rerender(); @@ -1237,6 +1373,86 @@ describe('useCallControl', () => { rerender(); expect(result.current.conferenceParticipantDropRoster).toBeNull(); }); + + it('clears a pending request when the interaction changes', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const firstTask = createParticipantDropTask(dropRequest); + const nextTask = createParticipantDropTask(); + nextTask.data.interactionId = 'next-interaction'; + nextTask.data.interaction.interactionId = 'next-interaction'; + let activeTask = firstTask; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + activeTask = nextTask; + rerender(); + expect(result.current.pendingParticipantDropId).toBeNull(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.participantDropAnnouncement).toBeNull(); + }); + + it('suppresses completion feedback when the current task terminates', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + task.data.interaction.isTerminated = true; + rerender(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toBeNull(); + }); }); it('should not call any call backs if callbacks are not provided', async () => { diff --git a/yarn.lock b/yarn.lock index 3134feea5..067f7d1dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9584,18 +9584,18 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.12.0-next.81": - version: 3.12.0-next.81 - resolution: "@webex/calling@npm:3.12.0-next.81" +"@webex/calling@npm:3.12.0-next.87": + version: 3.12.0-next.87 + resolution: "@webex/calling@npm:3.12.0-next.87" dependencies: "@types/platform": "npm:1.3.4" "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" - "@webex/internal-media-core": "npm:2.26.2" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/internal-plugin-feature": "npm:3.12.0-next.37" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.37" - "@webex/media-helpers": "npm:3.12.0-next.10" + "@webex/internal-media-core": "npm:2.28.2" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-feature": "npm:3.12.0-next.40" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/media-helpers": "npm:3.12.0-next.11" async-mutex: "npm:0.4.0" backoff: "npm:2.5.0" buffer: "npm:6.0.3" @@ -9604,7 +9604,7 @@ __metadata: uuid: "npm:8.3.2" ws: "npm:8.17.1" xstate: "npm:4.30.6" - checksum: 10c0/e61387d5ea986dede59aa7f13a2a2f4d76bcf2eb2c893756554f06c46317950bcd77c247de7226d01e16690cb0520ea8e5f63da376a97c9c79ca4d9a13ae5d71 + checksum: 10c0/53ab87c4661c1be73920161df11e51198fb4c7a15229b9a95fc9d3e14812cdfa404e14b460852106c69f80478bf53bc8aafad9abe9cb095fb5a7bce9ca443fbd languageName: node linkType: hard @@ -9815,7 +9815,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" - "@webex/contact-center": "npm:3.12.0-next.96" + "@webex/contact-center": "npm:3.12.0-next.106" "@webex/event-dictionary-ts": "npm:^1.0.2191" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" @@ -10170,6 +10170,26 @@ __metadata: languageName: node linkType: hard +"@webex/contact-center@npm:3.12.0-next.106": + version: 3.12.0-next.106 + resolution: "@webex/contact-center@npm:3.12.0-next.106" + dependencies: + "@types/platform": "npm:1.3.4" + "@webex/calling": "npm:3.12.0-next.87" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.44" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/internal-plugin-support": "npm:3.12.0-next.46" + "@webex/plugin-authorization": "npm:3.12.0-next.40" + "@webex/plugin-logger": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.40" + jest-html-reporters: "npm:3.0.11" + lodash: "npm:^4.17.21" + uuid: "npm:^3.3.2" + xstate: "npm:5.24.0" + checksum: 10c0/3921600575393326c6015f77d49232b7316c9a148ac247e9c9f6c2d546e098a3d748689189df059290d10b7ab9221cb99229b0284973b36382a33d8fb944ef6f + languageName: node + linkType: hard + "@webex/contact-center@npm:3.12.0-next.42": version: 3.12.0-next.42 resolution: "@webex/contact-center@npm:3.12.0-next.42" @@ -10188,26 +10208,6 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-next.96": - version: 3.12.0-next.96 - resolution: "@webex/contact-center@npm:3.12.0-next.96" - dependencies: - "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.81" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.40" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.37" - "@webex/internal-plugin-support": "npm:3.12.0-next.41" - "@webex/plugin-authorization": "npm:3.12.0-next.37" - "@webex/plugin-logger": "npm:3.12.0-next.37" - "@webex/webex-core": "npm:3.12.0-next.37" - jest-html-reporters: "npm:3.0.11" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - xstate: "npm:5.24.0" - checksum: 10c0/db2ca28f278a1e3fccd6ba4365e948ac35f9e2a260ec55c98ac504460a75a65b4507a4e23a622c7f898b6a8332cfc5c3604cfac2b2a471a4a2094a0f1530f073 - languageName: node - linkType: hard - "@webex/event-dictionary-ts@npm:^1.0.2191": version: 1.0.2204 resolution: "@webex/event-dictionary-ts@npm:1.0.2204" @@ -10221,16 +10221,16 @@ __metadata: languageName: node linkType: hard -"@webex/event-dictionary-ts@npm:^1.0.2215": - version: 1.0.2215 - resolution: "@webex/event-dictionary-ts@npm:1.0.2215" +"@webex/event-dictionary-ts@npm:^1.0.2220": + version: 1.0.2234 + resolution: "@webex/event-dictionary-ts@npm:1.0.2234" dependencies: amf-client-js: "npm:^5.2.6" json-schema-to-typescript: "npm:^15.0.4" minimist: "npm:^1.2.8" shelljs: "npm:^0.8.5" webapi-parser: "npm:^0.5.0" - checksum: 10c0/3650f1bef836f48abcf88e635ef3fd2ad7d53b6a9d714aa5dd6fbaa8b8a6bf3facccfd6c72b37fe1616cab5c903d5f32685fe4353281283f6c241f2bf48cd6b3 + checksum: 10c0/18f6484615d33123668ec48bca57143d1c299512359da2c6fa43b12bb07f7bd4d03796d8f403bca89e7ae185574de79bcd4d83d26c926d48f1cdaf6bca1a9306 languageName: node linkType: hard @@ -10261,12 +10261,12 @@ __metadata: languageName: node linkType: hard -"@webex/helper-html@npm:3.12.0-next.5": - version: 3.12.0-next.5 - resolution: "@webex/helper-html@npm:3.12.0-next.5" +"@webex/helper-html@npm:3.12.0-next.6": + version: 3.12.0-next.6 + resolution: "@webex/helper-html@npm:3.12.0-next.6" dependencies: lodash: "npm:^4.17.21" - checksum: 10c0/698e4b079a18bbc41dc4f8764afce2ee2446d4f852892529961e3f1b7f92c636401b26ac3d3f8a8ea634ef0ae4f6ff612e5591ee1cfc41ab1086d68418f039bb + checksum: 10c0/2631deae59cb15b2f21f915e300f36b32147340484a496f3b35cfd06850e797cb27a0bc572beff7cf462913c7f55e5ee2e35863ec3bd7ef5dd62f40b3858b48c languageName: node linkType: hard @@ -10470,23 +10470,23 @@ __metadata: languageName: node linkType: hard -"@webex/internal-media-core@npm:2.26.2": - version: 2.26.2 - resolution: "@webex/internal-media-core@npm:2.26.2" +"@webex/internal-media-core@npm:2.28.2": + version: 2.28.2 + resolution: "@webex/internal-media-core@npm:2.28.2" dependencies: "@babel/runtime": "npm:^7.18.9" "@babel/runtime-corejs2": "npm:^7.25.0" "@webex/rtcstats": "npm:^1.5.5" "@webex/ts-sdp": "npm:1.8.2" "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-client-media-engine": "npm:3.40.2" + "@webex/web-client-media-engine": "npm:3.42.1" events: "npm:^3.3.0" ip-anonymize: "npm:^0.1.0" typed-emitter: "npm:^2.1.0" uuid: "npm:^8.3.2" webrtc-adapter: "npm:^8.1.2" xstate: "npm:^4.30.6" - checksum: 10c0/3755c93df231e0af3e5c4b145489fe4b051ac4d5e9832d85c346e633168c67e3f58d78d2dce2131a7a003afba99df772647ba94d00c41cddf311ef5fd4714c7a + checksum: 10c0/490c786b53310e3ea021e198a16183465ad181fbe3f9a47b5d1c581cc313d3469609346e269c02d7dd79d85058923bf3618cc591408e6c72bd195e0ee73d94ac languageName: node linkType: hard @@ -10572,21 +10572,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.12.0-next.41": - version: 3.12.0-next.41 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.41" +"@webex/internal-plugin-conversation@npm:3.12.0-next.46": + version: 3.12.0-next.46 + resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.46" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/helper-html": "npm:3.12.0-next.5" + "@webex/helper-html": "npm:3.12.0-next.6" "@webex/helper-image": "npm:3.12.0-next.5" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.40" - "@webex/internal-plugin-user": "npm:3.12.0-next.38" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.44" + "@webex/internal-plugin-user": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.40" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/a1f8acdb0848d72294d65a80a4be4fed1a233568ad8ac72e6bd2dff8a59aaef781a74703e9fa25c01822da84d63500b642d317492b2c2ff4b83d53eb9b74cc89 + checksum: 10c0/3d024f2dcee3684825937a0aefa941e954bbb2136839e05b1fbff4e296b84c68930f5d65b7c2ebf44f3a55d2e905431da6d6eb260c348f388d0f9434849983d2 languageName: node linkType: hard @@ -10640,20 +10640,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-device@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.37" +"@webex/internal-plugin-device@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/internal-plugin-device@npm:3.12.0-next.40" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/http-core": "npm:3.12.0-next.5" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.37" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.40" ampersand-collection: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/7266fcc15e013067b4f879684ebbbb0a5f763e67d00e390f46eed70a9586c5467c6638b37abc10da58349904a45600390c3fa1b36769d51aae358cffc145348d + checksum: 10c0/8b5d3cbb0f0d6dc53b401b853ab8c276ec8516ef4c965488e45db4c1c56ecfea041ebaae326ed3654fb5b5a118d28d24f29ca1356118b7bf12f17a4582e96e30 languageName: node linkType: hard @@ -10749,17 +10749,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.40" +"@webex/internal-plugin-encryption@npm:3.12.0-next.44": + version: 3.12.0-next.44 + resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.44" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/http-core": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.44" "@webex/test-helper-file": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" asn1js: "npm:^2.0.26" debug: "npm:^4.3.4" isomorphic-webcrypto: "npm:^2.3.8" @@ -10771,7 +10771,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/a1c7926e9c572cc3741ecc70a640f50380fd6d4dd9621abfcf45b01c1ee8c9e1408878f8cd09bd8fd1ff040470ce3101ac14a63b3a0c1b2b1927dff0168ddf28 + checksum: 10c0/1ec9cb4ed3095c39f259820a986ebdaa5cb6b3a282e0e23639d248d73677c2cafdf7b47f62ae1a9a46a55692442dabc7f59d5decf9ae8b260472c054a3ac2cfa languageName: node linkType: hard @@ -10809,14 +10809,14 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-feature@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.37" +"@webex/internal-plugin-feature@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.40" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.40" lodash: "npm:^4.17.21" - checksum: 10c0/b530f2805e123cd8b902d4eaf1aafbf01a20e75829f048b68493f31ce973a3604a396f18a550aa723ceb06890c3b415c8d92b44d3b93124bb634142ba5097c38 + checksum: 10c0/a0d67aed66702a122f21d9baae0d8ddeea9f864573be18bea07b9e609c46af1821fc8319bd9ca4d5065a1504f9212bbab56e4a64f4a39d64239fa94bdf177421 languageName: node linkType: hard @@ -10963,27 +10963,27 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-mercury@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.40" +"@webex/internal-plugin-mercury@npm:3.12.0-next.44": + version: 3.12.0-next.44 + resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.44" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/internal-plugin-feature": "npm:3.12.0-next.37" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.37" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-feature": "npm:3.12.0-next.40" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-refresh-callback": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" backoff: "npm:^2.5.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" ws: "npm:^8.17.1" - checksum: 10c0/361bcbf838101447a9838b83682ab1abcff151d74cf3927f6a8c72c58644b2dcab2722593467cbdbe78880b94c904e23aa823afcbc9b4e3a8728cf5fff12078f + checksum: 10c0/0948067042f4a11900b239847fd3e68479acf3409795509b487712b5a521fed490bf72c33b008fb23485e95d5fd82b8713c0315c935b721a20aa0c12ad200d4a languageName: node linkType: hard @@ -11032,21 +11032,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-metrics@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.37" +"@webex/internal-plugin-metrics@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.40" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" - "@webex/event-dictionary-ts": "npm:^1.0.2215" + "@webex/event-dictionary-ts": "npm:^1.0.2220" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" ip-anonymize: "npm:^0.1.0" isbot: "npm:5.2.1" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/09e291ac15c7c00d9b14d14be03ccf6e58a5178dd1e30c89affc0d2e533ecfaced2b12f5bec17622fa7fd8fe23f5557db042b6fc00adc930fdd0c682a38f3e61 + checksum: 10c0/c395e5491e6a7d650ac73311c0084acf7ec53786ac3002cde1849efad9cea478fe954c0427da7001de659b61cc9e445bcd2357afe030658b5f08b81188143687 languageName: node linkType: hard @@ -11112,18 +11112,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.12.0-next.41": - version: 3.12.0-next.41 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.41" +"@webex/internal-plugin-search@npm:3.12.0-next.46": + version: 3.12.0-next.46 + resolution: "@webex/internal-plugin-search@npm:3.12.0-next.46" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.41" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.46" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.44" + "@webex/webex-core": "npm:3.12.0-next.40" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/9c3621b38e09b5c842eb02ce993ce50b3a19c442643d18a72f8bee0206f982a6af376c028181af223a471eeabb1fa8132a4d3359208a7bbf83dccea655cd44f1 + checksum: 10c0/e5dd18dc81181d93de255726ee275001d707cca659c5b749ce76a7c0983302ac8bc6518944cee543f2091e619d25a25475a9c88aef55e56c39ad350379f20f4f languageName: node linkType: hard @@ -11161,20 +11161,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.12.0-next.41": - version: 3.12.0-next.41 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.41" +"@webex/internal-plugin-support@npm:3.12.0-next.46": + version: 3.12.0-next.46 + resolution: "@webex/internal-plugin-support@npm:3.12.0-next.46" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/internal-plugin-search": "npm:3.12.0-next.41" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-search": "npm:3.12.0-next.46" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-file": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/fbf43bfe6ffc790cba8f25a8f513708990d74b432c8fdd0768fe5716dadbf65b48a5bd4a9202c3c0597a7b8ccf9210a047e91ec2580b451fba3ae250fd42bd70 + checksum: 10c0/cf3dcb2a26778e5972a477593b94af0757d52fd6fd18798d8818050f89146b0920a151eda045d5f77bfec4cfe6800c7bd55a9d244134238192910f9e35f48e11 languageName: node linkType: hard @@ -11240,19 +11240,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-user@npm:3.12.0-next.38": - version: 3.12.0-next.38 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.38" +"@webex/internal-plugin-user@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-user@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e102da9efed09275128c16cf6312dfcd89c123aef27fb32ceb6f46120378b47c58b832984f952c2bcfca96db111a7228a86b5a2e1de2b7c88fad682651b64e8d + checksum: 10c0/45746e930b4675d80a1415c5c2d1a21f0a715466e39c0c84b72722cd7eb463eb1c95dcf99037fda8110860f8dffa36dd25dea820bfd353133372cd8e90e675e7 languageName: node linkType: hard @@ -11288,14 +11288,14 @@ __metadata: languageName: node linkType: hard -"@webex/media-helpers@npm:3.12.0-next.10": - version: 3.12.0-next.10 - resolution: "@webex/media-helpers@npm:3.12.0-next.10" +"@webex/media-helpers@npm:3.12.0-next.11": + version: 3.12.0-next.11 + resolution: "@webex/media-helpers@npm:3.12.0-next.11" dependencies: - "@webex/internal-media-core": "npm:2.26.2" + "@webex/internal-media-core": "npm:2.28.2" "@webex/ts-events": "npm:^1.1.0" - "@webex/web-media-effects": "npm:2.33.5" - checksum: 10c0/75860e5409cfbfa0bcd5238c4fbdc1d8eecc793966156c81232bcb97d3afdc59606e61ae258da309c66d48845df90eae76fec306ee03d4e63ecf2b4dd8bc033f + "@webex/web-media-effects": "npm:2.34.0" + checksum: 10c0/b7cf13637d871b7d196da2fae757b245bf0bcfbba796e82d6d7d40620f29d534ae7e2920dbbf58a5428a1135202acd99fd10e5032a69c40cc5ea2146944c5f57 languageName: node linkType: hard @@ -11392,20 +11392,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-browser@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.37" +"@webex/plugin-authorization-browser@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.40" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/plugin-authorization-node": "npm:3.12.0-next.37" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.37" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/plugin-authorization-node": "npm:3.12.0-next.40" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.40" "@webex/storage-adapter-spec": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/02d8e0e3e78e86a4231c938822df7387008bd1921d330806b36688c9e493a6c88e761deaecd12fb1a6a05764d4f69dd3350f32514a005c268354a53188c017e2 + checksum: 10c0/e4d1f87a22ff7ac3cb2d4dd63d040f05c2bf4e7ce8cbecae8a6bf3491e64a7b691912a03074056ab483a7ec8c1f7e17bf286815fcc8d7dd584a20cde787e654e languageName: node linkType: hard @@ -11435,16 +11435,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-node@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.37" +"@webex/plugin-authorization-node@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.40" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.37" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.40" jsonwebtoken: "npm:^9.0.0" uuid: "npm:^3.3.2" - checksum: 10c0/1b65965ebd7b8fb666af3726a9081c585e1e628d85a9d26e5356f0ebcd749076d5bcdafef11c7a6d5e15d440159a09cd5b7b5116ebfb9045a8cdcfc5bc167cca + checksum: 10c0/6f9f5ac5bb6d236b0270de34db9fcef4116f5b21aa26d92de18a2f1c693e9a92f8d2e43ec6a96497b85a68b30c1e937b14b888eb9f821189b0b584553323c3fe languageName: node linkType: hard @@ -11468,13 +11468,13 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.37" +"@webex/plugin-authorization@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/plugin-authorization@npm:3.12.0-next.40" dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.37" - "@webex/plugin-authorization-node": "npm:3.12.0-next.37" - checksum: 10c0/28db9bb063a7e65b36d68dfa4ab0d035bb8af10030358a43c7d718e858328524078c14ed76d9d07f56b65806667b541cabb0d9a87cb9a6ce499ffa430b7c5c9f + "@webex/plugin-authorization-browser": "npm:3.12.0-next.40" + "@webex/plugin-authorization-node": "npm:3.12.0-next.40" + checksum: 10c0/1a3c75769a7b474106b786f9cd08488632c9adb9fb035e46307bf70d1c10f9e42117399db4f03b570adbe856a69c104ea191824109387d779b264ba4fb1ea152 languageName: node linkType: hard @@ -11552,17 +11552,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-logger@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/plugin-logger@npm:3.12.0-next.37" +"@webex/plugin-logger@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/plugin-logger@npm:3.12.0-next.40" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" + "@webex/webex-core": "npm:3.12.0-next.40" lodash: "npm:^4.17.21" - checksum: 10c0/934712e278a23c1c627d56338b9d9797a8463738434363f0e590696fe64459c493d73d622a7907eae8a9a4b44b3f30e83dc26b4d2379e645e175b27e7a4d84ba + checksum: 10c0/e09fc2597f46cb4e6449e57859a5c55532c94d480868eebbdba2896c05ee5c905d6bd03dce3404b2e060034be2e1fbddd1a20b6e13a6d3b92928300008ed009f languageName: node linkType: hard @@ -11891,14 +11891,14 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-local-storage@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.37" +"@webex/storage-adapter-local-storage@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.40" dependencies: "@webex/storage-adapter-spec": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.37" - checksum: 10c0/b09e54bb1a4b461734dfec7defc45d058256d76c015af5b004959eb8408fb92718e3c6e941574463e2a36fc526b35f3536d032e250ec32458a2bcd63f411c052 + "@webex/webex-core": "npm:3.12.0-next.40" + checksum: 10c0/b0dca260739f851b4b043fbb2cd337765eb246b3699d9ff7f42972e8312047a72c658efdcba0ad7c1018d18feaac19f154cebe2edbffaff2edad48003037c6d3 languageName: node linkType: hard @@ -12434,6 +12434,15 @@ __metadata: languageName: node linkType: hard +"@webex/web-capabilities@npm:^1.12.0": + version: 1.13.0 + resolution: "@webex/web-capabilities@npm:1.13.0" + dependencies: + bowser: "npm:^2.11.0" + checksum: 10c0/f58363aabdf7e9f174164bf772f68f4b7d9b18670fe2b9f843223affdf3b442358364d99baa322eede7079fd2a0a16dd0ce1bc4e18aa0cb2b2c4623376b01979 + languageName: node + linkType: hard + "@webex/web-capabilities@npm:^1.6.1": version: 1.6.1 resolution: "@webex/web-capabilities@npm:1.6.1" @@ -12462,22 +12471,22 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.40.2": - version: 3.40.2 - resolution: "@webex/web-client-media-engine@npm:3.40.2" +"@webex/web-client-media-engine@npm:3.42.1": + version: 3.42.1 + resolution: "@webex/web-client-media-engine@npm:3.42.1" dependencies: "@webex/json-multistream": "npm:^2.4.3" "@webex/rtcstats": "npm:^1.5.5" "@webex/ts-events": "npm:^1.2.1" "@webex/ts-sdp": "npm:1.8.2" "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-media-effects": "npm:2.33.5" - "@webex/webrtc-core": "npm:2.14.0" + "@webex/web-media-effects": "npm:2.34.0" + "@webex/webrtc-core": "npm:2.14.1" async: "npm:^3.2.4" js-logger: "npm:^1.6.1" typed-emitter: "npm:^2.1.0" uuid: "npm:^8.3.2" - checksum: 10c0/4054a7295c8e8c7c530a175bf19b999891b5d519dd11ef56cb0e5b35560771e3520cd5760e4ea49ab709a374fa4f1bbd980746b9e9aa54d86e41f8f0d09d4790 + checksum: 10c0/1e1ee382265dd4ec72737a57b6e103f98abe306710461ddb03671899c41092c62125c556cf98bc15ec4c0fb2cb97b76b509a8b8a39894f40516a13ce98c554d5 languageName: node linkType: hard @@ -12496,6 +12505,22 @@ __metadata: languageName: node linkType: hard +"@webex/web-media-effects@npm:2.34.0": + version: 2.34.0 + resolution: "@webex/web-media-effects@npm:2.34.0" + dependencies: + "@webex/ladon-ts": "npm:^5.10.0" + "@webex/web-capabilities": "npm:^1.12.0" + events: "npm:^3.3.0" + js-logger: "npm:^1.6.1" + typed-emitter: "npm:^1.4.0" + uuid: "npm:^9.0.1" + worker-timers: "npm:^8.0.21" + yarn: "npm:^1.22.22" + checksum: 10c0/10b25716e7c00c2517c849847c05072f3c29f63b56b8a63ef031ed9b766a2b77a3e05e8f2ae04f18807d42f24b8355705eb10d8290490ae00efe56dbc5668ad4 + languageName: node + linkType: hard + "@webex/webex-core@npm:2.60.4": version: 2.60.4 resolution: "@webex/webex-core@npm:2.60.4" @@ -12558,9 +12583,9 @@ __metadata: languageName: node linkType: hard -"@webex/webex-core@npm:3.12.0-next.37": - version: 3.12.0-next.37 - resolution: "@webex/webex-core@npm:3.12.0-next.37" +"@webex/webex-core@npm:3.12.0-next.40": + version: 3.12.0-next.40 + resolution: "@webex/webex-core@npm:3.12.0-next.40" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" @@ -12574,7 +12599,7 @@ __metadata: jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/2867f42a96cf8aa5b8423b0036111b4de41e19f27657bf9c9a0762d8600855a2d6e60e0bbd3c10c692977eb2cefee96760a7ecc65ea6ba6495a6e19981aaea15 + checksum: 10c0/60f0812479c996627f4fa989f9535cda37eba0f14d1ec97382f1414a2f7c07dc5044cf7b0e73b190814f2136072f55904dc476f32ad39113debe53554cd49ae9 languageName: node linkType: hard @@ -12593,18 +12618,18 @@ __metadata: languageName: node linkType: hard -"@webex/webrtc-core@npm:2.14.0": - version: 2.14.0 - resolution: "@webex/webrtc-core@npm:2.14.0" +"@webex/webrtc-core@npm:2.14.1": + version: 2.14.1 + resolution: "@webex/webrtc-core@npm:2.14.1" dependencies: "@webex/ts-events": "npm:^1.2.1" "@webex/web-capabilities": "npm:^1.6.1" - "@webex/web-media-effects": "npm:2.33.5" + "@webex/web-media-effects": "npm:2.34.0" events: "npm:^3.3.0" js-logger: "npm:^1.6.1" typed-emitter: "npm:^2.1.0" webrtc-adapter: "npm:^8.1.2" - checksum: 10c0/0302ea8b41e9b44635fb0b79999782462f340bef37f0d61bed930662d1de052c2439f955960677fe4b4343c779534cb214b00ecfe39138025a6ed6bfeb39b186 + checksum: 10c0/53f1f0429389d6e24100b22f97b000060db31205c1eef2c0f20edb7d6c18be704d90294737785a31f094413d1b87e7aace1dae2562322ff32b0807baa84fa95a languageName: node linkType: hard From 5ba57861d1416ab8d0f8b561d5f58dab52076d0d Mon Sep 17 00:00:00 2001 From: ajitku3 Date: Mon, 24 Aug 2026 16:09:42 +0530 Subject: [PATCH 5/6] Adopt Momentum primitives for participant drop UI --- ai-docs/features/participant-drop-intake.md | 4 +- .../ai-docs/cc-components-spec.md | 6 +- .../call-control-cad.styles.scss | 212 +++-------- .../task/CallControlCAD/call-control-cad.tsx | 348 +++++++++--------- .../CallControlCAD/call-control-cad.types.ts | 2 +- .../call-control-cad.snapshot.tsx.snap | 20 + .../task/CallControlCAD/call-control-cad.tsx | 190 ++++++---- 7 files changed, 376 insertions(+), 406 deletions(-) diff --git a/ai-docs/features/participant-drop-intake.md b/ai-docs/features/participant-drop-intake.md index f3a4b48d3..4707f60c4 100644 --- a/ai-docs/features/participant-drop-intake.md +++ b/ai-docs/features/participant-drop-intake.md @@ -53,13 +53,15 @@ For EP-DN cross-channel lifecycle ordering, `ContactMerged` replaces the child t - On success announce `Participant removed from the conference.` - On failure announce `Unable to drop participant from the call. Try again.` and invoke the existing `CallControlCAD` host error callback with a sanitized error. +The `CallControlCAD` participant surface uses the installed Momentum Design primitives throughout: a compact tertiary `Button` with Momentum secondary-button background tokens triggers a focus-trapped `Popover`; `List`/`ListItem`, `Icon`, `Text`, and `Divider` render the roster; `primary-participant-regular` identifies the primary agent while `meet-regular` remains on other rows; compact secondary/negative `Button` components render Drop actions; and a controlled `Dialog` with Momentum footer actions handles Customer confirmation. The installed Dialog version forces its named footer-button slots to the default color, so its documented custom footer slot is used to preserve the destructive primary/negative Drop action; the remaining footer CSS controls layout only. The participant Popover closes before Customer confirmation so two focus traps never overlap. The Dialog is positioned against the viewport and focus is explicitly restored to the stable participant trigger, falling back to end-call when hydration removes the roster. Momentum owns overlay visuals, keyboard dismissal, and focus trapping. Other component CSS is restricted to compact layout, sizing, overflow/truncation, overlay positioning, and theme-token colors; visible live-region feedback is not duplicated with a screen-reader announcer. + Backend authorization remains mandatory. Owner-based UI visibility is not an authorization boundary. ## Public surfaces and compatibility Only the existing React `CallControlCAD` export and `widget-cc-call-control-cad` custom element gain behavior. Standard `CallControl` remains visually unchanged. No widget prop, custom-element attribute/property, initialization option, or callback is added. -The widget change must use a local, untracked SDK link during development. Before PR readiness, replace `@webex/contact-center@3.12.0-next.96` in `@webex/cc-store` with the exact published SDK `next` version, update `yarn.lock`, remove the local link, and verify from a clean install. Absolute paths, portals, tarballs, and temporary resolutions must never be committed. +The widget consumes published `@webex/contact-center@3.12.0-next.106` from `@webex/cc-store`; local links, absolute paths, portals, tarballs, and temporary resolutions must never be committed. ## Verification diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index fcf173506..46b26b40a 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -150,7 +150,7 @@ Compatibility notes: | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | | `CC-COMPONENTS-R-016` | `E911Modal` gates `Save & Continue` on the acknowledgment checkbox, disables both `Save & Continue` and `Cancel` while `onSaveAndContinue` is in flight (guarding against a double-click firing concurrent saves), shows a user-facing error and re-enables the buttons if the save rejects, and only `Cancel` (not the Dialog's built-in close button or Escape) dismisses the modal; checkbox/saving/error state resets when the modal closes. | An emergency-notification acknowledgment must not be skippable, must not double-submit against the preference API, and must give the agent visible recourse on failure. | `src/components/StationLogin/E911Modal/e911-modal.tsx` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (checkbox gating, save-in-flight button disabling, save-error display, close-only-via-Cancel) | None | PRESENT | -| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. The component receives request/confirmation state and handlers from `useCallControl`; it owns only roster rendering plus native dialog open/close, Escape, and focus restoration. Exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/CallControlCAD/call-control-cad.types.ts`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | +| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, marks the primary agent with Momentum's `primary-participant-regular` icon while retaining `meet-regular` for other rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. The compact tertiary participant trigger uses Momentum secondary-button background tokens in normal, hover, pressed, and expanded states so it remains identifiable without custom chrome. The component receives request/confirmation state and handlers from `useCallControl`; it owns only presentation and focus fallback. The roster uses Momentum Design `Button`, `Popover`, `List`, `ListItem`, `Icon`, `Text`, and `Divider`, while Customer confirmation uses Momentum Design `Dialog` and footer `Button` components. Before Customer confirmation opens, the participant Popover closes so only one focus-trapped overlay is active; the Dialog is viewport-positioned and focus is explicitly restored to the stable participant trigger, with end-call fallback if hydration removes the roster. Styling is limited to compact layout, sizing, overflow/truncation, overlay positioning, and Momentum theme-token colors. Exact visible polite success/assertive failure feedback stays outside the roster, and no duplicate screen-reader announcer is added. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, visually consistent with Momentum Design, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/CallControlCAD/call-control-cad.types.ts`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | | `CC-COMPONENTS-R-018` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render `ContactServiceQueue`, `EntryPointRecord`, and dial-number arrays in received order. | The component must preserve backend-selected order and established SDK entity records instead of introducing a second list policy or destination abstraction. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | ## Design Overview @@ -345,8 +345,8 @@ Each component is tested in isolation with React Testing Library: render from a | `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | | `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | | `CC-COMPONENTS-R-016` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` | None | -| `CC-COMPONENTS-R-017` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (sections, immediate/confirmed Drop, pending/consult disabled state, read-only rows, focus, live regions) | Native browser dialog behavior receives final manual coverage | -| `CC-COMPONENTS-R-017` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | +| `CC-COMPONENTS-R-017` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (Momentum structure/configuration, sections, immediate/confirmed Drop, pending/consult disabled state, read-only rows, focus fallback, live regions) | Momentum overlay keyboard behavior and visual placement receive final manual coverage | +| `CC-COMPONENTS-R-018` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | ## Traceability diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index c9cac45c8..fefcaed5f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -112,11 +112,8 @@ } .vertical-divider { - width: 0.0625rem; height: 0.875rem; - background-color: var(--mds-color-theme-outline-secondary-normal); margin: 0 0.5rem; - opacity: 0.7; } .participants-section { @@ -126,48 +123,24 @@ gap: 0.25rem; } -.participants-icon { - color: var(--mds-color-theme-text-primary-normal); -} - -.participants-count { - font-size: 0.875rem; - color: var(--mds-color-theme-text-secondary-normal); - white-space: nowrap; - font-weight: 500; -} - .participants-select-button { - display: inline-flex; - align-items: center; + --mdc-button-background: var(--mds-color-theme-button-secondary-hover); + flex: 0 0 auto; - gap: 0.3rem; - padding: 0 0.4rem; - min-height: 1.5rem; width: max-content; white-space: nowrap; - border-radius: 62.4375rem; - background: transparent !important; - border: 1px solid var(--mds-color-theme-outline-secondary-normal) !important; - color: var(--mds-color-theme-text-secondary-normal); - - &:hover, - &.hover { - background: var(--mds-color-theme-background-secondary-hover) !important; - } } -.participants-select-button::part(button-text) { - display: inline-flex; - align-items: center; - white-space: nowrap; +.participants-select-button:hover { + --mdc-button-background: var(--mds-color-theme-button-secondary-pressed); +} + +.participants-select-button:active, +.participants-select-button.pressed { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-hover); } .participants-select-button::part(postfix-icon) { - width: 0.75rem; - height: 0.75rem; - color: var(--mds-color-theme-text-secondary-normal); - flex: 0 0 auto; transition: transform 0.15s ease; } @@ -175,6 +148,19 @@ transform: rotate(180deg); } +.participants-select-button[aria-expanded='true'] { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-normal); +} + +.participants-select-button[aria-expanded='true']:hover { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-hover); +} + +.participants-select-button[aria-expanded='true']:active, +.participants-select-button[aria-expanded='true'].pressed { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-pressed); +} + .call-status { display: flex; align-items: center; @@ -310,165 +296,63 @@ } .participants-popover { - background: var(--mds-color-theme-background-solid-primary-normal) !important; - align-items: normal; - width: 100%; - padding: 0 !important; - border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); - - &::part(popover-content) { - background: var(--mds-color-theme-background-solid-primary-normal); - border-radius: 0.75rem; - border: 1px solid var(--mds-color-theme-outline-secondary-normal); - box-shadow: var(--md-globals-elevation-2); - padding: 0; - width: 16.125rem; /* 258px */ - overflow: hidden; - } + --mdc-popover-width: 16.125rem; + --mdc-popover-max-height: 18rem; .participants-menu { - padding: 0.5rem; - max-height: 18rem; - min-width: 16.125rem; - max-width: 16.125rem; + max-height: 16.5rem; overflow-y: auto; overflow-x: hidden; } - .participant-roster-section + .participant-roster-section { - border-top: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); - margin-top: 0.5rem; - padding-top: 0.5rem; - } - .participant-roster-heading { - margin: 0 0 0.25rem; - padding: 0 0.25rem; - font-weight: 700; - } - - .participant-roster-list { - list-style: none; - margin: 0; - padding: 0; - } + margin: 0 0 0.25rem 0.25rem; - .participant-menu-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.25rem; - font-size: 0.875rem; - color: var(--mds-color-theme-text-primary-normal); - border-radius: 0.5rem; - min-width: 0; - max-width: 100%; - } - - .participant-menu-identity { - flex: 1 1 auto; - min-width: 0; - } - - .participant-menu-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .participant-drop-button { - background: transparent; - border: 0.0625rem solid var(--mds-color-theme-outline-button-normal); - border-radius: 1rem; - color: var(--mds-color-theme-text-error-normal); - cursor: pointer; - flex: 0 0 auto; - font: inherit; - padding: 0.25rem 0.625rem; - - &:hover:not(:disabled) { - background: var(--mds-color-theme-background-alert-error-normal); - } - - &:focus-visible { - outline: 0.125rem solid var(--mds-color-theme-outline-input-focus); - outline-offset: 0.125rem; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.55; + &::part(text) { + margin: 0; } } -} -.participant-drop-dialog { - background: var(--mds-color-theme-background-solid-primary-normal); - border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); - border-radius: 0.75rem; - color: var(--mds-color-theme-text-primary-normal); - max-width: min(25rem, calc(100vw - 2rem)); - padding: 1.5rem; - - &::backdrop { - background: var(--mds-color-theme-background-overlay); + .participant-roster-list { + width: 100%; } - h2, - p { - margin: 0; + .participant-menu-item { + --mdc-listitem-padding-left-right: 0.25rem; + --mdc-listitem-padding-top-bottom: 0.5rem; } - p { - margin-top: 0.75rem; + .participant-roster-divider { + margin: 0.5rem 0; } } .participant-drop-dialog-actions { display: flex; - gap: 0.5rem; + align-items: center; justify-content: flex-end; - margin-top: 1.5rem; - - button { - border: 0.0625rem solid var(--mds-color-theme-outline-button-normal); - border-radius: 1rem; - cursor: pointer; - font: inherit; - padding: 0.5rem 1rem; - - &:focus-visible { - outline: 0.125rem solid var(--mds-color-theme-outline-input-focus); - outline-offset: 0.125rem; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.55; - } - } -} - -.participant-drop-dialog-cancel { - background: var(--mds-color-theme-background-solid-primary-normal); - color: var(--mds-color-theme-text-primary-normal); + gap: 0.5rem; + width: 100%; } -.participant-drop-dialog-confirm { - background: var(--mds-color-theme-background-alert-error-normal); - color: var(--mds-color-theme-text-error-normal); +// Momentum Dialog defaults to absolute positioning. Keep this modal centered in the viewport +// when CallControlCAD is rendered inside a relatively positioned or scrollable widget container. +.participant-drop-dialog { + position: fixed; } .participant-drop-feedback { - p { - margin: 0.5rem 0 0; + margin-top: 0.5rem; + + mdc-text::part(text) { + margin: 0; } - [role='status'] { + .participant-drop-feedback-success { color: var(--mds-color-theme-text-success-normal); } - [role='alert'] { + .participant-drop-feedback-error { color: var(--mds-color-theme-text-error-normal); } } 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 581734c4d..4a59068c9 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 @@ -1,7 +1,19 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef} from 'react'; import CallControlComponent from '../CallControl/call-control'; -import {Text, PopoverNext, PopoverInstance} from '@momentum-ui/react-collaboration'; -import {Avatar, Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; +import {Text} from '@momentum-ui/react-collaboration'; +import { + Avatar, + Brandvisual, + Button, + Dialog, + Divider, + Icon, + List, + ListItem, + Popover, + Text as MomentumText, + Tooltip, +} from '@momentum-design/components/dist/react'; import './call-control-cad.styles.scss'; import TaskTimer from '../TaskTimer/index'; import CallControlConsultComponent from '../CallControl/CallControlCustom/call-control-consult'; @@ -35,34 +47,52 @@ const ParticipantRosterSection: React.FC = ({ onParticipantDropRequest, }) => (
-

+ {heading} -

-
    - {targets.map((target) => ( -
  • - - - + + + {targets.map((target) => { + const dropIsDisabled = target.isDropDisabled || participantDropIsPending || rosterDropDisabled; + + return ( + + + {target.displayName} {target.isPrimary ? ' (Primary)' : ''} - - - {!target.isReadOnly && ( - - )} -
  • - ))} -
+ + {!target.isReadOnly && ( + + )} + + ); + })} +
); @@ -117,16 +147,14 @@ const CallControlCADComponent: React.FC = (props) => const participantsCount = conferenceParticipantDropRoster?.participants.length ?? 0; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; const shouldShowParticipantsList = Boolean(conferenceParticipantDropRoster); - const customerDropDialogRef = useRef(null); - const customerDropTriggerRef = useRef(null); const participantMenuTriggerRef = useRef(null); const callControlContainerRef = useRef(null); - const customerDropConfirmRef = useRef(null); const customerDropDialogWasOpenRef = useRef(false); - const [participantPopoverInstance, setParticipantPopoverInstance] = useState(); + const participantPopoverRef = useRef | null>(null); const participantDropIsPending = pendingParticipantDropId !== null; const interactionId = currentTask.data.interaction.interactionId; - const customerDropDialogTitleId = `participant-drop-dialog-title-${interactionId}`; + const participantsTriggerId = `participants-trigger-${interactionId}`; + const participantsPopoverId = `participants-popover-${interactionId}`; const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; @@ -158,97 +186,43 @@ const CallControlCADComponent: React.FC = (props) => } const globalVariables = globalVariablesRef.current; - const restoreCustomerDropFocus = () => { - const customerTriggerIsAvailable = Boolean( - conferenceParticipantDropRoster?.customer && customerDropTriggerRef.current?.isConnected - ); - const focusCurrentControl = () => { + const restoreCustomerDropFocusFallback = () => { + // Closing the roster before opening the Dialog makes its Customer button unavailable for + // focus restoration. Return focus to the stable participant trigger, or to end-call if + // hydration removed the entire roster while the confirmation was open. + participantPopoverRef.current?.hide(); + globalThis.setTimeout(() => { const participantMenuTrigger = participantMenuTriggerRef.current; const stableCallControl = callControlContainerRef.current?.querySelector( '[data-testid="call-control:end-call"]' ); - const focusTarget = customerTriggerIsAvailable - ? customerDropTriggerRef.current - : participantMenuTrigger?.isConnected - ? participantMenuTrigger - : stableCallControl; - - focusTarget?.focus(); - customerDropTriggerRef.current = null; - }; - - if (!customerTriggerIsAvailable) { - // The participant popover's focus lock otherwise keeps focus inside content whose - // Customer row has just disappeared. Close it before focusing its trigger (or a - // stable call control when the roster itself has also disappeared). - participantPopoverInstance?.hide(); - globalThis.setTimeout(focusCurrentControl, 0); - return; - } - - focusCurrentControl(); - }; - - const closeCustomerDropDialog = (restoreFocus = true) => { - const dialog = customerDropDialogRef.current; - - if (dialog?.open && typeof dialog.close === 'function') { - dialog.close(); - } else { - dialog?.removeAttribute('open'); - } - - customerDropDialogWasOpenRef.current = false; - if (restoreFocus) { - restoreCustomerDropFocus(); - } + (participantMenuTrigger?.isConnected ? participantMenuTrigger : stableCallControl)?.focus(); + }, 0); }; useEffect(() => { - const dialog = customerDropDialogRef.current; - - if (!participantDropConfirmationTarget) { - if (customerDropDialogWasOpenRef.current) { - closeCustomerDropDialog(); - } - return; - } - - if (!dialog) { + if (participantDropConfirmationTarget) { + customerDropDialogWasOpenRef.current = true; return; } - if (!dialog.open) { - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } else { - dialog.setAttribute('open', ''); - } + if (customerDropDialogWasOpenRef.current) { + customerDropDialogWasOpenRef.current = false; + restoreCustomerDropFocusFallback(); } - - customerDropDialogWasOpenRef.current = true; - customerDropConfirmRef.current?.focus(); }, [participantDropConfirmationTarget]); - useEffect(() => { - closeCustomerDropDialog(false); - customerDropTriggerRef.current = null; - }, [interactionId]); - - const handleParticipantDropRequest = (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => { + const handleParticipantDropRequest = (target: ConferenceParticipantDropTarget) => { if (target.requiresConfirmation) { - customerDropTriggerRef.current = trigger; + // Avoid stacking two focus-trapped Momentum overlays. The customer confirmation dialog + // becomes the only active overlay and returns focus to the stable participant trigger. + participantPopoverRef.current?.hide(); } void requestParticipantDrop(target); }; - const handleConfirmCustomerDrop = () => { - closeCustomerDropDialog(); - void confirmParticipantDrop(); - }; - // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; const customerNameTooltipId = `customer-name-tooltip-${currentTask.data.interaction.interactionId}`; @@ -370,43 +344,47 @@ const CallControlCADComponent: React.FC = (props) => {shouldShowParticipantsList && ( <> -
+
- { + participantMenuTriggerRef.current = element as HTMLElement | null; + }} + id={participantsTriggerId} + aria-label="Conference participants" + data-testid="call-control:participants-trigger" + className="participants-select-button" + color="default" + variant="tertiary" + size={24} + postfix-icon="arrow-down-bold" + > + +{participantsCount} {participantsLabel} + + { - participantMenuTriggerRef.current = element as HTMLElement | null; - }} - id="participants-trigger" - aria-label="Conference participants" - data-testid="call-control:participants-trigger" - className="participants-select-button" - color="default" - variant="tertiary" - postfix-icon="arrow-down-bold" - > - - +{participantsCount} {participantsLabel} - - - } + data-testid="call-control:participants-popover" >
{conferenceParticipantDropRoster?.customer && ( = (props) => onParticipantDropRequest={handleParticipantDropRequest} /> )} + {conferenceParticipantDropRoster?.customer && + conferenceParticipantDropRoster.participants.length > 0 && ( + + )} {conferenceParticipantDropRoster?.participants.length ? ( = (props) => /> ) : null}
-
+
)} @@ -462,53 +444,71 @@ const CallControlCADComponent: React.FC = (props) =>
- {participantDropConfirmationTarget && ( - { - event.preventDefault(); - closeCustomerDropDialog(); - cancelParticipantDropConfirmation(); - }} - > -

Drop customer from conference?

-

The customer will be removed from this conference. The remaining participants can continue the call.

-
- - -
-
- )} + +
+ + +
+
{participantDropAnnouncement && (
{participantDropAnnouncement.type === 'success' ? ( -

+ {participantDropAnnouncement.message} -

+ ) : ( -

+ {participantDropAnnouncement.message} -

+ )}
)} diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts index e844324d0..946b4c88b 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts @@ -7,5 +7,5 @@ export type ParticipantRosterSectionProps = { pendingParticipantDropId: string | null; participantDropIsPending: boolean; rosterDropDisabled: boolean; - onParticipantDropRequest: (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => void; + onParticipantDropRequest: (target: ConferenceParticipantDropTarget) => void; }; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap index a8cdf4aff..27ec18d9c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap @@ -93,6 +93,26 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control
+ +
+ + Cancel + + + Drop + +
+
diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index d6d246572..e400ff194 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -11,17 +11,33 @@ import { import {BuddyDetails, ConferenceParticipantDropRoster} from '@webex/cc-store'; import '@testing-library/jest-dom'; -jest.mock('@momentum-ui/react-collaboration', () => { - const actual = jest.requireActual('@momentum-ui/react-collaboration'); - const react = jest.requireActual('react'); - - const popover = ({triggerComponent, children}: {triggerComponent: React.ReactNode; children: React.ReactNode}) => - react.createElement(react.Fragment, null, triggerComponent, children); - - return new Proxy(actual, { - get: (target, property) => (property === 'PopoverNext' ? popover : Reflect.get(target, property)), - }); -}); +type MomentumElement = HTMLElement & { + ariaLabelledby?: string; + color?: string; + delay?: string; + descriptionText?: string; + focusBackToTrigger?: boolean; + focusTrap?: boolean; + headerText?: string; + hideOnEscape?: boolean; + hideOnOutsideClick?: boolean; + interactive?: boolean; + name?: string; + offset?: number; + placement?: string; + showArrow?: boolean; + size?: number | string; + tagname?: string; + trigger?: string; + triggerID?: string; + type?: string; + variant?: string; + visible?: boolean; +}; + +type MomentumPopoverElement = MomentumElement & { + hide: () => void; +}; // Mock MediaStream for testing Object.defineProperty(window, 'MediaStream', { @@ -172,18 +188,6 @@ describe('CallControlCADComponent', () => { beforeEach(() => { jest.clearAllMocks(); - Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { - configurable: true, - value: function showModal() { - this.setAttribute('open', ''); - }, - }); - Object.defineProperty(HTMLDialogElement.prototype, 'close', { - configurable: true, - value: function close() { - this.removeAttribute('open'); - }, - }); }); it('should render telephony call control with all basic information', () => { @@ -484,8 +488,6 @@ describe('CallControlCADComponent', () => { }); const screen = render(); - fireEvent.click(screen.getByTestId('call-control:participants-trigger')); - return { screen, requestParticipantDrop, @@ -498,15 +500,44 @@ describe('CallControlCADComponent', () => { it('renders Customer and Participants sections and immediately drops an Agent target', () => { const {screen, requestParticipantDrop} = renderRoster(); - const participantsTrigger = screen.getByTestId('call-control:participants-trigger'); + const participantsTrigger = screen.getByTestId('call-control:participants-trigger') as MomentumElement; + const participantsPopover = screen.getByTestId('call-control:participants-popover') as MomentumElement; + const customerHeading = screen.getByText('Customer') as MomentumElement; + const participantsHeading = screen.getByText('Participants') as MomentumElement; + const agentDropButton = screen.getByLabelText('Drop agent Agent Two') as MomentumElement; + const participantIcons = Array.from(screen.container.querySelectorAll('.participant-menu-icon')); expect(participantsTrigger).toHaveAttribute('postfix-icon', 'arrow-down-bold'); - expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); - expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(participantsTrigger.variant).toBe('tertiary'); + expect(participantsTrigger.size).toBe(24); + expect(participantsPopover.triggerID).toBe(participantsTrigger.id); + expect(participantsPopover.trigger).toBe('click'); + expect(participantsPopover.placement).toBe('bottom-end'); + expect(participantsPopover.color).toBe('tonal'); + expect(participantsPopover.delay).toBe('0,0'); + expect(participantsPopover.offset).toBe(6); + expect(participantsPopover.showArrow).toBe(false); + expect(participantsPopover.interactive).toBe(true); + expect(participantsPopover.focusTrap).toBe(true); + expect(participantsPopover.hideOnEscape).toBe(true); + expect(participantsPopover.hideOnOutsideClick).toBe(true); + expect(participantsPopover.focusBackToTrigger).toBe(true); + expect(customerHeading.tagname).toBe('h4'); + expect(customerHeading.type).toBe('body-midsize-bold'); + expect(participantsHeading.tagname).toBe('h4'); + expect(participantsHeading.type).toBe('body-midsize-bold'); + expect(screen.container.querySelectorAll('mdc-list')).toHaveLength(2); + expect(screen.container.querySelectorAll('mdc-listitem')).toHaveLength(2); + expect(screen.container.querySelectorAll('mdc-divider')).toHaveLength(2); expect(screen.getByText('+15551234567')).toBeInTheDocument(); expect(screen.getByText('Agent Two')).toBeInTheDocument(); + expect(participantIcons.map((icon) => (icon as MomentumElement).name)).toEqual(['meet-regular', 'meet-regular']); + expect(agentDropButton.variant).toBe('secondary'); + expect(agentDropButton.color).toBe('negative'); + expect(agentDropButton.size).toBe(24); + expect(agentDropButton).toHaveAttribute('slot', 'trailing-controls'); - fireEvent.click(screen.getByRole('button', {name: 'Drop agent Agent Two'})); + fireEvent.click(agentDropButton); expect(requestParticipantDrop).toHaveBeenCalledWith(ownerDropRoster.participants[0]); }); @@ -531,11 +562,11 @@ describe('CallControlCADComponent', () => { }, }); - expect(screen.queryByRole('heading', {name: 'Customer'})).not.toBeInTheDocument(); - expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(screen.queryByText('Customer')).not.toBeInTheDocument(); + expect(screen.getByText('Participants')).toBeInTheDocument(); expect(screen.getByText('Agent Two')).toBeInTheDocument(); expect(screen.getByText('Agent Three')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeEnabled(); + expect(screen.getByLabelText('Drop agent Agent Two')).toBeEnabled(); }); it('keeps a valid multiparty roster visible when wrap-up controls appear', () => { @@ -544,34 +575,54 @@ describe('CallControlCADComponent', () => { }); expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); - expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); - expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(screen.getByText('Customer')).toBeInTheDocument(); + expect(screen.getByText('Participants')).toBeInTheDocument(); }); - it('requires confirmation for Customer Drop and cancel restores focus without confirming', async () => { + it('requires confirmation for Customer Drop and connects Momentum focus restoration through triggerID', async () => { const {screen, requestParticipantDrop, cancelParticipantDropConfirmation, rerenderRoster} = renderRoster(); - const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); + const customerDropButton = screen.getByLabelText('Drop customer +15551234567'); + const participantsTrigger = screen.getByTestId('call-control:participants-trigger'); + const participantsPopover = screen.getByTestId('call-control:participants-popover') as MomentumPopoverElement; + const hideParticipantsPopover = jest.spyOn(participantsPopover, 'hide'); fireEvent.click(customerDropButton); expect(requestParticipantDrop).toHaveBeenCalledWith(ownerDropRoster.customer); + expect(hideParticipantsPopover).toHaveBeenCalledTimes(1); rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); - const dialog = screen.getByTestId('call-control:customer-drop-dialog'); - expect(dialog).toHaveAttribute('open'); - expect(screen.getByRole('heading', {name: 'Drop customer from conference?'})).toBeInTheDocument(); + const dialog = screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement; + const cancelButton = screen.getByTestId('call-control:customer-drop-cancel') as MomentumElement; + const confirmButton = screen.getByTestId('call-control:customer-drop-confirm') as MomentumElement; + expect(dialog.visible).toBe(true); + expect(dialog.headerText).toBe('Drop customer from conference?'); + expect(dialog.descriptionText).toBe( + 'The customer will be removed from this conference. The remaining participants can continue the call.' + ); + expect(dialog.triggerID).toBe(participantsTrigger.id); + expect(dialog).toHaveClass('participant-drop-dialog'); + expect(cancelButton.parentElement).toHaveAttribute('slot', 'footer'); + await waitFor(() => { + expect(cancelButton.variant).toBe('secondary'); + expect(cancelButton.color).toBe('default'); + expect(confirmButton.variant).toBe('primary'); + expect(confirmButton.color).toBe('negative'); + }); - fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + fireEvent.click(cancelButton); expect(cancelParticipantDropConfirmation).toHaveBeenCalledTimes(1); - await waitFor(() => expect(customerDropButton).toHaveFocus()); + rerenderRoster({participantDropConfirmationTarget: null}); + expect((screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement).visible).toBe(false); + await waitFor(() => expect(participantsTrigger).toHaveFocus()); }); it('restores focus to the roster trigger when the confirmed Customer row disappears', async () => { const {screen, rerenderRoster} = renderRoster(); - fireEvent.click(screen.getByRole('button', {name: 'Drop customer +15551234567'})); + fireEvent.click(screen.getByLabelText('Drop customer +15551234567')); rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); - expect(screen.getByTestId('call-control:customer-drop-dialog')).toHaveAttribute('open'); + expect((screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement).visible).toBe(true); rerenderRoster({ conferenceParticipantDropRoster: {...ownerDropRoster, customer: null}, @@ -584,46 +635,51 @@ describe('CallControlCADComponent', () => { it('restores focus to a stable call control when the roster disappears after confirmation', async () => { const {screen, rerenderRoster} = renderRoster(); - fireEvent.click(screen.getByRole('button', {name: 'Drop customer +15551234567'})); + fireEvent.click(screen.getByLabelText('Drop customer +15551234567')); rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); - expect(screen.getByTestId('call-control:customer-drop-dialog')).toHaveAttribute('open'); + expect((screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement).visible).toBe(true); rerenderRoster({conferenceParticipantDropRoster: null, participantDropConfirmationTarget: null}); await waitFor(() => expect(screen.getByTestId('call-control:end-call')).toHaveFocus()); }); - it('confirms Customer Drop and supports Escape cancellation', () => { + it('confirms Customer Drop and routes Momentum close or Escape through cancellation', () => { const {screen, confirmParticipantDrop, cancelParticipantDropConfirmation, rerenderRoster} = renderRoster(); - const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); + const customerDropButton = screen.getByLabelText('Drop customer +15551234567'); fireEvent.click(customerDropButton); rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); - fireEvent.click(screen.getByRole('button', {name: 'Drop'})); + fireEvent.click(screen.getByTestId('call-control:customer-drop-confirm')); expect(confirmParticipantDrop).toHaveBeenCalledTimes(1); + rerenderRoster({participantDropConfirmationTarget: null}); fireEvent.click(customerDropButton); rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); const dialog = screen.getByTestId('call-control:customer-drop-dialog'); - fireEvent(dialog, new Event('cancel', {cancelable: true})); + fireEvent(dialog, new CustomEvent('close')); expect(cancelParticipantDropConfirmation).toHaveBeenCalledTimes(1); }); - it('globally disables Drop controls and shows loading only on the selected row', () => { + it('globally disables Drop controls and shows loading only on the selected row', async () => { const {screen} = renderRoster({pendingParticipantDropId: 'agent-2'}); - expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toHaveTextContent('Dropping…'); - expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeDisabled(); - expect(screen.getByRole('button', {name: 'Drop customer +15551234567'})).toBeDisabled(); + expect(screen.getByLabelText('Drop agent Agent Two')).toHaveTextContent('Dropping…'); + await waitFor(() => { + expect(screen.getByLabelText('Drop agent Agent Two')).toBeDisabled(); + expect(screen.getByLabelText('Drop customer +15551234567')).toBeDisabled(); + }); }); - it('disables all owner Drop controls during an active non-held consult', () => { + it('disables all owner Drop controls during an active non-held consult', async () => { const {screen} = renderRoster({ conferenceParticipantDropRoster: {...ownerDropRoster, isDropDisabled: true}, }); - expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeDisabled(); - expect(screen.getByRole('button', {name: 'Drop customer +15551234567'})).toBeDisabled(); + await waitFor(() => { + expect(screen.getByLabelText('Drop agent Agent Two')).toBeDisabled(); + expect(screen.getByLabelText('Drop customer +15551234567')).toBeDisabled(); + }); }); it('renders non-owner and Supervisor rows without Drop actions or read-only labels', () => { @@ -644,15 +700,23 @@ describe('CallControlCADComponent', () => { isDropDisabled: false, }; const {screen} = renderRoster({conferenceParticipantDropRoster: readOnlyRoster}); + const participantIcons = Array.from(screen.container.querySelectorAll('.participant-menu-icon')); expect(screen.getByText('Agent Two (Primary)')).toBeInTheDocument(); expect(screen.getByText('Supervisor One')).toBeInTheDocument(); + expect(participantIcons.map((icon) => (icon as MomentumElement).name)).toEqual([ + 'meet-regular', + 'primary-participant-regular', + 'meet-regular', + ]); expect(screen.queryByText('Read only')).not.toBeInTheDocument(); expect(document.querySelector('.participant-menu-type')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', {name: /Drop (agent|customer|supervisor)/})).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Drop agent Agent Two')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Drop customer +15551234567')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Drop supervisor Supervisor One')).not.toBeInTheDocument(); }); - it('counts visible non-customer rows and disables a pending EP-DN action', () => { + it('counts visible non-customer rows and disables a pending EP-DN action', async () => { const pendingEpDn = { participantType: 'EP-DN' as const, displayName: '+15551230000', @@ -679,13 +743,13 @@ describe('CallControlCADComponent', () => { }); expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+3 Participants'); - const pendingDrop = screen.getByRole('button', {name: 'Drop ep-dn +15551230000'}); - expect(pendingDrop).toBeDisabled(); + const pendingDrop = screen.getByLabelText('Drop ep-dn +15551230000'); + await waitFor(() => expect(pendingDrop).toBeDisabled()); fireEvent.click(pendingDrop); expect(requestParticipantDrop).not.toHaveBeenCalled(); }); - it('renders an answered Entry Point agent as disabled until conference merge', () => { + it('renders an answered Entry Point agent as disabled until conference merge', async () => { const answeredEntryPointAgent = { participantType: 'Agent' as const, displayName: 'Support Agent', @@ -705,8 +769,8 @@ describe('CallControlCADComponent', () => { expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+1 Participant'); expect(screen.getByText('Support Agent')).toBeInTheDocument(); - const answeredAgentDrop = screen.getByRole('button', {name: 'Drop agent Support Agent'}); - expect(answeredAgentDrop).toBeDisabled(); + const answeredAgentDrop = screen.getByLabelText('Drop agent Support Agent'); + await waitFor(() => expect(answeredAgentDrop).toBeDisabled()); fireEvent.click(answeredAgentDrop); expect(requestParticipantDrop).not.toHaveBeenCalled(); }); From b2d9c35acd6704295f3acc70c769cfdd21c25bc2 Mon Sep 17 00:00:00 2001 From: ajitku3 Date: Tue, 25 Aug 2026 13:47:56 +0530 Subject: [PATCH 6/6] Refactor contact center widgets --- ai-docs/CONTRACTS.md | 2 +- .../spec/feature-spec.md | 12 +- ai-docs/features/participant-drop-intake.md | 4 +- .../store/ai-docs/store-spec.md | 8 +- packages/contact-center/store/package.json | 2 +- .../store/tests/storeEventsWrapper.ts | 127 ++ .../cc/samples-cc-react-app/package.json | 2 +- yarn.lock | 1235 +++++------------ 8 files changed, 518 insertions(+), 874 deletions(-) diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 0ceccaa3e..0e10f2e0b 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -34,7 +34,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| | `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, `ITask.dropConferenceParticipant({participantId})`, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store/task object | Host establishes the authenticated Webex session; SDK and routing backend are assumed reachable and authoritative | `Store.init()` rejects after a 6s init timeout; participant Drop rejects and `CallControlCAD` shows generic feedback | Pinned by the SDK dependency in each package's `package.json`; Participant Drop requires the published SDK version recorded in the feature intake | -| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established queue records and entry-point response wrapper with mapped `number`, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | `3.12.0-next.106`, pinned by the store package dependency | +| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established queue records and entry-point response wrapper with mapped `number`, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | `3.12.0-next.109`, pinned by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index b2674284c..51f05c1ec 100644 --- a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -21,7 +21,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi | Work type | Defect | | Change class | Contract / UI | | Source/intake | Developer-approved consult/transfer behavior review and current code/tests | -| Last verified | 2026-08-24 with `@webex/contact-center` 3.12.0-next.106 | +| Last verified | 2026-08-25 with `@webex/contact-center` 3.12.0-next.109 | ## Applicability @@ -68,7 +68,7 @@ There are no open product decisions for this delta. - Load dial numbers through the generic SDK AddressBook service and rely on its default backend ordering. - Preserve the SDK's `data` order and pagination metadata without local sorting, channel filtering, or metadata reconstruction. - Keep loading, empty, and error behavior in the existing widget layers. -- Use the pinned `@webex/contact-center` 3.12.0-next.106 dependency containing the coordinated SDK change. +- Use the pinned `@webex/contact-center` 3.12.0-next.109 dependency containing the coordinated SDK change. ### Out of scope @@ -142,11 +142,11 @@ There are no open product decisions for this delta. - [x] Queue and entry-point fetchers delegate to existing SDK methods, while dial numbers use AddressBook, without local returned-data sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). - [x] Params-only telephony queue requests rely on SDK defaults; active non-telephony requests supply a complete channel filter, and legacy media-plus-params calls preserve their channel scope and caller filter. Entry-point requests always delegate directly, and no list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). -- [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with `@webex/contact-center` 3.12.0-next.106 (`WIDGET-LIST-R-006`). +- [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with `@webex/contact-center` 3.12.0-next.109 (`WIDGET-LIST-R-006`). - [x] Agent rows show active/away presence from buddy state, and dial-number/entry-point rows show their typed secondary identifiers (`WIDGET-LIST-R-008`). - [x] A late buddy-agent response from a previous Consult/Transfer action cannot overwrite the current action's list or loading state (`WIDGET-LIST-R-009`). -- [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with `@webex/contact-center` 3.12.0-next.106. -- [x] The complete cc-components unit suite and the focused consult/transfer suites pass with `@webex/contact-center` 3.12.0-next.106. +- [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with `@webex/contact-center` 3.12.0-next.109. +- [x] The complete cc-components unit suite and the focused consult/transfer suites pass with `@webex/contact-center` 3.12.0-next.109. ## Scenarios and applicable change views @@ -243,7 +243,7 @@ No event contract changes. | Risk or assumption | Evidence | Mitigation or decision owner | | --- | --- | --- | -| Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Pin the store to the compatible SDK release; currently `3.12.0-next.106`. | +| Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Pin the store to the compatible SDK release; currently `3.12.0-next.109`. | | A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | | Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | | A Consult buddy request completes after the user switches to Transfer. | `packages/contact-center/task/src/helper.ts` | Track the newest buddy request and ignore older data, error fallback, and loading-state updates. | diff --git a/ai-docs/features/participant-drop-intake.md b/ai-docs/features/participant-drop-intake.md index 4707f60c4..b6645f14d 100644 --- a/ai-docs/features/participant-drop-intake.md +++ b/ai-docs/features/participant-drop-intake.md @@ -36,6 +36,8 @@ Completion is event-correlated: `ParticipantLeftConference` resolves, `Participa For EP-DN cross-channel lifecycle ordering, `ContactMerged` replaces the child task with the main-interaction task and publishes `task:merged`. `ParticipantLeftConference` and `AgentConsultEnded` first use exact task correlation, then one unique `mainInteractionId`/`parentInteractionId` relationship. Updated `mainCall` membership is authoritative for whether the current Agent ended, independent of consult state or initiator role. Widgets consume the existing terminal events and defer their task-list read by one microtask so SDK final cleanup is visible; they never delete or terminate an SDK task locally. +Primary-agent promotion follows Agent Desktop's two-event flow. `ContactOwnerChanged` carries the authoritative owner to the promoted Agent, while an owner-changing `ContactUpdated` carries it to the other surviving Agents. The SDK normalizes both paths into the existing `task:hydrate` notification. The widget Store replaces its cloned current task from that hydrate, and roster Primary status and Drop permissions recompute solely from `interaction.owner`; widgets never elect or infer the next owner locally. + ## Widget behavior `@webex/cc-store` derives a Drop-specific roster from the current main-call media leg without changing `getConferenceParticipants` for its existing consumers. The viewing agent must remain active, and any supported non-customer row keeps the roster visible. Main-leg membership remains authoritative when conference state, flags, controls, or wrap-up signals lag behind participant updates. @@ -61,7 +63,7 @@ Backend authorization remains mandatory. Owner-based UI visibility is not an aut Only the existing React `CallControlCAD` export and `widget-cc-call-control-cad` custom element gain behavior. Standard `CallControl` remains visually unchanged. No widget prop, custom-element attribute/property, initialization option, or callback is added. -The widget consumes published `@webex/contact-center@3.12.0-next.106` from `@webex/cc-store`; local links, absolute paths, portals, tarballs, and temporary resolutions must never be committed. +The widget consumes published `@webex/contact-center@3.12.0-next.109` from `@webex/cc-store`; the React sample uses `webex@3.12.0-next.184`, which contains that Contact Center version. Local links, absolute paths, portals, tarballs, and temporary resolutions must never be committed. ## Verification diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 7c08ac838..111148b0d 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -44,7 +44,7 @@ A maintainer should start at `src/store.ts` to understand the observable shape a Owns Contact Center client-side state and the SDK boundary: initialize/register with `@webex/contact-center`, subscribe to CC and task events, expose reactive observables and mutators, fetch domain lists (buddy agents, queues, entry points, address book), and centralize the error callback. It does NOT own UI rendering, business validation, or any direct network protocol beyond delegating to the SDK. ## Stack -TypeScript 5.6.3, MobX 6.13.5 (`makeAutoObservable`, `observable.ref`, `runInAction`). Consumed in React 18 via `mobx-react-lite` `observer()` in downstream packages (not a dependency of this package itself). SDK dependency `@webex/contact-center` 3.12.0-next.106. Tests: Jest 29 + ts compile (`tsc --project tsconfig.test.json && jest --coverage`). Build target: `dist/index.js` (Webpack). Evidence: `packages/contact-center/store/package.json`. +TypeScript 5.6.3, MobX 6.13.5 (`makeAutoObservable`, `observable.ref`, `runInAction`). Consumed in React 18 via `mobx-react-lite` `observer()` in downstream packages (not a dependency of this package itself). SDK dependency `@webex/contact-center` 3.12.0-next.109. Tests: Jest 29 + ts compile (`tsc --project tsconfig.test.json && jest --coverage`). Build target: `dist/index.js` (Webpack). Evidence: `packages/contact-center/store/package.json`. ## Folder / Package Structure ``` @@ -83,7 +83,7 @@ Compatibility notes: - The `CC_EVENTS` / `TASK_EVENTS` enums are locally declared until the SDK exports them (see `// TODO: remove this once cc sdk exports this enum`, `store.types.ts:247`). They must stay byte-identical to the SDK's emitted event strings. ## Requires (dependencies) -- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.106`) — the entire CC runtime: `Webex.init()`, `webex.cc.*` methods, the CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()`. Consumed ONLY through the store. Fallback on unavailability: `Store.init()` rejects after a 6000ms timeout (`src/store.ts:140-142`); the wrapper wraps the rejection and invokes `onErrorCallback('Store', err)` (`src/storeEventsWrapper.ts:442-452`). +- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.109`) — the entire CC runtime: `Webex.init()`, `webex.cc.*` methods, the CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()`. Consumed ONLY through the store. Fallback on unavailability: `Store.init()` rejects after a 6000ms timeout (`src/store.ts:140-142`); the wrapper wraps the rejection and invokes `onErrorCallback('Store', err)` (`src/storeEventsWrapper.ts:442-452`). - `mobx` ^6.13.5 — observable state and `runInAction` for all mutations. - Internal: none upstream. The store is the lowest widget-layer dependency (`cc-components → widget packages → store → SDK`); it imports no widget package. @@ -114,6 +114,7 @@ Compatibility notes: | `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. Consult activity recognizes visible consult controls, `uiControls.main.endConsult`, and consult interaction states. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | | `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | | `STORE-R-024` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | +| `STORE-R-025` | `handleTaskHydrate` replaces the Store's cloned `currentTask` with a clone of the authoritative SDK task even when the interaction ID is unchanged. Participant Drop ownership and Primary-row state are always derived from the hydrated `interaction.owner`; the Store never elects a successor locally. | Agent Desktop receives the promoted agent through `ContactOwnerChanged` and remaining agents through an owner-changing `ContactUpdated`; the SDK normalizes those paths into hydration so every widget view updates without waiting for another participant event. | `src/storeEventsWrapper.ts`, `src/task-utils.ts` | `tests/storeEventsWrapper.ts` (owner-change hydration replaces the clone and updates promoted/secondary roster permissions) | Backend ownership and SDK hydration remain authoritative. | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. @@ -122,6 +123,8 @@ Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`) Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Terminal `task:end` and `task:consultEnd` signals coalesce that read into the next microtask because SDK final-state cleanup completes after event emission; consult flags still clear synchronously, and participant-left remains synchronous for surviving-call roster updates. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. +Ownership changes use the same authoritative hydration boundary. Agent Desktop's event flow distinguishes the newly promoted agent (`ContactOwnerChanged`) from other surviving agents (an owner-changing `ContactUpdated`); the SDK publishes the resulting owner snapshot through `task:hydrate`. `handleTaskHydrate` replaces the Store's shallow task clone, and owner-aware selectors recompute from `interaction.owner`. The Store does not infer, elect, or preserve a successor itself. + Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`. ## Data Flow @@ -352,6 +355,7 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-021` | None found | `handleRealtimeTranscription` untested | | `STORE-R-022` | `tests/task-utils.ts` (participant Drop roster, owner transfer, ANI/DNIS, filtering, consult gating, conference eligibility) | none | | `STORE-R-023` | `tests/storeEventsWrapper.ts` no longer exposes raw collaboration-policy getters | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | +| `STORE-R-025` | `tests/storeEventsWrapper.ts` (owner-change hydration and immediate Drop-roster recomputation) | none | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index 82361e22f..0ebcf0ee9 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -23,7 +23,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/contact-center": "3.12.0-next.106", + "@webex/contact-center": "3.12.0-next.109", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 05f50cd08..899eb8b8c 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -26,6 +26,7 @@ import {CC_EVENTS, TASK_EVENTS} from '../src/store.types'; import type {RealTimeAssistPayload} from '../src/store.types'; import storeWrapper from '../src/storeEventsWrapper'; import {ITask} from '../src/store.types'; +import {getConferenceParticipantDropRoster} from '../src/task-utils'; import { mockCC, mockTask as mockTaskFixture, @@ -2334,6 +2335,132 @@ describe('storeEventsWrapper', () => { }); }); + describe('owner-change hydration', () => { + const interactionId = 'owner-change-main'; + const oldOwnerId = 'agent1'; + const promotedOwnerId = 'agent2'; + const survivingSecondaryId = 'agent3'; + let originalAgentId: (typeof storeWrapper)['store']['agentId']; + let originalCurrentTask: (typeof storeWrapper)['store']['currentTask']; + let originalDeviceType: (typeof storeWrapper)['store']['deviceType']; + let originalGetAllTasks: (typeof storeWrapper)['store']['cc']['taskManager']['getAllTasks']; + let originalTaskList: (typeof storeWrapper)['store']['taskList']; + + const participant = (id: string, name: string, hasLeft = false) => ({ + id, + name, + pType: 'Agent', + type: 'Agent', + hasJoined: true, + hasLeft, + isInPredial: false, + }); + + const createOwnerTask = (owner: string, oldOwnerHasLeft = false): ITask => + makeMockTask({ + data: { + interactionId, + agentId: survivingSecondaryId, + isConferenceInProgress: true, + interaction: { + interactionId, + mediaType: 'telephony', + state: 'conference', + owner, + contactDirection: {type: 'inbound'}, + callAssociatedDetails: {ani: '+15550000001'}, + participants: { + [oldOwnerId]: participant(oldOwnerId, 'Original Owner', oldOwnerHasLeft), + [promotedOwnerId]: participant(promotedOwnerId, 'Promoted Owner'), + [survivingSecondaryId]: participant(survivingSecondaryId, 'Surviving Secondary'), + customer1: { + id: 'customer1', + name: 'Customer', + pType: 'Customer', + type: 'Customer', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + }, + media: { + [interactionId]: { + mediaResourceId: interactionId, + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + participants: oldOwnerHasLeft + ? [promotedOwnerId, survivingSecondaryId, 'customer1'] + : [oldOwnerId, promotedOwnerId, survivingSecondaryId, 'customer1'], + }, + }, + }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + originalAgentId = storeWrapper['store'].agentId; + originalCurrentTask = storeWrapper['store'].currentTask; + originalDeviceType = storeWrapper['store'].deviceType; + originalGetAllTasks = storeWrapper['store'].cc.taskManager.getAllTasks; + originalTaskList = storeWrapper['store'].taskList; + + storeWrapper['store'].agentId = survivingSecondaryId; + storeWrapper['store'].currentTask = null; + storeWrapper['store'].taskList = {}; + storeWrapper['store'].deviceType = 'EXTENSION'; + }); + + afterEach(() => { + storeWrapper['store'].agentId = originalAgentId; + storeWrapper['store'].currentTask = originalCurrentTask; + storeWrapper['store'].deviceType = originalDeviceType; + storeWrapper['store'].cc.taskManager.getAllTasks = originalGetAllTasks; + storeWrapper['store'].taskList = originalTaskList; + }); + + it('immediately replaces the cloned task and derives the authoritative promoted owner', () => { + const staleTask = createOwnerTask(oldOwnerId); + const hydratedTask = createOwnerTask(promotedOwnerId, true); + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({[interactionId]: staleTask}); + + storeWrapper.setCurrentTask(staleTask); + const staleCurrentTask = storeWrapper.currentTask as ITask; + const staleSecondaryRoster = getConferenceParticipantDropRoster(staleCurrentTask, survivingSecondaryId); + + expect(staleCurrentTask).not.toBe(staleTask); + expect(staleSecondaryRoster?.participants.find(({dropTargetId}) => dropTargetId === oldOwnerId)?.isPrimary).toBe( + true + ); + + // An owner-change task:hydrate is sufficient; no participant-join event or page refresh is needed. + storeWrapper.handleTaskHydrate(hydratedTask); + + const currentTask = storeWrapper.currentTask as ITask; + const secondaryRoster = getConferenceParticipantDropRoster(currentTask, survivingSecondaryId); + const promotedOwnerRoster = getConferenceParticipantDropRoster(currentTask, promotedOwnerId); + + expect(currentTask).not.toBe(staleCurrentTask); + expect(currentTask).not.toBe(hydratedTask); + expect(currentTask.data.interaction.owner).toBe(promotedOwnerId); + expect(secondaryRoster?.participants).not.toEqual( + expect.arrayContaining([expect.objectContaining({dropTargetId: oldOwnerId})]) + ); + expect(secondaryRoster?.participants.find(({dropTargetId}) => dropTargetId === promotedOwnerId)).toMatchObject({ + isPrimary: true, + isReadOnly: true, + }); + expect(secondaryRoster?.customer?.isReadOnly).toBe(true); + expect( + promotedOwnerRoster?.participants.find(({dropTargetId}) => dropTargetId === survivingSecondaryId) + ).toMatchObject({isPrimary: false, isReadOnly: false}); + expect(promotedOwnerRoster?.customer?.isReadOnly).toBe(false); + }); + }); + it('set TaskAssigned', () => { const setTaskAssignedSpy = jest.spyOn(storeWrapper, 'setTaskAssigned'); const mockTaskAssignedCallback = jest.fn(); diff --git a/widgets-samples/cc/samples-cc-react-app/package.json b/widgets-samples/cc/samples-cc-react-app/package.json index 1317e5971..d2f06ac42 100644 --- a/widgets-samples/cc/samples-cc-react-app/package.json +++ b/widgets-samples/cc/samples-cc-react-app/package.json @@ -32,7 +32,7 @@ "react-dom": "18.3.1", "ts-loader": "^9.5.1", "typescript": "^5.6.3", - "webex": "3.12.0-next.84", + "webex": "3.12.0-next.184", "webpack": "^5.94.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.1.0", diff --git a/yarn.lock b/yarn.lock index 067f7d1dd..432d287ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9560,41 +9560,17 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.12.0-next.34": - version: 3.12.0-next.34 - resolution: "@webex/calling@npm:3.12.0-next.34" - dependencies: - "@types/platform": "npm:1.3.4" - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/internal-media-core": "npm:2.25.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-feature": "npm:3.12.0-next.15" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/media-helpers": "npm:3.12.0-next.4" - async-mutex: "npm:0.4.0" - backoff: "npm:2.5.0" - buffer: "npm:6.0.3" - lodash: "npm:^4.17.21" - platform: "npm:1.3.6" - uuid: "npm:8.3.2" - ws: "npm:8.17.1" - xstate: "npm:4.30.6" - checksum: 10c0/721f9cafa26c610115b59e0bab6dbe7e8106796d468ed441af12dd6c301acdfd1f366851d27ea7046c59c9abd3c92e8d2e8768cf154b843016f492cb3ddbecf0 - languageName: node - linkType: hard - -"@webex/calling@npm:3.12.0-next.87": - version: 3.12.0-next.87 - resolution: "@webex/calling@npm:3.12.0-next.87" +"@webex/calling@npm:3.12.0-next.89": + version: 3.12.0-next.89 + resolution: "@webex/calling@npm:3.12.0-next.89" dependencies: "@types/platform": "npm:1.3.4" "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/internal-media-core": "npm:2.28.2" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-feature": "npm:3.12.0-next.40" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-feature": "npm:3.12.0-next.41" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" "@webex/media-helpers": "npm:3.12.0-next.11" async-mutex: "npm:0.4.0" backoff: "npm:2.5.0" @@ -9604,7 +9580,7 @@ __metadata: uuid: "npm:8.3.2" ws: "npm:8.17.1" xstate: "npm:4.30.6" - checksum: 10c0/53ab87c4661c1be73920161df11e51198fb4c7a15229b9a95fc9d3e14812cdfa404e14b460852106c69f80478bf53bc8aafad9abe9cb095fb5a7bce9ca443fbd + checksum: 10c0/8a770b35df53e1b54fd69bd49ffa323cae8f15772746f6ded53be04f8b8a6880552ee6c3f2a759cf807b1c5a29a3e4e9a0969ec06b1860103f4bb02275f95250 languageName: node linkType: hard @@ -9815,7 +9791,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" - "@webex/contact-center": "npm:3.12.0-next.106" + "@webex/contact-center": "npm:3.12.0-next.109" "@webex/event-dictionary-ts": "npm:^1.0.2191" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" @@ -10042,13 +10018,6 @@ __metadata: languageName: node linkType: hard -"@webex/common-timers@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/common-timers@npm:3.12.0-next.1" - checksum: 10c0/8181a7e7195df093db1ca649b32ada69b949b9f8f9d18c275120584f60ec912f5b2fa7772186f63c959eace900abb1ea4619db5bbac5839d6feda5c89758ae9a - languageName: node - linkType: hard - "@webex/common-timers@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/common-timers@npm:3.12.0-next.5" @@ -10104,21 +10073,6 @@ __metadata: languageName: node linkType: hard -"@webex/common@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/common@npm:3.12.0-next.1" - dependencies: - backoff: "npm:^2.5.0" - bowser: "npm:^2.11.0" - core-decorators: "npm:^0.20.0" - global: "npm:^4.4.0" - lodash: "npm:^4.17.21" - safe-buffer: "npm:^5.2.0" - urlsafe-base64: "npm:^1.0.0" - checksum: 10c0/6b604810d385f432671c3435211ddf097ac98d1d779acf418d1a53a9d8064e889d3381b2d48b0e43c008c806ce64387dccb1001b714acef2ab4b93f44fbb2bfe - languageName: node - linkType: hard - "@webex/common@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/common@npm:3.12.0-next.5" @@ -10170,41 +10124,23 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-next.106": - version: 3.12.0-next.106 - resolution: "@webex/contact-center@npm:3.12.0-next.106" +"@webex/contact-center@npm:3.12.0-next.109": + version: 3.12.0-next.109 + resolution: "@webex/contact-center@npm:3.12.0-next.109" dependencies: "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.87" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.44" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" - "@webex/internal-plugin-support": "npm:3.12.0-next.46" - "@webex/plugin-authorization": "npm:3.12.0-next.40" - "@webex/plugin-logger": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/calling": "npm:3.12.0-next.89" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" + "@webex/internal-plugin-support": "npm:3.12.0-next.47" + "@webex/plugin-authorization": "npm:3.12.0-next.41" + "@webex/plugin-logger": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" jest-html-reporters: "npm:3.0.11" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" xstate: "npm:5.24.0" - checksum: 10c0/3921600575393326c6015f77d49232b7316c9a148ac247e9c9f6c2d546e098a3d748689189df059290d10b7ab9221cb99229b0284973b36382a33d8fb944ef6f - languageName: node - linkType: hard - -"@webex/contact-center@npm:3.12.0-next.42": - version: 3.12.0-next.42 - resolution: "@webex/contact-center@npm:3.12.0-next.42" - dependencies: - "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.34" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/internal-plugin-support": "npm:3.12.0-next.17" - "@webex/plugin-authorization": "npm:3.12.0-next.15" - "@webex/plugin-logger": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - jest-html-reporters: "npm:3.0.11" - lodash: "npm:^4.17.21" - checksum: 10c0/7b4caa4a12dcd1070b7ed720fc56e7df357ae48126aa7a48c5ab2f854210093120891e2ab4d7cc8966539dbe204b0bf8e7d9aed22abab8216098c5dd72f760b4 + checksum: 10c0/62d3c399eec7b63e137f6df5edf06ee1b7b5809e445d381f196fddef279672383376d4444bcfe0874d3b1542ef992f6fa763808720944d4acc20f3dfe07b13d7 languageName: node linkType: hard @@ -10221,6 +10157,19 @@ __metadata: languageName: node linkType: hard +"@webex/event-dictionary-ts@npm:^1.0.2215": + version: 1.0.2236 + resolution: "@webex/event-dictionary-ts@npm:1.0.2236" + dependencies: + amf-client-js: "npm:^5.2.6" + json-schema-to-typescript: "npm:^15.0.4" + minimist: "npm:^1.2.8" + shelljs: "npm:^0.8.5" + webapi-parser: "npm:^0.5.0" + checksum: 10c0/5f80e017ea0f60db52605d61971f7062821a747dd02f9c6cc3dee324233163a44be6c66b549a469305524d124447e5dd0e2f92303ee679864fa075cb954fcaf5 + languageName: node + linkType: hard + "@webex/event-dictionary-ts@npm:^1.0.2220": version: 1.0.2234 resolution: "@webex/event-dictionary-ts@npm:1.0.2234" @@ -10252,15 +10201,6 @@ __metadata: languageName: node linkType: hard -"@webex/helper-html@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/helper-html@npm:3.12.0-next.1" - dependencies: - lodash: "npm:^4.17.21" - checksum: 10c0/cf325224593d4f2b52d0542828ef60bc22343721aa84047e97b3c12bc133f1e105db06c01ae9fb6f32bef6cae2a8f2e8b6c4aecae5083ffc72fad811dc6f88be - languageName: node - linkType: hard - "@webex/helper-html@npm:3.12.0-next.6": version: 3.12.0-next.6 resolution: "@webex/helper-html@npm:3.12.0-next.6" @@ -10304,23 +10244,6 @@ __metadata: languageName: node linkType: hard -"@webex/helper-image@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/helper-image@npm:3.12.0-next.1" - dependencies: - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-file": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - exifr: "npm:^5.0.3" - gm: "npm:^1.23.1" - lodash: "npm:^4.17.21" - mime: "npm:^2.4.4" - safe-buffer: "npm:^5.2.0" - checksum: 10c0/1ab6b056cf208541876299b6ba984856c2f65ccf1f2f2c34b3e2eb0eb963b14eba4759178dbb99e86dfbbaa6e515ec958d591450ac16902339f138b86a78a461 - languageName: node - linkType: hard - "@webex/helper-image@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/helper-image@npm:3.12.0-next.5" @@ -10397,24 +10320,6 @@ __metadata: languageName: node linkType: hard -"@webex/http-core@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/http-core@npm:3.12.0-next.1" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - file-type: "npm:^16.0.1" - global: "npm:^4.4.0" - is-function: "npm:^1.0.1" - lodash: "npm:^4.17.21" - parse-headers: "npm:^2.0.2" - qs: "npm:^6.7.3" - request: "npm:^2.88.0" - safe-buffer: "npm:^5.2.0" - xtend: "npm:^4.0.2" - checksum: 10c0/438aae8087f00da83b7a8790a74605167fc60ff5256bb79cd900803ee547b99046b8d2c7898f0c4d91092f28eee65ba8a8c2df591c3d784fff1b5e2b65af3876 - languageName: node - linkType: hard - "@webex/http-core@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/http-core@npm:3.12.0-next.5" @@ -10450,26 +10355,6 @@ __metadata: languageName: node linkType: hard -"@webex/internal-media-core@npm:2.25.1": - version: 2.25.1 - resolution: "@webex/internal-media-core@npm:2.25.1" - dependencies: - "@babel/runtime": "npm:^7.18.9" - "@babel/runtime-corejs2": "npm:^7.25.0" - "@webex/rtcstats": "npm:^1.5.5" - "@webex/ts-sdp": "npm:1.8.2" - "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-client-media-engine": "npm:3.39.12" - events: "npm:^3.3.0" - ip-anonymize: "npm:^0.1.0" - typed-emitter: "npm:^2.1.0" - uuid: "npm:^8.3.2" - webrtc-adapter: "npm:^8.1.2" - xstate: "npm:^4.30.6" - checksum: 10c0/7a726f3226c1a0d4a1b60abccbeae8d5a60560bb11d5f0877c8411fbdb91f2f86f56ac83fad1bc5ace21d45afdbc20f810340e74b1986d785fd9bfb8cee04eb1 - languageName: node - linkType: hard - "@webex/internal-media-core@npm:2.28.2": version: 2.28.2 resolution: "@webex/internal-media-core@npm:2.28.2" @@ -10504,17 +10389,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-calendar@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-calendar@npm:3.12.0-next.17" +"@webex/internal-plugin-calendar@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-calendar@npm:3.12.0-next.47" dependencies: - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/9461f0f5659a686da11686def1fdc1b265dd5185a50fe3e6e800f4dd6b5f5a88612e6a50d0ecfa39999ee79998e94f77f90f809f89543d1676ae353b727677d5 + checksum: 10c0/20cda926984961c620420d432859f90e9cd030e215ac2b19ae19e3b80115070c9e59c37487f12eaa7b563a1d79093c1458bf09767d8fff5419efc339adfa7155 languageName: node linkType: hard @@ -10554,39 +10439,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.17" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/helper-html": "npm:3.12.0-next.1" - "@webex/helper-image": "npm:3.12.0-next.1" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/internal-plugin-user": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - crypto-js: "npm:^4.1.1" - lodash: "npm:^4.17.21" - node-scr: "npm:^0.3.0" - uuid: "npm:^3.3.2" - checksum: 10c0/0d7345f75b13b905b6c3cc5a94bb37f36788a1750789b09a392e2046686916eceddc1d1913c34d850d3b2c5a33b8f3b5945415985c0d637b9879d4a57cb4065d - languageName: node - linkType: hard - -"@webex/internal-plugin-conversation@npm:3.12.0-next.46": - version: 3.12.0-next.46 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.46" +"@webex/internal-plugin-conversation@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.47" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/helper-html": "npm:3.12.0-next.6" "@webex/helper-image": "npm:3.12.0-next.5" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.44" - "@webex/internal-plugin-user": "npm:3.12.0-next.41" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/internal-plugin-user": "npm:3.12.0-next.42" + "@webex/webex-core": "npm:3.12.0-next.41" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/3d024f2dcee3684825937a0aefa941e954bbb2136839e05b1fbff4e296b84c68930f5d65b7c2ebf44f3a55d2e905431da6d6eb260c348f388d0f9434849983d2 + checksum: 10c0/bfef0474e6fd59dd99007c971f10fa49ece546cf4e682a4322da8958d37b600a6a111cae2402cc387e70cac50d769a960444c7e369e12826d938ef3c1f68e93f languageName: node linkType: hard @@ -10623,51 +10490,34 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-device@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - ampersand-collection: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/c9bef045a01429c75ef55eee0fcdc08e6d647e4a35a81217963157a24c7e454093419a9a68203bdd3fb3f1081ba269d96d410b870e1eae5f4154691f40dfb8ed - languageName: node - linkType: hard - -"@webex/internal-plugin-device@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.40" +"@webex/internal-plugin-device@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-device@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/http-core": "npm:3.12.0-next.5" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" ampersand-collection: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/8b5d3cbb0f0d6dc53b401b853ab8c276ec8516ef4c965488e45db4c1c56ecfea041ebaae326ed3654fb5b5a118d28d24f29ca1356118b7bf12f17a4582e96e30 + checksum: 10c0/c12d90722d7881b42a6918e04153c2ceea933185f00c90a051aef7cc5b82e9982ab0f8a58bceda7823b956d5d38af6ff0f6afb70926eebd806eeb422d9cbfca4 languageName: node linkType: hard -"@webex/internal-plugin-dss@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-dss@npm:3.12.0-next.16" +"@webex/internal-plugin-dss@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-dss@npm:3.12.0-next.45" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/common-timers": "npm:3.12.0-next.5" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/bb7d8c041171235beb09df72731256824daa50e2a662b5aaad8af53105b6833371d75785613c9df851b658e337cc64cbb1c1f4868970124a7542dc5898a3817b + checksum: 10c0/89e88c21e9349e425aa933af2d089320e4124ef7255865631d39daee3da5943ac73a80e22fe8ca956cf0e457a666c84ac1c5788ba900b0ba98a0dfc5f08f7c36 languageName: node linkType: hard @@ -10723,43 +10573,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.16" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/test-helper-file": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - asn1js: "npm:^2.0.26" - debug: "npm:^4.3.4" - isomorphic-webcrypto: "npm:^2.3.8" - lodash: "npm:^4.17.21" - node-jose: "npm:^2.2.0" - node-kms: "npm:^0.4.1" - node-scr: "npm:^0.3.0" - pkijs: "npm:^2.1.84" - safe-buffer: "npm:^5.2.0" - uuid: "npm:^3.3.2" - valid-url: "npm:^1.0.9" - checksum: 10c0/ed33bdfca34f2fe0bb81ef7b7ebcd4b336c7c36fdd6bc659941e56b5666448e7daf19fb1a0f7481d25ddb1924cbda8a536b87328196facccda0dca98e0942bc7 - languageName: node - linkType: hard - -"@webex/internal-plugin-encryption@npm:3.12.0-next.44": - version: 3.12.0-next.44 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.44" +"@webex/internal-plugin-encryption@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.45" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/http-core": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.44" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" "@webex/test-helper-file": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" asn1js: "npm:^2.0.26" debug: "npm:^4.3.4" isomorphic-webcrypto: "npm:^2.3.8" @@ -10771,7 +10595,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/1ec9cb4ed3095c39f259820a986ebdaa5cb6b3a282e0e23639d248d73677c2cafdf7b47f62ae1a9a46a55692442dabc7f59d5decf9ae8b260472c054a3ac2cfa + checksum: 10c0/46f06b1e60228ed58525dc1ce45dc7a8946015f67e7e827662633f5ac69e5fcbb3bdbf73191ee3566197edb2c7d19f6d2344a5de97c0570e43136286dc17e54b languageName: node linkType: hard @@ -10798,34 +10622,23 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-feature@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.15" - dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - checksum: 10c0/c6d99e9b93ac300ec601c8d1da5a33e5249aadf49aec14166f5a14000045fcf07b28bf55890691680dab219f7f5291e9b2b74af56bf6473121eb862d402c439b - languageName: node - linkType: hard - -"@webex/internal-plugin-feature@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.40" +"@webex/internal-plugin-feature@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" - checksum: 10c0/a0d67aed66702a122f21d9baae0d8ddeea9f864573be18bea07b9e609c46af1821fc8319bd9ca4d5065a1504f9212bbab56e4a64f4a39d64239fa94bdf177421 + checksum: 10c0/c70bb582aaf8a56d56f1a39cbf21a20f4b20902c440dac4356447cd62e0870ee52066888d5c66e8d7d8d39789add999208dab8d448a9f153b3dd5a18859149e8 languageName: node linkType: hard -"@webex/internal-plugin-llm@npm:3.12.0-next.18": - version: 3.12.0-next.18 - resolution: "@webex/internal-plugin-llm@npm:3.12.0-next.18" +"@webex/internal-plugin-llm@npm:3.12.0-next.48": + version: 3.12.0-next.48 + resolution: "@webex/internal-plugin-llm@npm:3.12.0-next.48" dependencies: - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - checksum: 10c0/a045c09cce44b6e3d6048dbb79bc3a4becd5206ba3c9c91529dc5eee707847d21b8bc57423290e055949b3dbed70d2db8fc3560867642000a5a888f1250e6a7b + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + checksum: 10c0/c52c7b5d5694c298b746b2be9e2b001b2996fcad3302f89dc8083b5b1599d0152dac098a1557659d03712430c45fc2e6c3c55dad9b7f10c5c9b4af2a4fcb0b74 languageName: node linkType: hard @@ -10843,17 +10656,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-locus@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-locus@npm:3.12.0-next.16" +"@webex/internal-plugin-locus@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-locus@npm:3.12.0-next.45" dependencies: - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/7df66a258562ec27ced651a2be5dc002641414954df727f2d83dbc78e96b2d11bbe9209a0eb801a5bff2950796f100152ee28a60a245dd760b4aef70eadf3b2f + checksum: 10c0/9582f505e65ad9f96dc621f32d69577b517f408d266e9ea0883a61d6e10517bded348361fe318a362dfd8c5cac60aaef59af58fb43b84038222a6b880a39ff7a languageName: node linkType: hard @@ -10874,20 +10687,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-lyra@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-lyra@npm:3.12.0-next.17" +"@webex/internal-plugin-lyra@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-lyra@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/internal-plugin-feature": "npm:3.12.0-next.15" - "@webex/internal-plugin-locus": "npm:3.12.0-next.16" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/internal-plugin-feature": "npm:3.12.0-next.41" + "@webex/internal-plugin-locus": "npm:3.12.0-next.45" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" bowser: "npm:^2.11.0" uuid: "npm:^3.3.2" - checksum: 10c0/e94ed78b8f3bb50bcdbe68e7d4c0374a5ebe8ad19defb9a0cab90ceacc3ee4f6d2607d9ebc1ec751d1a4e4cfb55617973996ebc9e643da71054fe496b3a2bf04 + checksum: 10c0/eb920d13195e62a7d040ee9eeec12ed905854659cd0085fe0790de5f672e70db086593032e948de5afba7810ef7c2a9c9027a1150e6fb243084efc19faee959e languageName: node linkType: hard @@ -10939,51 +10752,27 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-mercury@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.16" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-feature": "npm:3.12.0-next.15" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-refresh-callback": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - backoff: "npm:^2.5.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - ws: "npm:^8.17.1" - checksum: 10c0/0cfe3cb25a5b42513103ff147b850476c6f0d885e317a13dc2fe34f3a1d9e605edbd75b421ea667b1470c414b82998340e37d64bb33176e19844fb1466347ded - languageName: node - linkType: hard - -"@webex/internal-plugin-mercury@npm:3.12.0-next.44": - version: 3.12.0-next.44 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.44" +"@webex/internal-plugin-mercury@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.45" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-feature": "npm:3.12.0-next.40" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-feature": "npm:3.12.0-next.41" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-refresh-callback": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" backoff: "npm:^2.5.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" ws: "npm:^8.17.1" - checksum: 10c0/0948067042f4a11900b239847fd3e68479acf3409795509b487712b5a521fed490bf72c33b008fb23485e95d5fd82b8713c0315c935b721a20aa0c12ad200d4a + checksum: 10c0/9c1aaea6f5e1797c7df9eba9c909d740157052a7d4760a00d40f3d06b616a0c422dc7f63a1166fe78368e6ddbaf50ac395ef7d09afb853365bc48a4085492144 languageName: node linkType: hard @@ -11016,37 +10805,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-metrics@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - ip-anonymize: "npm:^0.1.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/d8b1d3f1b1f34589f57e9a30030cd8922a95d009f3e2d0b57dd57ade34c439a371d38bb4c0960ccab65498c55b9b0f3d12451e3b0d66a95a0f59757b23c55a83 - languageName: node - linkType: hard - -"@webex/internal-plugin-metrics@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.40" +"@webex/internal-plugin-metrics@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/event-dictionary-ts": "npm:^1.0.2220" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" ip-anonymize: "npm:^0.1.0" isbot: "npm:5.2.1" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/c395e5491e6a7d650ac73311c0084acf7ec53786ac3002cde1849efad9cea478fe954c0427da7001de659b61cc9e445bcd2357afe030658b5f08b81188143687 + checksum: 10c0/f1c5d5bbf4719f2f207f2f28b94f879b31bfc90b584167c3c5ad5c83987300f1133bde9ceec94019b73cf8030267271dbcd5002559b21b1da33cb6c7092a99aa languageName: node linkType: hard @@ -11066,19 +10839,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-presence@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-presence@npm:3.12.0-next.16" +"@webex/internal-plugin-presence@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-presence@npm:3.12.0-next.45" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/test-helper-mocha": "npm:3.12.0-next.5" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" + "@webex/test-helper-test-users": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" - checksum: 10c0/557276a3f1c64eb4026a9dcba35df70b7ad890109026582297085add6c970ad53d98ca46fee66862850a5f7ac2b2d8368c8aff5ddf0eac52f3efc1ff14198417 + checksum: 10c0/6c0928cb4709d345a608a45d536ffcd1c24355c0e8f68149b080d5f840020ddf9d1861b2efa85d7a90d2f34cccf6a61822bfddde06c29eec669dc751ad61ee15 languageName: node linkType: hard @@ -11097,33 +10870,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.17" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/b5e13de9b8312333a7ff48630ea0bb306da5b2d250626806e08d3cad5a43c20a8a8f119175687d9320a7ea2e8c13c2d8daa8aa8d1cc6e400f5aab89b501f553d - languageName: node - linkType: hard - -"@webex/internal-plugin-search@npm:3.12.0-next.46": - version: 3.12.0-next.46 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.46" +"@webex/internal-plugin-search@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-search@npm:3.12.0-next.47" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.46" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.44" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e5dd18dc81181d93de255726ee275001d707cca659c5b749ce76a7c0983302ac8bc6518944cee543f2091e619d25a25475a9c88aef55e56c39ad350379f20f4f + checksum: 10c0/b133a290fe6d79d7e46a5428985137ef5b052610add90cb1018fcf81e5b29cd4a45955bb5a15d5065cee0fe982e6dc3678c907a2e4015322160da6d90dab6b6c languageName: node linkType: hard @@ -11144,41 +10902,24 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.17" +"@webex/internal-plugin-support@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-support@npm:3.12.0-next.47" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-search": "npm:3.12.0-next.17" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-file": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/e2d792681c264c646d47187a3a7a8f995fc63b1c724f78762d2e019db8798ae5b7ca6a7a516cf8938e66915f8c969c08a400806ffb51edf787397629bacc3c1c - languageName: node - linkType: hard - -"@webex/internal-plugin-support@npm:3.12.0-next.46": - version: 3.12.0-next.46 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.46" - dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-search": "npm:3.12.0-next.46" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-search": "npm:3.12.0-next.47" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-file": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/cf3dcb2a26778e5972a477593b94af0757d52fd6fd18798d8818050f89146b0920a151eda045d5f77bfec4cfe6800c7bd55a9d244134238192910f9e35f48e11 + checksum: 10c0/419554e9056b3744460fb8c6f2aabb8c10b83b2877d3358ffeb0e30d9cc3d453ac822bc8a244fc25a811a1926562b65e0d3682c4bd42f32009435a348effe9b9 languageName: node linkType: hard -"@webex/internal-plugin-task@npm:^3.12.0-next.17": +"@webex/internal-plugin-task@npm:^3.12.0-next.47": version: 3.12.0 resolution: "@webex/internal-plugin-task@npm:3.12.0" dependencies: @@ -11224,47 +10965,31 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-user@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.16" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/5f625971f05369f2ae1b22570383f5e6739291c66d1ac0dd44b7d2dfab19c844c0daafd6d5faa1afea3ef62656a27091a89604ab5e53479a9bc1663e5cc735ca - languageName: node - linkType: hard - -"@webex/internal-plugin-user@npm:3.12.0-next.41": - version: 3.12.0-next.41 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.41" +"@webex/internal-plugin-user@npm:3.12.0-next.42": + version: 3.12.0-next.42 + resolution: "@webex/internal-plugin-user@npm:3.12.0-next.42" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/45746e930b4675d80a1415c5c2d1a21f0a715466e39c0c84b72722cd7eb463eb1c95dcf99037fda8110860f8dffa36dd25dea820bfd353133372cd8e90e675e7 + checksum: 10c0/2c680b2f7b876dfd232b2a9cdebb6db9dca3b20d92f5e9a7c85a1d98338fc9703b67800cc96dbd35d02a2c5899b6f70b1d4be08653b6f51bf8e71b571afaccfd languageName: node linkType: hard -"@webex/internal-plugin-voicea@npm:3.12.0-next.18": - version: 3.12.0-next.18 - resolution: "@webex/internal-plugin-voicea@npm:3.12.0-next.18" +"@webex/internal-plugin-voicea@npm:3.12.0-next.50": + version: 3.12.0-next.50 + resolution: "@webex/internal-plugin-voicea@npm:3.12.0-next.50" dependencies: - "@webex/internal-plugin-llm": "npm:3.12.0-next.18" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-llm": "npm:3.12.0-next.48" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" uuid: "npm:^3.3.2" - checksum: 10c0/a95e8af210eb469d6501256216748ce7e066f567d02c3fab17430d825dd422068cc536ec88e9a06feac01d03b81aa6c5d799091cafb83ab54dc53398ac62903a + checksum: 10c0/c59a1cf3676ec4dcd017818df4d8267c0c15eb0882c0c7abbe6af5ad5476c130dca04fd1b96867d5e4ae832edf1b74f248b68e6b6f87c466d39d5552692ec38e languageName: node linkType: hard @@ -11299,17 +11024,6 @@ __metadata: languageName: node linkType: hard -"@webex/media-helpers@npm:3.12.0-next.4": - version: 3.12.0-next.4 - resolution: "@webex/media-helpers@npm:3.12.0-next.4" - dependencies: - "@webex/internal-media-core": "npm:2.25.1" - "@webex/ts-events": "npm:^1.1.0" - "@webex/web-media-effects": "npm:2.33.5" - checksum: 10c0/9ac15b19c7c450f4831b4d6afafc7873c86781670ea0fcd468f25b193f50d13d2c0b13a70b927a94b4d372ebe03f2ecad141df32cb0836dbcdb604c7de9f9799 - languageName: node - linkType: hard - "@webex/package-tools@npm:0.0.0-next.6": version: 0.0.0-next.6 resolution: "@webex/package-tools@npm:0.0.0-next.6" @@ -11340,21 +11054,40 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-attachment-actions@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-attachment-actions@npm:3.12.0-next.17" +"@webex/plugin-attachment-actions@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-attachment-actions@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-messages": 3.12.0-next.17 - "@webex/plugin-people": 3.12.0-next.16 - checksum: 10c0/2e615f347362c443c4931a0bfbed9c551df76319755abecde51b78ed53b269cec337325ea9e13ffe260a58bb165817d63e5ac56d0cb5b70bfdb7d041979f87bb + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-messages": 3.12.0-next.47 + "@webex/plugin-people": 3.12.0-next.45 + checksum: 10c0/26cd3b6fd793dfdbcc93aefa8ad8f926766381a0a6f76ab1293b59328624f83a2e4919ff57d5e2127719eb7d53e711d1206349fdabbe32dd01148411dc8dcab2 + languageName: node + linkType: hard + +"@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.42": + version: 3.12.0-next.42 + resolution: "@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.42" + dependencies: + "@webex/common": "npm:3.12.0-next.5" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.41" + "@webex/test-helper-automation": "npm:3.12.0-next.5" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/test-helper-mocha": "npm:3.12.0-next.5" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" + "@webex/test-helper-test-users": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" + crypto-js: "npm:^4.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^3.3.2" + checksum: 10c0/81844df9583c458828adc44cc6dfe2a6892791b13594a3006c446bfa4cfd62b2aec9db68a88a52709ea4b3d484d80101f2b2e12a0ab29b71a3dd46915f2e19fc languageName: node linkType: hard @@ -11375,37 +11108,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-browser@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/plugin-authorization-node": "npm:3.12.0-next.15" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.15" - "@webex/storage-adapter-spec": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - jsonwebtoken: "npm:^9.0.2" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/04f15ad355e5431dcad6a545f2b1e5c6e99e734743a310107996319cbcd080f6a5d0e115e297fdf414a3aa6c421bba7f786abfbee1fd7aec60d0333796712313 - languageName: node - linkType: hard - -"@webex/plugin-authorization-browser@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.40" +"@webex/plugin-authorization-browser@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/plugin-authorization-node": "npm:3.12.0-next.40" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/plugin-authorization-node": "npm:3.12.0-next.41" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.41" "@webex/storage-adapter-spec": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e4d1f87a22ff7ac3cb2d4dd63d040f05c2bf4e7ce8cbecae8a6bf3491e64a7b691912a03074056ab483a7ec8c1f7e17bf286815fcc8d7dd584a20cde787e654e + checksum: 10c0/6df2a01cf1274275f0fbd83832b27ea068793e00ecede71310c8f151bc1290403848f7d39f814f1fc04058a83ba0a27449e9f10e79f8cf10131a7ffa3ace39ee languageName: node linkType: hard @@ -11422,29 +11138,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-node@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - jsonwebtoken: "npm:^9.0.0" - uuid: "npm:^3.3.2" - checksum: 10c0/8bca668f4e7a17504674a02291ea699df1b350deea8cb9ab1c411e484e17ab765274227f5d1d4a9f4b2e3a4e7223739e38ec1b551780758e12900687960b87f3 - languageName: node - linkType: hard - -"@webex/plugin-authorization-node@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.40" +"@webex/plugin-authorization-node@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" jsonwebtoken: "npm:^9.0.0" uuid: "npm:^3.3.2" - checksum: 10c0/6f9f5ac5bb6d236b0270de34db9fcef4116f5b21aa26d92de18a2f1c693e9a92f8d2e43ec6a96497b85a68b30c1e937b14b888eb9f821189b0b584553323c3fe + checksum: 10c0/b22359724b3b352bb1f3890b4f0f24e569f0d234fd67f4af200745a4285c23800f08357f1e298b0373fa54c52089bd78f5fc27dcf011e873fa9796df820734ac languageName: node linkType: hard @@ -11458,23 +11161,13 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.15" - dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.15" - "@webex/plugin-authorization-node": "npm:3.12.0-next.15" - checksum: 10c0/97f50fb9b111628ea46be900e6f69ca61a7220c9dcc1b66a705f452178ecb9622ebdcb6f442139da09141fc34111b503035e38ce005d5ca5adff3cb86766148c - languageName: node - linkType: hard - -"@webex/plugin-authorization@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.40" +"@webex/plugin-authorization@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-authorization@npm:3.12.0-next.41" dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.40" - "@webex/plugin-authorization-node": "npm:3.12.0-next.40" - checksum: 10c0/1a3c75769a7b474106b786f9cd08488632c9adb9fb035e46307bf70d1c10f9e42117399db4f03b570adbe856a69c104ea191824109387d779b264ba4fb1ea152 + "@webex/plugin-authorization-browser": "npm:3.12.0-next.41" + "@webex/plugin-authorization-node": "npm:3.12.0-next.41" + checksum: 10c0/0d9e52d7c0acbfb8cb954384edf577d780b0f23f2fa1fd905db630746ad20c314a92f07f8851470c34fca01a14b278cbb737f2fb584f5b729a8f742e4c62712e languageName: node linkType: hard @@ -11496,31 +11189,31 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-device-manager@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-device-manager@npm:3.12.0-next.17" +"@webex/plugin-device-manager@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-device-manager@npm:3.12.0-next.47" dependencies: - "@webex/internal-plugin-calendar": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-lyra": "npm:3.12.0-next.17" - "@webex/internal-plugin-search": "npm:3.12.0-next.17" - "@webex/plugin-authorization": "npm:3.12.0-next.15" - "@webex/plugin-logger": "npm:3.12.0-next.15" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-calendar": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-lyra": "npm:3.12.0-next.47" + "@webex/internal-plugin-search": "npm:3.12.0-next.47" + "@webex/plugin-authorization": "npm:3.12.0-next.41" + "@webex/plugin-logger": "npm:3.12.0-next.41" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/4aaa87651b9666ab9b6824938ddf2f2c00839aff0b5c6a9e89699f75b587107e0eaa6852fe9dd2bdb99b8f5e1c411dc5c6022ce56c8b8bb9a7a2ef7544182cf2 + checksum: 10c0/d11b299403d6712b6bad8e582e49063f53c6941a0847c69c0a56e1458395aae554197a34ae6cdef73ec81489973ea6fb998679b29ac8ad8086ad106329a122be languageName: node linkType: hard -"@webex/plugin-encryption@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/plugin-encryption@npm:3.12.0-next.16" +"@webex/plugin-encryption@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/plugin-encryption@npm:3.12.0-next.45" dependencies: - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - checksum: 10c0/36e987ef8439913fdead76cdfec4184f1d39bb96a38bf3e2eacde4162a3e170d92593ba3649b08488e99244c9cec037c56e2ee0ce7d72fa2b9b264eb6679b1d8 + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" + checksum: 10c0/cdf3e292d98e374ac03bca0d224d5830f01221a6d9234db6d9f4efb57c40a865ea7568f25c401e22151cda75a1b1a6a548d9057be34a7b02fe9797b2a331e083 languageName: node linkType: hard @@ -11538,31 +11231,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-logger@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-logger@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - checksum: 10c0/3db9830fb48a66caa3ed0c9a2a3548f6bf9c3b9b07cfd0eeda0cb090c71db42399bbbfc5747848a7069a1d75cf34f072a08adf277caa76e50fc490afeeb833af - languageName: node - linkType: hard - -"@webex/plugin-logger@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-logger@npm:3.12.0-next.40" +"@webex/plugin-logger@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-logger@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" - checksum: 10c0/e09fc2597f46cb4e6449e57859a5c55532c94d480868eebbdba2896c05ee5c905d6bd03dce3404b2e060034be2e1fbddd1a20b6e13a6d3b92928300008ed009f + checksum: 10c0/d378d43d0225d6c265a8597fc74a94ee5b6ba94bd3578e7b2f72f0fb321c2c5e02cdc6a80b562b02ee069138139cf9b196f1102dad46f2406203a2ff040596b3 languageName: node linkType: hard @@ -11593,26 +11272,27 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-meetings@npm:3.12.0-next.62": - version: 3.12.0-next.62 - resolution: "@webex/plugin-meetings@npm:3.12.0-next.62" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-media-core": "npm:2.25.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-llm": "npm:3.12.0-next.18" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/internal-plugin-support": "npm:3.12.0-next.17" - "@webex/internal-plugin-user": "npm:3.12.0-next.16" - "@webex/internal-plugin-voicea": "npm:3.12.0-next.18" - "@webex/media-helpers": "npm:3.12.0-next.4" - "@webex/plugin-people": "npm:3.12.0-next.16" - "@webex/plugin-rooms": "npm:3.12.0-next.17" +"@webex/plugin-meetings@npm:3.12.0-next.127": + version: 3.12.0-next.127 + resolution: "@webex/plugin-meetings@npm:3.12.0-next.127" + dependencies: + "@webex/common": "npm:3.12.0-next.5" + "@webex/event-dictionary-ts": "npm:^1.0.2215" + "@webex/internal-media-core": "npm:2.28.2" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-llm": "npm:3.12.0-next.48" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" + "@webex/internal-plugin-support": "npm:3.12.0-next.47" + "@webex/internal-plugin-user": "npm:3.12.0-next.42" + "@webex/internal-plugin-voicea": "npm:3.12.0-next.50" + "@webex/media-helpers": "npm:3.12.0-next.11" + "@webex/plugin-people": "npm:3.12.0-next.45" + "@webex/plugin-rooms": "npm:3.12.0-next.47" "@webex/ts-sdp": "npm:^1.8.1" - "@webex/web-capabilities": "npm:^1.10.0" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/web-capabilities": "npm:^1.13.0" + "@webex/webex-core": "npm:3.12.0-next.41" ampersand-collection: "npm:^2.0.2" bowser: "npm:^2.11.0" btoa: "npm:^1.2.1" @@ -11626,7 +11306,7 @@ __metadata: uuid: "npm:^3.3.2" webrtc-adapter: "npm:^8.1.2" xxh3-ts: "npm:^2.0.1" - checksum: 10c0/65e30bde464ac5e2da09a6485d663323daee2ec26c528d761dd80b51566c277c338c80f949c28555e7329a5107534250692068c08ce546a5c4cfe15ff47e5ea4 + checksum: 10c0/a6edb140ff571f833af3de4f18155a22ae9378f1ee8854f3829bc10ee9d6d9d85dc7069ff08f890959448feaaf38efd075a50a6bbfd9c41052d1eebe588ad868 languageName: node linkType: hard @@ -11648,22 +11328,22 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-memberships@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-memberships@npm:3.12.0-next.17" +"@webex/plugin-memberships@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-memberships@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-messages": 3.12.0-next.17 - "@webex/plugin-people": 3.12.0-next.16 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/617e1eae28b9de947cd6ea33a779e88a1442b9a0de44290f28c9aae8a0b907bbbf14fe071cf373dce4dcb4f6d1368450269da2617a2b6ab330b240ac3b5ecbf6 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-messages": 3.12.0-next.47 + "@webex/plugin-people": 3.12.0-next.45 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/024d99150664ca14d4c5bac974613dc3ed0fd6d8bdceb163818e86088224f532f5c5a162795e4fc8cd5ea70a8d60a09b5527bd5a4648a9ce9e0c389674dec316 languageName: node linkType: hard @@ -11685,22 +11365,22 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-messages@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-messages@npm:3.12.0-next.17" +"@webex/plugin-messages@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-messages@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-people": 3.12.0-next.16 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/74015a95b1d35d9c360a16cffa712fcd5261405b7b5c4955ad1d2fd4b9bff0a5d196f5ea343edeb1aeb0a98042afc3f414911eb0dd84dc7db67077fb70fb724c + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-people": 3.12.0-next.45 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/b4ee388b277e811dbf7cbc51fbd7a4cdd6f4f33cfd6210ede459d7da3678579e98d3f6908ab179827087f416968e5c71cd8043c26f6d1a93b6d1fd5e05573aa6 languageName: node linkType: hard @@ -11715,14 +11395,14 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-people@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/plugin-people@npm:3.12.0-next.16" +"@webex/plugin-people@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/plugin-people@npm:3.12.0-next.45" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - checksum: 10c0/328aedf95ae0cd8ebbd91b8424f0279551b6a66fdb8fde83d8bf4e130ecdc5e49aeb516e9d6a365c6b3a67e3e916b3352ff33ab5e1ed1c7622f4842508d055d4 + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" + checksum: 10c0/9b2ebbf32cf16af994910cfce92d054c931a095fec78242201be3fde1f2abed308c10c95b0378c60af47ea5576a0dbdb3e211a9044ada492628db303d4e24254 languageName: node linkType: hard @@ -11744,20 +11424,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-rooms@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-rooms@npm:3.12.0-next.17" +"@webex/plugin-rooms@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-rooms@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-people": 3.12.0-next.16 - checksum: 10c0/384483ea056ff2d2f7470521fc32743cb106264f0f830b3f79edce5f01dc7d47244e71189296ad2d15c76303e2b9428488f55ac0065a28a525aa02f5b3ad0525 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-people": 3.12.0-next.45 + checksum: 10c0/04cb907e1c711692823f9c45d9ea85a13754c04564abe5267085b7e5c5210590f6ff5fcd6aeb7ea2854d9a84b0c4f987fdb706295ca5160baa0096ebabc30398 languageName: node linkType: hard @@ -11774,17 +11454,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-team-memberships@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-team-memberships@npm:3.12.0-next.15" +"@webex/plugin-team-memberships@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-team-memberships@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-rooms": 3.12.0-next.17 - "@webex/plugin-teams": 3.12.0-next.15 - checksum: 10c0/dd6c8720f9daabad5e455b2782012559f605733e578e94b5d923d63380bc0ec6403d20c80c8ef05ba8fdd16937b76318c9a83c9dc7084cded0021985b109e603 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-rooms": 3.12.0-next.47 + "@webex/plugin-teams": 3.12.0-next.41 + checksum: 10c0/60a8c59b5760ad8d2c1f68d53afd4c48ff0c8c84e3d3ed10e52ba9e46eac54b6cdd82977255d7c08d445df525484d2ad31e431ec3821d17508b5b4ad349762ad languageName: node linkType: hard @@ -11804,18 +11484,18 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-teams@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-teams@npm:3.12.0-next.15" +"@webex/plugin-teams@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-teams@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-memberships": 3.12.0-next.17 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/b37c81368bdc3079da07c8bfc0f6e9c5818b34fab02a05003cc5ede264e82a951e4d343cb2317d087b64cf58ec4dff4030d86b56c2b76c1b62e28078f06eda5a + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-memberships": 3.12.0-next.47 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/caf787a817538fd938f8914e8dc9a13bdb5fb6d0399618d86ffb79c568323c55b15977bba081215693163bcf0433693ffaa09336c6bbabe7b3f847172bbb720e languageName: node linkType: hard @@ -11831,16 +11511,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-webhooks@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-webhooks@npm:3.12.0-next.15" +"@webex/plugin-webhooks@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-webhooks@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/dd50f24056870b667686e96d7e578170eae030bd7cff69ca5c3ab3aaa5d54fd7e51d0c60903506b6f6980c7cfad720188a98fd6b6cb5459b46f359b11111e810 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/afc51e58bb6fc4fc10e3a4ab186a5c43c119ec4421085dbf42bf53084434f8ebe94e4f73c9b11e5673c30a47f2f4b689d4abce71a4917e74e6dad756d9605175 languageName: node linkType: hard @@ -11880,25 +11560,14 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-local-storage@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.15" - dependencies: - "@webex/storage-adapter-spec": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - checksum: 10c0/211a82fcf5d06d2eb7c47c08538e4d23334354767d442b03219382aaef0d8537186850345d1b128b1f8014df82c7e06eee3bea88edeea97125fa113f83c4c5e2 - languageName: node - linkType: hard - -"@webex/storage-adapter-local-storage@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.40" +"@webex/storage-adapter-local-storage@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.41" dependencies: "@webex/storage-adapter-spec": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" - checksum: 10c0/b0dca260739f851b4b043fbb2cd337765eb246b3699d9ff7f42972e8312047a72c658efdcba0ad7c1018d18feaac19f154cebe2edbffaff2edad48003037c6d3 + "@webex/webex-core": "npm:3.12.0-next.41" + checksum: 10c0/de8f0afe03a1f59c782d703ed27f4c8c0717bd806925d7f8604eb799aa5a1050207d0ab4ed0936539c4d411a3db179f53ff47be3ad4592e9a3491cf3f474ae42 languageName: node linkType: hard @@ -11920,15 +11589,6 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-spec@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/storage-adapter-spec@npm:3.12.0-next.1" - dependencies: - "@webex/test-helper-chai": "npm:3.12.0-next.1" - checksum: 10c0/daaf7fa3eeae4749485d1f64eba9f2c20bbdaec1870873f6b393ec0f3dc6a8728c0dbf46c8969bb49c34208ad0cf56cbc5fde0575d3e1a786e8a744be2c57ea5 - languageName: node - linkType: hard - "@webex/storage-adapter-spec@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/storage-adapter-spec@npm:3.12.0-next.5" @@ -11969,6 +11629,17 @@ __metadata: languageName: unknown linkType: soft +"@webex/test-helper-automation@npm:3.12.0-next.5": + version: 3.12.0-next.5 + resolution: "@webex/test-helper-automation@npm:3.12.0-next.5" + dependencies: + es6-promise: "npm:^4.2.8" + lodash: "npm:^4.17.21" + require-dir: "npm:^0.3.2" + checksum: 10c0/a859924e8ba756997d7991a2a9a50567b5c5aec34d858071429e3999fae4674a330634ea47d629f7e33a0a1f2c7ef45e256541bbc68a646f2f5028b2956ee4ea + languageName: node + linkType: hard + "@webex/test-helper-chai@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-chai@npm:2.60.4" @@ -11991,17 +11662,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-chai@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-chai@npm:3.12.0-next.1" - dependencies: - "@webex/test-helper-file": "npm:3.12.0-next.1" - check-error: "npm:^1.0.2" - lodash: "npm:^4.17.21" - checksum: 10c0/5d8139752365ed2dc3cd4ae790b5ca542d2c689108df414082a907a8bdad674293fffe49858b7cec64bac1085579b6d55f7c3aea40edefc4d6d23f17b3b46bd1 - languageName: node - linkType: hard - "@webex/test-helper-chai@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-chai@npm:3.12.0-next.5" @@ -12039,19 +11699,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-file@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-file@npm:3.12.0-next.1" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/test-helper-make-local-url": "npm:3.12.0-next.1" - es6-promise: "npm:^4.2.8" - file-type: "npm:^16.0.1" - xhr: "npm:^2.5.0" - checksum: 10c0/6ed8abaec3268ac09dafbb9da2e639c3b3f10f01ffe619c10e78b9834caf36b1fba55d4cab031ff55f6b159be40f1c61a3f5ac6e782f1ba626e6782392485643 - languageName: node - linkType: hard - "@webex/test-helper-file@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-file@npm:3.12.0-next.5" @@ -12079,13 +11726,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-make-local-url@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-make-local-url@npm:3.12.0-next.1" - checksum: 10c0/13dd73a17c3d241b02affcabf831828dcef0df8850240142159ce15b0970a66ca2caf5d4c40cf54d9fde27b61abcf86d40a0895069e3f8d0f79f769f5c952ab4 - languageName: node - linkType: hard - "@webex/test-helper-make-local-url@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-make-local-url@npm:3.12.0-next.5" @@ -12111,15 +11751,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-mocha@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-mocha@npm:3.12.0-next.1" - dependencies: - bowser: "npm:^2.11.0" - checksum: 10c0/3df1caa81e06216abcfeee71b340684dc43c85660c6f4b1ecf7db692cd8901496aac8e9572acdd20b1b443f4330e4a1ed7f1a0ae9f94e9f7f6123011762b42f1 - languageName: node - linkType: hard - "@webex/test-helper-mocha@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-mocha@npm:3.12.0-next.5" @@ -12143,13 +11774,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-mock-web-socket@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-mock-web-socket@npm:3.12.0-next.1" - checksum: 10c0/a572fa41d1420a36d57fc57b9705297dff5c62fb479ef6b7433ce56102b3e331f894dad679a04ec98cf4c45f8007cbc4129a07036dc9d4f5a11b0f40a18adc5c - languageName: node - linkType: hard - "@webex/test-helper-mock-web-socket@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-mock-web-socket@npm:3.12.0-next.5" @@ -12179,17 +11803,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-mock-webex@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-mock-webex@npm:3.12.0-next.1" - dependencies: - ampersand-state: "npm:^5.0.3" - es6-promise: "npm:^4.2.8" - lodash: "npm:^4.17.21" - checksum: 10c0/d422edd71d6ac2031215ab479e409686526324698e96fb0baaf588047779e33fa138273c213716c4937f78b254646b74aa8fc08d663b0aeebb02439e4804f88a - languageName: node - linkType: hard - "@webex/test-helper-mock-webex@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-mock-webex@npm:3.12.0-next.5" @@ -12215,13 +11828,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-refresh-callback@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-refresh-callback@npm:3.12.0-next.1" - checksum: 10c0/3144709abb36533e76891052e8d805c8377129a099a5972f2f2dad99cff163b555d683babc5a5cb1316fe92d6ba9588e890121c67df3355e47137dab5d7829a0 - languageName: node - linkType: hard - "@webex/test-helper-refresh-callback@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-refresh-callback@npm:3.12.0-next.5" @@ -12247,15 +11853,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-retry@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-retry@npm:3.12.0-next.1" - dependencies: - es6-promise: "npm:^4.2.8" - checksum: 10c0/0a94cef8493b53a43c42187ca4cfa6e4fefd569203c97018b59f93c99225f9ff07e24ecce150f231e552c948789925c82abc7aa68a9a96e8aa59967f162126fc - languageName: node - linkType: hard - "@webex/test-helper-retry@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-retry@npm:3.12.0-next.5" @@ -12291,17 +11888,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-test-users@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-test-users@npm:3.12.0-next.1" - dependencies: - "@webex/test-helper-retry": "npm:3.12.0-next.1" - "@webex/test-users": "npm:3.12.0-next.1" - lodash: "npm:^4.17.21" - checksum: 10c0/e4f9f44c1cc88042fa6b8b5d1f0b8379158fac09cb5cdc4abd2d5c656b0f4bc863b16baea885f0f4aaea9aa7aa8e10663268490d85528f9e0fa1d3e819d11198 - languageName: node - linkType: hard - "@webex/test-helper-test-users@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-test-users@npm:3.12.0-next.5" @@ -12341,20 +11927,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-users@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-users@npm:3.12.0-next.1" - dependencies: - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - btoa: "npm:^1.2.1" - lodash: "npm:^4.17.21" - node-random-name: "npm:^1.0.1" - uuid: "npm:^3.3.2" - checksum: 10c0/29ba5766b6c150ec3d1ec908dc4078816dedae21a773b056102fafe7ff474856573dfb53885e63475fdac3a35900827224578e24588d75127ebde5f00405ba68 - languageName: node - linkType: hard - "@webex/test-users@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-users@npm:3.12.0-next.5" @@ -12434,7 +12006,7 @@ __metadata: languageName: node linkType: hard -"@webex/web-capabilities@npm:^1.12.0": +"@webex/web-capabilities@npm:^1.12.0, @webex/web-capabilities@npm:^1.13.0": version: 1.13.0 resolution: "@webex/web-capabilities@npm:1.13.0" dependencies: @@ -12452,25 +12024,6 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.39.12": - version: 3.39.12 - resolution: "@webex/web-client-media-engine@npm:3.39.12" - dependencies: - "@webex/json-multistream": "npm:^2.4.3" - "@webex/rtcstats": "npm:^1.5.5" - "@webex/ts-events": "npm:^1.2.1" - "@webex/ts-sdp": "npm:1.8.2" - "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-media-effects": "npm:2.33.5" - "@webex/webrtc-core": "npm:2.13.7" - async: "npm:^3.2.4" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^2.1.0" - uuid: "npm:^8.3.2" - checksum: 10c0/16d7d144ff7f2a95c5d407481d2c9871910c1d2cd20350837e019f86c36be2fe27fe88ee5b182c003274777b6298b898193d88c5e8df5309379ec8b9edd306f7 - languageName: node - linkType: hard - "@webex/web-client-media-engine@npm:3.42.1": version: 3.42.1 resolution: "@webex/web-client-media-engine@npm:3.42.1" @@ -12490,21 +12043,6 @@ __metadata: languageName: node linkType: hard -"@webex/web-media-effects@npm:2.33.5": - version: 2.33.5 - resolution: "@webex/web-media-effects@npm:2.33.5" - dependencies: - "@webex/ladon-ts": "npm:^5.10.0" - events: "npm:^3.3.0" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^1.4.0" - uuid: "npm:^9.0.1" - worker-timers: "npm:^8.0.21" - yarn: "npm:^1.22.22" - checksum: 10c0/d09da2ca72143a5d79f3e46e2c04f13b38b8351490440b2d1142f926ca0b5df499962e8c6411241f588e74f1757cb8904f029783b25a6751a762e3abdfdf8642 - languageName: node - linkType: hard - "@webex/web-media-effects@npm:2.34.0": version: 2.34.0 resolution: "@webex/web-media-effects@npm:2.34.0" @@ -12563,29 +12101,9 @@ __metadata: languageName: node linkType: hard -"@webex/webex-core@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/webex-core@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/storage-adapter-spec": "npm:3.12.0-next.1" - ampersand-collection: "npm:^2.0.2" - ampersand-events: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - core-decorators: "npm:^0.20.0" - crypto-js: "npm:^4.1.1" - jsonwebtoken: "npm:^9.0.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/295175e66e511f3d63649baba8cbf325a10b4f6adeddbfbfb2a7a483ee68c25f0730ea13a5c6e3c90ec5540d4cb94cc8d90083bbff14ac7d201b5217a60dbe80 - languageName: node - linkType: hard - -"@webex/webex-core@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/webex-core@npm:3.12.0-next.40" +"@webex/webex-core@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/webex-core@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" @@ -12599,22 +12117,7 @@ __metadata: jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/60f0812479c996627f4fa989f9535cda37eba0f14d1ec97382f1414a2f7c07dc5044cf7b0e73b190814f2136072f55904dc476f32ad39113debe53554cd49ae9 - languageName: node - linkType: hard - -"@webex/webrtc-core@npm:2.13.7": - version: 2.13.7 - resolution: "@webex/webrtc-core@npm:2.13.7" - dependencies: - "@webex/ts-events": "npm:^1.2.1" - "@webex/web-capabilities": "npm:^1.6.1" - "@webex/web-media-effects": "npm:2.33.5" - events: "npm:^3.3.0" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^2.1.0" - webrtc-adapter: "npm:^8.1.2" - checksum: 10c0/48f781ca9d57330b6498e155e2b46bd914773dfc8ce21df80d0d79bf902a224ca4961f848999a3d1b779b49aef8ce7e797374e9c42eec016f88cd2fdc433895a + checksum: 10c0/a17374ad6147be5f1a01f030e9d2aa951be5408316e042829df20840edb7f16df44822716ab8873d4037cb46beef2144f2dc5ae15a995f24b81a02041acc0ec4 languageName: node linkType: hard @@ -30764,6 +30267,13 @@ __metadata: languageName: node linkType: hard +"require-dir@npm:^0.3.2": + version: 0.3.2 + resolution: "require-dir@npm:0.3.2" + checksum: 10c0/3009f8e59eaa6744cd55c7978e44aa1bbf9e48c786b4ccbad84c178bf32a393caad6612f9ef9baf8421921fafaed605840cf7f2d503836f372716313e9b12742 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -31276,7 +30786,7 @@ __metadata: ts-loader: "npm:^9.5.1" typescript: "npm:^5.6.3" typescript-eslint: "npm:^8.24.1" - webex: "npm:3.12.0-next.84" + webex: "npm:3.12.0-next.184" webpack: "npm:^5.94.0" webpack-cli: "npm:^5.1.4" webpack-dev-server: "npm:^5.1.0" @@ -35410,42 +34920,43 @@ __metadata: languageName: node linkType: hard -"webex@npm:3.12.0-next.84": - version: 3.12.0-next.84 - resolution: "webex@npm:3.12.0-next.84" +"webex@npm:3.12.0-next.184": + version: 3.12.0-next.184 + resolution: "webex@npm:3.12.0-next.184" dependencies: "@babel/polyfill": "npm:^7.12.1" "@babel/runtime-corejs2": "npm:^7.14.8" - "@webex/calling": "npm:3.12.0-next.34" - "@webex/common": "npm:3.12.0-next.1" - "@webex/contact-center": "npm:3.12.0-next.42" - "@webex/internal-plugin-calendar": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-dss": "npm:3.12.0-next.16" - "@webex/internal-plugin-llm": "npm:3.12.0-next.18" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/internal-plugin-presence": "npm:3.12.0-next.16" - "@webex/internal-plugin-support": "npm:3.12.0-next.17" - "@webex/internal-plugin-task": "npm:^3.12.0-next.17" - "@webex/internal-plugin-voicea": "npm:3.12.0-next.18" - "@webex/plugin-attachment-actions": "npm:3.12.0-next.17" - "@webex/plugin-authorization": "npm:3.12.0-next.15" - "@webex/plugin-device-manager": "npm:3.12.0-next.17" - "@webex/plugin-encryption": "npm:3.12.0-next.16" - "@webex/plugin-logger": "npm:3.12.0-next.15" - "@webex/plugin-meetings": "npm:3.12.0-next.62" - "@webex/plugin-memberships": "npm:3.12.0-next.17" - "@webex/plugin-messages": "npm:3.12.0-next.17" - "@webex/plugin-people": "npm:3.12.0-next.16" - "@webex/plugin-rooms": "npm:3.12.0-next.17" - "@webex/plugin-team-memberships": "npm:3.12.0-next.15" - "@webex/plugin-teams": "npm:3.12.0-next.15" - "@webex/plugin-webhooks": "npm:3.12.0-next.15" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/calling": "npm:3.12.0-next.89" + "@webex/common": "npm:3.12.0-next.5" + "@webex/contact-center": "npm:3.12.0-next.109" + "@webex/internal-plugin-calendar": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-dss": "npm:3.12.0-next.45" + "@webex/internal-plugin-llm": "npm:3.12.0-next.48" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/internal-plugin-presence": "npm:3.12.0-next.45" + "@webex/internal-plugin-support": "npm:3.12.0-next.47" + "@webex/internal-plugin-task": "npm:^3.12.0-next.47" + "@webex/internal-plugin-voicea": "npm:3.12.0-next.50" + "@webex/plugin-attachment-actions": "npm:3.12.0-next.47" + "@webex/plugin-authorization": "npm:3.12.0-next.41" + "@webex/plugin-authorization-browser-first-party": "npm:3.12.0-next.42" + "@webex/plugin-device-manager": "npm:3.12.0-next.47" + "@webex/plugin-encryption": "npm:3.12.0-next.45" + "@webex/plugin-logger": "npm:3.12.0-next.41" + "@webex/plugin-meetings": "npm:3.12.0-next.127" + "@webex/plugin-memberships": "npm:3.12.0-next.47" + "@webex/plugin-messages": "npm:3.12.0-next.47" + "@webex/plugin-people": "npm:3.12.0-next.45" + "@webex/plugin-rooms": "npm:3.12.0-next.47" + "@webex/plugin-team-memberships": "npm:3.12.0-next.41" + "@webex/plugin-teams": "npm:3.12.0-next.41" + "@webex/plugin-webhooks": "npm:3.12.0-next.41" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" safe-buffer: "npm:^5.2.0" - checksum: 10c0/2498002ee3e82e639209852eabb942bdabfae305d63e9cc0f5d3b2a015110c18f1e8edd198a6b095a7058eac96c947ee8df8d997d8d8434334ea0fa066be57c3 + checksum: 10c0/d15212ca96b07f6f682ec87a6d64861e7725d56593997284c189d0dc365470696f56e07218a32f1da13196b5510363af1856d4b76d342739ccc8d55fa4bd1098 languageName: node linkType: hard