diff --git a/.gitignore b/.gitignore index 563a57023..e53b20db0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ reports/ #Local stuff *.json +!packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationDark.json +!packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationLight.json !docs/changelog/logs/*.json !.sdd/manifest.json .env* diff --git a/.sdd/manifest.json b/.sdd/manifest.json index 17249044a..99ac46ff9 100644 --- a/.sdd/manifest.json +++ b/.sdd/manifest.json @@ -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": "React widget aggregate and r2wc Web Component wrappers for framework-agnostic consumption.", "coverageState": "DRAFT", "tier": 1 }, @@ -96,7 +96,7 @@ "package": "@webex/cc-ai-assistant", "path": "packages/contact-center/ai-assistant", "spec": "packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md", - "responsibility": "AI Assistant widget: panel chrome, Real-time Assist requests, transcript, feedback actions.", + "responsibility": "AI Assistant widget: panel chrome, Real-time Assist, and Agent Wellness Break orchestration.", "coverageState": "DRAFT", "tier": 1 }, diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index e0d1d6c1a..96d7ea9b5 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -24,7 +24,8 @@ SDK at runtime — so there is no datastore, schema, or migration discipline to |---|---|---| | `store/` | MobX singleton: global CC state, SDK event wiring, SDK access surface | `packages/contact-center/store/ai-docs/store-spec.md` | | `cc-components/` | Pure presentational React primitives (props-only) | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | -| `cc-widgets/` | r2wc Web Component wrappers; aggregates and exports all widgets | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | +| `cc-widgets/` | React widget aggregate, including AI Assistant, and r2wc Web Component wrappers | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | +| `ai-assistant/` | AI Assistant observer/orchestrator for Real-time Assist and Agent Wellness Break | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | | `station-login/` | Agent login widget (team + device selection) | `packages/contact-center/station-login/ai-docs/station-login-spec.md` | | `user-state/` | Agent state widget (state, idle codes, timer) | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | | `task/` | Task widgets: CallControl, CallControlCAD, IncomingTask, OutdialCall, TaskList | `packages/contact-center/task/ai-docs/task-spec.md` | @@ -36,7 +37,7 @@ SDK at runtime — so there is no datastore, schema, or migration discipline to ```mermaid graph TD Host[Host app / Web Component] --> Widget - subgraph WidgetPackages[Widget packages: station-login, user-state, task] + subgraph WidgetPackages[Widget packages: station-login, user-state, task, ai-assistant] Widget[Widget = observer HOC] --> Hook[Custom hook helper.ts] end Hook --> Comp[Presentational component cc-components] @@ -112,7 +113,7 @@ For Consult/Transfer, `Task.uiControls.consultTransferDestinations` is the visib `widgets-samples/**/**`. - **Inter-package dependency graph** (from each package's `package.json`): ``` -cc-widgets ── wraps ──> station-login, user-state, task (+ cc-digital-channels) +cc-widgets ── wraps ──> station-login, user-state, task, ai-assistant (+ cc-digital-channels) station-login ─┐ user-state ─┼──> cc-components, cc-store task ─┘ diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index d0899d3de..d160f4f90 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -9,7 +9,7 @@ This repo is consumed as libraries: its public surface is the package **exports* ### Exported API & Types -The aggregator package `@webex/cc-widgets` re-exports every widget plus the `store` singleton (`packages/contact-center/cc-widgets/src/index.ts:8-19`). When imported via the bundled `wc.ts` entry, each widget is also registered as a custom element (tag names below). +The React entry of `@webex/cc-widgets` re-exports the widgets, including `AIAssistant`, and the `store` singleton. React hosts can also import `AIAssistant` directly from `@webex/cc-ai-assistant`. The existing `@webex/cc-widgets/wc` entry registers all widget custom elements, including AI Assistant. | Contract ID | Owner module/package | Symbol | Signature | Stability / deprecation | Schema / detail link | Defined at | |---|---|---|---|---|---|---| @@ -22,6 +22,9 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | 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` | | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | +| ai-assistant.AIAssistant | `@webex/cc-widgets` and `@webex/cc-ai-assistant` (React); `@webex/cc-widgets/wc` (custom element) | `AIAssistant`; `widget-cc-ai-assistant` | React component and existing custom element; wellness callbacks `onWellnessBreakOffered`, `onWellnessBreakAccepted`, `onWellnessBreakStarted`, `onWellnessBreakEnded`, `onWellnessBreakError`; optional `wellnessAudioUrl` and `wellnessBreakOverlayTarget` | Both React import paths remain available; WC entry retains the tag | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | `packages/contact-center/ai-assistant/src/ai-assistant.types.ts`, `packages/contact-center/cc-widgets/src/index.ts`, `packages/contact-center/cc-widgets/src/wc.ts` | +| ai-assistant.wellness-types | `@webex/cc-ai-assistant` | `WellnessBreakPhase`, `WellnessBreakErrorCode`, `WellnessBreakError`, `WellnessBreakOverlayTarget` | Exported TypeScript lifecycle, error, and layout contracts | additive minor surface; literals are stable | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | `packages/contact-center/ai-assistant/src/ai-assistant.types.ts` | +| cc-components.WellnessBreak | `@webex/cc-components` | `WellnessBreakRequestCard`, `WellnessBreakOfferCard`, `WellnessBreakModal`, `WellnessBreakError` | Props-only Agent Wellness Break presentation; modal accepts an optional animation loader supplied by AI Assistant and owns no wellness media | monorepo component surface; no SDK/store or Lottie dependency | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/` | | 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.host-init.enableWxBetterTogether | `@webex/cc-store` | `webexConfig.cc.enableWxBetterTogether` → `store.enableWxBetterTogether` | Host init boolean (default `false`), set **before** `store.init()`: read from `webexConfig.cc` on `{ webexConfig, access_token }` init or `webex.config.cc` on `{ webex }` init; read-only observable after init (Phase 1: re-init to change). Gates wxApp Mute/Keypad visibility and wxApp mute seed; SDK owns mute/DTMF API routing. | stable semver; renaming or removing the init key or store getter is breaking | `packages/contact-center/store/ai-docs/store-spec.md` (`STORE-R-030`); `packages/contact-center/task/ai-docs/task-spec.md` (WXCC-6026) | `packages/contact-center/store/src/store.ts`, `packages/contact-center/store/src/store.types.ts` (`WebexCcInitConfig`) | | store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | 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` | @@ -38,7 +41,8 @@ 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.109`, 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.131`, pinned by the store package dependency | +| `@webex/contact-center` SDK — Agent Wellness Break | Effective `Profile.isWellnessBreakEnabled`, public `WellnessBreak` event and action constants/types, `getWellbeingBreakIdleCode()`, `setAgentState()`, identity-free `apiAIAssistant.requestWellnessBreak()` / `respondToWellnessBreak({action})`, and optional active `Profile.agentSessionId` on logged-in registration | Release contract in `ai-docs/features/agent-wellness-break-intake.md`; package-root declarations are authoritative | Registered-session projection requires the companion SDK release before refresh behavior can be verified | UI stays gated when session/system code is unavailable; no local SDK path or unpublished version is committed | Existing APIs: `3.12.0-next.131`; session projection: pending release | | `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/SPEC_INDEX.md b/ai-docs/SPEC_INDEX.md index 681e8f99a..8fbbe6946 100644 --- a/ai-docs/SPEC_INDEX.md +++ b/ai-docs/SPEC_INDEX.md @@ -16,7 +16,7 @@ | `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` | -| `ai-assistant/` | AI Assistant widget: chrome + Real-time Assist requests, transcript, feedback | DRAFT | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | +| `ai-assistant/` | AI Assistant widget: chrome, Real-time Assist, and Agent Wellness Break orchestration | 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` | | `@webex/widgets/` | Legacy meetings widgets (separate family) | DRAFT | `packages/@webex/widgets/ai-docs/widgets-spec.md` | diff --git a/ai-docs/features/agent-wellness-break-intake.md b/ai-docs/features/agent-wellness-break-intake.md new file mode 100644 index 000000000..2737ed252 --- /dev/null +++ b/ai-docs/features/agent-wellness-break-intake.md @@ -0,0 +1,146 @@ +# Agent Wellness Break — widgets implementation contract (WXCC-12423) + +Status: implemented for Widgets and aligned with `@webex/contact-center` 3.12.0-next.131 on 2026-09-28; +final live-flow verification is pending with a wellness-enabled test agent. + +## Scope and ownership + +`@webex/cc-store` projects the SDK's effective enablement, current station session, system-owned +`WellbeingBreak` code, `WellnessBreak` event, and legacy state. It uses only the wellness API that the +SDK publishes from its package root. +`@webex/cc-ai-assistant` owns request/offer actions, safe-state orchestration, timers, media, recovery, +and host callbacks. `@webex/cc-components` remains props-only and accepts an injected animation +loader. React hosts import `@webex/cc-ai-assistant` directly. The existing `@webex/cc-widgets/wc` +entry registers `widget-cc-ai-assistant` and distributes its lazy media. + +## Behavioral contract + +- `SUGGEST_WELLNESS_BREAK` enables a manual request. A successful HTTP 202 leaves it pending until an + independent `PROVIDE_WELLNESS_BREAK` or `WELLNESS_BREAK_NOT_ALLOWED` event. +- The suggestion CTA follows Desktop empty-state eligibility: active Real-time Assist content takes + precedence; otherwise the centered wellness suggestion replaces the normal landing content. Its + hover/focus tooltip uses the trimmed event `actionText`, falling back to the pre-approved-break copy. +- Manual request availability is one-shot. Requesting a break, receiving `PROVIDE`/`NOT_ALLOWED`, + declining or timing out an offer, rotating the session, and completing/restoring a break all + consume it. Only a later `SUGGEST_WELLNESS_BREAK` enables it again. +- Eligible suggestions, offer/request states, acknowledgements, completion notices, status, and errors + own the assistant body while visible; the normal landing or Real-time Assist surface is not stacked + beneath them. +- A provided offer expires after five minutes and emits `NO_RESPONSE` once. Session rotation invalidates + it without a response action and leaves its history card visible with disabled actions. When the assistant panel is closed or minimized, + a Desktop-style actionable toast exposes the same Take a break and Later actions; opening the panel + shows the offer in the assistant body without duplicating the toast. +- Wellness notification eligibility is scoped to the current agent and organization, not to equality + with the notification-provided `agentSessionId`. The widget calls `requestWellnessBreak()` without + arguments and calls `respondToWellnessBreak({action})` without identity fields. The SDK supplies its + active registration and login/relogin context rather than accepting notification identity metadata. +- Agent acceptance changes state before sending `ACCEPTED`. A pending manual request approved by + `PROVIDE` changes state without a duplicate action. Repeated clicks are guarded so a direct offer + sends at most one acceptance request. +- **Later** on a direct offer consumes the offer locally and sends exactly one `REJECTED` response; it + never changes agent state. Delivery failure is reported through the existing error callback/metric. +- Backend copy is event-specific and plain text: suggestion `actionText` is the CTA tooltip, direct + `PROVIDE` `actionText` is the offer body, and `WELLNESS_BREAK_NOT_ALLOWED` `actionText` is the denial + body. Blank text uses the approved local fallback for that state. +- Start requires exact legacy `Idle / WellbeingBreak` confirmation, all `store.taskList` entries safe, + and two continuous safe seconds; any unsafe transition restarts that settle window. Waiting copy + mentions current work only while `store.taskList` contains an actual blocking task. +- The User State widget renders the system-owned `WellbeingBreak` code as the current, timed state while + it is active, without adding it to the manually selectable idle-code list or echoing SDK-driven entry + and restoration transitions back through `setAgentState`. It waits for the system-code lookup before + deciding whether a state change is SDK-owned; a user selection made during lookup is sent after it completes. +- Playback is 5 seconds starting, 60 seconds playing (copy changes at 40 seconds), and 5 seconds ending. + Audio and animation are independently lazy-loaded during the starting countdown. The full-bleed + animation holds its first frame behind the inline `5 4 3 2 1` start sequence, plays with the 60-second + timeline, then holds its final frame behind the ending sequence; audio is rewound and starts with playback. +- When the break ends, the widget immediately requests Available (`auxCodeId: 0`), including when the + agent was in another idle state before the break or an external state appeared during playback. + The request is attempted up to three times with ten-second spacing, with five additional bounded + background attempts after exhaustion. + Each accepted break resets that background retry budget. Unmount waits for a pending change-to-break + request, uses the same guarded Available request and retries, and preserves an external state. + Before playback, an incompatible external/RONA transition cancels and restores; + during playback/ending it does not interrupt the timeline. +- A host-scoped `sessionStorage` marker contains only its version, station session, and captured legacy + state and idle code. Refresh never replays an offer, action, or media. + Registration uses the SDK's projected active station session and prior auxiliary code when available; + it clears stale session data when the projection is absent. This requires the companion SDK release. +- The full-screen dialog is independent of assistant chrome, traps focus, ignores Escape, restores focus, + exposes live announcements, and honors reduced motion without changing timing. Its rounded canvas, + full-bleed animation crop, overlaid phase copy, and bottom progress treatment follow Desktop. +- Behavioral lifecycle and agent-action metrics use Desktop's existing wellness event names and + taxonomy through the Webex metrics transport. Properties remain bounded and exclude notification + bodies, `actionText`, session identifiers, and interaction/customer data. Toast close and timeout + emit only their Desktop-equivalent behavioral metrics; they do not invent backend user actions. + +## Public surface + +`IAIAssistantProps` adds `onWellnessBreakOffered`, `onWellnessBreakAccepted`, +`onWellnessBreakStarted`, `onWellnessBreakEnded`, `onWellnessBreakError`, `wellnessAudioUrl`, and +`wellnessBreakOverlayTarget`. The overlay target defaults to `viewport` and portals to the document body; +`assistant` scopes it to the widget root, while React hosts may pass an `HTMLElement` to portal into +a custom positioned container. +The serializable Web Component modes are `viewport` and `assistant`. +`WellnessBreakPhase`, `WellnessBreakErrorCode`, and `WellnessBreakError` are exported by +`@webex/cc-ai-assistant`. + +The widget does not consume an AI Assistant RTD status event; that event is not part of the +published SDK wellness contract. + +The React sample exposes all three modes in an **AI Assistant → Wellness break overlay target** selector. +Its custom mode passes the bordered demo container's `HTMLElement`, making coverage behavior verifiable +without changing sample source. + +During an active break, the selected scroll surface is locked and its prior inline styles are restored +on cleanup. This prevents the document scrollbar gutter from showing beside a viewport overlay. Wellness +offers, user actions, acknowledgements, denials/timeouts, and completion messages append to a chronological +assistant transcript. Closing, minimizing, reopening, receiving another wellness event, or rotating the +station session does not clear that transcript. Only the first header action, **Clear**, clears displayed +Real-time Assist and prior wellness history while retaining an actionable current offer; it does not cancel an +active offer/break or fabricate a backend action. + +The landing feature list matches Desktop copy and icons exactly: `✨ Real-time Assist`, `🪷 Wellness breaks`, +and `✍🏻 Smart summaries`, with their approved Desktop descriptions. + +## Media provenance + +| Asset | Approved source checksum | Packaged checksum | Transformation | +| ------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------------------------- | +| Light Lottie | `6a28e4866e52a1b463fb45e46aa53c4c531a8fc45c793251b75cfece73c91e21` | `d6eaa49aa6ee9956d22d3d3fb65c357a51f9d6b832c2939123c0584a4c3208d5` | Removed the missing `audio_0` asset and its type-6 layer. | +| Dark Lottie | `5e9a5eec6f7e370e6c386a79c0bc12b43d4b397e9a9ac94dedbe58f1a93c2f46` | `193052c64943fd94d993916936ef6cb2a2c9fc642c9856892c6ddda983ec92ac` | Removed the missing `audio_0` asset and its type-6 layer. | +| Desktop MP3 | `19e4474587759c98638ade310b8873ad957e03686c5a8af04c9a5d272c81699b` | same | Unmodified 65.097-second source. | + +The renderer, theme JSON, and audio URL module are dynamic imports. The MP3 is emitted as +`assets/wellness/WellnessBreakSound.mp3`; `wellnessAudioUrl` overrides it for CDN/subpath deployments. + +A production build followed by `npm pack --dry-run --json` shows the media boundary: + +| Package | Lottie renderer chunk | Wellness MP3 | +| --- | --- | --- | +| `@webex/cc-components` | absent | absent | +| `@webex/cc-ai-assistant` | present (305,431 B) | present (2,603,884 B) | +| `@webex/cc-widgets` | present for its WC entry (305,431 B) | present for its WC entry (2,603,884 B) | + +The React entry of `@webex/cc-widgets` does not import AI Assistant or reference its wellness media. +The WC entry preserves the existing AI Assistant registration and copies its lazy media into the +aggregate package. The assistant loads the renderer and audio for imminent playback. Hosts that consume +the prebuilt WC entry must serve its lazy chunks and MP3 from the expected public path; the React sample +builds the assistant from source and emits those files itself. The Web Component sample does not copy +the aggregate package's media into its output automatically. + +## Release gates + +- Keep the store and React sample pinned to the published `@webex/contact-center` 3.12.0-next.131 build. + Pair the React sample with `webex` 3.12.0-next.217 so `Webex.init()` loads the same SDK. Consume its + wellness constants and types from the package root. Do not add local paths, tarballs, or structural + copies of the SDK wellness contract. +- Retain the media redistribution approval and checksum record. +- Verify multi-client action idempotency with the backend. +- Approve the US English copy before GA. + +## Samples and verification + +The React and Web Component samples use real store/SDK events, expose the lifecycle callbacks, log only +lifecycle/error categories, and do not include a fake wellness event generator. Verify the legacy state +flow, active voice/digital work, RONA phase boundaries, refresh recovery, media failure, reduced motion, +focus restoration, and lazy asset loading with a wellness-enabled test agent. diff --git a/jest.setup.js b/jest.setup.js index bfefd399e..eab9e204d 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,6 +2,13 @@ // This is a workaround for the fact that JSDOM does not support canvas methods like getContext. import 'jest-canvas-mock'; +// Webpack's browser-relative lazy chunks derive their base URL from the +// currently loaded script. JSDOM does not create one, so provide the same +// minimal browser invariant for tests that import built workspace packages. +const webpackEntryScript = document.createElement('script'); +webpackEntryScript.src = 'http://localhost/index.js'; +document.head.appendChild(webpackEntryScript); + const createEnumProxy = () => new Proxy( {}, diff --git a/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md b/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md index 4b49fc76b..17beac66b 100644 --- a/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md +++ b/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md @@ -4,25 +4,31 @@ > Context-efficiency: link to canonical docs — don't duplicate them. Load specs on demand per `SPEC_INDEX.md`. ## Metadata -| Field | Value | -|---|---| -| Module id | `ai-assistant` | -| Source path(s) | `packages/contact-center/ai-assistant/src/` | -| Doc kind | Module spec | -| Coverage score | Pending coverage assessment | -| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | -| generated_by / approved_by / updated_at | generated_by `ai-assistant feature work` / approved_by `pending` / updated_at `2026-07-29` | -| Validation status | not-run | + +| Field | Value | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| Module id | `ai-assistant` | +| Source path(s) | `packages/contact-center/ai-assistant/src/` | +| Doc kind | Module spec | +| Coverage score | Pending coverage assessment | +| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | +| generated_by / approved_by / updated_at | generated_by `ai-assistant feature work` / approved_by `pending` / updated_at `2026-09-04` | +| Validation status | not-run | ## Evidence Rules + Every requirement below cites concrete source evidence using `file path`. Source evidence, test evidence, and gaps are kept separate so validators and future agents can distinguish truth from context. ## Overview + `ai-assistant` is the AI Assistant widget for Webex Contact Center, published as `@webex/cc-ai-assistant`. -It owns the assistant's chrome (launcher → open → minimized → fullscreen) and the **Real-time Assist** -feature: on request, the SDK returns suggestion Adaptive Cards for the active interaction, which the agent -can refine with extra context and act on with like / dislike / copy. +It owns the assistant's chrome (launcher → open → minimized → fullscreen), **Real-time Assist**, and the +Agent Wellness Break lifecycle. Inbound wellness notifications are agent/org-scoped and independent of +an active interaction or a matching notification session; outbound wellness actions use the SDK's +identity-free methods and therefore remain owned by the SDK's active registration and login/relogin +context. The widget requests or responds to offers, coordinates agent state, waits for safe work +completion, runs the timed media surface, and restores state. The package is the container layer only. All UI lives in `@webex/cc-components` (`AIAssistantComponent`, `RealTimeAssist`, `AdaptiveCardRenderer`), all SDK access goes through @@ -31,22 +37,26 @@ at `src/ai-assistant/index.tsx` (observer container), then `src/helper.ts` (the behavior), then `src/ai-assistant.types.ts` (public props). ## Purpose / Responsibility + Owns: assistant chrome state, the real-time assist request lifecycle, chat transcript assembly from store payloads, and forwarding agent feedback actions to the SDK. Does NOT own: card rendering, panel markup, SDK transport, or the store's `realTimeAssist` event plumbing. ## Stack + TypeScript 5.6.3, React (peer `>=18.3.1`) function components + hooks, MobX via `mobx-react-lite` `observer`, `react-error-boundary`. Tests: Jest 29.7.0 + React Testing Library 16.0.1, jsdom. Build: Webpack 5 to `dist/`. Source of truth: `package.json`. ## Folder / Package Structure + ``` packages/contact-center/ai-assistant/ ├── src/ │ ├── index.ts # Barrel: AIAssistant + public types │ ├── ai-assistant/index.tsx # observer container + ErrorBoundary │ ├── helper.ts # useAIAssistantChrome, useRealTimeAssist, useAiAssistant +│ ├── wellness/ # lifecycle hook, pure decisions, lazy media assets │ └── ai-assistant.types.ts # IAIAssistantProps + hook input types ├── tests/ │ ├── ai-assistant/index.tsx # container render/integration tests @@ -56,45 +66,95 @@ packages/contact-center/ai-assistant/ ``` ## Key Files (source of truth) -| File | Holds | -|---|---| -| `packages/contact-center/ai-assistant/src/index.ts` | Public export barrel (`AIAssistant` + types). | -| `packages/contact-center/ai-assistant/src/ai-assistant/index.tsx` | Store wiring: feature flag, active interaction, logger, deprecated-callback bridge, ErrorBoundary. | -| `packages/contact-center/ai-assistant/src/helper.ts` | All behavior: chrome transitions, request lifecycle, chat assembly, feedback dispatch, `REAL_TIME_ASSIST_FLAG`. | -| `packages/contact-center/ai-assistant/src/ai-assistant.types.ts` | `IAIAssistantProps` — the host-facing contract. | -| `packages/contact-center/cc-widgets/src/wc.ts` | `widget-cc-ai-assistant` custom element and its prop map. | + +| File | Holds | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `packages/contact-center/ai-assistant/src/index.ts` | Public export barrel (`AIAssistant` + types). | +| `packages/contact-center/ai-assistant/src/ai-assistant/index.tsx` | Store wiring: feature flag, active interaction, logger, deprecated-callback bridge, ErrorBoundary. | +| `packages/contact-center/ai-assistant/src/helper.ts` | All behavior: chrome transitions, request lifecycle, chat assembly, feedback dispatch, `REAL_TIME_ASSIST_FLAG`. | +| `packages/contact-center/ai-assistant/src/ai-assistant.types.ts` | `IAIAssistantProps` — the host-facing contract. | +| `packages/contact-center/cc-widgets/src/wc.ts` | `widget-cc-ai-assistant` custom element and its prop map. | ## Public Surface -| Contract ID | Type | Surface | Purpose | Compatibility | Detail | -|---|---|---|---|---|---| -| `ai-assistant.AIAssistant` | SDK | `` | React entry point for the widget. | stable | `src/ai-assistant/index.tsx` | -| `ai-assistant.IAIAssistantProps` | SDK | Host callbacks + `className` | Opt-in host notifications. | additive props = minor | `src/ai-assistant.types.ts` | -| `widget-cc-ai-assistant` | Custom element | r2wc wrapper | Web Component host integration. | prop map must mirror `IAIAssistantProps` | `packages/contact-center/cc-widgets/src/wc.ts` | + +| Contract ID | Type | Surface | Purpose | Compatibility | Detail | +| -------------------------------- | -------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------- | +| `ai-assistant.AIAssistant` | SDK | `` | React entry point for the widget. | stable | `src/ai-assistant/index.tsx` | +| `ai-assistant.IAIAssistantProps` | SDK | Host callbacks, `className`, and `wellnessBreakOverlayTarget` | Opt-in host notifications and host-controlled break coverage. | additive props = minor | `src/ai-assistant.types.ts` | +| `ai-assistant.wellness` | SDK | Five wellness callbacks, media/overlay configuration, and exported types | Host notification, media, and layout integration without exposing SDK transport. | additive props/types = minor | `src/ai-assistant.types.ts`, `src/wellness/useWellnessBreak.ts` | +| `widget-cc-ai-assistant` | Custom element | r2wc wrapper | Web Component host integration. | prop map must mirror `IAIAssistantProps` | `packages/contact-center/cc-widgets/src/wc.ts` | + +Import `AIAssistant` from `@webex/cc-widgets` or directly from `@webex/cc-ai-assistant` for React. The existing +`@webex/cc-widgets/wc` entry registers `widget-cc-ai-assistant`; there is no +separate AI Assistant WC entry. This package owns `lottie-web` and the wellness +MP3; `cc-components` receives an animation loader as a prop and has no media dependency. + +The CommonJS package includes the MP3, animation data, and its media loader under +`dist/assets/wellness`. Webpack emits the MP3 and lazy Lottie/animation chunks into +the consuming app without an asset rule or copy step. The browser fetches them +only when the wellness UI uses them. The React sample also supports this path +when it builds the assistant source. + +The orchestration uses only the published wellness contract and legacy `setAgentState()` path from the +store. It does not consume an AI Assistant RTD status event. `onSuggestionReceived` is deprecated in favour of `onRealTimeAssistReceived`; the container accepts either (`src/ai-assistant/index.tsx`) and both are registered on the custom element. ## Requires (dependencies) + - `@webex/cc-store` — `store.cc.apiAIAssistant` (`getRealTimeAssistance`, `sendRealTimeAssistanceUserAction`), `store.realTimeAssist`, `store.currentTask`, `store.agentId`, `store.agentProfile`, `store.featureFlags`, `store.logger`. - `@webex/cc-components` — `AIAssistantComponent` and its types. - `@webex/cc-ui-logging` — `withMetrics` (applied inside `cc-components`). - `mobx-react-lite`, `react-error-boundary`. ## Requirements -| ID | WHAT | WHY | Source Evidence | Test Evidence | Confidence | -|---|---|---|---|---|---| -| `ai-assistant-R-001` | Chrome moves closed → open → minimized → open and fires the matching host callback on each transition; closing preserves chat state. | The agent must be able to park the panel mid-interaction without losing context. | `src/helper.ts` (`useAIAssistantChrome`) | `tests/helper.ts`, `tests/ai-assistant/index.tsx` | PRESENT | -| `ai-assistant-R-002` | The landing view is shown when the feature flag is off or no interaction is active, and `requestStatus` stays `idle` in that case (never `error`). | Absence of a call is not a failure; showing an error would be misleading. | `packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx`, `src/helper.ts` (early return in `requestRealTimeAssist`) | `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx` | PRESENT | -| `ai-assistant-R-003` | The opening prompt stays until the first `getRealTimeAssistance` call resolves: in-flight shows a spinner, failure keeps the prompt plus an error message, success switches to listening mode permanently. | A failed first request must be retryable; later failures must not unwind an established session. | `src/helper.ts` (`hasInitialRequestSucceeded` set after `await`), `packages/contact-center/cc-components/src/components/AIAssistant/RealTimeAssist/real-time-assist.tsx` | `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx`, `tests/helper.ts` | PRESENT | -| `ai-assistant-R-004` | `isRequesting` is true only while a `getRealTimeAssistance` call is unresolved; the UI uses it to show the spinner and to block the request controls. | Double-clicking a request control must not fan out duplicate SDK calls. | `src/helper.ts` (`isRequesting`), `packages/contact-center/cc-components/src/components/AIAssistant/RealTimeAssist/real-time-assist.tsx` (disabled submit + guarded `handleContextSubmit`) | `tests/helper.ts`, `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx` | PRESENT | -| `ai-assistant-R-005` | Every unseen `realTimeAssist` payload invokes `onRealTimeAssistReceived` once, even when several land before React commits. | Hosts mirror the transcript; a dropped payload is unrecoverable. | `src/helper.ts` (response effect loops from the previous cursor) | `tests/helper.ts` | PRESENT | -| `ai-assistant-R-006` | Changing `interactionId` resets request status, error, context draft, pending flag, first-success flag, user messages, and the payload cursor. | Session state from one customer must never surface on the next. | `src/helper.ts` (reset effect keyed on `interactionId`) | `tests/helper.ts` | PRESENT | -| `ai-assistant-R-007` | `chatEntries` is a chronological transcript — greeting (only after first success), then user and assistant entries ordered by timestamp, user first on ties. | The agent reads context and suggestion in the order they happened. | `src/helper.ts` (`chatEntries` memo) | `tests/helper.ts` | PRESENT | -| `ai-assistant-R-008` | Like / dislike / copy call `sendRealTimeAssistanceUserAction` and return its promise; the card marks the control as selected only after that promise resolves, and a rejection is logged and leaves the control untouched. | The UI must not claim feedback was recorded when it was not. | `src/helper.ts` (`handleRealTimeAssistAction`), `packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx` (`emitUserAction`) | `tests/ai-assistant/feedback.tsx`, `packages/contact-center/cc-components/tests/components/AIAssistant/adaptive-card-renderer.test.tsx` | PRESENT | -| `ai-assistant-R-009` | When the action cannot be sent (missing `adaptiveCardId`, ids, or SDK method), the reason is logged via `store.logger.warn` and the returned promise rejects. | A silent no-op leaves both agent and support blind. | `src/helper.ts` | `tests/ai-assistant/feedback.tsx` | PRESENT | -| `ai-assistant-R-010` | A render failure is contained by the widget's `ErrorBoundary` and reported through `store.onErrorCallback('AIAssistant', error)`. | A crashing assistant must not take the agent desktop down. | `src/ai-assistant/index.tsx` | `tests/ai-assistant/index.tsx` | PRESENT | + +| ID | WHAT | WHY | Source Evidence | Test Evidence | Confidence | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `ai-assistant-R-001` | Chrome moves closed → open → minimized → open and fires the matching host callback on each transition; closing preserves chat state. | The agent must be able to park the panel mid-interaction without losing context. | `src/helper.ts` (`useAIAssistantChrome`) | `tests/helper.ts`, `tests/ai-assistant/index.tsx` | PRESENT | +| `ai-assistant-R-002` | The landing view is shown when the feature flag is off or no interaction is active, and `requestStatus` stays `idle` in that case (never `error`). | Absence of a call is not a failure; showing an error would be misleading. | `packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx`, `src/helper.ts` (early return in `requestRealTimeAssist`) | `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx` | PRESENT | +| `ai-assistant-R-003` | The opening prompt stays until the first `getRealTimeAssistance` call resolves: in-flight shows a spinner, failure keeps the prompt plus an error message, success switches to listening mode permanently. | A failed first request must be retryable; later failures must not unwind an established session. | `src/helper.ts` (`hasInitialRequestSucceeded` set after `await`), `packages/contact-center/cc-components/src/components/AIAssistant/RealTimeAssist/real-time-assist.tsx` | `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx`, `tests/helper.ts` | PRESENT | +| `ai-assistant-R-004` | `isRequesting` is true only while a `getRealTimeAssistance` call is unresolved; the UI uses it to show the spinner and to block the request controls. | Double-clicking a request control must not fan out duplicate SDK calls. | `src/helper.ts` (`isRequesting`), `packages/contact-center/cc-components/src/components/AIAssistant/RealTimeAssist/real-time-assist.tsx` (disabled submit + guarded `handleContextSubmit`) | `tests/helper.ts`, `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx` | PRESENT | +| `ai-assistant-R-005` | Every unseen `realTimeAssist` payload invokes `onRealTimeAssistReceived` once, even when several land before React commits. | Hosts mirror the transcript; a dropped payload is unrecoverable. | `src/helper.ts` (response effect loops from the previous cursor) | `tests/helper.ts` | PRESENT | +| `ai-assistant-R-006` | Changing `interactionId` resets request status, error, context draft, pending flag, first-success flag, user messages, and the payload cursor. | Session state from one customer must never surface on the next. | `src/helper.ts` (reset effect keyed on `interactionId`) | `tests/helper.ts` | PRESENT | +| `ai-assistant-R-007` | `chatEntries` is a chronological transcript — greeting (only after first success), then user and assistant entries ordered by timestamp, user first on ties. | The agent reads context and suggestion in the order they happened. | `src/helper.ts` (`chatEntries` memo) | `tests/helper.ts` | PRESENT | +| `ai-assistant-R-008` | Like / dislike / copy call `sendRealTimeAssistanceUserAction` and return its promise; the card marks the control as selected only after that promise resolves, and a rejection is logged and leaves the control untouched. | The UI must not claim feedback was recorded when it was not. | `src/helper.ts` (`handleRealTimeAssistAction`), `packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx` (`emitUserAction`) | `tests/ai-assistant/feedback.tsx`, `packages/contact-center/cc-components/tests/components/AIAssistant/adaptive-card-renderer.test.tsx` | PRESENT | +| `ai-assistant-R-009` | When the action cannot be sent (missing `adaptiveCardId`, ids, or SDK method), the reason is logged via `store.logger.warn` and the returned promise rejects. | A silent no-op leaves both agent and support blind. | `src/helper.ts` | `tests/ai-assistant/feedback.tsx` | PRESENT | +| `ai-assistant-R-010` | A render failure is contained by the widget's `ErrorBoundary` and reported through `store.onErrorCallback('AIAssistant', error)`. | A crashing assistant must not take the agent desktop down. | `src/ai-assistant/index.tsx` | `tests/ai-assistant/index.tsx` | PRESENT | +| `ai-assistant-R-011` | Wellness controls require effective enablement, login, agent/session IDs, and the `WellbeingBreak` system code; `SUGGEST` enables a one-shot request and HTTP 202 remains pending until an independent `PROVIDE` or `NOT_ALLOWED`. Request, offer, denial, timeout, session rotation, and completed restoration consume availability; only a fresh `SUGGEST` enables it again. | HTTP acceptance is not business approval, and a consumed Desktop CTA must not reappear from stale state. | `src/wellness/useWellnessBreak.ts` | `tests/wellness.orchestrator.ts` | PRESENT | +| `ai-assistant-R-012` | A direct offered break uses legacy `setAgentState()` before sending exactly one `ACCEPTED`; **Later** sends exactly one `REJECTED` without changing state; manual approval sends no redundant `ACCEPTED`; state failure sends no action. | Prevents backend/UI disagreement, duplicate responses, and stranded agent state. | `src/wellness/useWellnessBreak.ts`, `src/wellness/wellness-internal.types.ts` | `tests/wellness.orchestrator.ts` | PRESENT | +| `ai-assistant-R-013` | Playback waits for exact legacy wellness-state confirmation, all-task safety, and a 2-second settle window; it runs a 5/60/5 timeline, exposes whether a real task is blocking status copy, reverts to waiting if work appears during start, and requests Available (`auxCodeId: 0`) as soon as the break ends. | Active voice, digital, campaign, consult, or wrap-up work must not be interrupted; transient state confirmation must not be described as active work. Completion returns the agent to Available even when the pre-break state was Meeting or another state appeared during playback. | `src/wellness/useWellnessBreak.ts`, `src/wellness/wellness.utils.ts` | `tests/wellness.utils.ts`, `tests/wellness.orchestrator.ts` | PRESENT | +| `ai-assistant-R-014` | Restoration is bounded, preserves externally-owned/RONA idle states before playback, stores only minimal session-scoped legacy recovery data, ignores stale async results after session rotation, and never replays media or actions after refresh. | The agent must not remain in `WellbeingBreak` or have another session's state claimed. | `src/wellness/useWellnessBreak.ts`, `src/wellness/wellness.utils.ts` | `tests/wellness.utils.ts`, `tests/wellness.orchestrator.ts` | PRESENT | +| `ai-assistant-R-015` | This package owns the MP3 and `lottie-web`; the MP3, Lottie data, and renderer are independently lazy-loaded during the five-second starting countdown. The renderer is injected into the shared modal. Lottie holds its first frame during start, plays with the 60-second timeline, and holds its final frame during ending; audio is rewound and starts with playback. Reduced motion uses a static surface and media failure does not block restoration. | Both React import paths retain the same widget behavior; the media remains in separate package files and loads when needed. The existing WC entry retains the assistant. | `webpack.config.js`, `src/wellness/assets/`, `src/wellness/animation.ts`, `src/wellness/useWellnessBreak.ts`, `package.json` | `tests/wellness.orchestrator.ts`, component and package archive verification | PRESENT | +| `ai-assistant-R-016` | Wellness lifecycle, CTA, card, notification, error, and restore actions emit the equivalent Desktop behavioral metric names/taxonomy through the store boundary; properties never include event text, agent/session IDs, or interaction/customer data. | Product analytics need action coverage without duplicating SDK operational metrics or leaking sensitive content. | `src/wellness/wellness.metrics.ts`, `src/wellness/useWellnessBreak.ts` | `tests/wellness.orchestrator.ts`, `packages/contact-center/store/tests/wellness.ts` | PRESENT | +| `ai-assistant-R-017` | `wellnessBreakOverlayTarget` defaults to the viewport, accepts `assistant` for widget-root coverage, and accepts an `HTMLElement` for a custom React host container. The active target's scrolling is locked and restored without retaining host style mutations. | Hosts need full-viewport, widget-local, and custom layout options without changing wellness lifecycle ownership. | `src/ai-assistant.types.ts`, `src/ai-assistant/index.tsx`, `WellnessBreakModal` | `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx`; build/type verification | PRESENT | +| `ai-assistant-R-018` | Wellness offers, user actions, acknowledgements, notices, and completion append chronologically and persist across close/minimize/reopen, fresh notifications, and session rotation. The first header Clear action clears both displayed Real-time Assist and wellness history without changing an active wellness phase or sending a backend action. | Desktop keeps the assistant conversation visible until the agent explicitly clears it; chrome transitions and later events must not erase context. | `src/helper.ts`, `src/ai-assistant/index.tsx`, `src/wellness/useWellnessBreak.ts` | `tests/helper.ts`, `tests/wellness.orchestrator.ts`, `packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx` | PRESENT | +| `ai-assistant-R-019` | A direct offer may carry a different notification `agentSessionId`; request calls pass no identity fields, acceptance/rejection/timeout pass only `action`, and the state change still precedes `ACCEPTED`. The SDK supplies the active registration and login/relogin identity context. | Desktop accepts current-agent/current-org nudges independent of diagnostic notification session metadata, while the public SDK prevents callers from overriding action identity. | `src/wellness/useWellnessBreak.ts` | `tests/wellness.orchestrator.ts` | PRESENT | + +## Agent Wellness Break lifecycle + +The public phases are `idle`, `offer-pending`, `request-pending`, `changing-to-break`, +`waiting-for-safe-state`, `starting`, `playing`, `ending`, `restoring`, and `error`. The orchestrator owns +notification response, safe-state checks across `store.taskList`, the exact 5/60/5 timeline, RONA-aware +cancellation/restoration, bounded retries, and the session-storage refresh marker. +Session rotation keeps offer history but makes its old actions inactive. Each accepted break starts +with a fresh background restore retry budget. Unmount waits for a pending change-to-break request, +then uses the normal guarded Available request and bounded retries; an external state is preserved +on early exit. These rules prevent stale actions and state writes after the +widget or station session changes. Evidence: `src/wellness/useWellnessBreak.ts` and +`tests/wellness.orchestrator.ts`. +Clear removes prior history but keeps a pending offer visible and actionable until the agent responds or +its timer expires. Playback requires two continuous safe seconds; a task or unconfirmed state cancels +the current settle timer. +The overlay is supplied to `cc-components` independently of assistant chrome, so it remains visible while +the launcher is closed or minimized. Its host-facing target defaults to the current viewport; React hosts +may scope it to the assistant root, which expands to panel height during an active overlay, or portal it +into a supplied element. See +[`agent-wellness-break-intake.md`](../../../../ai-docs/features/agent-wellness-break-intake.md). ## Design Overview + `src/ai-assistant/index.tsx` is a thin `observer` container: it reads the store, derives `isFeatureEnabled` from `featureFlags[REAL_TIME_ASSIST_FLAG]` and the active interaction from `currentTask.data.interactionId`, resolves the deprecated callback alias, and hands everything to @@ -114,6 +174,7 @@ Refs (`pendingRequestRef`, `requestStatusRef`, `onRealTimeAssistReceivedRef`) ke dependent on `realTimeAssist` alone, so unrelated state changes don't replay payloads. ## Data Flow + ```mermaid graph LR Agent[Agent] -->|Get Assistance / Send context| RTA[RealTimeAssist UI] @@ -129,8 +190,9 @@ graph LR ``` ## Sequence Diagram(s) -| Operation group | Diagram | Failure coverage | -|---|---|---| + +| Operation group | Diagram | Failure coverage | +| ------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------- | | First request → suggestion → feedback | "Real-time assist round trip" | request rejection keeps the opening prompt; feedback rejection leaves the card control unselected | ```mermaid @@ -167,29 +229,33 @@ sequenceDiagram ``` ## Use Cases + - **UC-1 Ask for a suggestion:** agent opens the panel during a call and clicks "Get Assistance" → spinner → listening mode with the assistant greeting; cards stream in as the conversation progresses. - **UC-2 Refine with context:** agent types context and sends → an `ADD_SUGGESTIONS_EXTRA_CONTEXT` request goes out, the message appears in the transcript, and the panel remains in listening mode whether or not it succeeds. - **UC-3 Give feedback:** agent likes/dislikes/copies a card → the SDK records the action and the icon then shows as selected; a failure leaves it unselected so the agent can retry. - **UC-4 No active call:** agent opens the panel with no interaction or the feature disabled → landing view listing AI features, no error state. ## Error Handling & Failure Modes -| Condition | Signal | Recovery | -|---|---|---| -| Feature disabled / no interaction | `requestStatus` stays `idle`; landing view | None needed — informational state | -| First `getRealTimeAssistance` rejects | `requestStatus = 'error'`, message under the prompt | Agent clicks the button again | -| Later request rejects | Panel stays in listening mode | Next successful push resumes the transcript | -| Overlapping request | Request controls are disabled while `isRequesting` | Wait for the current call to settle | -| Feedback action fails | `store.logger.error`, promise rejects, control stays unselected | Agent can click again | -| Render crash | `ErrorBoundary` renders nothing, `store.onErrorCallback('AIAssistant', error)` | Host decides | + +| Condition | Signal | Recovery | +| ------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | +| Feature disabled / no interaction | `requestStatus` stays `idle`; landing view | None needed — informational state | +| First `getRealTimeAssistance` rejects | `requestStatus = 'error'`, message under the prompt | Agent clicks the button again | +| Later request rejects | Panel stays in listening mode | Next successful push resumes the transcript | +| Overlapping request | Request controls are disabled while `isRequesting` | Wait for the current call to settle | +| Feedback action fails | `store.logger.error`, promise rejects, control stays unselected | Agent can click again | +| Render crash | `ErrorBoundary` renders nothing, `store.onErrorCallback('AIAssistant', error)` | Host decides | ## Pitfalls -- `hasInitialRequestSucceeded` means *the first request resolved*, not *a request was fired*. Setting it before the `await` would strand the agent in listening mode after a failure. + +- `hasInitialRequestSucceeded` means _the first request resolved_, not _a request was fired_. Setting it before the `await` would strand the agent in listening mode after a failure. - `requestStatus === 'listening'` is the steady post-success state, not an "in flight" flag. Use `isRequesting` for that, and `pendingRequest` for "waiting on a pushed payload". - The response effect must depend on `realTimeAssist` only; adding state to its dependency array replays payloads to the host. - The widget never imports the SDK directly — everything goes through `store.cc`. - `cc-components` cannot import the store, so failures there (clipboard, card render) surface through the `logger` prop this package passes down. ## Module Do's / Don'ts + - DO put behavior in `helper.ts` and keep `ai-assistant/index.tsx` a thin store-to-props adapter. - DO pass `store.logger` down so `cc-components` can report failures. - DON'T import from `@webex/cc-widgets` (circular) or from `@webex/contact-center` directly. @@ -197,6 +263,7 @@ sequenceDiagram - DON'T remove `onSuggestionReceived` without a major bump; bridge it instead. ## Test-Case Strategy (module) + `tests/helper.ts` drives the hooks with `renderHook`: chrome transitions, request success/failure, the `isRequesting` lifecycle, interaction reset, batched payload replay, and transcript ordering. `tests/ai-assistant/index.tsx` renders the container against a mocked store for the landing/listening @@ -205,5 +272,6 @@ and the missing-`adaptiveCardId` warning. UI-level rendering (spinner, error tex snapshots) is covered in `cc-components/tests/components/AIAssistant/`. ## Traceability + - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md` - Coverage state & contracts baseline: `.sdd/manifest.json` diff --git a/packages/contact-center/ai-assistant/eslint.config.mjs b/packages/contact-center/ai-assistant/eslint.config.mjs index ec394673b..28b33e768 100644 --- a/packages/contact-center/ai-assistant/eslint.config.mjs +++ b/packages/contact-center/ai-assistant/eslint.config.mjs @@ -17,4 +17,9 @@ export default [ }, eslintPluginPrettierRecommended, eslintConfigPrettier, + { + files: ['**/src/wellness/assets/*.cjs'], + languageOptions: {globals: globals.node}, + rules: {'@typescript-eslint/no-require-imports': 'off'}, + }, ]; diff --git a/packages/contact-center/ai-assistant/jest.config.js b/packages/contact-center/ai-assistant/jest.config.js index 42ff9801e..91e8ab712 100644 --- a/packages/contact-center/ai-assistant/jest.config.js +++ b/packages/contact-center/ai-assistant/jest.config.js @@ -2,5 +2,7 @@ const jestConfig = require('../../../jest.config.js'); jestConfig.rootDir = '../../../'; jestConfig.testMatch = ['**/ai-assistant/tests/**/*.ts', '**/ai-assistant/tests/**/*.tsx']; +jestConfig.moduleNameMapper['^@webex/cc-components$'] = + '/packages/contact-center/cc-components/src/index.ts'; module.exports = jestConfig; diff --git a/packages/contact-center/ai-assistant/package.json b/packages/contact-center/ai-assistant/package.json index fdf95ffcb..76466c49f 100644 --- a/packages/contact-center/ai-assistant/package.json +++ b/packages/contact-center/ai-assistant/package.json @@ -26,6 +26,7 @@ "@webex/cc-components": "workspace:*", "@webex/cc-store": "workspace:*", "@webex/cc-ui-logging": "workspace:*", + "lottie-web": "5.13.0", "mobx-react-lite": "^4.1.0", "react-error-boundary": "^6.0.0", "typescript": "5.6.3" diff --git a/packages/contact-center/ai-assistant/src/ai-assistant.types.ts b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts index 1a50f40fb..17deb274f 100644 --- a/packages/contact-center/ai-assistant/src/ai-assistant.types.ts +++ b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts @@ -1,4 +1,15 @@ -import type {RealTimeAssistPayload} from '@webex/cc-store'; +import type { + IdleCode, + ITask, + RealTimeAssistPayload, + WellnessBreakError, + WellnessBreakEvent, + WellnessBreakState, +} from '@webex/cc-store'; +import type {WellnessBreakOverlayTarget} from '@webex/cc-components'; + +export type {WellnessBreakError, WellnessBreakErrorCode, WellnessBreakPhase} from '@webex/cc-store'; +export type {WellnessBreakOverlayTarget} from '@webex/cc-components'; /** * Public props for the `AIAssistant` widget. All callbacks are optional — @@ -19,6 +30,20 @@ export interface IAIAssistantProps { onRealTimeAssistReceived?: (payload: RealTimeAssistPayload) => void; /** Optional extra class applied to the widget root. */ className?: string; + /** Fired for a valid backend-provided actionable offer. */ + onWellnessBreakOffered?: (event: WellnessBreakEvent) => void; + /** Fired after the break request changes agent state and any required ACCEPTED action is delivered. */ + onWellnessBreakAccepted?: (event: WellnessBreakEvent) => void; + /** Fired when the timed break begins playing. */ + onWellnessBreakStarted?: () => void; + /** Fired after the timed break ends and state restoration succeeds. */ + onWellnessBreakEnded?: () => void; + /** Fired with a stable, non-PII lifecycle error shape. */ + onWellnessBreakError?: (error: WellnessBreakError) => void; + /** Optional host/CDN audio URL. Defaults to the packaged wellness audio asset. */ + wellnessAudioUrl?: string; + /** Controls whether the break covers the viewport, assistant root, or a host element. */ + wellnessBreakOverlayTarget?: WellnessBreakOverlayTarget; } export interface UseAiAssistantInput extends IAIAssistantProps { @@ -39,3 +64,27 @@ export type UseRealTimeAssistInput = Pick< >; export type UserMessage = {id: string; text: string; sentAt: number}; + +/** @internal */ +export interface UseWellnessBreakInput + extends Pick< + IAIAssistantProps, + | 'onWellnessBreakOffered' + | 'onWellnessBreakAccepted' + | 'onWellnessBreakStarted' + | 'onWellnessBreakEnded' + | 'onWellnessBreakError' + | 'wellnessAudioUrl' + > { + enabled: boolean; + isLoggedIn: boolean; + agentId: string; + agentSessionId: string; + wellbeingBreakIdleCode?: IdleCode; + wellnessBreakState: WellnessBreakState; + wellnessEventSequence: number; + legacyAgentState: string; + legacyAuxCodeId: string; + taskList: Record; + theme: string; +} diff --git a/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx index 3435ad694..0f979e7ae 100644 --- a/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx +++ b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx @@ -1,14 +1,32 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; import {ErrorBoundary} from 'react-error-boundary'; import {AIAssistantComponent} from '@webex/cc-components'; import {useAiAssistant, REAL_TIME_ASSIST_FLAG} from '../helper'; +import {useWellnessBreak} from '../wellness/useWellnessBreak'; +import {loadWellnessAnimation} from '../wellness/animation'; import {IAIAssistantProps} from '../ai-assistant.types'; const AIAssistantInternal: React.FunctionComponent = observer((props) => { - const {currentTask, agentId, agentProfile, featureFlags, realTimeAssist} = store; + const { + currentTask, + agentId, + agentProfile, + featureFlags, + realTimeAssist, + isWellnessBreakEnabled, + isAgentLoggedIn, + wellnessAgentSessionId, + wellbeingBreakIdleCode, + wellnessBreakState, + wellnessEventSequence, + legacyAgentState, + legacyAuxCodeId, + taskList, + currentTheme, + } = store; const interactionId = currentTask?.data?.interactionId; const isFeatureEnabled = Boolean(featureFlags?.[REAL_TIME_ASSIST_FLAG]); const activeRealTimeAssist = interactionId ? realTimeAssist?.[interactionId] || [] : []; @@ -20,6 +38,35 @@ const AIAssistantInternal: React.FunctionComponent = observer isFeatureEnabled, realTimeAssist: activeRealTimeAssist, }); + const wellness = useWellnessBreak({ + ...props, + enabled: Boolean(isWellnessBreakEnabled), + isLoggedIn: Boolean(isAgentLoggedIn), + agentId: agentId || '', + agentSessionId: wellnessAgentSessionId || '', + wellbeingBreakIdleCode, + wellnessBreakState: wellnessBreakState || {phase: 'idle'}, + wellnessEventSequence: wellnessEventSequence || 0, + legacyAgentState: legacyAgentState || '', + legacyAuxCodeId: legacyAuxCodeId || '', + taskList: taskList || {}, + theme: currentTheme || 'light', + }); + const clearContent = useCallback(() => { + hookProps.clearTranscript(); + wellness.onClearHistory?.(); + }, [hookProps.clearTranscript, wellness.onClearHistory]); + const hasClearableContent = Boolean( + hookProps.chatEntries.length || + wellness.history?.length || + (!wellness.contentCleared && + (wellness.requestAvailable || + wellness.notice || + wellness.error || + ['offer-pending', 'request-pending', 'changing-to-break', 'waiting-for-safe-state', 'restoring'].includes( + wellness.phase + ))) + ); return ( = observer agentName={agentProfile?.agentName} logger={store.logger} className={props.className} + wellnessBreakOverlayTarget={props.wellnessBreakOverlayTarget} + wellness={wellness} + loadWellnessAnimation={loadWellnessAnimation} + clearContent={clearContent} + hasClearableContent={hasClearableContent} /> ); }); diff --git a/packages/contact-center/ai-assistant/src/helper.ts b/packages/contact-center/ai-assistant/src/helper.ts index 4c126762e..4ce3c9b90 100644 --- a/packages/contact-center/ai-assistant/src/helper.ts +++ b/packages/contact-center/ai-assistant/src/helper.ts @@ -89,6 +89,7 @@ export const useRealTimeAssist = ({ // ADD_SUGGESTIONS_EXTRA_CONTEXT only makes sense once a GET_SUGGESTIONS has succeeded. const [hasInitialRequestSucceeded, setHasInitialRequestSucceeded] = useState(false); const [userMessages, setUserMessages] = useState([]); + const [clearBeforeCount, setClearBeforeCount] = useState(0); const lastSeenCountRef = useRef(0); // Stash these in refs so the response effect can read the latest values @@ -113,12 +114,14 @@ export const useRealTimeAssist = ({ setPendingRequest(false); setHasInitialRequestSucceeded(false); setUserMessages([]); + setClearBeforeCount(0); }, [interactionId]); useEffect(() => { const len = realTimeAssist.length; if (len === 0) { lastSeenCountRef.current = 0; + setClearBeforeCount(0); return; } if (len <= lastSeenCountRef.current) return; @@ -178,6 +181,19 @@ export const useRealTimeAssist = ({ setContextDraft(''); }, [contextDraft, requestRealTimeAssist]); + const clearTranscript = useCallback(() => { + if (interactionId) store.clearRealTimeAssist(interactionId); + lastSeenCountRef.current = realTimeAssist.length; + setClearBeforeCount(realTimeAssist.length); + setRequestStatus('idle'); + setErrorMessage(undefined); + setContextDraft(''); + setIsRequesting(false); + setPendingRequest(false); + setHasInitialRequestSucceeded(false); + setUserMessages([]); + }, [interactionId, realTimeAssist.length]); + // Returns the SDK promise so the card only marks like/dislike as selected // once the action has actually reached the backend. const handleRealTimeAssistAction = useCallback( @@ -215,8 +231,9 @@ export const useRealTimeAssist = ({ // Chronological transcript: optional greeting, then interleaved user/assistant entries. const chatEntries = useMemo(() => { - const assistantEntries: Array<{ts: number; order: number; entry: AIAssistantChatEntry}> = realTimeAssist.map( - (suggestion, index) => { + const assistantEntries: Array<{ts: number; order: number; entry: AIAssistantChatEntry}> = realTimeAssist + .slice(clearBeforeCount) + .map((suggestion, index) => { const publishTimestamp = suggestion?.data?.publishTimestamp; const ts = typeof publishTimestamp === 'number' @@ -226,8 +243,7 @@ export const useRealTimeAssist = ({ : 0; const id = suggestion?.data?.adaptiveCardId ?? suggestion?.data?.trackingId ?? `assistant-${index}`; return {ts, order: 2, entry: {type: 'assistant', id, realTimeAssist: suggestion}}; - } - ); + }); const userEntries: Array<{ts: number; order: number; entry: AIAssistantChatEntry}> = userMessages.map( (message) => ({ @@ -244,7 +260,7 @@ export const useRealTimeAssist = ({ if (!hasInitialRequestSucceeded) return sorted; return [{type: 'assistant-greeting', id: 'greeting-assistant', text: GREETING_TEXT}, ...sorted]; - }, [realTimeAssist, userMessages, hasInitialRequestSucceeded]); + }, [clearBeforeCount, realTimeAssist, userMessages, hasInitialRequestSucceeded]); return useMemo( () => ({ @@ -257,6 +273,7 @@ export const useRealTimeAssist = ({ requestRealTimeAssist, setContextDraft, submitContext, + clearTranscript, onRealTimeAssistAction: handleRealTimeAssistAction, }), [ @@ -268,6 +285,7 @@ export const useRealTimeAssist = ({ chatEntries, requestRealTimeAssist, submitContext, + clearTranscript, handleRealTimeAssistAction, ] ); diff --git a/packages/contact-center/ai-assistant/src/wellness/animation.ts b/packages/contact-center/ai-assistant/src/wellness/animation.ts new file mode 100644 index 000000000..7939efdbd --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/animation.ts @@ -0,0 +1,15 @@ +import type {AnimationConfigWithData} from 'lottie-web'; +import type {WellnessAnimationLoader} from '@webex/cc-components'; +import {loadLottie} from './assets/media.cjs'; + +export const loadWellnessAnimation: WellnessAnimationLoader = async (container, animationData) => { + const lottie = await loadLottie(); + return lottie.loadAnimation({ + container, + renderer: 'svg', + loop: false, + autoplay: false, + animationData: animationData as AnimationConfigWithData['animationData'], + rendererSettings: {preserveAspectRatio: 'xMidYMid slice'}, + }); +}; diff --git a/packages/contact-center/ai-assistant/src/wellness/assets.d.ts b/packages/contact-center/ai-assistant/src/wellness/assets.d.ts new file mode 100644 index 000000000..8b559dacf --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/assets.d.ts @@ -0,0 +1,9 @@ +declare module '*.mp3' { + const url: string; + export default url; +} + +declare module '*.json' { + const data: unknown; + export default data; +} diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationDark.json b/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationDark.json new file mode 100644 index 000000000..e5d116a80 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationDark.json @@ -0,0 +1,3193 @@ +{ + "v": "5.9.0", + "fr": 60, + "ip": 0, + "op": 3600, + "w": 1920, + "h": 1080, + "nm": "Comp 1", + "ddd": 0, + "assets": [ + { + "id": "comp_0", + "nm": "comppp", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 0, + "nm": "Pre-comp 2", + "refId": "comp_1", + "sr": 1.76380002498627, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 2311, + "op": 3722.04001998901, + "st": 2311, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "Pre-comp 2", + "refId": "comp_1", + "sr": 1.4438, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 1155, + "op": 2310.04, + "st": 1155, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 0, + "nm": "Pre-comp 2", + "refId": "comp_1", + "sr": 1.4438, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 1155.04, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_1", + "nm": "Pre-comp 2", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "Layer 10 Comp 1", + "refId": "comp_2", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 0, + "nm": "Layer 9 Comp 1", + "refId": "comp_3", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 0, + "nm": "Layer 8 Comp 1", + "refId": "comp_4", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 0, + "nm": "Layer 7 Comp 1", + "refId": "comp_5", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 0, + "nm": "Layer 6 Comp 1", + "refId": "comp_6", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 0, + "nm": "Layer 5 Comp 1", + "refId": "comp_7", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 8, + "ty": 0, + "nm": "Layer 4 Comp 1", + "refId": "comp_8", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 9, + "ty": 0, + "nm": "Layer 1 Comp 1", + "refId": "comp_9", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_2", + "nm": "Layer 10 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 10", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 186, + 186, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 346, + "s": [ + 555.4, + 555.4, + 100 + ] + }, + { + "t": 784, + "s": [ + 186, + 186, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_3", + "nm": "Layer 9 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 9", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 175, + 175, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 377, + "s": [ + 462.6, + 462.6, + 100 + ] + }, + { + "t": 766, + "s": [ + 175, + 175, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_4", + "nm": "Layer 8 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 8", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 161, + 161, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 398, + "s": [ + 375.8, + 375.8, + 100 + ] + }, + { + "t": 743, + "s": [ + 161, + 161, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_5", + "nm": "Layer 7 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 7", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 143, + 143, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 307.8, + 307.8, + 100 + ] + }, + { + "t": 713, + "s": [ + 143, + 143, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_6", + "nm": "Layer 6 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 6", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 117, + 117, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 252.4, + 252.4, + 100 + ] + }, + { + "t": 669, + "s": [ + 117, + 117, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_7", + "nm": "Layer 5 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 5", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 81, + 81, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 194, + 194, + 100 + ] + }, + { + "t": 626, + "s": [ + 81, + 81, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_8", + "nm": "Layer 4 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 4", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 5, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 43, + 43, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 142, + 142, + 100 + ] + }, + { + "t": 581, + "s": [ + 43, + 43, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.941, + 0.576, + 0.984, + 0.5, + 0.67, + 0.335, + 0.797, + 1, + 0.398, + 0.094, + 0.609 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_9", + "nm": "Layer 1 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 1", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 539.795, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -960, + -540 + ], + [ + 960, + -540 + ], + [ + 960, + 540 + ], + [ + -960, + 540 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 5, + "k": { + "a": 0, + "k": [ + 0, + 0.125, + 0.102, + 0.216, + 0.255, + 0.114, + 0.124, + 0.267, + 0.51, + 0.102, + 0.145, + 0.318, + 0.755, + 0.086, + 0.188, + 0.324, + 1, + 0.071, + 0.231, + 0.329 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -1126.504, + -888.205 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 70.161, + -2275.102 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + } + ], + "layers": [ + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "comppp", + "refId": "comp_0", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "Noise", + "np": 5, + "mn": "ADBE Noise2", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Amount of Noise", + "mn": "ADBE Noise2-0001", + "ix": 1, + "v": { + "a": 0, + "k": 3, + "ix": 1 + } + }, + { + "ty": 7, + "nm": "Noise Type", + "mn": "ADBE Noise2-0002", + "ix": 2, + "v": { + "a": 0, + "k": 1, + "ix": 2 + } + }, + { + "ty": 7, + "nm": "Clipping", + "mn": "ADBE Noise2-0003", + "ix": 3, + "v": { + "a": 0, + "k": 1, + "ix": 3 + } + } + ] + } + ], + "w": 1920, + "h": 1080, + "ip": -119, + "op": 3603, + "st": -119, + "bm": 0 + } + ], + "markers": [] +} diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationLight.json b/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationLight.json new file mode 100644 index 000000000..0f9cc4bf9 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakAnimationLight.json @@ -0,0 +1,3193 @@ +{ + "v": "5.9.0", + "fr": 60, + "ip": 0, + "op": 3600, + "w": 1920, + "h": 1080, + "nm": "Comp 1", + "ddd": 0, + "assets": [ + { + "id": "comp_0", + "nm": "comppp", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 0, + "nm": "Pre-comp 2", + "refId": "comp_1", + "sr": 1.76380002498627, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 2311, + "op": 3722.04001998901, + "st": 2311, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "Pre-comp 2", + "refId": "comp_1", + "sr": 1.4438, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 1155, + "op": 2310.04, + "st": 1155, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 0, + "nm": "Pre-comp 2", + "refId": "comp_1", + "sr": 1.4438, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 1155.04, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_1", + "nm": "Pre-comp 2", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "Layer 10 Comp 1", + "refId": "comp_2", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 0, + "nm": "Layer 9 Comp 1", + "refId": "comp_3", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 0, + "nm": "Layer 8 Comp 1", + "refId": "comp_4", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 0, + "nm": "Layer 7 Comp 1", + "refId": "comp_5", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 0, + "nm": "Layer 6 Comp 1", + "refId": "comp_6", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 0, + "nm": "Layer 5 Comp 1", + "refId": "comp_7", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 8, + "ty": 0, + "nm": "Layer 4 Comp 1", + "refId": "comp_8", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 9, + "ty": 0, + "nm": "Layer 1 Comp 1", + "refId": "comp_9", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "w": 1920, + "h": 1080, + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_2", + "nm": "Layer 10 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 10", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 186, + 186, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 346, + "s": [ + 555.4, + 555.4, + 100 + ] + }, + { + "t": 784, + "s": [ + 186, + 186, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.988, + 0.971, + 1, + 1, + 0.976, + 0.941, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_3", + "nm": "Layer 9 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 9", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 175, + 175, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 377, + "s": [ + 462.6, + 462.6, + 100 + ] + }, + { + "t": 766, + "s": [ + 175, + 175, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.988, + 0.971, + 1, + 1, + 0.976, + 0.941, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_4", + "nm": "Layer 8 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 8", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 161, + 161, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 398, + "s": [ + 375.8, + 375.8, + 100 + ] + }, + { + "t": 743, + "s": [ + 161, + 161, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.985, + 0.963, + 1, + 1, + 0.969, + 0.925, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_5", + "nm": "Layer 7 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 7", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 143, + 143, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 307.8, + 307.8, + 100 + ] + }, + { + "t": 713, + "s": [ + 143, + 143, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.988, + 0.971, + 1, + 1, + 0.976, + 0.941, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_6", + "nm": "Layer 6 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 6", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 117, + 117, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 252.4, + 252.4, + 100 + ] + }, + { + "t": 669, + "s": [ + 117, + 117, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.988, + 0.971, + 1, + 1, + 0.976, + 0.941, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_7", + "nm": "Layer 5 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 5", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 81, + 81, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 194, + 194, + 100 + ] + }, + { + "t": 626, + "s": [ + 81, + 81, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.988, + 0.971, + 1, + 0.999, + 0.977, + 0.943, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_8", + "nm": "Layer 4 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 4", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 15, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 959.504, + 1373, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 0, + "s": [ + 43, + 43, + 100 + ] + }, + { + "i": { + "x": [ + 0.34, + 0.34, + 0.34 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 0.66, + 0.66, + 0.66 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 400, + "s": [ + 142, + 142, + 100 + ] + }, + { + "t": 581, + "s": [ + 43, + 43, + 100 + ] + } + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ], + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ] + ], + "o": [ + [ + 181.34, + 0 + ], + [ + 0, + 181.34 + ], + [ + -181.341, + 0 + ], + [ + 0, + -181.341 + ] + ], + "v": [ + [ + 0, + -328.346 + ], + [ + 328.346, + 0 + ], + [ + 0, + 328.346 + ], + [ + -328.346, + 0 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 1, + 1, + 1, + 0.5, + 0.989, + 0.973, + 1, + 1, + 0.978, + 0.946, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -327.504, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 322.391, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + }, + { + "id": "comp_9", + "nm": "Layer 1 Comp 1", + "fr": 60, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Layer 2", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 960, + 540 + ], + [ + -960, + 540 + ], + [ + -960, + -540 + ], + [ + 960, + -540 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 5, + "k": { + "a": 0, + "k": [ + 0, + 0.776, + 0.741, + 0.914, + 0.25, + 0.759, + 0.775, + 0.957, + 0.5, + 0.741, + 0.808, + 1, + 0.75, + 0.718, + 0.873, + 1, + 1, + 0.694, + 0.937, + 1 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -914, + -555.99 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 837.026, + 511.001 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Group 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 800, + "st": 0, + "bm": 0 + } + ] + } + ], + "layers": [ + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "comppp", + "refId": "comp_0", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ + 960, + 540, + 0 + ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "ef": [ + { + "ty": 5, + "nm": "Noise", + "np": 5, + "mn": "ADBE Noise2", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 0, + "nm": "Amount of Noise", + "mn": "ADBE Noise2-0001", + "ix": 1, + "v": { + "a": 0, + "k": 3, + "ix": 1 + } + }, + { + "ty": 7, + "nm": "Noise Type", + "mn": "ADBE Noise2-0002", + "ix": 2, + "v": { + "a": 0, + "k": 1, + "ix": 2 + } + }, + { + "ty": 7, + "nm": "Clipping", + "mn": "ADBE Noise2-0003", + "ix": 3, + "v": { + "a": 0, + "k": 1, + "ix": 3 + } + } + ] + } + ], + "w": 1920, + "h": 1080, + "ip": -119, + "op": 3603, + "st": -119, + "bm": 0 + } + ], + "markers": [] +} diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakSound.mp3 b/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakSound.mp3 new file mode 100644 index 000000000..3cc51ebfa Binary files /dev/null and b/packages/contact-center/ai-assistant/src/wellness/assets/WellnessBreakSound.mp3 differ diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs b/packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs new file mode 100644 index 000000000..5a0dca421 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/assets/emit-wellness-asset.cjs @@ -0,0 +1,9 @@ +const path = require('path'); + +module.exports = function emitWellnessAsset(source) { + const filename = `assets/wellness/${path.basename(this.resourcePath)}`; + this.emitFile(filename, source); + return `module.exports = __webpack_public_path__ + ${JSON.stringify(filename)};`; +}; + +module.exports.raw = true; diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs b/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs new file mode 100644 index 000000000..5f0f7a1d0 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/assets/media.cjs @@ -0,0 +1,27 @@ +// Keep these CommonJS requests visible to the consuming Webpack build. The +// assistant's package build leaves this file alongside its published assets. +exports.loadWellnessSoundUrl = () => Promise.resolve(require('!!./emit-wellness-asset.cjs!./WellnessBreakSound.mp3')); + +exports.loadWellnessAnimationData = (theme) => + new Promise((resolve, reject) => { + if (theme === 'dark') { + require.ensure( + [], + (require) => resolve(require('./WellnessBreakAnimationDark.json')), + reject, + 'wellness-animation-dark' + ); + } else { + require.ensure( + [], + (require) => resolve(require('./WellnessBreakAnimationLight.json')), + reject, + 'wellness-animation-light' + ); + } + }); + +exports.loadLottie = () => + new Promise((resolve, reject) => { + require.ensure([], (require) => resolve(require('lottie-web')), reject, 'lottie-web'); + }); diff --git a/packages/contact-center/ai-assistant/src/wellness/assets/media.d.cts b/packages/contact-center/ai-assistant/src/wellness/assets/media.d.cts new file mode 100644 index 000000000..1a46e1082 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/assets/media.d.cts @@ -0,0 +1,3 @@ +export function loadWellnessSoundUrl(): Promise; +export function loadWellnessAnimationData(theme: 'dark' | 'light'): Promise; +export function loadLottie(): Promise<(typeof import('lottie-web'))['default']>; diff --git a/packages/contact-center/ai-assistant/src/wellness/useWellnessBreak.ts b/packages/contact-center/ai-assistant/src/wellness/useWellnessBreak.ts new file mode 100644 index 000000000..ff75aa25f --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/useWellnessBreak.ts @@ -0,0 +1,1042 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import store, {WELLNESS_BREAK_NOTIFICATION_ACTIONS, WELLNESS_BREAK_USER_ACTIONS} from '@webex/cc-store'; +import type {WellnessBreakError, WellnessBreakErrorCode, WellnessBreakEvent, WellnessBreakPhase} from '@webex/cc-store'; +import type { + WellnessBreakHistoryEntry, + WellnessBreakNotice, + WellnessBreakResponseSource, + WellnessBreakViewModel, +} from '@webex/cc-components'; +import type {UseWellnessBreakInput} from '../ai-assistant.types'; +import type {WellnessBreakRecoveryMarkerV1} from './wellness-internal.types'; +import { + areAllTasksSafeForWellness, + createWellnessRecoveryMarker, + getWellnessRecoveryDecision, + normalizeWellnessState, + parseWellnessRecoveryMarker, +} from './wellness.utils'; +import {logWellnessMetric, WELLNESS_METRIC} from './wellness.metrics'; +import {loadWellnessAnimationData, loadWellnessSoundUrl} from './assets/media.cjs'; + +export const WELLNESS_OFFER_TIMEOUT_MS = 5 * 60 * 1000; +export const WELLNESS_STATE_SETTLE_MS = 2 * 1000; +export const WELLNESS_STARTING_MS = 5 * 1000; +export const WELLNESS_PLAYING_MS = 60 * 1000; +export const WELLNESS_ENDING_MS = 5 * 1000; +export const WELLNESS_RESTORE_RETRY_MS = 10 * 1000; +export const WELLNESS_RECOVERY_KEY = 'webex-cc-agent-wellness-break:v1'; + +const RESTORE_ATTEMPTS = 3; +const LEGACY_RECOVERY_ATTEMPTS = 5; +const LEGACY_RECOVERY_INTERVAL_MS = 20 * 1000; +const ACTIVE_PHASES = new Set([ + 'changing-to-break', + 'waiting-for-safe-state', + 'starting', + 'playing', + 'ending', + 'restoring', +]); +const PRE_PLAY_PHASES = new Set(['changing-to-break', 'waiting-for-safe-state', 'starting']); +const MODULE = 'wellness/useWellnessBreak.ts'; + +type CapturedBreakState = { + theme: string; +} & Pick; + +type RestoreResult = 'restored' | 'not-owned' | 'failed' | 'cancelled'; + +const AVAILABLE_STATE = {state: 'Available' as const, auxCodeId: '0'}; + +const wait = (milliseconds: number): Promise => + new Promise((resolve) => { + window.setTimeout(resolve, milliseconds); + }); + +/** @internal */ +export const useWellnessBreak = (input: UseWellnessBreakInput): WellnessBreakViewModel => { + const [requestAvailable, setRequestAvailable] = useState(false); + const [notice, setNotice] = useState(); + const [error, setError] = useState(); + const [countdown, setCountdown] = useState(); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [animationData, setAnimationData] = useState(); + const [reducedMotion, setReducedMotion] = useState(false); + const [history, setHistory] = useState([]); + const [contentCleared, setContentCleared] = useState(false); + + const latestRef = useRef(input); + const phaseRef = useRef(input.wellnessBreakState.phase); + const callbackRef = useRef(input); + const previousSessionRef = useRef(input.agentSessionId); + const lastEventSequenceRef = useRef(0); + const offerTimerRef = useRef(); + const settleTimerRef = useRef(); + const timelineIntervalRef = useRef(); + const timelineTimerRef = useRef(); + const audioRef = useRef(); + const audioLoadPromiseRef = useRef>(); + const mediaOperationRef = useRef(0); + const capturedRef = useRef(); + const stateRequestResolvedRef = useRef(false); + const restoringRef = useRef(false); + const operationRef = useRef(0); + const acceptedEventRef = useRef(); + const completionRef = useRef(false); + const legacyRecoveryCountRef = useRef(0); + const pendingStateRequestRef = useRef>(); + const reportedErrorCodeRef = useRef(); + const historySequenceRef = useRef(0); + const activeOfferHistoryIdRef = useRef(); + + latestRef.current = input; + callbackRef.current = input; + phaseRef.current = input.wellnessBreakState.phase; + + const invokeHost = useCallback((callback: (() => void) | undefined, name: string) => { + try { + callback?.(); + } catch { + store.logger?.warn('CC-Widgets: Agent Wellness Break host callback failed', {module: MODULE, method: name}); + } + }, []); + + const invokeHostWithEvent = useCallback( + (callback: ((event: WellnessBreakEvent) => void) | undefined, event: WellnessBreakEvent, name: string) => { + try { + callback?.(event); + } catch { + store.logger?.warn('CC-Widgets: Agent Wellness Break host callback failed', {module: MODULE, method: name}); + } + }, + [] + ); + + const createHistoryMetadata = useCallback((type: WellnessBreakHistoryEntry['type']) => { + historySequenceRef.current += 1; + return {id: `${type}-${Date.now()}-${historySequenceRef.current}`, createdAt: Date.now()}; + }, []); + + const appendHistory = useCallback((entry: WellnessBreakHistoryEntry) => { + setHistory((current) => [...current, entry]); + setContentCleared(false); + }, []); + + const appendUserAction = useCallback( + (action: Extract['action']) => { + appendHistory({type: 'user-action', action, ...createHistoryMetadata('user-action')}); + }, + [appendHistory, createHistoryMetadata] + ); + + const appendAcknowledgement = useCallback( + (hasBlockingTasks: boolean) => { + appendHistory({type: 'acknowledgement', hasBlockingTasks, ...createHistoryMetadata('acknowledgement')}); + }, + [appendHistory, createHistoryMetadata] + ); + + const appendNotice = useCallback( + (nextNotice: WellnessBreakNotice, actionText?: string) => { + appendHistory({type: 'notice', notice: nextNotice, actionText, ...createHistoryMetadata('notice')}); + }, + [appendHistory, createHistoryMetadata] + ); + + const appendOffer = useCallback( + (event: WellnessBreakEvent) => { + const metadata = createHistoryMetadata('offer'); + activeOfferHistoryIdRef.current = metadata.id; + appendHistory({type: 'offer', event, actionable: true, ...metadata}); + }, + [appendHistory, createHistoryMetadata] + ); + + const resolveActiveOffer = useCallback(() => { + const offerId = activeOfferHistoryIdRef.current; + if (!offerId) return; + setHistory((current) => + current.map((entry) => (entry.type === 'offer' && entry.id === offerId ? {...entry, actionable: false} : entry)) + ); + activeOfferHistoryIdRef.current = undefined; + }, []); + + const clearHistory = useCallback(() => { + const offerPending = phaseRef.current === 'offer-pending'; + const activeOfferId = activeOfferHistoryIdRef.current; + setHistory((current) => + offerPending ? current.filter((entry) => entry.type === 'offer' && entry.id === activeOfferId) : [] + ); + setContentCleared(!offerPending); + setRequestAvailable(false); + setNotice(undefined); + setError(undefined); + if (!offerPending) activeOfferHistoryIdRef.current = undefined; + }, []); + + const setPhase = useCallback( + ( + phase: WellnessBreakPhase, + patch: Partial<{ + event: WellnessBreakEvent; + responseDeadline: number; + errorCode: WellnessBreakErrorCode; + }> = {} + ) => { + phaseRef.current = phase; + store.setWellnessBreakState({phase, ...patch}); + }, + [] + ); + + const reportError = useCallback( + (code: WellnessBreakErrorCode, phase: WellnessBreakPhase, recoverable: boolean, showError = true) => { + const wellnessError: WellnessBreakError = {code, phase, recoverable}; + reportedErrorCodeRef.current = code; + if (showError) { + setError(wellnessError); + setPhase('error', {errorCode: code}); + } + try { + callbackRef.current.onWellnessBreakError?.(wellnessError); + } catch { + store.logger?.warn('CC-Widgets: Agent Wellness Break host callback failed', { + module: MODULE, + method: 'onWellnessBreakError', + }); + } + store.logger?.error(`CC-Widgets: Agent Wellness Break ${code}`, {module: MODULE, method: 'reportError'}); + logWellnessMetric( + code === 'STATE_CHANGE_FAILED' ? WELLNESS_METRIC.ERROR_CHANGE_STATE : WELLNESS_METRIC.GENERIC_ERROR, + code === 'MEDIA_UNAVAILABLE' ? {degradedMedia: true} : {recoverable} + ); + }, + [setPhase] + ); + + const clearOfferTimer = useCallback(() => { + if (offerTimerRef.current !== undefined) window.clearTimeout(offerTimerRef.current); + offerTimerRef.current = undefined; + }, []); + + const clearTimeline = useCallback(() => { + if (settleTimerRef.current !== undefined) window.clearTimeout(settleTimerRef.current); + if (timelineIntervalRef.current !== undefined) window.clearInterval(timelineIntervalRef.current); + if (timelineTimerRef.current !== undefined) window.clearTimeout(timelineTimerRef.current); + settleTimerRef.current = undefined; + timelineIntervalRef.current = undefined; + timelineTimerRef.current = undefined; + setCountdown(undefined); + }, []); + + const stopMedia = useCallback(() => { + mediaOperationRef.current += 1; + audioLoadPromiseRef.current = undefined; + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + audioRef.current = undefined; + setAnimationData(undefined); + }, []); + + const clearRecoveryMarker = useCallback(() => { + try { + window.sessionStorage.removeItem(WELLNESS_RECOVERY_KEY); + } catch { + // Browser storage may be unavailable in privacy-restricted embeds. + } + }, []); + + const saveRecoveryMarker = useCallback((marker: WellnessBreakRecoveryMarkerV1) => { + try { + window.sessionStorage.setItem(WELLNESS_RECOVERY_KEY, JSON.stringify(marker)); + } catch { + store.logger?.warn('CC-Widgets: wellness refresh recovery storage unavailable', { + module: MODULE, + method: 'saveRecoveryMarker', + }); + } + }, []); + + const requestAvailableState = useCallback( + async ( + captured: CapturedBreakState, + sessionId: string, + agentId: string, + wellnessCodeId: string | undefined, + ownStateRequestSucceeded: boolean, + completedTimeline: boolean, + operation: number, + attempts: number + ): Promise => { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + if ( + operation !== operationRef.current || + latestRef.current.agentSessionId !== sessionId || + ('wellnessAgentSessionId' in store && store.wellnessAgentSessionId !== sessionId) + ) { + return 'cancelled'; + } + + const currentState = store.legacyAgentState || latestRef.current.legacyAgentState; + const currentCode = store.legacyAuxCodeId || latestRef.current.legacyAuxCodeId; + const ownsCurrentState = + currentCode === wellnessCodeId || normalizeWellnessState(currentState) === 'wellbeingbreak'; + const unchangedPreBreakState = + currentCode === captured.preBreakLegacyAuxCodeId && + normalizeWellnessState(currentState) === normalizeWellnessState(captured.preBreakLegacyState); + if (completedTimeline && currentCode === '0' && normalizeWellnessState(currentState) === 'available') { + return 'not-owned'; + } + if (!completedTimeline && !ownsCurrentState && !(ownStateRequestSucceeded && unchangedPreBreakState)) { + return 'not-owned'; + } + + try { + await store.cc.setAgentState({ + ...AVAILABLE_STATE, + agentId, + lastStateChangeReason: 'wellness-break-complete', + }); + if ( + operation !== operationRef.current || + latestRef.current.agentSessionId !== sessionId || + ('wellnessAgentSessionId' in store && store.wellnessAgentSessionId !== sessionId) + ) { + return 'cancelled'; + } + if (attempt > 1) logWellnessMetric(WELLNESS_METRIC.RESTORE_STATE_RETRY_ATTEMPT, {attempt, success: true}); + return 'restored'; + } catch { + store.logger?.warn(`CC-Widgets: Agent Wellness Break restore attempt ${attempt} failed`, { + module: MODULE, + method: 'requestAvailableState', + }); + logWellnessMetric(WELLNESS_METRIC.RESTORE_STATE_RETRY_ATTEMPT, {attempt, success: false}); + if (attempt < attempts) await wait(WELLNESS_RESTORE_RETRY_MS); + } + } + return 'failed'; + }, + [] + ); + + const performRestore = useCallback( + async (completedTimeline: boolean, attempts = RESTORE_ATTEMPTS): Promise => { + if (restoringRef.current) return false; + restoringRef.current = true; + clearOfferTimer(); + clearTimeline(); + stopMedia(); + setPhase('restoring'); + const operation = ++operationRef.current; + const latest = latestRef.current; + const captured = capturedRef.current; + const pendingStateRequest = pendingStateRequestRef.current; + const ownStateRequestSucceeded = pendingStateRequest + ? await pendingStateRequest + : stateRequestResolvedRef.current; + const result = + latest.agentSessionId && captured + ? await requestAvailableState( + captured, + latest.agentSessionId, + latest.agentId, + latest.wellbeingBreakIdleCode?.id, + ownStateRequestSucceeded, + completedTimeline, + operation, + attempts + ) + : 'cancelled'; + restoringRef.current = false; + if (result === 'cancelled') return false; + if (result === 'failed') { + reportError('RESTORE_FAILED', 'restoring', true); + return false; + } + clearRecoveryMarker(); + capturedRef.current = undefined; + stateRequestResolvedRef.current = false; + setError(undefined); + setRequestAvailable(false); + setNotice(completedTimeline ? 'completed' : undefined); + setPhase('idle'); + if (completedTimeline) { + appendNotice('completed'); + logWellnessMetric(WELLNESS_METRIC.BREAK_ENDED); + invokeHost(callbackRef.current.onWellnessBreakEnded, 'onWellnessBreakEnded'); + } + return true; + }, + [ + appendNotice, + clearOfferTimer, + clearRecoveryMarker, + clearTimeline, + invokeHost, + reportError, + requestAvailableState, + setPhase, + stopMedia, + ] + ); + + const prepareAudio = useCallback((): Promise => { + if (audioRef.current) return Promise.resolve(audioRef.current); + if (audioLoadPromiseRef.current) return audioLoadPromiseRef.current; + + const latest = latestRef.current; + const mediaOperation = mediaOperationRef.current; + const audioUrlPromise = latest.wellnessAudioUrl ? Promise.resolve(latest.wellnessAudioUrl) : loadWellnessSoundUrl(); + const audioLoadPromise = audioUrlPromise + .then((audioUrl) => { + if (mediaOperation !== mediaOperationRef.current) return undefined; + const audio = new Audio(audioUrl); + audio.preload = 'auto'; + audio.load(); + audioRef.current = audio; + return audio; + }) + .catch(() => { + reportError('MEDIA_UNAVAILABLE', phaseRef.current, true, false); + return undefined; + }); + + audioLoadPromiseRef.current = audioLoadPromise; + void audioLoadPromise.then(() => { + if (audioLoadPromiseRef.current === audioLoadPromise) audioLoadPromiseRef.current = undefined; + }); + return audioLoadPromise; + }, [reportError]); + + const loadAnimation = useCallback(async (): Promise => { + const latest = latestRef.current; + const mediaOperation = mediaOperationRef.current; + try { + const animation = await loadWellnessAnimationData( + normalizeWellnessState(capturedRef.current?.theme || latest.theme).includes('dark') ? 'dark' : 'light' + ); + if (mediaOperation === mediaOperationRef.current) setAnimationData(animation); + } catch { + reportError('MEDIA_UNAVAILABLE', phaseRef.current, true, false); + } + }, [reportError]); + + const startAudio = useCallback(async (): Promise => { + const mediaOperation = mediaOperationRef.current; + const audio = await prepareAudio(); + if (!audio || mediaOperation !== mediaOperationRef.current || phaseRef.current !== 'playing') return; + audio.currentTime = 0; + audio.muted = false; + audio.volume = 1; + try { + await audio.play(); + } catch { + reportError('MEDIA_UNAVAILABLE', 'playing', true, false); + } + }, [prepareAudio, reportError]); + + const beginPlayback = useCallback(() => { + clearTimeline(); + setPhase('playing'); + setElapsedSeconds(0); + invokeHost(callbackRef.current.onWellnessBreakStarted, 'onWellnessBreakStarted'); + logWellnessMetric(WELLNESS_METRIC.BREAK_STARTED); + void startAudio(); + timelineIntervalRef.current = window.setInterval(() => { + setElapsedSeconds((value) => Math.min(value + 1, 60)); + }, 1000); + timelineTimerRef.current = window.setTimeout(() => { + if (timelineIntervalRef.current !== undefined) window.clearInterval(timelineIntervalRef.current); + setPhase('ending'); + setCountdown(5); + timelineIntervalRef.current = window.setInterval(() => { + setCountdown((value) => (value && value > 1 ? value - 1 : 1)); + }, 1000); + timelineTimerRef.current = window.setTimeout(() => { + completionRef.current = true; + void performRestore(true); + }, WELLNESS_ENDING_MS); + }, WELLNESS_PLAYING_MS); + }, [clearTimeline, invokeHost, performRestore, setPhase, startAudio]); + + const beginStartingCountdown = useCallback(() => { + if (phaseRef.current !== 'waiting-for-safe-state') return; + clearTimeline(); + setPhase('starting'); + setCountdown(5); + void loadAnimation(); + void prepareAudio(); + timelineIntervalRef.current = window.setInterval(() => { + setCountdown((value) => (value && value > 1 ? value - 1 : 1)); + }, 1000); + timelineTimerRef.current = window.setTimeout(beginPlayback, WELLNESS_STARTING_MS); + }, [beginPlayback, clearTimeline, loadAnimation, prepareAudio, setPhase]); + + const requestBreak = useCallback(async () => { + const latest = latestRef.current; + const api = store.cc?.apiAIAssistant; + if (!latest.enabled || !latest.agentId || !latest.agentSessionId || !latest.wellbeingBreakIdleCode) { + reportError( + !latest.enabled + ? 'FEATURE_DISABLED' + : !latest.agentSessionId || !latest.agentId + ? 'SESSION_UNAVAILABLE' + : 'SYSTEM_CODE_UNAVAILABLE', + 'idle', + true + ); + return; + } + if (!api?.requestWellnessBreak) { + reportError('ACTION_REQUEST_FAILED', 'idle', true); + return; + } + + setNotice(undefined); + setError(undefined); + setRequestAvailable(false); + appendUserAction('take-break'); + setPhase('request-pending'); + logWellnessMetric(WELLNESS_METRIC.CTA_USER_REQUEST); + const operation = ++operationRef.current; + const sessionId = latest.agentSessionId; + try { + await api.requestWellnessBreak(); + } catch { + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + setPhase('idle'); + reportError('ACTION_REQUEST_FAILED', 'request-pending', true); + } + }, [appendUserAction, reportError, setPhase]); + + const enterBreak = useCallback( + async (event: WellnessBreakEvent, sendAccepted: boolean, responseSource: WellnessBreakResponseSource = 'card') => { + const latest = latestRef.current; + clearOfferTimer(); + setRequestAvailable(false); + setNotice(undefined); + setError(undefined); + completionRef.current = false; + stateRequestResolvedRef.current = false; + acceptedEventRef.current = event; + + if (!latest.agentSessionId) { + reportError('INVALID_EVENT', 'offer-pending', false); + return; + } + if (!latest.wellbeingBreakIdleCode) { + reportError('SYSTEM_CODE_UNAVAILABLE', 'offer-pending', true); + return; + } + + legacyRecoveryCountRef.current = 0; + + const operation = ++operationRef.current; + const sessionId = latest.agentSessionId; + const scheduledAfterWork = !areAllTasksSafeForWellness(latest.taskList); + if (sendAccepted) { + resolveActiveOffer(); + appendUserAction('take-break'); + } + capturedRef.current = { + theme: latest.theme, + preBreakLegacyState: latest.legacyAgentState, + preBreakLegacyAuxCodeId: latest.legacyAuxCodeId, + }; + saveRecoveryMarker( + createWellnessRecoveryMarker({ + agentSessionId: sessionId, + preBreakLegacyState: latest.legacyAgentState, + preBreakLegacyAuxCodeId: latest.legacyAuxCodeId, + }) + ); + setPhase('changing-to-break', {event}); + + const stateRequest = Promise.resolve() + .then(() => + store.cc.setAgentState({ + state: 'Idle', + auxCodeId: latest.wellbeingBreakIdleCode.id, + agentId: latest.agentId, + lastStateChangeReason: 'wellness-break', + }) + ) + .then( + () => true, + () => false + ); + pendingStateRequestRef.current = stateRequest; + const stateChanged = await stateRequest; + if (pendingStateRequestRef.current === stateRequest) pendingStateRequestRef.current = undefined; + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + if (!stateChanged) { + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + clearRecoveryMarker(); + capturedRef.current = undefined; + reportError('STATE_CHANGE_FAILED', 'changing-to-break', true); + return; + } + stateRequestResolvedRef.current = true; + + if (sendAccepted) { + try { + const api = store.cc.apiAIAssistant; + if (!api?.respondToWellnessBreak) throw new Error('Wellness response API unavailable'); + await api.respondToWellnessBreak({ + action: WELLNESS_BREAK_USER_ACTIONS.ACCEPTED, + }); + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + } catch { + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + reportError('ACTION_REQUEST_FAILED', 'changing-to-break', true, false); + void performRestore(false); + return; + } + } + + invokeHostWithEvent(callbackRef.current.onWellnessBreakAccepted, event, 'onWellnessBreakAccepted'); + appendAcknowledgement(scheduledAfterWork); + logWellnessMetric( + sendAccepted + ? responseSource === 'notification' + ? WELLNESS_METRIC.NOTIFICATION_ACCEPTED + : WELLNESS_METRIC.CARD_BREAK_ACCEPTED + : WELLNESS_METRIC.CTA_APPROVED, + scheduledAfterWork ? {scheduledAfterWork: true} : {} + ); + setPhase('waiting-for-safe-state', {event}); + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = undefined; + const current = latestRef.current; + const stateConfirmed = + current.legacyAuxCodeId === current.wellbeingBreakIdleCode?.id && + normalizeWellnessState(current.legacyAgentState) === 'idle'; + if ( + phaseRef.current === 'waiting-for-safe-state' && + stateConfirmed && + areAllTasksSafeForWellness(current.taskList) + ) { + beginStartingCountdown(); + } + }, WELLNESS_STATE_SETTLE_MS); + }, + [ + beginStartingCountdown, + appendAcknowledgement, + appendUserAction, + clearOfferTimer, + clearRecoveryMarker, + invokeHostWithEvent, + performRestore, + reportError, + resolveActiveOffer, + saveRecoveryMarker, + setPhase, + ] + ); + + const acceptOffer = useCallback( + (responseSource: WellnessBreakResponseSource = 'card') => { + const event = latestRef.current.wellnessBreakState.event; + if (event && phaseRef.current === 'offer-pending') void enterBreak(event, true, responseSource); + }, + [enterBreak] + ); + + const later = useCallback( + async (responseSource: WellnessBreakResponseSource = 'card') => { + const latest = latestRef.current; + const event = latest.wellnessBreakState.event; + if (!event || phaseRef.current !== 'offer-pending') return; + const operation = ++operationRef.current; + const sessionId = latest.agentSessionId; + clearOfferTimer(); + setRequestAvailable(false); + setPhase('idle'); + setNotice('declined'); + resolveActiveOffer(); + appendUserAction('later'); + appendNotice('declined'); + logWellnessMetric( + responseSource === 'notification' ? WELLNESS_METRIC.NOTIFICATION_REJECTED : WELLNESS_METRIC.CARD_BREAK_REJECTED + ); + try { + const api = store.cc.apiAIAssistant; + if (!api?.respondToWellnessBreak) throw new Error('Wellness response API unavailable'); + await api.respondToWellnessBreak({ + action: WELLNESS_BREAK_USER_ACTIONS.REJECTED, + }); + } catch { + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + reportError('ACTION_REQUEST_FAILED', 'offer-pending', true, false); + } + }, + [appendNotice, appendUserAction, clearOfferTimer, reportError, resolveActiveOffer, setPhase] + ); + + const dismissNotification = useCallback(() => { + if (phaseRef.current === 'offer-pending') { + logWellnessMetric(WELLNESS_METRIC.NOTIFICATION_DISMISSED); + } + }, []); + + const handleMediaError = useCallback(() => { + reportError('MEDIA_UNAVAILABLE', phaseRef.current, true, false); + }, [reportError]); + + // The store can originate configuration errors while resolving the system + // code, before the orchestrator has a user action from which to report them. + useEffect(() => { + const {errorCode, phase} = input.wellnessBreakState; + if (phase !== 'error') { + reportedErrorCodeRef.current = undefined; + return; + } + if (errorCode && reportedErrorCodeRef.current !== errorCode) { + reportError(errorCode, 'idle', true); + } + }, [input.wellnessBreakState, reportError]); + + // A notification sequence is the only source of offer/request decisions. + useEffect(() => { + const {wellnessEventSequence, wellnessBreakState} = input; + if (!wellnessEventSequence || wellnessEventSequence === lastEventSequenceRef.current) return; + lastEventSequenceRef.current = wellnessEventSequence; + const event = wellnessBreakState.event; + if (!event) return; + + clearOfferTimer(); + setNotice(undefined); + setContentCleared(false); + if (event.actionEvent === WELLNESS_BREAK_NOTIFICATION_ACTIONS.SUGGEST_WELLNESS_BREAK) { + setRequestAvailable(true); + if (!ACTIVE_PHASES.has(phaseRef.current)) setPhase('idle', {event}); + logWellnessMetric(WELLNESS_METRIC.DISPLAY_CTA); + } else if (event.actionEvent === WELLNESS_BREAK_NOTIFICATION_ACTIONS.WELLNESS_BREAK_NOT_ALLOWED) { + setRequestAvailable(false); + if (phaseRef.current === 'request-pending') { + setNotice('not-allowed'); + appendNotice('not-allowed', event.actionText); + setPhase('idle', {event}); + logWellnessMetric(WELLNESS_METRIC.CTA_REJECTED); + } + } else if (event.actionEvent === WELLNESS_BREAK_NOTIFICATION_ACTIONS.PROVIDE_WELLNESS_BREAK) { + setRequestAvailable(false); + if (phaseRef.current === 'request-pending') { + logWellnessMetric(WELLNESS_METRIC.PROVIDE_BREAK_EVENT_RECEIVED); + void enterBreak(event, false); + } else if (!ACTIVE_PHASES.has(phaseRef.current)) { + appendOffer(event); + setPhase('offer-pending', {event, responseDeadline: Date.now() + WELLNESS_OFFER_TIMEOUT_MS}); + invokeHostWithEvent(callbackRef.current.onWellnessBreakOffered, event, 'onWellnessBreakOffered'); + logWellnessMetric(WELLNESS_METRIC.PROVIDE_BREAK_EVENT_RECEIVED); + logWellnessMetric(WELLNESS_METRIC.NOTIFICATION_FIRED); + offerTimerRef.current = window.setTimeout(() => { + const latest = latestRef.current; + if (phaseRef.current !== 'offer-pending' || !latest.agentSessionId) { + return; + } + setRequestAvailable(false); + setPhase('idle'); + setNotice('no-response'); + resolveActiveOffer(); + appendNotice('no-response'); + logWellnessMetric(WELLNESS_METRIC.NO_RESPONSE); + logWellnessMetric(WELLNESS_METRIC.NOTIFICATION_TIMEOUT); + const api = store.cc.apiAIAssistant; + if (!api?.respondToWellnessBreak) { + reportError('ACTION_REQUEST_FAILED', 'offer-pending', true, false); + return; + } + const operation = ++operationRef.current; + const sessionId = latest.agentSessionId; + void api + .respondToWellnessBreak({ + action: WELLNESS_BREAK_USER_ACTIONS.NO_RESPONSE, + }) + .catch(() => { + if (operation !== operationRef.current || latestRef.current.agentSessionId !== sessionId) return; + reportError('ACTION_REQUEST_FAILED', 'offer-pending', true, false); + }); + }, WELLNESS_OFFER_TIMEOUT_MS); + } + } + }, [ + appendNotice, + appendOffer, + clearOfferTimer, + enterBreak, + input.wellnessBreakState, + input.wellnessEventSequence, + invokeHostWithEvent, + reportError, + resolveActiveOffer, + setPhase, + ]); + + // Continuously re-evaluate tasks and confirmed state. A new blocker during + // the countdown returns the lifecycle to the safe-state wait. + useEffect(() => { + const phase = input.wellnessBreakState.phase; + if (phase !== 'waiting-for-safe-state' && phase !== 'starting') return; + const stateConfirmed = + input.legacyAuxCodeId === input.wellbeingBreakIdleCode?.id && + normalizeWellnessState(input.legacyAgentState) === 'idle'; + const safe = stateConfirmed && areAllTasksSafeForWellness(input.taskList); + if (!safe) { + if (phase === 'starting') { + clearTimeline(); + setPhase('waiting-for-safe-state', acceptedEventRef.current ? {event: acceptedEventRef.current} : {}); + } else if (settleTimerRef.current !== undefined) { + window.clearTimeout(settleTimerRef.current); + settleTimerRef.current = undefined; + } + } else if (safe && phase === 'waiting-for-safe-state' && stateRequestResolvedRef.current) { + if (settleTimerRef.current === undefined) { + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = undefined; + beginStartingCountdown(); + }, WELLNESS_STATE_SETTLE_MS); + } + } + }, [ + beginStartingCountdown, + clearTimeline, + input.legacyAgentState, + input.legacyAuxCodeId, + input.taskList, + input.wellbeingBreakIdleCode?.id, + input.wellnessBreakState.phase, + setPhase, + ]); + + // Detect an externally-owned state transition (including RONA) before media + // begins. Unchanged pre-break state is ignored while the state API settles. + useEffect(() => { + const phase = input.wellnessBreakState.phase; + const captured = capturedRef.current; + if (!captured || !PRE_PLAY_PHASES.has(phase) || !stateRequestResolvedRef.current) return; + const incompatible = + input.legacyAuxCodeId !== input.wellbeingBreakIdleCode?.id && + (input.legacyAuxCodeId !== captured.preBreakLegacyAuxCodeId || + input.legacyAgentState !== captured.preBreakLegacyState); + if (incompatible) void performRestore(false); + }, [ + input.legacyAgentState, + input.legacyAuxCodeId, + input.wellbeingBreakIdleCode?.id, + input.wellnessBreakState.phase, + performRestore, + ]); + + // Session ownership is never derived from the wellness event. + useEffect(() => { + if (input.agentSessionId === previousSessionRef.current) return; + const previousSessionId = previousSessionRef.current; + previousSessionRef.current = input.agentSessionId; + operationRef.current += 1; + restoringRef.current = false; + clearOfferTimer(); + clearTimeline(); + stopMedia(); + setRequestAvailable(false); + setNotice(undefined); + setError(undefined); + resolveActiveOffer(); + capturedRef.current = undefined; + legacyRecoveryCountRef.current = 0; + if (previousSessionId) clearRecoveryMarker(); + }, [clearOfferTimer, clearRecoveryMarker, clearTimeline, input.agentSessionId, resolveActiveOffer, stopMedia]); + + // Feature revocation hides new controls only after state-owned work restores. + useEffect(() => { + if (!input.enabled && ACTIVE_PHASES.has(input.wellnessBreakState.phase)) { + void performRestore(false); + } else if (!input.enabled) { + clearOfferTimer(); + if (phaseRef.current === 'offer-pending' || phaseRef.current === 'request-pending') setPhase('idle'); + setRequestAvailable(false); + setNotice(undefined); + } + }, [clearOfferTimer, input.enabled, input.wellnessBreakState.phase, performRestore, setPhase]); + + // Refresh recovery never replays notification actions or media. + useEffect(() => { + // A marker created by this mounted lifecycle is not a refresh-recovery + // candidate. Wait for its confirmed state/restoration path to own it. + if (capturedRef.current) return; + if (!input.isLoggedIn || !input.agentSessionId || !input.wellbeingBreakIdleCode) return; + let serialized: string | null = null; + try { + serialized = window.sessionStorage.getItem(WELLNESS_RECOVERY_KEY); + } catch { + return; + } + const marker = parseWellnessRecoveryMarker(serialized); + const decision = getWellnessRecoveryDecision({ + marker, + agentSessionId: input.agentSessionId, + wellnessAuxCodeId: input.wellbeingBreakIdleCode.id, + legacyStateKnown: Boolean(input.legacyAgentState || input.legacyAuxCodeId), + legacyAuxCodeId: input.legacyAuxCodeId, + }); + if (decision === 'discard' || decision === 'clear') { + clearRecoveryMarker(); + } else if (decision === 'restore' && marker && !capturedRef.current) { + capturedRef.current = { + theme: input.theme, + preBreakLegacyState: marker.preBreakLegacyState, + preBreakLegacyAuxCodeId: marker.preBreakLegacyAuxCodeId, + }; + void performRestore(false); + } + }, [ + clearRecoveryMarker, + input.agentSessionId, + input.isLoggedIn, + input.legacyAgentState, + input.legacyAuxCodeId, + input.wellbeingBreakIdleCode, + performRestore, + ]); + + // After a completed break, keep trying Available. For an early exit, + // continue only while this host still owns the wellness state. + useEffect(() => { + if (input.wellnessBreakState.phase !== 'error' || error?.code !== 'RESTORE_FAILED' || !capturedRef.current) { + return undefined; + } + const timer = window.setInterval(() => { + if (legacyRecoveryCountRef.current >= LEGACY_RECOVERY_ATTEMPTS) { + window.clearInterval(timer); + return; + } + const latest = latestRef.current; + if (!completionRef.current && latest.legacyAuxCodeId !== latest.wellbeingBreakIdleCode?.id) { + clearRecoveryMarker(); + window.clearInterval(timer); + return; + } + legacyRecoveryCountRef.current += 1; + void performRestore(completionRef.current, 1); + }, LEGACY_RECOVERY_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [clearRecoveryMarker, error?.code, input.wellnessBreakState.phase, performRestore]); + + useEffect(() => { + const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + if (!mediaQuery) return undefined; + const update = () => setReducedMotion(mediaQuery.matches); + update(); + mediaQuery.addEventListener?.('change', update); + return () => mediaQuery.removeEventListener?.('change', update); + }, []); + + useEffect( + () => () => { + const restoring = restoringRef.current; + if (!restoring) operationRef.current += 1; + clearOfferTimer(); + clearTimeline(); + stopMedia(); + const latest = latestRef.current; + const captured = capturedRef.current; + if (!captured && (phaseRef.current === 'offer-pending' || phaseRef.current === 'request-pending')) { + store.setWellnessBreakState({phase: 'idle'}); + } + if (!restoring && captured && latest.agentSessionId) { + const operation = operationRef.current; + const pendingStateRequest = pendingStateRequestRef.current; + void (async () => { + const ownStateRequestSucceeded = pendingStateRequest + ? await pendingStateRequest + : stateRequestResolvedRef.current; + if (pendingStateRequest && !ownStateRequestSucceeded) { + clearRecoveryMarker(); + return; + } + const result = await requestAvailableState( + captured, + latest.agentSessionId, + latest.agentId, + latest.wellbeingBreakIdleCode?.id, + ownStateRequestSucceeded, + false, + operation, + RESTORE_ATTEMPTS + ); + if (result === 'restored' || result === 'not-owned') { + clearRecoveryMarker(); + } else if (result === 'failed') { + if ('wellnessAgentSessionId' in store && store.wellnessAgentSessionId !== latest.agentSessionId) return; + store.logger?.error('CC-Widgets: Agent Wellness Break unmount restore failed', { + module: MODULE, + method: 'restoreOnUnmount', + }); + store.setWellnessBreakState({phase: 'error', errorCode: 'RESTORE_FAILED'}); + } + })(); + } + }, + [clearOfferTimer, clearRecoveryMarker, clearTimeline, requestAvailableState, stopMedia] + ); + + const uiEnabled = Boolean( + input.enabled && input.isLoggedIn && input.agentId && input.agentSessionId && input.wellbeingBreakIdleCode + ); + const hasVisibleStoreError = Boolean( + input.enabled && input.isLoggedIn && input.agentSessionId && input.wellnessBreakState.phase === 'error' + ); + + return useMemo( + () => ({ + enabled: uiEnabled || hasVisibleStoreError || ACTIVE_PHASES.has(input.wellnessBreakState.phase), + phase: input.wellnessBreakState.phase, + event: input.wellnessBreakState.event, + error, + notice, + requestAvailable: uiEnabled && requestAvailable, + history, + contentCleared, + hasBlockingTasks: !areAllTasksSafeForWellness(input.taskList), + countdown, + elapsedSeconds, + animationData, + reducedMotion, + onRequest: requestBreak, + onAccept: acceptOffer, + onLater: later, + onClearHistory: clearHistory, + onDismissNotification: dismissNotification, + onMediaError: handleMediaError, + }), + [ + acceptOffer, + animationData, + countdown, + clearHistory, + contentCleared, + dismissNotification, + elapsedSeconds, + error, + handleMediaError, + hasVisibleStoreError, + input.wellnessBreakState.event, + input.wellnessBreakState.phase, + input.taskList, + history, + later, + notice, + reducedMotion, + requestAvailable, + requestBreak, + uiEnabled, + ] + ); +}; diff --git a/packages/contact-center/ai-assistant/src/wellness/wellness-internal.types.ts b/packages/contact-center/ai-assistant/src/wellness/wellness-internal.types.ts new file mode 100644 index 000000000..fc53edd2f --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/wellness-internal.types.ts @@ -0,0 +1,7 @@ +/** Short-lived, session-scoped recovery data owned by the wellness widget. @internal */ +export interface WellnessBreakRecoveryMarkerV1 { + version: 1; + agentSessionId: string; + preBreakLegacyState?: string; + preBreakLegacyAuxCodeId?: string; +} diff --git a/packages/contact-center/ai-assistant/src/wellness/wellness.metrics.ts b/packages/contact-center/ai-assistant/src/wellness/wellness.metrics.ts new file mode 100644 index 000000000..fbe546197 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/wellness.metrics.ts @@ -0,0 +1,100 @@ +import store, {WidgetsBehavioralMetricAgent, WidgetsBehavioralMetricVerb} from '@webex/cc-store'; + +export const WELLNESS_METRIC = { + BREAK_STARTED: 'Wellness Break Started', + BREAK_ENDED: 'Wellness Break Ended', + CARD_BREAK_ACCEPTED: 'Wellness Break Card Accepted', + CARD_BREAK_REJECTED: 'Wellness Break Card Rejected', + CTA_APPROVED: 'Wellness Break CTA Approved', + CTA_REJECTED: 'Wellness Break CTA Rejected', + CTA_USER_REQUEST: 'Wellness Break CTA User Request', + DISPLAY_CTA: 'Wellness Break Display CTA', + ERROR_CHANGE_STATE: 'Wellness Break Change State Error', + GENERIC_ERROR: 'Wellness Break Generic Error', + NO_RESPONSE: 'Wellness Break No Response', + NOTIFICATION_FIRED: 'Wellness Break Notification Fired', + NOTIFICATION_ACCEPTED: 'Wellness Break Notification Accepted', + NOTIFICATION_DISMISSED: 'Wellness Break Notification Dismissed', + NOTIFICATION_REJECTED: 'Wellness Break Notification Rejected', + NOTIFICATION_TIMEOUT: 'Wellness Break Notification Timeout', + PROVIDE_BREAK_EVENT_RECEIVED: 'Wellness Break Provide Break Event Received', + RESTORE_STATE_RETRY_ATTEMPT: 'Wellness Break Restore State Retry Attempt', + RESTORE_STATE_RECONNECT_ATTEMPT: 'Wellness Break Restore State Reconnect Attempt', +} as const; + +export type WellnessMetricName = (typeof WELLNESS_METRIC)[keyof typeof WELLNESS_METRIC]; + +const WELLNESS_METRIC_TAXONOMY: Record< + WellnessMetricName, + {agent: WidgetsBehavioralMetricAgent; target: string; verb: WidgetsBehavioralMetricVerb} +> = { + [WELLNESS_METRIC.BREAK_STARTED]: {agent: 'service', target: 'wellness_break', verb: 'start'}, + [WELLNESS_METRIC.BREAK_ENDED]: {agent: 'service', target: 'wellness_break', verb: 'end'}, + [WELLNESS_METRIC.CARD_BREAK_ACCEPTED]: {agent: 'user', target: 'wellness_break_card', verb: 'accept'}, + [WELLNESS_METRIC.CARD_BREAK_REJECTED]: {agent: 'user', target: 'wellness_break_card', verb: 'reject'}, + [WELLNESS_METRIC.CTA_APPROVED]: {agent: 'service', target: 'wellness_break_cta', verb: 'accept'}, + [WELLNESS_METRIC.CTA_REJECTED]: {agent: 'service', target: 'wellness_break_cta', verb: 'reject'}, + [WELLNESS_METRIC.CTA_USER_REQUEST]: {agent: 'user', target: 'wellness_break_cta', verb: 'request'}, + [WELLNESS_METRIC.DISPLAY_CTA]: {agent: 'user', target: 'wellness_break_cta', verb: 'display'}, + [WELLNESS_METRIC.ERROR_CHANGE_STATE]: { + agent: 'service', + target: 'wellness_break_change_state', + verb: 'fail', + }, + [WELLNESS_METRIC.GENERIC_ERROR]: {agent: 'service', target: 'wellness_break', verb: 'fail'}, + [WELLNESS_METRIC.NO_RESPONSE]: {agent: 'user', target: 'wellness_break', verb: 'ignore'}, + [WELLNESS_METRIC.NOTIFICATION_FIRED]: { + agent: 'service', + target: 'wellness_break_notification', + verb: 'fire', + }, + [WELLNESS_METRIC.NOTIFICATION_ACCEPTED]: { + agent: 'user', + target: 'wellness_break_notification', + verb: 'accept', + }, + [WELLNESS_METRIC.NOTIFICATION_DISMISSED]: { + agent: 'user', + target: 'wellness_break_notification', + verb: 'dismiss', + }, + [WELLNESS_METRIC.NOTIFICATION_REJECTED]: { + agent: 'user', + target: 'wellness_break_notification', + verb: 'reject', + }, + [WELLNESS_METRIC.NOTIFICATION_TIMEOUT]: { + agent: 'service', + target: 'wellness_break_notification', + verb: 'expire', + }, + [WELLNESS_METRIC.PROVIDE_BREAK_EVENT_RECEIVED]: {agent: 'service', target: 'wellness_break', verb: 'load'}, + [WELLNESS_METRIC.RESTORE_STATE_RETRY_ATTEMPT]: { + agent: 'service', + target: 'wellness_break_restore_state_retry', + verb: 'retry', + }, + [WELLNESS_METRIC.RESTORE_STATE_RECONNECT_ATTEMPT]: { + agent: 'service', + target: 'wellness_break_restore_state_reconnect', + verb: 'retry', + }, +}; + +/** Emits only behavior categories and bounded numeric/boolean properties; never session or event data. */ +export const logWellnessMetric = ( + name: WellnessMetricName, + properties: Record = {} +): void => { + store.logger?.log('CC-Widgets: Agent Wellness Break behavioral metric', { + module: 'wellness/wellness.metrics.ts', + method: 'logWellnessMetric', + data: {name, ...properties}, + }); + const taxonomy = WELLNESS_METRIC_TAXONOMY[name]; + store.submitBehavioralMetric({ + name, + ...taxonomy, + ...(Object.keys(properties).length > 0 ? {properties} : {}), + }); +}; diff --git a/packages/contact-center/ai-assistant/src/wellness/wellness.utils.ts b/packages/contact-center/ai-assistant/src/wellness/wellness.utils.ts new file mode 100644 index 000000000..aae635f4c --- /dev/null +++ b/packages/contact-center/ai-assistant/src/wellness/wellness.utils.ts @@ -0,0 +1,81 @@ +import type {ITask} from '@webex/cc-store'; +import type {WellnessBreakRecoveryMarkerV1} from './wellness-internal.types'; + +const TERMINAL_TASK_STATES = new Set(['completed', 'disconnected', 'ended', 'terminated', 'wrappedup']); + +/** @internal */ +export type WellnessRecoveryDecision = 'discard' | 'wait' | 'clear' | 'restore'; + +/** @internal */ +export const normalizeWellnessState = (value?: string): string => + typeof value === 'string' ? value.replace(/[\s_-]/g, '').toLowerCase() : ''; + +/** @internal */ +export const isTaskBlockingWellness = (task?: ITask | null): boolean => { + if (!task) return false; + if (!task.data) return true; + if (task.data.wrapUpRequired === true) return true; + + const state = normalizeWellnessState(task.data.interaction?.state); + return !state || !TERMINAL_TASK_STATES.has(state); +}; + +/** @internal */ +export const areAllTasksSafeForWellness = (taskList: Record): boolean => + Object.values(taskList || {}).every((task) => !isTaskBlockingWellness(task)); + +/** @internal */ +export const createWellnessRecoveryMarker = ({ + agentSessionId, + preBreakLegacyState, + preBreakLegacyAuxCodeId, +}: Omit): WellnessBreakRecoveryMarkerV1 => ({ + version: 1, + agentSessionId, + ...(typeof preBreakLegacyState === 'string' ? {preBreakLegacyState} : {}), + ...(typeof preBreakLegacyAuxCodeId === 'string' ? {preBreakLegacyAuxCodeId} : {}), +}); + +/** @internal */ +export const parseWellnessRecoveryMarker = (serialized: string | null): WellnessBreakRecoveryMarkerV1 | undefined => { + if (!serialized) return undefined; + try { + const marker = JSON.parse(serialized) as Partial; + if ( + marker.version !== 1 || + typeof marker.agentSessionId !== 'string' || + !marker.agentSessionId || + (marker.preBreakLegacyState !== undefined && typeof marker.preBreakLegacyState !== 'string') || + (marker.preBreakLegacyAuxCodeId !== undefined && typeof marker.preBreakLegacyAuxCodeId !== 'string') + ) { + return undefined; + } + + return createWellnessRecoveryMarker({ + agentSessionId: marker.agentSessionId, + preBreakLegacyState: marker.preBreakLegacyState, + preBreakLegacyAuxCodeId: marker.preBreakLegacyAuxCodeId, + }); + } catch { + return undefined; + } +}; + +/** @internal */ +export const getWellnessRecoveryDecision = ({ + marker, + agentSessionId, + wellnessAuxCodeId, + legacyStateKnown, + legacyAuxCodeId, +}: { + marker?: WellnessBreakRecoveryMarkerV1; + agentSessionId: string; + wellnessAuxCodeId?: string; + legacyStateKnown: boolean; + legacyAuxCodeId?: string; +}): WellnessRecoveryDecision => { + if (!marker || marker.agentSessionId !== agentSessionId) return 'discard'; + if (!legacyStateKnown) return 'wait'; + return legacyAuxCodeId === wellnessAuxCodeId ? 'restore' : 'clear'; +}; diff --git a/packages/contact-center/ai-assistant/tests/helper.ts b/packages/contact-center/ai-assistant/tests/helper.ts index ed53e7fcf..700dc144b 100644 --- a/packages/contact-center/ai-assistant/tests/helper.ts +++ b/packages/contact-center/ai-assistant/tests/helper.ts @@ -9,12 +9,14 @@ jest.mock('@webex/cc-store', () => { __esModule: true, default: { cc: mockCC, + clearRealTimeAssist: jest.fn(), }, }; }); type StoreMock = { cc: typeof mockCC; + clearRealTimeAssist: jest.Mock; }; const storeMock = store as unknown as StoreMock; @@ -192,6 +194,18 @@ describe('useAiAssistant', () => { expect(types.filter((t) => t === 'assistant')).toHaveLength(2); }); + it('clears the real-time transcript only when Clear is invoked', async () => { + const suggestion = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'first'}}; + const {result} = renderHook(() => useAiAssistant({...baseProps, realTimeAssist: [suggestion]})); + + await waitFor(() => expect(result.current.chatEntries).toHaveLength(1)); + act(() => result.current.clearTranscript()); + + expect(storeMock.clearRealTimeAssist).toHaveBeenCalledWith('interaction-1'); + expect(result.current.chatEntries).toEqual([]); + expect(result.current.requestStatus).toBe('idle'); + }); + it('stays idle when the feature flag is off', async () => { const {result} = renderHook(() => useAiAssistant({...baseProps, isFeatureEnabled: false})); diff --git a/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts b/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts new file mode 100644 index 000000000..e18613c52 --- /dev/null +++ b/packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts @@ -0,0 +1,916 @@ +import {act, renderHook, waitFor} from '@testing-library/react'; +import type {UseWellnessBreakInput} from '../src/ai-assistant.types'; +import {useWellnessBreak, WELLNESS_OFFER_TIMEOUT_MS, WELLNESS_RECOVERY_KEY} from '../src/wellness/useWellnessBreak'; +import store from '@webex/cc-store'; + +jest.mock('../src/wellness/assets/media.cjs', () => ({ + loadWellnessSoundUrl: jest.fn().mockResolvedValue('/wellness.mp3'), + loadWellnessAnimationData: jest.fn().mockResolvedValue({}), +})); + +jest.mock('@webex/cc-store', () => ({ + __esModule: true, + WELLNESS_BREAK_NOTIFICATION_ACTIONS: { + PROVIDE_WELLNESS_BREAK: 'PROVIDE_WELLNESS_BREAK', + SUGGEST_WELLNESS_BREAK: 'SUGGEST_WELLNESS_BREAK', + WELLNESS_BREAK_NOT_ALLOWED: 'WELLNESS_BREAK_NOT_ALLOWED', + }, + WELLNESS_BREAK_USER_ACTIONS: { + REQUESTED: 'REQUESTED', + ACCEPTED: 'ACCEPTED', + REJECTED: 'REJECTED', + NO_RESPONSE: 'NO_RESPONSE', + }, + default: { + cc: { + setAgentState: jest.fn(), + apiAIAssistant: { + requestWellnessBreak: jest.fn(), + respondToWellnessBreak: jest.fn(), + }, + }, + setWellnessBreakState: jest.fn(), + submitBehavioralMetric: jest.fn(), + logger: { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, +})); + +type StoreMock = { + cc: { + setAgentState: jest.Mock; + apiAIAssistant: { + requestWellnessBreak: jest.Mock; + respondToWellnessBreak: jest.Mock; + }; + }; + setWellnessBreakState: jest.Mock; + submitBehavioralMetric: jest.Mock; +}; + +const storeMock = store as unknown as StoreMock; +const OriginalAudio = window.Audio; +const event = { + agentId: 'agent-1', + orgId: 'org-1', + agentSessionId: 'session-1', + actionEvent: 'SUGGEST_WELLNESS_BREAK' as const, +}; + +const baseInput: UseWellnessBreakInput = { + enabled: true, + isLoggedIn: true, + agentId: 'agent-1', + agentSessionId: 'session-1', + wellbeingBreakIdleCode: {id: 'wellness', name: 'WellbeingBreak', isSystem: true, isDefault: false}, + wellnessBreakState: {phase: 'idle'}, + wellnessEventSequence: 0, + legacyAgentState: 'Available', + legacyAuxCodeId: '0', + taskList: {}, + theme: 'LIGHT', +}; + +describe('useWellnessBreak', () => { + beforeEach(() => { + jest.clearAllMocks(); + window.sessionStorage.clear(); + storeMock.cc.setAgentState.mockResolvedValue(undefined); + storeMock.cc.apiAIAssistant.requestWellnessBreak.mockResolvedValue(undefined); + storeMock.cc.apiAIAssistant.respondToWellnessBreak.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + window.Audio = OriginalAudio; + }); + + it('enables REQUESTED only after SUGGEST and leaves approval to a later notification', async () => { + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellnessBreakState: {phase: 'idle', event}, + wellnessEventSequence: 1, + }) + ); + + await waitFor(() => expect(result.current.requestAvailable).toBe(true)); + await act(async () => result.current.onRequest()); + + expect(storeMock.cc.apiAIAssistant.requestWellnessBreak).toHaveBeenCalledWith(); + expect(result.current.requestAvailable).toBe(false); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith({phase: 'idle', event}); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith({phase: 'request-pending'}); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).not.toHaveBeenCalled(); + expect(storeMock.submitBehavioralMetric).toHaveBeenCalledWith({ + name: 'Wellness Break CTA User Request', + agent: 'user', + target: 'wellness_break_cta', + verb: 'request', + }); + }); + + it('ignores a request failure after the agent session changes', async () => { + let rejectRequest: ((reason?: unknown) => void) | undefined; + storeMock.cc.apiAIAssistant.requestWellnessBreak.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }) + ); + const onWellnessBreakError = jest.fn(); + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'idle', event}, + wellnessEventSequence: 1, + onWellnessBreakError, + }, + }); + + await waitFor(() => expect(result.current.requestAvailable).toBe(true)); + act(() => { + result.current.onRequest(); + }); + rerender({ + ...baseInput, + agentSessionId: 'session-2', + onWellnessBreakError, + }); + await act(async () => { + rejectRequest?.(new Error('stale request')); + await Promise.resolve(); + }); + + expect(onWellnessBreakError).not.toHaveBeenCalled(); + expect(storeMock.setWellnessBreakState).not.toHaveBeenCalledWith( + expect.objectContaining({phase: 'error', errorCode: 'ACTION_REQUEST_FAILED'}) + ); + }); + + it('keeps a consumed manual CTA unavailable until a fresh SUGGEST arrives', async () => { + const denied = { + ...event, + actionEvent: 'WELLNESS_BREAK_NOT_ALLOWED' as const, + actionText: 'Sorry, you have already reached your limit for today.', + }; + const nextSuggestion = {...event, actionText: 'A new suggested break is available.'}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'idle', event}, + wellnessEventSequence: 1, + }, + }); + + await waitFor(() => expect(result.current.requestAvailable).toBe(true)); + await act(async () => result.current.onRequest()); + expect(result.current.requestAvailable).toBe(false); + + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'request-pending', event: denied}, + wellnessEventSequence: 2, + }); + await waitFor(() => expect(result.current.notice).toBe('not-allowed')); + expect(result.current.requestAvailable).toBe(false); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith({phase: 'idle', event: denied}); + + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'idle', event: nextSuggestion}, + wellnessEventSequence: 3, + }); + await waitFor(() => expect(result.current.requestAvailable).toBe(true)); + expect(result.current.notice).toBeUndefined(); + }); + + it('consumes a stale suggestion when a direct PROVIDE becomes an offer', async () => { + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'idle', event}, + wellnessEventSequence: 1, + }, + }); + + await waitFor(() => expect(result.current.requestAvailable).toBe(true)); + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'idle', event: provide}, + wellnessEventSequence: 2, + }); + + await waitFor(() => expect(result.current.requestAvailable).toBe(false)); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith( + expect.objectContaining({phase: 'offer-pending', event: provide}) + ); + }); + + it('treats PROVIDE for a pending manual request as approval without redundant ACCEPTED', async () => { + const onWellnessBreakAccepted = jest.fn(); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'request-pending'}, + onWellnessBreakAccepted, + }, + }); + + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'request-pending', event: provide}, + wellnessEventSequence: 1, + onWellnessBreakAccepted, + }); + + await waitFor(() => expect(storeMock.cc.setAgentState).toHaveBeenCalled()); + expect(storeMock.cc.setAgentState).toHaveBeenCalledWith( + expect.objectContaining({state: 'Idle', auxCodeId: 'wellness', agentId: 'agent-1'}) + ); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).not.toHaveBeenCalled(); + await waitFor(() => expect(onWellnessBreakAccepted).toHaveBeenCalledWith(provide)); + expect(JSON.parse(window.sessionStorage.getItem(WELLNESS_RECOVERY_KEY) || '{}')).toEqual({ + version: 1, + agentSessionId: 'session-1', + preBreakLegacyState: 'Available', + preBreakLegacyAuxCodeId: '0', + }); + }); + + it('sends ACCEPTED exactly once after a direct PROVIDE changes legacy state', async () => { + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + }) + ); + + await act(async () => { + result.current.onAccept('card'); + result.current.onAccept('card'); + await Promise.resolve(); + }); + + expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(1); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledTimes(1); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledWith({ + action: 'ACCEPTED', + }); + expect(storeMock.cc.setAgentState.mock.invocationCallOrder[0]).toBeLessThan( + storeMock.cc.apiAIAssistant.respondToWellnessBreak.mock.invocationCallOrder[0] + ); + expect(storeMock.submitBehavioralMetric).toHaveBeenCalledWith({ + name: 'Wellness Break Card Accepted', + agent: 'user', + target: 'wellness_break_card', + verb: 'accept', + }); + }); + + it('accepts a direct offer from another notification session through the SDK-owned active context', async () => { + const provide = { + ...event, + agentSessionId: 'notification-session', + actionEvent: 'PROVIDE_WELLNESS_BREAK' as const, + }; + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + }) + ); + + await act(async () => result.current.onAccept('card')); + + expect(storeMock.cc.setAgentState).toHaveBeenCalledWith( + expect.objectContaining({state: 'Idle', auxCodeId: 'wellness', agentId: 'agent-1'}) + ); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledWith({ + action: 'ACCEPTED', + }); + }); + + it('keeps a chronological wellness transcript until it is explicitly cleared', async () => { + const provide = { + ...event, + actionEvent: 'PROVIDE_WELLNESS_BREAK' as const, + actionText: 'This break is pre-approved.', + }; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'idle', event: provide}, + wellnessEventSequence: 1, + }, + }); + + await waitFor(() => expect(result.current.history.map(({type}) => type)).toEqual(['offer'])); + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + wellnessEventSequence: 1, + }); + await act(async () => result.current.onAccept('card')); + + expect(result.current.history.map(({type}) => type)).toEqual(['offer', 'user-action', 'acknowledgement']); + expect(result.current.history[0]).toMatchObject({type: 'offer', actionable: false, event: provide}); + + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'waiting-for-safe-state', event: provide}, + wellnessEventSequence: 1, + }); + act(() => result.current.onClearHistory()); + expect(result.current.history).toEqual([]); + expect(result.current.contentCleared).toBe(true); + }); + + it('keeps the current offer actionable when Clear removes prior history', async () => { + const denied = {...event, actionEvent: 'WELLNESS_BREAK_NOT_ALLOWED' as const}; + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'request-pending', event: denied}, + wellnessEventSequence: 1, + }, + }); + + await waitFor(() => expect(result.current.history[0]).toMatchObject({type: 'notice', notice: 'not-allowed'})); + rerender({...baseInput, wellnessBreakState: {phase: 'idle', event: provide}, wellnessEventSequence: 2}); + await waitFor(() => expect(result.current.history.map(({type}) => type)).toEqual(['notice', 'offer'])); + rerender({...baseInput, wellnessBreakState: {phase: 'offer-pending', event: provide}, wellnessEventSequence: 2}); + + act(() => result.current.onClearHistory()); + + expect(result.current.contentCleared).toBe(false); + expect(result.current.history).toEqual([expect.objectContaining({type: 'offer', actionable: true})]); + + await act(async () => result.current.onLater('card')); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledWith({action: 'REJECTED'}); + expect(result.current.history[0]).toMatchObject({type: 'offer', actionable: false}); + }); + + it('keeps a rotated-session offer in history without leaving its actions enabled', async () => { + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'idle', event: provide}, + wellnessEventSequence: 1, + }, + }); + + await waitFor(() => expect(result.current.history[0]).toMatchObject({type: 'offer', actionable: true})); + rerender({...baseInput, agentSessionId: 'session-2', wellnessBreakState: {phase: 'idle'}}); + + expect(result.current.history[0]).toMatchObject({type: 'offer', actionable: false}); + act(() => result.current.onAccept('card')); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).not.toHaveBeenCalled(); + }); + + it('sends REJECTED exactly once when Later is selected for a direct PROVIDE', async () => { + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + }) + ); + + await act(async () => { + result.current.onLater('notification'); + result.current.onLater('notification'); + await Promise.resolve(); + }); + + expect(storeMock.cc.setAgentState).not.toHaveBeenCalled(); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledTimes(1); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledWith({ + action: 'REJECTED', + }); + expect(result.current.notice).toBe('declined'); + expect(storeMock.submitBehavioralMetric).toHaveBeenCalledWith({ + name: 'Wellness Break Notification Rejected', + agent: 'user', + target: 'wellness_break_notification', + verb: 'reject', + }); + }); + + it('sends NO_RESPONSE once for a live same-session offer', async () => { + jest.useFakeTimers(); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessBreakState: {phase: 'idle', event: provide}, + wellnessEventSequence: 1, + }, + }); + rerender({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + wellnessEventSequence: 1, + }); + + await act(async () => { + jest.advanceTimersByTime(WELLNESS_OFFER_TIMEOUT_MS); + await Promise.resolve(); + }); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledTimes(1); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).toHaveBeenCalledWith({ + action: 'NO_RESPONSE', + }); + expect(storeMock.submitBehavioralMetric).toHaveBeenCalledWith({ + name: 'Wellness Break Notification Timeout', + agent: 'service', + target: 'wellness_break_notification', + verb: 'expire', + }); + }); + + it('records notification dismissal without inventing a backend action', () => { + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + }) + ); + + act(() => result.current.onDismissNotification?.()); + + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).not.toHaveBeenCalled(); + expect(storeMock.cc.setAgentState).not.toHaveBeenCalled(); + expect(storeMock.submitBehavioralMetric).toHaveBeenCalledWith({ + name: 'Wellness Break Notification Dismissed', + agent: 'user', + target: 'wellness_break_notification', + verb: 'dismiss', + }); + }); + + it('does not send ACCEPTED when the state request fails', async () => { + storeMock.cc.setAgentState.mockRejectedValueOnce(new Error('state failed')); + const onWellnessBreakError = jest.fn(); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellnessBreakState: {phase: 'offer-pending', event: provide}, + onWellnessBreakError, + }) + ); + + await act(async () => result.current.onAccept()); + + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).not.toHaveBeenCalled(); + expect(onWellnessBreakError).toHaveBeenCalledWith({ + code: 'STATE_CHANGE_FAILED', + phase: 'changing-to-break', + recoverable: true, + }); + }); + + it('sets Available when the break ends even if the agent was in Meeting before it', async () => { + jest.useFakeTimers(); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const input = { + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'meeting', + wellnessBreakState: {phase: 'offer-pending' as const, event: provide}, + }; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: input, + }); + + await act(async () => result.current.onAccept()); + rerender({ + ...input, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + wellnessBreakState: {phase: 'waiting-for-safe-state', event: provide}, + }); + + await act(async () => { + jest.advanceTimersByTime(2_000 + 5_000 + 60_000 + 5_000); + await Promise.resolve(); + }); + + await waitFor(() => + expect(storeMock.cc.setAgentState).toHaveBeenLastCalledWith({ + state: 'Available', + auxCodeId: '0', + agentId: 'agent-1', + lastStateChangeReason: 'wellness-break-complete', + }) + ); + }); + + it('sets Available at the scheduled end even after an external state change during playback', async () => { + jest.useFakeTimers(); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: {...baseInput, wellnessBreakState: {phase: 'offer-pending', event: provide}}, + }); + + await act(async () => result.current.onAccept()); + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + wellnessBreakState: {phase: 'waiting-for-safe-state', event: provide}, + }); + await act(async () => { + jest.advanceTimersByTime(2_000 + 5_000); + await Promise.resolve(); + }); + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'rona', + wellnessBreakState: {phase: 'playing', event: provide}, + }); + await act(async () => { + jest.advanceTimersByTime(60_000 + 5_000); + await Promise.resolve(); + }); + + expect(storeMock.cc.setAgentState).toHaveBeenLastCalledWith({ + state: 'Available', + auxCodeId: '0', + agentId: 'agent-1', + lastStateChangeReason: 'wellness-break-complete', + }); + }); + + it('keeps retrying Available after a completed break even when the last observed state was external', async () => { + jest.useFakeTimers(); + let restoreAttempts = 0; + storeMock.cc.setAgentState.mockImplementation(({lastStateChangeReason}) => { + if (lastStateChangeReason === 'wellness-break') return Promise.resolve(); + restoreAttempts += 1; + return restoreAttempts <= 3 ? Promise.reject(new Error('restore failed')) : Promise.resolve(); + }); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: {...baseInput, wellnessBreakState: {phase: 'offer-pending', event: provide}}, + }); + + await act(async () => result.current.onAccept()); + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + wellnessBreakState: {phase: 'waiting-for-safe-state', event: provide}, + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(2_000 + 5_000); + }); + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'rona', + wellnessBreakState: {phase: 'playing', event: provide}, + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000 + 5_000 + 20_000); + }); + expect(restoreAttempts).toBe(3); + + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'rona', + wellnessBreakState: {phase: 'error', errorCode: 'RESTORE_FAILED'}, + }); + await act(async () => { + await jest.advanceTimersByTimeAsync(20_000); + }); + expect(restoreAttempts).toBe(4); + expect(storeMock.cc.setAgentState).toHaveBeenLastCalledWith( + expect.objectContaining({state: 'Available', auxCodeId: '0'}) + ); + }); + + it('sets Available from a refresh marker after the first session hydration', async () => { + window.sessionStorage.setItem( + WELLNESS_RECOVERY_KEY, + JSON.stringify({ + version: 1, + agentSessionId: 'session-1', + preBreakLegacyState: 'Idle', + preBreakLegacyAuxCodeId: 'meeting', + }) + ); + const {rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + isLoggedIn: false, + agentSessionId: '', + legacyAgentState: '', + legacyAuxCodeId: '', + }, + }); + + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + }); + + await waitFor(() => + expect(storeMock.cc.setAgentState).toHaveBeenCalledWith({ + state: 'Available', + auxCodeId: '0', + agentId: 'agent-1', + lastStateChangeReason: 'wellness-break-complete', + }) + ); + expect(window.sessionStorage.getItem(WELLNESS_RECOVERY_KEY)).toBeNull(); + }); + + it('surfaces a store-originated system-code error to the host', async () => { + const onWellnessBreakError = jest.fn(); + const {result} = renderHook(() => + useWellnessBreak({ + ...baseInput, + wellbeingBreakIdleCode: undefined, + wellnessBreakState: {phase: 'error', errorCode: 'SYSTEM_CODE_UNAVAILABLE'}, + onWellnessBreakError, + }) + ); + + await waitFor(() => expect(onWellnessBreakError).toHaveBeenCalledTimes(1)); + expect(result.current.enabled).toBe(true); + expect(result.current.error).toEqual({ + code: 'SYSTEM_CODE_UNAVAILABLE', + phase: 'idle', + recoverable: true, + }); + }); + + it('reports whether an active task is delaying the break', () => { + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: baseInput, + }); + + expect(result.current.hasBlockingTasks).toBe(false); + rerender({ + ...baseInput, + taskList: { + active: {data: {interaction: {state: 'connected'}}}, + } as unknown as UseWellnessBreakInput['taskList'], + }); + expect(result.current.hasBlockingTasks).toBe(true); + }); + + it('requires a full safe settle window after a task briefly blocks the break', async () => { + jest.useFakeTimers(); + const audio = { + load: jest.fn(), + pause: jest.fn(), + play: jest.fn().mockResolvedValue(undefined), + removeAttribute: jest.fn(), + } as unknown as HTMLAudioElement; + window.Audio = jest.fn(() => audio) as unknown as typeof Audio; + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessAudioUrl: '/wellness.mp3', + wellnessBreakState: {phase: 'offer-pending', event: provide}, + }, + }); + + await act(async () => result.current.onAccept()); + const safeInput: UseWellnessBreakInput = { + ...baseInput, + wellnessAudioUrl: '/wellness.mp3', + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + wellnessBreakState: {phase: 'waiting-for-safe-state', event: provide}, + }; + rerender(safeInput); + act(() => jest.advanceTimersByTime(1000)); + rerender({ + ...safeInput, + taskList: {active: {data: {interaction: {state: 'connected'}}}} as unknown as UseWellnessBreakInput['taskList'], + }); + act(() => jest.advanceTimersByTime(200)); + rerender(safeInput); + + act(() => jest.advanceTimersByTime(900)); + expect(storeMock.setWellnessBreakState).not.toHaveBeenCalledWith({phase: 'starting'}); + + act(() => jest.advanceTimersByTime(1100)); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith({phase: 'starting'}); + }); + + it('preloads break audio during the starting countdown and plays it with the timeline', async () => { + jest.useFakeTimers(); + const audio = { + currentTime: 8, + load: jest.fn(), + muted: true, + pause: jest.fn(), + play: jest.fn().mockResolvedValue(undefined), + removeAttribute: jest.fn(), + volume: 0, + } as unknown as HTMLAudioElement; + const audioConstructor = jest.fn(() => audio); + window.Audio = audioConstructor as unknown as typeof Audio; + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender, unmount} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: { + ...baseInput, + wellnessAudioUrl: '/wellness.mp3', + wellnessBreakState: {phase: 'offer-pending', event: provide}, + }, + }); + + await act(async () => result.current.onAccept()); + rerender({ + ...baseInput, + wellnessAudioUrl: '/wellness.mp3', + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + wellnessBreakState: {phase: 'waiting-for-safe-state', event: provide}, + }); + + await act(async () => { + jest.advanceTimersByTime(2_000); + await Promise.resolve(); + }); + expect(audioConstructor).toHaveBeenCalledWith('/wellness.mp3'); + expect(audio.load).toHaveBeenCalledTimes(1); + expect(audio.play).not.toHaveBeenCalled(); + + await act(async () => { + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(audio.currentTime).toBe(0); + expect(audio.muted).toBe(false); + expect(audio.volume).toBe(1); + expect(audio.play).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('clears pre-accept state on feature revocation and unmount', () => { + const {rerender, unmount} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: {...baseInput, wellnessBreakState: {phase: 'offer-pending', event}}, + }); + + rerender({...baseInput, enabled: false, wellnessBreakState: {phase: 'offer-pending', event}}); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith({phase: 'idle'}); + + storeMock.setWellnessBreakState.mockClear(); + rerender({...baseInput, wellnessBreakState: {phase: 'request-pending'}}); + unmount(); + expect(storeMock.setWellnessBreakState).toHaveBeenCalledWith({phase: 'idle'}); + }); + + it('waits for a pending break-state request before restoring on feature revocation', async () => { + let resolveState: (() => void) | undefined; + storeMock.cc.setAgentState.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveState = resolve; + }) + ); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: {...baseInput, wellnessBreakState: {phase: 'offer-pending', event: provide}}, + }); + + act(() => result.current.onAccept('card')); + await act(async () => { + await Promise.resolve(); + }); + rerender({...baseInput, enabled: false, wellnessBreakState: {phase: 'changing-to-break', event: provide}}); + expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveState?.(); + await Promise.resolve(); + }); + await waitFor(() => expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(2)); + expect(storeMock.cc.setAgentState).toHaveBeenLastCalledWith( + expect.objectContaining({state: 'Available', auxCodeId: '0', lastStateChangeReason: 'wellness-break-complete'}) + ); + }); + + it('waits for a pending break-state request before restoring on unmount', async () => { + let resolveState: (() => void) | undefined; + storeMock.cc.setAgentState.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveState = resolve; + }) + ); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, unmount} = renderHook(() => + useWellnessBreak({...baseInput, wellnessBreakState: {phase: 'offer-pending', event: provide}}) + ); + + act(() => result.current.onAccept('card')); + await act(async () => { + await Promise.resolve(); + }); + expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(1); + + unmount(); + expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(1); + await act(async () => { + resolveState?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(2)); + expect(storeMock.cc.setAgentState).toHaveBeenLastCalledWith( + expect.objectContaining({state: 'Available', auxCodeId: '0', lastStateChangeReason: 'wellness-break-complete'}) + ); + expect(storeMock.cc.apiAIAssistant.respondToWellnessBreak).not.toHaveBeenCalled(); + }); + + it('does not overwrite an external state when unmounted during playback', async () => { + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const {result, rerender, unmount} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: {...baseInput, wellnessBreakState: {phase: 'offer-pending', event: provide}}, + }); + + await act(async () => result.current.onAccept('card')); + rerender({ + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'rona', + wellnessBreakState: {phase: 'playing', event: provide}, + }); + unmount(); + await act(async () => { + await Promise.resolve(); + }); + + expect(storeMock.cc.setAgentState).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.getItem(WELLNESS_RECOVERY_KEY)).toBeNull(); + }); + + it('gives a later break a fresh background restore retry budget', async () => { + jest.useFakeTimers(); + storeMock.cc.apiAIAssistant.respondToWellnessBreak.mockRejectedValue(new Error('accept failed')); + storeMock.cc.setAgentState.mockImplementation(({lastStateChangeReason}) => + lastStateChangeReason === 'wellness-break' ? Promise.resolve() : Promise.reject(new Error('restore failed')) + ); + const provide = {...event, actionEvent: 'PROVIDE_WELLNESS_BREAK' as const}; + const offerInput = {...baseInput, wellnessBreakState: {phase: 'offer-pending' as const, event: provide}}; + const errorInput = { + ...baseInput, + legacyAgentState: 'Idle', + legacyAuxCodeId: 'wellness', + wellnessBreakState: {phase: 'error' as const, errorCode: 'RESTORE_FAILED' as const}, + }; + const {result, rerender, unmount} = renderHook((props: UseWellnessBreakInput) => useWellnessBreak(props), { + initialProps: offerInput, + }); + const advance = async (milliseconds: number) => { + await act(async () => { + await jest.advanceTimersByTimeAsync(milliseconds); + }); + }; + const restoreCalls = () => + storeMock.cc.setAgentState.mock.calls.filter( + ([params]) => params.lastStateChangeReason === 'wellness-break-complete' + ).length; + + await act(async () => result.current.onAccept('card')); + await advance(20_000); + expect(restoreCalls()).toBe(3); + rerender(errorInput); + await advance(100_000); + expect(restoreCalls()).toBe(8); + + rerender(offerInput); + await act(async () => result.current.onAccept('card')); + await advance(20_000); + expect(restoreCalls()).toBe(11); + rerender(errorInput); + await advance(20_000); + expect(restoreCalls()).toBe(12); + + rerender({...baseInput, agentSessionId: 'session-2'}); + unmount(); + }); +}); diff --git a/packages/contact-center/ai-assistant/tests/wellness.utils.ts b/packages/contact-center/ai-assistant/tests/wellness.utils.ts new file mode 100644 index 000000000..7ae322e9e --- /dev/null +++ b/packages/contact-center/ai-assistant/tests/wellness.utils.ts @@ -0,0 +1,85 @@ +import type {ITask} from '@webex/cc-store'; +import type {WellnessBreakRecoveryMarkerV1} from '../src/wellness/wellness-internal.types'; +import { + areAllTasksSafeForWellness, + createWellnessRecoveryMarker, + getWellnessRecoveryDecision, + parseWellnessRecoveryMarker, +} from '../src/wellness/wellness.utils'; + +const task = (state: string, wrapUpRequired = false): ITask => + ({data: {interaction: {state}, wrapUpRequired}}) as ITask; + +describe('Agent Wellness Break lifecycle decisions', () => { + it('requires every task, including wrap-up and campaign tasks, to be terminal', () => { + expect(areAllTasksSafeForWellness({one: task('completed'), two: task('terminated')})).toBe(true); + expect(areAllTasksSafeForWellness({one: task('connected')})).toBe(false); + expect(areAllTasksSafeForWellness({one: task('completed', true)})).toBe(false); + expect(areAllTasksSafeForWellness({one: task('new')})).toBe(false); + }); + + it('stores only minimal same-session recovery data and rejects malformed markers', () => { + const marker = createWellnessRecoveryMarker({ + agentSessionId: 'session-1', + preBreakLegacyState: 'Idle', + preBreakLegacyAuxCodeId: 'lunch', + }); + + expect(marker).toEqual({ + version: 1, + agentSessionId: 'session-1', + preBreakLegacyState: 'Idle', + preBreakLegacyAuxCodeId: 'lunch', + }); + expect(parseWellnessRecoveryMarker(JSON.stringify(marker))).toEqual(marker); + expect(parseWellnessRecoveryMarker('{bad-json')).toBeUndefined(); + expect( + parseWellnessRecoveryMarker(JSON.stringify({version: 1, agentSessionId: 'session-1', preBreakLegacyAuxCodeId: 7})) + ).toBeUndefined(); + }); + + it('restores only a matching session that still owns WellbeingBreak', () => { + const marker: WellnessBreakRecoveryMarkerV1 = { + version: 1, + agentSessionId: 'session-1', + preBreakLegacyState: 'Idle', + preBreakLegacyAuxCodeId: 'lunch', + }; + + expect( + getWellnessRecoveryDecision({ + marker, + agentSessionId: 'session-1', + wellnessAuxCodeId: 'wellness', + legacyStateKnown: true, + legacyAuxCodeId: 'wellness', + }) + ).toBe('restore'); + expect( + getWellnessRecoveryDecision({ + marker, + agentSessionId: 'different-session', + wellnessAuxCodeId: 'wellness', + legacyStateKnown: true, + legacyAuxCodeId: 'wellness', + }) + ).toBe('discard'); + expect( + getWellnessRecoveryDecision({ + marker, + agentSessionId: 'session-1', + wellnessAuxCodeId: 'wellness', + legacyStateKnown: false, + }) + ).toBe('wait'); + expect( + getWellnessRecoveryDecision({ + marker, + agentSessionId: 'session-1', + wellnessAuxCodeId: 'wellness', + legacyStateKnown: true, + legacyAuxCodeId: 'external-state', + }) + ).toBe('clear'); + }); +}); diff --git a/packages/contact-center/ai-assistant/tsconfig.test.json b/packages/contact-center/ai-assistant/tsconfig.test.json index 179268a69..716eaf660 100644 --- a/packages/contact-center/ai-assistant/tsconfig.test.json +++ b/packages/contact-center/ai-assistant/tsconfig.test.json @@ -1,7 +1,7 @@ // This config is to do type checking in our files while running tests. { "extends": "./tsconfig.json", - "include": ["./tests"], + "include": ["./tests", "./src/wellness/assets.d.ts"], "exclude": ["**/node_modules/**"], "compilerOptions": { "noEmit": true diff --git a/packages/contact-center/ai-assistant/webpack.config.js b/packages/contact-center/ai-assistant/webpack.config.js index b9add6b7f..c4a6e3a98 100644 --- a/packages/contact-center/ai-assistant/webpack.config.js +++ b/packages/contact-center/ai-assistant/webpack.config.js @@ -1,8 +1,41 @@ const {merge} = require('webpack-merge'); const path = require('path'); +const fs = require('fs'); const baseConfig = require('../../../webpack.config'); +const wellnessAssets = [ + 'media.cjs', + 'emit-wellness-asset.cjs', + 'WellnessBreakSound.mp3', + 'WellnessBreakAnimationDark.json', + 'WellnessBreakAnimationLight.json', +]; + +class EmitWellnessAssetsPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap('EmitWellnessAssetsPlugin', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'EmitWellnessAssetsPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + () => { + for (const filename of wellnessAssets) { + const sourcePath = path.resolve(__dirname, 'src/wellness/assets', filename); + compilation.emitAsset( + `assets/wellness/${filename}`, + new compiler.webpack.sources.RawSource(fs.readFileSync(sourcePath)), + // A consuming Webpack build must still recognize require.ensure's require callback. + {minimized: filename.endsWith('.cjs')} + ); + } + } + ); + }); + } +} + module.exports = merge(baseConfig, { output: { path: path.resolve(__dirname, 'dist'), @@ -14,5 +47,7 @@ module.exports = merge(baseConfig, { 'react-dom': 'react-dom', '@webex/cc-store': '@webex/cc-store', '@momentum-ui/react-collaboration': '@momentum-ui/react-collaboration', + './assets/media.cjs': 'commonjs ./assets/wellness/media.cjs', }, + plugins: [new EmitWellnessAssetsPlugin()], }); 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 1ce7f9136..7551a10d5 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 @@ -1,3 +1,6 @@ +Warning: truncated output (original token count: 30677) +Total output lines: 373 + # cc-components — SPEC > Start here → root [`AGENTS.md`](../../../../AGENTS.md) (agent entry) · router [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · system [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md). This is the module's canonical spec: orientation, requirements, design, flows, UI, and tests. @@ -12,7 +15,7 @@ | Doc kind | Module spec | | Coverage score | Pending coverage assessment | | Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | -| generated_by / approved_by / updated_at | generated_by `migration agent` / approved_by `pending` / updated_at `2026-06-29` | +| generated_by / approved_by / updated_at | generated_by `migration agent` / approved_by `pending` / updated_at `2026-09-04` | | Validation status | not-run | ## Evidence Rules @@ -92,31 +95,31 @@ tests/hooks/ # Hook tests | `src/components/UserState/user-state.types.ts` | `IUserState`, `UserStateComponentsProps` Pick, `AgentUserState` enum. | | `src/components/StationLogin/constants.ts` | Login labels/error strings — re-exported from the barrel; never hardcode these elsewhere. | | `src/components/task/constants.ts` | Task UI label/string constants (e.g. `CAMPAIGN_CALL`, `WRAP_UP`). | -| `src/utils/index.ts` | `formatTime` (timer formatting), `getMediaTypeInfo` (media icon/label mapping), and wxApp telephony visibility helpers (`isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` in `wxapp-telephony.utils.ts`, re-exported from package index). | +| `src/utils/index.ts` | `formatTime` (timer formatting), `getMediaTypeInfo` (media icon/label mapping), and wxApp telephony visibility helpers (`isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` in `wxapp-telephony.utils.ts`, re-exported from package index). | ## Public Surface Consumed as an imported SDK/code API. The React barrel (`src/index.ts`) is the primary surface; `src/wc.ts` exposes the same components as custom elements for the library build. Exact prop schemas live in the `*.types.ts` files (linked below); the root contract index (`CONTRACTS.md`) documents how the consuming `cc-widgets`/widget layer re-exposes these as custom elements. -| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | -| -------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | -| `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.consult-transfer-lists` | SDK callback props | `getQueuesFetcher`/`getQueues` use `FetchPaginatedList` and `getEntryPoints` uses `FetchPaginatedList` | Render the SDK's established queue and entry-point records directly in backend order | types track the existing SDK/store entity contracts | `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.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) | -| `cc-components.RealTimeTranscriptComponent` | SDK | `RealTimeTranscriptComponent` (`RealTimeTranscriptComponentProps`) | Renders sorted live transcript entries; empty state when none | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CampaignCountdownComponent` | SDK | `CampaignCountdownComponent` (`CampaignCountdownProps`) | Campaign preview offer countdown; fires `onTimeout` at zero | semver as above | `src/components/task/CampaignCountdown/campaign-countdown.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CampaignErrorDialogComponent` | SDK | `CampaignErrorDialogComponent` (`CampaignErrorDialogProps`) | Modal shown when a campaign action (accept/skip/remove/cancel) fails | semver as above | `src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CampaignTaskComponent` | SDK | `CampaignTaskComponent` (`CampaignTaskProps`) | Campaign preview card: accept/skip/remove, countdown, error dialog | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.E911Modal` | SDK | `E911Modal` (`E911ModalProps`) | Emergency-service (E911) acknowledgment modal shown on BROWSER station login; Cancel is the only dismissal path, Save & Continue is gated on the checkbox and disabled while the save is in flight | semver as above; not yet in `wc.ts` (React-only, no custom-element wrapper) | `src/components/StationLogin/E911Modal/e911-modal.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.TelephonyActionToast` | SDK | `TelephonyActionToast` (`TelephonyActionToastProps`) | wxApp thick-client telephony error toast (Momentum `Toast` `variant="error"`); consumed by `@webex/cc-task` CallControl / CallControlCAD wrappers, not intended for third-party host apps | semver; widget-internal surface — exported from package index for cross-package use | `src/components/task/TelephonyActionToast/telephony-action-toast.tsx` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.WxAppOfferActionError` | SDK | `WxAppOfferActionError` (`WxAppOfferActionErrorProps`) | Inline wxApp offer-action error as red text (`--mds-color-theme-text-error-normal`) on a full-width row below Accept/Decline — no alert background, padding, or border; parent `Task` sets `ListItemBase size="auto"` when an error is present so the row grows naturally; store-backed per `interactionId`, shared between TaskList and IncomingTask via `@webex/cc-store` (immutable map updates for memo-safe sync) | semver; widget-internal surface — exported from package index for cross-package use | `src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.wxapp-telephony-utils` | SDK | `isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` | Shared wxApp visibility helpers for mute/keypad gating across cc-components and cc-task | semver; utility exports from `src/utils/wxapp-telephony.utils.ts` | `src/utils/wxapp-telephony.utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.wc` | SDK | `@webex/cc-components/wc` → custom elements `component-cc-user-state`, `component-cc-station-login`, `component-cc-call-control`, `component-cc-call-control-cad`, `component-cc-incoming-task`, `component-cc-task-list`, `component-cc-out-dial-call`, `component-cc-realtime-transcript` | Custom-element build of the components | tag names are breaking surface; r2wc prop type map is part of the contract | `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | +| -------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | +| `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.consult-transfer-lists` | SDK callback props | `getQueuesFetcher`/`getQueues` use `FetchPaginatedList` and `getEntryPoints` uses `FetchPaginatedList` | Render the SDK's established queue and entry-point records directly in backend order | types track the existing SDK/store entity contracts | `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.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) | +| `cc-components.RealTimeTranscriptComponent` | SDK | `RealTimeTranscriptComponent` (`RealTimeTranscriptComponentProps`) | Renders sorted live transcript entries; empty state when none | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CampaignCountdownComponent` | SDK | `CampaignCountdownComponent` (`CampaignCountdownProps`) | Campaign preview offer countdown; fires `onTimeout` at zero | semver as above | `src/components/task/CampaignCountdown/campaign-countdown.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CampaignErrorDialogComponent` | SDK | `CampaignErrorDialogComponent` (`CampaignErrorDialogProps`) | Modal shown when a campaign action (accept/skip/remove/cancel) fails | semver as above | `src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CampaignTaskComponent` | SDK | `CampaignTaskComponent` (`CampaignTaskProps`) | Campaign preview card: accept/skip/remove, countdown, error dialog | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.E911Modal` | SDK | `E911Modal` (`E911ModalProps`) | Emergency-service (E911) acknowledgment modal shown on BROWSER station login; Cancel is the only dismissal path, Save & Continue is gated on the checkbox and disabled while the save is in flight | semver as above; not yet in `wc.ts` (React-only, no custom-element wrapper) | `src/components/StationLogin/E911Modal/e911-modal.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.TelephonyActionToast` | SDK | `TelephonyActionToast` (`TelephonyActionToastProps`) | wxApp thick-client telephony error toast (Momentum `Toast` `variant="error"`); consumed by `@webex/cc-task` CallControl / CallControlCAD wrappers, not intended for third-party host apps | semver; widget-internal surface — exported from package index for cross-package use | `src/components/task/TelephonyActionToast/telephony-action-toast.tsx` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.WxAppOfferActionError` | SDK | `WxAppOfferActionError` (`WxAppOfferActionErrorProps`) | Inline wxApp offer-action error as red text (`--mds-color-theme-text-error-normal`) on a full-width row below Accept/Decline — no alert background, padding, or border; parent `Task` sets `ListItemBase size="auto"` when an error is present so the row grows naturally; store-backed per `interactionId`, shared between TaskList and IncomingTask via `@webex/cc-store` (immutable map updates for memo-safe sync) | semver; widget-internal surface — exported from package index for cross-package use | `src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.wxapp-telephony-utils` | SDK | `isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` | Shared wxApp visibility helpers for mute/keypad gating across cc-components and cc-task | semver; utility exports from `src/utils/wxapp-telephony.utils.ts` | `src/utils/wxapp-telephony.utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.wc` | SDK | `@webex/cc-components/wc` → custom elements `component-cc-user-state`, `component-cc-station-login`, `component-cc-call-control`, `component-cc-call-control-cad`, `component-cc-incoming-task`, `component-cc-task-list`, `component-cc-out-dial-call`, `component-cc-realtime-transcript` | Custom-element build of the components | tag names are breaking surface; r2wc prop type map is part of the contract | `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: @@ -136,27 +139,26 @@ Compatibility notes: ## Requirements -| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------- | -| `CC-COMPONENTS-R-001` | Components are pure presentational: they receive data + callbacks via props and never read the MobX store or call the SDK directly. | Keeps presentation decoupled from state/SDK so components are testable in isolation and reusable across widgets. | `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx` (props-only; no `@webex/cc-store` singleton import) | `tests/components/StationLogin/station-login.tsx` (renders from props, mocks callbacks) | None | PRESENT | -| `CC-COMPONENTS-R-002` | The public React surface is exactly the 12 components exported from `index.ts` (11 base components plus `E911Modal`) plus the re-exported type barrels. | Defines the supported import surface; consumers must not import internal subcomponents. | `src/index.ts` | `tests/components/StationLogin/station-login.tsx`, `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/RealtimeTranscript/realtime-transcript.tsx`, `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (import the exported components) | `E911Modal` is not yet wrapped as a custom element in `wc.ts` - React-only for now | PRESENT | -| `CC-COMPONENTS-R-003` | `StationLoginComponent` invokes the supplied callbacks (`login`, `setDeviceType`, `setDialNumber`, `handleContinue`, `saveLoginOptions`) on the matching user action and surfaces `loginFailure`/`saveError` as error UI. | Login intent and errors must propagate to the widget layer without the component owning login logic. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`calls login function...`, `renders login failure when passed`, `renders save error when passed`) | None | PRESENT | -| `CC-COMPONENTS-R-004` | `StationLoginComponent` hides the Desktop login option when `hideDesktopLogin` is true (in both login and profile mode) and shows it when false/undefined. | Deployments can disable Desktop login; must be honored consistently across modes. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`hides Desktop login option when hideDesktopLogin is true`, `... when false`, `... when undefined`, `... in profile mode`) | None | PRESENT | -| `CC-COMPONENTS-R-005` | `UserStateComponent` renders idle codes sorted/built into the dropdown, reflects `currentState`/`elapsedTime`, and calls `setAgentStatus(auxCodeId)` on selection; error-triggering idle codes are styled distinctly. | Agent must change state and see correct current state + timing; error idle codes need visual emphasis. | `src/components/UserState/user-state.tsx`, `src/components/UserState/user-state.utils.ts` (`buildDropdownItems`, `sortDropdownItems`, `handleSelectionChange`, `getDropdownClass`) | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-006` | `CallControlComponent` builds its button set from Task controls, passes the matching SDK-ordered `consultTransferDestinations` action array into the popover, and routes button presses to the matching callback; wrapup requires selecting a reason. The popover renders destination categories in supplied order and may only remove Dial Number/Entry Point through explicit host hide options. | Call control must reflect SDK decisions without reconstructing profile/media/direction policy, while retaining supported host presentation overrides. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `src/components/task/CallControl/call-control.utils.ts` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | Consumers cannot enable a destination omitted by the SDK. | PRESENT | -| `CC-COMPONENTS-R-007` | `CallControlCADComponent` renders the customer/queue/caller header and an agent-viewable CAD global variables panel, and renders the campaign call icon + "Campaign call" label when `isCampaignCall` is true. | CAD/header info and campaign branding must be visible to the agent during a call. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/Task/task.utils.ts` (`getAgentViewableGlobalVariables`) | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-008` | `IncomingTaskComponent` renders the standard `Task` with Answer/Decline when an `incomingTask` is present and renders nothing (hidden) when it is absent; Accept/Decline invoke `accept(task)`/`reject(task)`. | Avoids a stray empty notification when no task; routes accept/decline intent up. | `src/components/task/IncomingTask/incoming-task.tsx`, `src/components/task/IncomingTask/incoming-task.utils.tsx` (`extractIncomingTaskData`) | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-009` | `TaskListComponent` renders nothing when the task list is empty, otherwise renders one row per task; campaign preview tasks render `CampaignTask` (instead of `Task`) only when `hasCampaignPreviewEnabled` (default true) and the task is a campaign preview. | List must collapse when empty and switch row UI for campaign previews per the feature flag. | `src/components/task/TaskList/task-list.tsx`, `src/components/task/TaskList/task-list.utils.ts` (`isTaskListEmpty`, `getTasksArray`, `isCampaignPreviewTask`, `getActiveCampaignPreviewId`) | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-010` | `OutdialCallComponent` validates the entered destination, supports dialpad / ANI / address-book tabs, disables the outdial action while a telephony task is active, and calls `startOutdial(destination, origin?)`. | Outbound dialing must validate input and not start a second call over an active one. | `src/components/task/OutdialCall/outdial-call.tsx`, `src/components/task/OutdialCall/constants.ts` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-011` | `RealTimeTranscriptComponent` sorts `liveTranscriptEntries` by ascending `timestamp` before rendering and shows an empty-state message when there are no entries. | Transcript must read chronologically and degrade gracefully when empty. | `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-012` | Campaign preview UI: `CampaignTaskComponent` renders accept/skip/remove + countdown and triggers the configured auto-action on timeout; failed actions open `CampaignErrorDialogComponent` with the mapped `CampaignErrorType`; `CampaignCountdownComponent` fires `onTimeout` at zero. | Campaign preview offers are time-boxed; failures and timeouts must be surfaced and auto-handled. | `src/components/task/CampaignTask/campaign-task.tsx`, `src/components/task/CampaignErrorDialog/campaign-error-dialog.tsx` (+ `.types.ts` `CAMPAIGN_ACTION_ERROR_MAP`, `ERROR_TITLES`), `src/components/task/CampaignCountdown/campaign-countdown.tsx` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | -| `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, 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 | -| `CC-COMPONENTS-R-019` | wxApp thick-client telephony: `CallControlComponent` accepts optional `sendDtmf(digit)` (defaults to no-op), renders `CallControlDtmfKeypad` in the Keypad popover (keyed by `currentTask.data.interactionId` so dial state resets on task switch; popover closes when interaction ID changes), serializes in-flight DTMF transmissions and cancels/discards queued digits on unmount or interaction change via a generation token (stale queue entries must not invoke `onDigitPress`), disables keypad input when the control is disabled, and applies `applyWxAppTelephonyControlVisibility` to force-show Mute/Keypad when wxApp is engaged and SDK `isEnabled` is true (even if `isVisible` is false); suppresses visible+disabled ghost controls for Extension agents when wxApp is not engaged. | DTMF must not drop digits under rapid input; mute/keypad must match wxApp session state without widgets calling telephony REST directly; keypad must not leak prior-call digits across interaction changes; queued DTMF must not transmit on a different interaction after task switch. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/call-control-dtmf-keypad.tsx`, `src/components/task/CallControl/call-control.utils.ts`, `src/components/task/task.types.ts` | `tests/components/task/CallControl/call-control-dtmf-keypad.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | Visibility gating reads `enableWxBetterTogether` + engaged call id from widget props; store owns mute sync events. | PRESENT | +| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `CC-COMPONENTS-R-001` | Components are pure presentational: they receive data + callbacks via props and never read the MobX store or call the SDK directly. | Keeps presentation decoupled from state/SDK so components are testable in isolation and reusable across widgets. | `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx` (props-only; no `@webex/cc-store` singleton import) | `tests/components/StationLogin/station-login.tsx` (renders from props, mocks callbacks) | None | PRESENT | +| `CC-COMPONENTS-R-002` | The public React surface is the component and type inventory exported from `index.ts`, including the five Agent Wellness Break presentational components. | Defines the supported import surface; consumers must not import unlisted internal subcomponents. | `src/index.ts` | Component tests import the supported entries; wellness composition is covered in `tests/components/AIAssistant/ai-assistant.tsx`. | `E911Modal` and wellness subcomponents are React exports, not independent custom elements. | PRESENT | +| `CC-COMPONENTS-R-003` | `StationLoginComponent` invokes the supplied callbacks (`login`, `setDeviceType`, `setDialNumber`, `handleContinue`, `saveLoginOptions`) on the matching user action and surfaces `loginFailure`/`saveError` as error UI. | Login intent and errors must propagate to the widget layer without the component owning login logic. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`calls login function...`, `renders login failure when passed`, `renders save error when passed`) | None | PRESENT | +| `CC-COMPONENTS-R-004` | `StationLoginComponent` hides the Desktop login option when `hideDesktopLogin` is true (in both login and profile mode) and shows it when false/undefined. | Deployments can disable Desktop login; must be honored consistently across modes. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`hides Desktop login option when hideDesktopLogin is true`, `... when false`, `... when undefined`, `... in profile mode`) | None | PRESENT | +| `CC-COMPONENTS-R-005` | `UserStateComponent` renders idle codes sorted/built into the dropdown, reflects `currentState`/`elapsedTime`, and calls `setAgentStatus(auxCodeId)` on selection; error-triggering idle codes are styled distinctly. | Agent must change state and see correct current state + timing; error idle codes need visual emphasis. | `src/components/UserState/user-state.tsx`, `src/components/UserState/user-state.utils.ts` (`buildDropdownItems`, `sortDropdownItems`, `handleSelectionChange`, `getDropdownClass`) | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-006` | `CallControlComponent` builds its button set from Task controls, passes the matching SDK-ordered `consultTransferDestinations` action array into the popover, and routes button presses to the matching callback; wrapup requires selecting a reason. The popover renders destination categories in supplied order and may only remove Dial Number/Entry Point through explicit host hide options. | Call control must reflect SDK decisions without reconstructing profile/media/direction policy, while retaining supported host presentation overrides. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `src/components/task/CallControl/call-control.utils.ts` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | Consumers cannot enable a destination omitted by the SDK. | PRESENT | +| `CC-COMPONENTS-R-007` | `CallControlCADComponent` renders the customer/queue/caller header and an agent-viewable CAD global variables panel, and renders the campaign call icon + "Campaign call" label when `isCampaignCall` is true. | CAD/header info and campaign branding must be visible to the agent during a call. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/Task/task.utils.ts` (`getAgentViewableGlobalVariables`) | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-008` | `IncomingTaskComponent` renders the standard `Task` with Answer/Decline when an `incomingTask` is present and renders nothing (hidden) when it is absent; Accept/Decline invoke `accept(task)`/`reject(task)`. …677 tokens truncated… | List must collapse when empty and switch row UI for campaign previews per the feature flag. | `src/components/task/TaskList/task-list.tsx`, `src/components/task/TaskList/task-list.utils.ts` (`isTaskListEmpty`, `getTasksArray`, `isCampaignPreviewTask`, `getActiveCampaignPreviewId`) | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-010` | `OutdialCallComponent` validates the entered destination, supports dialpad / ANI / address-book tabs, disables the outdial action while a telephony task is active, and calls `startOutdial(destination, origin?)`. | Outbound dialing must validate input and not start a second call over an active one. | `src/components/task/OutdialCall/outdial-call.tsx`, `src/components/task/OutdialCall/constants.ts` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-011` | `RealTimeTranscriptComponent` sorts `liveTranscriptEntries` by ascending `timestamp` before rendering and shows an empty-state message when there are no entries. | Transcript must read chronologically and degrade gracefully when empty. | `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-012` | Campaign preview UI: `CampaignTaskComponent` renders accept/skip/remove + countdown and triggers the configured auto-action on timeout; failed actions open `CampaignErrorDialogComponent` with the mapped `CampaignErrorType`; `CampaignCountdownComponent` fires `onTimeout` at zero. | Campaign preview offers are time-boxed; failures and timeouts must be surfaced and auto-handled. | `src/components/task/CampaignTask/campaign-task.tsx`, `src/components/task/CampaignErrorDialog/campaign-error-dialog.tsx` (+ `.types.ts` `CAMPAIGN_ACTION_ERROR_MAP`, `ERROR_TITLES`), `src/components/task/CampaignCountdown/campaign-countdown.tsx` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | +| `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, 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 | +| `CC-COMPONENTS-R-019` | wxApp thick-client telephony: `CallControlComponent` accepts optional `sendDtmf(digit)` (defaults to no-op), renders `CallControlDtmfKeypad` in the Keypad popover (keyed by `currentTask.data.interactionId` so dial state resets on task switch; popover closes when interaction ID changes), serializes in-flight DTMF transmissions and cancels/discards queued digits on unmount or interaction change via a generation token (stale queue entries must not invoke `onDigitPress`), disables keypad input when the control is disabled, and applies `applyWxAppTelephonyControlVisibility` to force-show Mute/Keypad when wxApp is engaged and SDK `isEnabled` is true (even if `isVisible` is false); suppresses visible+disabled ghost controls for Extension agents when wxApp is not engaged. | DTMF must not drop digits under rapid input; mute/keypad must match wxApp session state without widgets calling telephony REST directly; keypad must not leak prior-call digits across interaction changes; queued DTMF must not transmit on a different interaction after task switch. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/call-control-dtmf-keypad.tsx`, `src/components/task/CallControl/call-control.utils.ts`, `src/components/task/task.types.ts` | `tests/components/task/CallControl/call-control-dtmf-keypad.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | Visibility gating reads `enableWxBetterTogether` + engaged call id from widget props; store owns mute sync events. | PRESENT | ## Design Overview @@ -332,28 +334,54 @@ This package is published (`@webex/cc-components`, `main` → `dist/index.js`, ` Each component is tested in isolation with React Testing Library: render from a minimal props object, assert rendered UI (positive) and assert callbacks fire on interaction / error props render error UI (negative), with snapshot tests guarding stable markup. Utils have dedicated `*.utils.tsx` tests asserting pure transformations (e.g. button building, sorting, data extraction). Edge cases covered include empty task list/incoming task (hidden render), `hideDesktopLogin` across modes, campaign timeout/error, and transcript empty + sort. Gaps: no dedicated test file for `formatTime`/`getMediaTypeInfo` in `src/utils/index.ts` and no explicit assertion that components are `withMetrics`-wrapped. wxApp visibility helpers have dedicated tests in `tests/utils/wxapp-telephony.utils.test.ts`. -| Behavior / Requirement | Existing test evidence | Gap | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `CC-COMPONENTS-R-001` | `tests/components/StationLogin/station-login.tsx`, `tests/components/UserState/user-state.tsx`, `tests/components/task/CallControl/call-control.tsx` | None | -| `CC-COMPONENTS-R-002` | Import sites across `tests/components/**` | No single test asserting the full export list | -| `CC-COMPONENTS-R-003` | `tests/components/StationLogin/station-login.tsx` (actions + failure + save error) | None | -| `CC-COMPONENTS-R-004` | `tests/components/StationLogin/station-login.tsx` (hideDesktopLogin cases) | None | -| `CC-COMPONENTS-R-005` | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | -| `CC-COMPONENTS-R-006` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | None | -| `CC-COMPONENTS-R-007` | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | -| `CC-COMPONENTS-R-008` | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | -| `CC-COMPONENTS-R-009` | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | -| `CC-COMPONENTS-R-010` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | -| `CC-COMPONENTS-R-011` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | -| `CC-COMPONENTS-R-012` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | -| `CC-COMPONENTS-R-013` | `tests/components/task/CallControl/call-control.utils.tsx`, component snapshots | No dedicated `formatTime`/`getMediaTypeInfo` unit test | -| `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 | +| Behavior / Requirement | Existing test evidence | Gap | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `CC-COMPONENTS-R-001` | `tests/components/StationLogin/station-login.tsx`, `tests/components/UserState/user-state.tsx`, `tests/components/task/CallControl/call-control.tsx` | None | +| `CC-COMPONENTS-R-002` | Import sites across `tests/components/**` | No single test asserting the full export list | +| `CC-COMPONENTS-R-003` | `tests/components/StationLogin/station-login.tsx` (actions + failure + save error) | None | +| `CC-COMPONENTS-R-004` | `tests/components/StationLogin/station-login.tsx` (hideDesktopLogin cases) | None | +| `CC-COMPONENTS-R-005` | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | +| `CC-COMPONENTS-R-006` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | None | +| `CC-COMPONENTS-R-007` | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | +| `CC-COMPONENTS-R-008` | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | +| `CC-COMPONENTS-R-009` | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | +| `CC-COMPONENTS-R-010` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | +| `CC-COMPONENTS-R-011` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | +| `CC-COMPONENTS-R-012` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | +| `CC-COMPONENTS-R-013` | `tests/components/task/CallControl/call-control.utils.tsx`, component snapshots | No dedicated `formatTime`/`getMediaTypeInfo` unit test | +| `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` (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 | +| `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 - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) - Coverage state & contracts baseline: `.sdd/manifest.json` + +## Agent Wellness Break addendum (WXCC-12423) + +| ID | Requirement | Source evidence | Test evidence | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `CC-COMPONENTS-R-020` | Export props-only request, offer, offer-toast, error, and modal components; SDK/store access remains in the AI Assistant container hook. | `src/components/AIAssistant/WellnessBreak/`, `src/index.ts` | `tests/components/AIAssistant/ai-assistant.tsx` | +| `CC-COMPONENTS-R-021` | Follow Desktop presentation priority: an eligible empty-state suggestion owns the complete assistant body as an opaque body-covering layer and temporarily covers retained wellness history; otherwise the chronological wellness transcript owns the body instead of stacking with landing/Real-time Assist content. Preserve resolved offer, user action, acknowledgement, denial/timeout, and completion entries until Clear. Keep the full-screen overlay as a sibling of closed/open/minimized chrome, constrain conversation content at wide widths, and mention current work only when `hasBlockingTasks` is true. | `src/components/AIAssistant/ai-assistant.tsx`, `src/components/AIAssistant/WellnessBreak/wellness-break-history.tsx`, wellness styles | `tests/components/AIAssistant/ai-assistant.tsx` | +| `CC-COMPONENTS-R-022` | Present an eligible suggestion as the centered Desktop-style AI mark, helper copy, and outlined manual CTA. The CTA exposes a hover/focus description tooltip from trimmed suggestion `actionText`; denial renders trimmed `WELLNESS_BREAK_NOT_ALLOWED` `actionText`; both use their approved local fallback when blank. Pending, denied, and completed states do not render the manual CTA. | `src/components/AIAssistant/WellnessBreak/wellness-break-request-card.tsx`, styles | `tests/components/AIAssistant/ai-assistant.tsx` | +| `CC-COMPONENTS-R-023` | A direct offer renders as an actionable Momentum toast when the assistant panel is closed or minimized, with Take a break, Later, and dismiss controls; an open panel renders the offer card without a duplicate toast. | `src/components/AIAssistant/WellnessBreak/wellness-break-offer-toast.tsx`, `src/components/AIAssistant/ai-assistant.tsx` | `tests/components/AIAssistant/ai-assistant.tsx` | +| `CC-COMPONENTS-R-024` | The modal is an aria-modal dialog with focus capture/restoration, Tab containment, Escape suppression, live text/countdowns, progress semantics, and a reduced-motion static visual. Match Desktop's full-bleed rounded canvas: starting and ending use an overlaid inline `5 4 3 2 1` sequence, while playing overlays its message and compact bottom progress bar on the animation. The overlay defaults to fixed viewport coverage, supports absolute assistant-root coverage, and portals into a custom `HTMLElement`; it locks and restores the selected scroll surface so no scrollbar gutter remains. | `src/components/AIAssistant/WellnessBreak/wellness-break-modal.tsx`, styles | `tests/components/AIAssistant/ai-assistant.tsx`; manual accessibility verification remains required | +| `CC-COMPONENTS-R-025` | Accept an optional animation loader supplied by the AI Assistant widget; hold the first frame during starting, play during `playing`, and hold the final frame during ending. The shared package must not depend on or ship `lottie-web` or the MP3. Report renderer failure through a callback without owning lifecycle. | `wellness-break-modal.tsx`, `ai-assistant.types.ts`, `package.json` | component and package archive verification | +| `CC-COMPONENTS-R-026` | Render Clear as the first open-panel header action, disable it when no displayed assistant content exists, and provide Desktop-style hover/focus tooltips for header actions. Clear is presentation-only and delegates lifecycle behavior to the container. | `src/components/AIAssistant/ai-assistant.tsx` | `tests/components/AIAssistant/ai-assistant.tsx` | +| `CC-COMPONENTS-R-027` | Match Desktop landing feature copy and icons exactly: `✨ Real-time Assist`, `🪷 Wellness breaks`, and `✍🏻 Smart summaries`, including their approved descriptions. | `src/components/AIAssistant/ai-assistant-landing.tsx`, `src/components/AIAssistant/constants.ts` | `tests/components/AIAssistant/ai-assistant.tsx` | + +The default viewport wellness overlay portals to `document.body` so transformed or clipped host +containers cannot bound it. Assistant mode remains in the widget root, which keeps a panel-height +canvas during an active overlay even when the assistant is closed or minimized. A custom target uses +its provided element. + +See [`agent-wellness-break-intake.md`](../../../../ai-docs/features/agent-wellness-break-intake.md) for +copy, timing, media provenance, and release gates. + +The modal accepts a renderer loader from `@webex/cc-ai-assistant`. This package does not +import or declare a dependency on `lottie-web` and does not ship the wellness MP3. Momentum +may still emit its own dynamic icon chunks. Evidence: `package.json`, +`src/components/AIAssistant/WellnessBreak/wellness-break-modal.tsx`. diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-copy.ts b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-copy.ts new file mode 100644 index 000000000..df898b87d --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-copy.ts @@ -0,0 +1,18 @@ +import type {WellnessBreakNotice} from '../ai-assistant.types'; + +export const WELLNESS_SUGGESTION_COPY = + "Looks like it's a busy day. Here's how I can help you stay focussed and on top of your game"; +export const WELLNESS_SUGGESTION_TOOLTIP_FALLBACK = + 'This break is pre-approved by your organization for your well-being. You deserve it.'; +export const WELLNESS_ACKNOWLEDGEMENT_COPY = 'Great. Your well-being break will begin shortly.'; +export const WELLNESS_BLOCKED_ACKNOWLEDGEMENT_COPY = + 'Great. Your well-being break starts right after your current work.'; + +export const WELLNESS_NOTICE_COPY: Record = { + declined: + "It's great to see your dedication. But remember, taking breaks can boost your productivity and your health.", + 'not-allowed': + "I'm sorry, you've reached your well-being break limit today. Continue with your tasks, but remember to take care of yourself.", + 'no-response': "Looks like you're busy. I didn't get a response, so I'll check back with you shortly.", + completed: "I hope you're feeling recharged after that well-being break. See you in your next break!", +}; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-error.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-error.tsx new file mode 100644 index 000000000..e61693c75 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-error.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {Text} from '@momentum-design/components/dist/react'; +import type {WellnessBreakErrorProps} from '../ai-assistant.types'; + +const WellnessBreakError: React.FC = ({error}) => { + if (!error) return null; + const message = + error.code === 'RESTORE_FAILED' + ? 'We encountered an issue setting your status to Available.' + : error.code === 'STATE_CHANGE_FAILED' + ? "We couldn't start your well-being break due to a system issue. Please continue with your tasks and we will see you in your next well-being break." + : "We couldn't start your well-being break due to a system issue."; + + return ( +
+ + {message} + +
+ ); +}; + +export default WellnessBreakError; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-history.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-history.tsx new file mode 100644 index 000000000..6ff98a9f7 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-history.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import {Text} from '@momentum-design/components/dist/react'; +import CiscoAIAssistantColorIcon from '../CiscoAIAssistantColorIcon'; +import type {WellnessBreakHistoryEntry, WellnessBreakHistoryProps} from '../ai-assistant.types'; +import WellnessBreakOfferCard from './wellness-break-offer-card'; +import { + WELLNESS_ACKNOWLEDGEMENT_COPY, + WELLNESS_BLOCKED_ACKNOWLEDGEMENT_COPY, + WELLNESS_NOTICE_COPY, +} from './wellness-break-copy'; + +const formatMessageTime = (createdAt: number): string => + new Intl.DateTimeFormat(undefined, {hour: '2-digit', minute: '2-digit'}).format(createdAt); + +const AssistantIcon: React.FC = () => ( + +); + +const AssistantMessage: React.FC<{entry: Extract}> = ({ + entry, +}) => { + const isCompletion = entry.type === 'notice' && entry.notice === 'completed'; + const text = + entry.type === 'acknowledgement' + ? entry.hasBlockingTasks + ? WELLNESS_BLOCKED_ACKNOWLEDGEMENT_COPY + : WELLNESS_ACKNOWLEDGEMENT_COPY + : entry.notice === 'not-allowed' && entry.actionText?.trim() + ? entry.actionText.trim() + : WELLNESS_NOTICE_COPY[entry.notice]; + + return ( +
+
+ + + {isCompletion ? 'Well-being break completed' : text} + +
+ {isCompletion ? ( +
+ + {text} + + +
+ ) : null} +
+ ); +}; + +const WellnessBreakHistory: React.FC = ({entries, onAccept, onLater}) => ( +
+ {entries.map((entry) => { + if (entry.type === 'offer') { + return ( +
+ + +
+ ); + } + if (entry.type === 'user-action') { + return ( + + {entry.action === 'later' ? 'Later' : 'Take a break'} + + ); + } + return ; + })} +
+); + +export default WellnessBreakHistory; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-modal.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-modal.tsx new file mode 100644 index 000000000..0d5981042 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-modal.tsx @@ -0,0 +1,215 @@ +import React, {useEffect, useRef} from 'react'; +import {createPortal} from 'react-dom'; +import type { + WellnessAnimationController, + WellnessBreakModalProps, + WellnessBreakOverlayTarget, +} from '../ai-assistant.types'; + +const COUNTDOWN_DIGITS = [5, 4, 3, 2, 1]; + +const syncAnimationPhase = ( + animation: WellnessAnimationController | undefined, + phase: WellnessBreakModalProps['phase'] +): void => { + if (!animation) return; + if (phase === 'playing') { + animation.goToAndPlay(0, true); + } else if (phase === 'ending') { + animation.goToAndStop(Math.max(animation.totalFrames - 1, 0), true); + } else { + animation.goToAndStop(0, true); + } +}; + +const WellnessBreakModal: React.FC = ({ + phase, + countdown, + elapsedSeconds, + animationData, + reducedMotion, + onMediaError, + overlayTarget, + loadAnimation, +}) => { + const overlayRef = useRef(null); + const dialogRef = useRef(null); + const animationRef = useRef(null); + const animationItemRef = useRef(); + const phaseRef = useRef(phase); + phaseRef.current = phase; + const resolvedTarget: WellnessBreakOverlayTarget = + overlayTarget === 'assistant' || (typeof overlayTarget === 'object' && overlayTarget) ? overlayTarget : 'viewport'; + const overlayScope = typeof resolvedTarget === 'string' ? resolvedTarget : 'custom'; + + useEffect(() => { + const overlay = overlayRef.current; + const ownerDocument = overlay?.ownerDocument; + if (!overlay || !ownerDocument) return undefined; + + const container = overlay.parentElement; + const scrollTargets = + overlayScope === 'viewport' ? [ownerDocument.documentElement, ownerDocument.body] : container ? [container] : []; + const previousOverflow = scrollTargets.map((element) => element.style.overflow); + scrollTargets.forEach((element) => { + element.style.overflow = 'hidden'; + }); + + let restoreContainerPosition: (() => void) | undefined; + const containerPosition = container ? ownerDocument.defaultView?.getComputedStyle(container).position : undefined; + if (overlayScope === 'custom' && container && (!containerPosition || containerPosition === 'static')) { + const previousPosition = container.style.position; + container.style.position = 'relative'; + restoreContainerPosition = () => { + container.style.position = previousPosition; + }; + } + + return () => { + scrollTargets.forEach((element, index) => { + element.style.overflow = previousOverflow[index]; + }); + restoreContainerPosition?.(); + }; + }, [overlayScope, resolvedTarget]); + + useEffect(() => { + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : undefined; + dialogRef.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + } + if (event.key === 'Tab' && dialogRef.current && !dialogRef.current.querySelector('button, [href], input')) { + event.preventDefault(); + dialogRef.current.focus(); + } + }; + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + previousFocus?.focus(); + }; + }, []); + + useEffect(() => { + let cancelled = false; + if (reducedMotion || !animationData || !animationRef.current || !loadAnimation) return undefined; + + void loadAnimation(animationRef.current, animationData) + .then((animation) => { + if (cancelled || !animationRef.current) { + animation.destroy(); + return; + } + animationItemRef.current = animation; + syncAnimationPhase(animation, phaseRef.current); + }) + .catch(() => onMediaError()); + + return () => { + cancelled = true; + animationItemRef.current?.destroy(); + animationItemRef.current = undefined; + }; + }, [animationData, loadAnimation, onMediaError, reducedMotion]); + + useEffect(() => { + syncAnimationPhase(animationItemRef.current, phase); + }, [phase]); + + const heading = phase === 'starting' ? 'Relax' : ''; + const message = + phase === 'starting' + ? 'Your 1 minute well-being break is starting in' + : phase === 'ending' + ? 'Transitioning back to work mode in' + : elapsedSeconds >= 40 + ? "In a few moments, you'll return to your day." + : 'This moment is yours.'; + + const overlay = ( +
+
+ +
+ ); + + if (overlayScope === 'assistant' || typeof document === 'undefined') return overlay; + return createPortal(overlay, overlayScope === 'viewport' ? document.body : (resolvedTarget as HTMLElement)); +}; + +export default WellnessBreakModal; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-offer-card.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-offer-card.tsx new file mode 100644 index 000000000..3e72ff050 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-offer-card.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import {Button, Text} from '@momentum-design/components/dist/react'; +import type {WellnessBreakOfferCardProps} from '../ai-assistant.types'; + +export const WELLNESS_BREAK_APPROVAL_COPY = + 'This break is pre-approved by your organization for your well-being. You deserve it.'; + +const WellnessBreakOfferCard: React.FC = ({ + event, + disabled, + showActions = true, + onAccept, + onLater, +}) => { + const actionText = event?.actionText?.trim() || WELLNESS_BREAK_APPROVAL_COPY; + + return ( +
+ + Well-being break scheduled + + + {actionText} + + {showActions ? ( +
+ + +
+ ) : null} +
+ ); +}; + +export default WellnessBreakOfferCard; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-offer-toast.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-offer-toast.tsx new file mode 100644 index 000000000..7a9419c92 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-offer-toast.tsx @@ -0,0 +1,68 @@ +import React, {useEffect, useState} from 'react'; +import {Button, Text, Toast} from '@momentum-design/components/dist/react'; +import CiscoAIAssistantColorIcon from '../CiscoAIAssistantColorIcon'; +import type {WellnessBreakOfferToastProps} from '../ai-assistant.types'; +import {WELLNESS_BREAK_APPROVAL_COPY} from './wellness-break-offer-card'; + +const WellnessBreakOfferToast: React.FC = ({ + visible, + event, + disabled, + onAccept, + onLater, + onDismiss, +}) => { + const [dismissed, setDismissed] = useState(false); + const actionText = event?.actionText?.trim() || WELLNESS_BREAK_APPROVAL_COPY; + + useEffect(() => setDismissed(false), [event]); + + if (!visible || dismissed) return null; + + const actAndDismiss = (action?: () => void) => { + setDismissed(true); + action?.(); + }; + + return ( +
+ actAndDismiss(onDismiss)} + > + + + {actionText} + + + + +
+ ); +}; + +export default WellnessBreakOfferToast; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-request-card.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-request-card.tsx new file mode 100644 index 000000000..2df3e769d --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break-request-card.tsx @@ -0,0 +1,107 @@ +import React, {useState} from 'react'; +import {Button, Text, Tooltip} from '@momentum-design/components/dist/react'; +import CiscoAIAssistantColorIcon from '../CiscoAIAssistantColorIcon'; +import type {WellnessBreakRequestCardProps} from '../ai-assistant.types'; +import { + WELLNESS_NOTICE_COPY, + WELLNESS_SUGGESTION_COPY, + WELLNESS_SUGGESTION_TOOLTIP_FALLBACK, +} from './wellness-break-copy'; +let requestButtonSequence = 0; + +const WellnessBreakRequestCard: React.FC = ({ + phase, + notice, + disabled, + actionText, + onRequest, +}) => { + const [requestButtonId] = useState(() => { + requestButtonSequence += 1; + return `wellness-break-request-${requestButtonSequence}`; + }); + const notificationText = actionText?.trim(); + const tooltip = notificationText || WELLNESS_SUGGESTION_TOOLTIP_FALLBACK; + const noticeText = + notice === 'not-allowed' && notificationText ? notificationText : notice ? WELLNESS_NOTICE_COPY[notice] : ''; + const showRequestConversation = phase === 'request-pending' || notice === 'not-allowed'; + + if (!notice && phase === 'idle') { + return ( +
+ + + {WELLNESS_SUGGESTION_COPY} + + + + + {tooltip} + + +
+ ); + } + + if (showRequestConversation) { + return ( +
+ + Take a break + + {notice ? ( +
+ + + {noticeText} + +
+ ) : null} +
+ ); + } + + return ( +
+ + {notice === 'completed' ? 'Well-being break completed' : 'Take a moment for your well-being'} + + {notice ? ( + + {noticeText} + + ) : null} +
+ ); +}; + +export default WellnessBreakRequestCard; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break.styles.scss b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break.styles.scss new file mode 100644 index 000000000..bec38e80e --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/wellness-break.styles.scss @@ -0,0 +1,399 @@ +.wellness-break-card { + align-self: center; + background: var(--mds-color-theme-background-secondary-normal, rgba(0, 0, 0, 0.04)); + border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal, rgba(0, 0, 0, 0.18)); + border-radius: 0.75rem; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 40rem; + padding: 1rem; + width: 100%; +} + +.wellness-break-card__title, +.wellness-break-card__message, +.wellness-break-error p { + margin: 0; +} + +.wellness-break-card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.wellness-break-suggestion { + align-items: center; + background: var(--mds-color-theme-background-primary-normal, #fff); + box-sizing: border-box; + display: flex; + flex: 1; + flex-direction: column; + inset: 0; + justify-content: center; + min-height: 100%; + overflow-y: auto; + padding: 2.5rem 2rem; + position: absolute; + text-align: center; + z-index: 1; +} + +.wellness-break-suggestion__logo { + display: flex; + margin-bottom: 1.25rem; +} + +.wellness-break-suggestion__message { + color: var(--mds-color-theme-text-secondary-normal); + margin: 0 0 1rem; + max-width: 20rem; +} + +.wellness-break-suggestion > mdc-button { + width: min(100%, 20rem); +} + +.wellness-break-conversation { + align-self: center; + box-sizing: border-box; + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + justify-content: flex-end; + max-width: 40rem; + min-height: 0; + width: 100%; +} + +.wellness-break-conversation__request { + align-self: flex-end; + background: var(--mds-color-theme-button-primary-normal, #000); + border-radius: 0.75rem 0.75rem 0 0.75rem; + color: var(--mds-color-theme-inverted-text-primary-normal, #fff); + margin: 0; + padding: 0.625rem 0.875rem; +} + +.wellness-break-conversation__response { + align-items: flex-start; + display: flex; + gap: 0.5rem; +} + +.wellness-break-conversation__response p { + margin: 0; +} + +.wellness-break-conversation__response-icon { + display: flex; + flex: 0 0 1.25rem; + margin-top: 0.125rem; +} + +.wellness-break-history { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow-y: auto; + padding: 0.25rem; + scroll-behavior: smooth; +} + +.wellness-break-history__offer { + align-items: flex-start; + display: flex; + gap: 0.5rem; +} + +.wellness-break-history__offer .wellness-break-card { + flex: 1; + min-width: 0; +} + +.wellness-break-history__assistant-entry { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.wellness-break-history__assistant-heading { + align-items: flex-start; + display: flex; + gap: 0.5rem; +} + +.wellness-break-history__assistant-heading h3 { + margin: 0; +} + +.wellness-break-history__assistant-icon { + display: flex; + flex: 0 0 1.25rem; + margin-top: 0.125rem; +} + +.wellness-break-history__card { + background: var(--mds-color-theme-background-secondary-normal, rgba(0, 0, 0, 0.04)); + border-radius: 0.5rem; + margin-inline-start: 1.75rem; + padding: 0.75rem; +} + +.wellness-break-history__card p { + margin: 0; +} + +.wellness-break-history__card time { + border-top: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal, rgba(0, 0, 0, 0.18)); + color: var(--mds-color-theme-text-secondary-normal); + display: block; + font-size: 0.75rem; + margin-top: 0.75rem; + padding-top: 0.5rem; + text-align: end; +} + +.wellness-break-history__user-action { + align-self: flex-end; + background: var(--mds-color-theme-button-primary-normal, #000); + border-radius: 0.75rem 0.75rem 0 0.75rem; + color: var(--mds-color-theme-inverted-text-primary-normal, #fff); + margin: 0; + padding: 0.625rem 0.875rem; +} + +.wellness-break-toast-anchor { + max-width: calc(100vw - 2rem); + position: fixed; + right: 1rem; + top: 1rem; + z-index: 1100; +} + +.wellness-break-toast-anchor > mdc-toast { + --mdc-toast-width: min(24rem, calc(100vw - 2rem)); +} + +.wellness-break-toast__icon { + display: flex; +} + +.wellness-break-error { + color: var(--mds-color-theme-text-error-normal, #b50000); + padding: 0.75rem; +} + +.wellness-break-overlay { + background: var(--mds-color-theme-common-overlays-secondary-normal, rgba(0, 0, 0, 0.4)); + box-sizing: border-box; + display: flex; + inset: 0; + padding-block-start: 4rem; + padding-inline-start: 3.5rem; + position: fixed; + z-index: 10000; +} + +.wellness-break-overlay--assistant, +.wellness-break-overlay--custom { + padding: 0; + position: absolute; +} + +.wellness-break-modal { + align-items: center; + background: var(--mds-color-theme-background-primary-normal, #fff); + border-radius: 0.5rem; + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + overflow: hidden; + position: relative; + text-align: center; + width: 100%; + + &:focus { + outline: none; + } + + &::after { + background: linear-gradient( + color-mix(in srgb, var(--mds-color-theme-background-primary-normal, #fff) 20%, transparent), + color-mix(in srgb, var(--mds-color-theme-background-primary-normal, #fff) 60%, transparent) + ); + content: ''; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + transition: opacity 0.5s; + z-index: 1; + } +} + +.wellness-break-modal--starting::after, +.wellness-break-modal--ending::after { + opacity: 1; +} + +.wellness-break-modal__animation { + height: 100%; + inset: 0; + position: absolute; + width: 100%; + + svg { + height: 100% !important; + width: 100% !important; + } +} + +.wellness-break-modal__animation--static { + background: + radial-gradient( + ellipse at 50% 110%, + color-mix(in srgb, var(--mds-color-theme-background-primary-normal, #fff) 52%, transparent) 0 30%, + transparent 31% + ), + linear-gradient( + 110deg, + var(--mds-color-theme-background-accent-normal, #c9c4ef), + var(--mds-color-theme-background-accent-light, #bfeaf4) + ); +} + +.wellness-break-modal__content { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + z-index: 2; +} + +.wellness-break-modal__title { + color: var(--mds-color-theme-text-primary-normal, #121212); + font-size: 2.5rem; + font-weight: 500; + line-height: 3.25rem; + margin: 0; +} + +.wellness-break-modal__countdown-row { + align-items: baseline; + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-block-start: 1rem; +} + +.wellness-break-modal__countdown-message, +.wellness-break-modal__playing-message { + color: var(--mds-color-theme-text-primary-normal, #121212); + font-weight: 400; + margin: 0; +} + +.wellness-break-modal__countdown-message { + font-size: 1.5rem; + line-height: 2rem; +} + +.wellness-break-modal__playing-message { + font-size: 2rem; + line-height: 2.5rem; +} + +.wellness-break-modal__countdown { + display: flex; +} + +.wellness-break-modal__countdown-digit { + color: var(--mds-color-theme-text-primary-disabled, rgba(18, 18, 18, 0.35)); + font-size: 1.5rem; + font-weight: 700; + line-height: 2rem; + margin-inline-start: 1rem; +} + +.wellness-break-modal__countdown-digit--active { + color: var(--mds-color-theme-text-primary-normal, #121212); +} + +.wellness-break-modal__progress { + background: var(--mds-color-theme-background-secondary-normal, #ddd); + border-radius: 0.1875rem; + bottom: 5%; + height: 0.375rem; + min-width: 7rem; + overflow: hidden; + position: absolute; + width: 11%; + z-index: 2; +} + +.wellness-break-modal__progress span { + background: var(--mds-color-theme-text-accent-normal, #0051af); + display: block; + height: 100%; + transition: width 1s linear; +} + +.sr-only { + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (prefers-reduced-motion: reduce) { + .wellness-break-modal::after, + .wellness-break-modal__progress span { + transition: none; + } +} + +@media (max-width: 48rem) { + .wellness-break-overlay { + padding: 1rem; + } + + .wellness-break-modal__content { + padding: 1.5rem; + } + + .wellness-break-modal__title { + font-size: 2rem; + line-height: 2.5rem; + } + + .wellness-break-modal__countdown-message, + .wellness-break-modal__countdown-digit { + font-size: 1.125rem; + line-height: 1.5rem; + } + + .wellness-break-modal__countdown-digit { + margin-inline-start: 0.625rem; + } + + .wellness-break-modal__playing-message { + font-size: 1.5rem; + line-height: 2rem; + } + + .wellness-break-modal__progress { + width: 35%; + } +} diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss index b9912aa17..99aa5ce63 100644 --- a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss @@ -6,9 +6,14 @@ flex-direction: column; font-family: var(--mds-font-family-primary, CiscoSans, 'Helvetica Neue', Arial, sans-serif); padding: 0.75rem 0.5rem; + position: relative; width: 100%; } +.ai-assistant--wellness-overlay { + min-height: min(32rem, 90vh); +} + .ai-assistant__launcher { align-items: center; align-self: flex-start; @@ -110,6 +115,12 @@ padding: 0; } +.ai-assistant__body--wellness-suggestion { + isolation: isolate; + overflow: hidden; + position: relative; +} + .ai-assistant__landing { align-items: center; box-sizing: border-box; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx index b87fb7105..5b5188529 100644 --- a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx @@ -1,12 +1,24 @@ -import React from 'react'; -import {Button, Text} from '@momentum-design/components/dist/react'; +import React, {useState} from 'react'; +import {Button, Text, Tooltip} from '@momentum-design/components/dist/react'; import {withMetrics} from '@webex/cc-ui-logging'; import RealTimeAssist from './RealTimeAssist/real-time-assist'; import AIAssistantLanding from './ai-assistant-landing'; import CiscoAIAssistantColorIcon from './CiscoAIAssistantColorIcon'; -import {AIAssistantComponentProps} from './ai-assistant.types'; +import WellnessBreakError from './WellnessBreak/wellness-break-error'; +import WellnessBreakHistory from './WellnessBreak/wellness-break-history'; +import WellnessBreakModal from './WellnessBreak/wellness-break-modal'; +import WellnessBreakOfferCard from './WellnessBreak/wellness-break-offer-card'; +import WellnessBreakOfferToast from './WellnessBreak/wellness-break-offer-toast'; +import WellnessBreakRequestCard from './WellnessBreak/wellness-break-request-card'; +import {AIAssistantComponentProps, WellnessBreakModalProps, WellnessBreakViewModel} from './ai-assistant.types'; import {AI_ASSISTANT_TITLE, DISCLAIMER_TEXT} from './constants'; import './ai-assistant.styles.scss'; +import './WellnessBreak/wellness-break.styles.scss'; + +const isWellnessOverlayPhase = (phase: WellnessBreakViewModel['phase']): phase is WellnessBreakModalProps['phase'] => + phase === 'starting' || phase === 'playing' || phase === 'ending'; + +let assistantHeaderSequence = 0; const AIAssistantComponent: React.FC = ({ chrome, @@ -25,19 +37,76 @@ const AIAssistantComponent: React.FC = ({ minimize, restore, toggleFullScreen, + clearContent = () => undefined, + hasClearableContent = false, requestRealTimeAssist, setContextDraft, submitContext, onRealTimeAssistAction, logger, className, + wellnessBreakOverlayTarget, + wellness, + loadWellnessAnimation, }) => { + const [headerActionId] = useState(() => { + assistantHeaderSequence += 1; + return `ai-assistant-header-${assistantHeaderSequence}`; + }); // Fullscreen is consumer-owned: we emit onFullScreenToggle; the host owns layout. - const rootClass = ['ai-assistant', className || ''].filter(Boolean).join(' '); + const wellnessOverlayPhase = wellness && isWellnessOverlayPhase(wellness.phase) ? wellness.phase : undefined; + const rootClass = [ + 'ai-assistant', + wellnessOverlayPhase && wellnessBreakOverlayTarget === 'assistant' && 'ai-assistant--wellness-overlay', + className, + ] + .filter(Boolean) + .join(' '); const panelClass = ['ai-assistant__panel', isFullScreen ? 'ai-assistant__panel--full-screen' : ''] .filter(Boolean) .join(' '); const showLanding = !hasActiveInteraction || !isFeatureEnabled; + const wellnessHistory = wellness?.history ?? []; + const hasWellnessHistory = Boolean(wellness?.enabled && !wellness.contentCleared && wellnessHistory.length > 0); + // Desktop treats the suggested CTA as an empty-state action. It is only + // eligible when normal assistant content is not active; once eligible, the + // wellness experience owns the body instead of stacking above the landing. + const showWellnessSuggestion = Boolean( + wellness?.enabled && + !wellness.contentCleared && + showLanding && + wellness.phase === 'idle' && + wellness.requestAvailable && + !wellness.notice + ); + // Keep prior messages in the view model, but let a newly eligible Desktop-style + // suggestion temporarily own the complete assistant body. + const showWellnessHistory = hasWellnessHistory && !showWellnessSuggestion; + const showWellnessRequestState = Boolean( + wellness?.enabled && !wellness.contentCleared && (wellness.phase === 'request-pending' || wellness.notice) + ); + const showWellnessOffer = Boolean( + wellness?.enabled && !wellness.contentCleared && wellness.phase === 'offer-pending' + ); + const showWellnessStatus = Boolean( + wellness?.enabled && + !wellness.contentCleared && + ['changing-to-break', 'waiting-for-safe-state', 'restoring'].includes(wellness.phase) + ); + const showWellnessError = Boolean(wellness?.enabled && !wellness.contentCleared && wellness.phase === 'error'); + const showWellnessOverlay = Boolean(wellnessOverlayPhase); + const showWellnessContent = Boolean( + showWellnessSuggestion || + showWellnessHistory || + showWellnessRequestState || + showWellnessOffer || + showWellnessStatus || + showWellnessError || + showWellnessOverlay + ); + const showFooter = showWellnessContent + ? Boolean(!showWellnessSuggestion && !showWellnessOverlay && isFeatureEnabled) + : !showLanding; return (
@@ -61,6 +130,7 @@ const AIAssistantComponent: React.FC = ({
@@ -87,8 +164,28 @@ const AIAssistantComponent: React.FC = ({ {AI_ASSISTANT_TITLE} -
+
- {showLanding ? ( - - ) : ( - wellness.onAccept('card')} + onLater={() => wellness.onLater('card')} + /> + ) : null} + {(showWellnessSuggestion || (showWellnessRequestState && !showWellnessHistory)) && wellness ? ( + + ) : null} + {showWellnessOffer && !showWellnessHistory && wellness ? ( + wellness.onAccept('card')} + onLater={() => wellness.onLater('card')} /> - )} + ) : null} + {showWellnessStatus && !showWellnessHistory && wellness ? ( +
+ + {wellness.phase === 'restoring' + ? 'Restoring your status…' + : wellness.phase === 'waiting-for-safe-state' + ? wellness.hasBlockingTasks + ? 'Great. Your well-being break starts right after your current work.' + : 'Great. Your well-being break will begin shortly.' + : 'Great. Your well-being break will begin shortly.'} + +
+ ) : null} + {showWellnessError && wellness ? : null} + {!showWellnessContent ? ( + showLanding ? ( + + ) : ( + + ) + ) : null}
- {showLanding ? null : ( + {showFooter ? (
= ({ {DISCLAIMER_TEXT}
- )} + ) : null}
)} + {wellnessOverlayPhase && wellness ? ( + + ) : null} + {showWellnessOffer && wellness ? ( + wellness.onAccept('notification')} + onLater={() => wellness.onLater('notification')} + onDismiss={wellness.onDismissNotification} + /> + ) : null} ); }; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts index edd7a268a..d4b6ff0c8 100644 --- a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts @@ -1,4 +1,10 @@ -import type {ILogger, RealTimeAssistPayload} from '@webex/cc-store'; +import type { + ILogger, + RealTimeAssistPayload, + WellnessBreakError, + WellnessBreakEvent, + WellnessBreakPhase, +} from '@webex/cc-store'; /** Visual state of the AI Assistant panel chrome. */ export type AIAssistantChromeState = 'closed' | 'open' | 'minimized'; @@ -6,6 +12,75 @@ export type AIAssistantChromeState = 'closed' | 'open' | 'minimized'; /** Lifecycle state of a real-time assist request. */ export type AIAssistantRequestStatus = 'idle' | 'listening' | 'ready' | 'error'; +export type WellnessBreakNotice = 'declined' | 'not-allowed' | 'no-response' | 'completed'; +export type WellnessBreakResponseSource = 'card' | 'notification'; +/** Where the active wellness-break overlay is rendered. */ +export type WellnessBreakOverlayTarget = 'viewport' | 'assistant' | HTMLElement; + +export interface WellnessAnimationController { + totalFrames: number; + goToAndPlay(frame: number, isFrame: boolean): void; + goToAndStop(frame: number, isFrame: boolean): void; + destroy(): void; +} + +export type WellnessAnimationLoader = ( + container: HTMLElement, + animationData: unknown +) => Promise; + +interface WellnessBreakHistoryEntryBase { + id: string; + createdAt: number; +} + +/** One persisted message in the Desktop-style wellness transcript. */ +export type WellnessBreakHistoryEntry = + | (WellnessBreakHistoryEntryBase & { + type: 'offer'; + event: WellnessBreakEvent; + actionable: boolean; + }) + | (WellnessBreakHistoryEntryBase & { + type: 'user-action'; + action: 'take-break' | 'later'; + }) + | (WellnessBreakHistoryEntryBase & { + type: 'acknowledgement'; + hasBlockingTasks: boolean; + }) + | (WellnessBreakHistoryEntryBase & { + type: 'notice'; + notice: WellnessBreakNotice; + actionText?: string; + }); + +/** Presentational contract supplied by the AI Assistant wellness orchestrator. */ +export interface WellnessBreakViewModel { + enabled: boolean; + phase: WellnessBreakPhase; + event?: WellnessBreakEvent; + error?: WellnessBreakError; + notice?: WellnessBreakNotice; + requestAvailable: boolean; + /** Chronological wellness messages retained until the agent presses Clear. */ + history?: WellnessBreakHistoryEntry[]; + /** Whether current notification content was explicitly cleared from the panel. */ + contentCleared?: boolean; + /** Whether an active task is currently delaying an accepted break. */ + hasBlockingTasks: boolean; + countdown?: number; + elapsedSeconds: number; + animationData?: unknown; + reducedMotion: boolean; + onRequest: () => void; + onAccept: (source?: WellnessBreakResponseSource) => void; + onLater: (source?: WellnessBreakResponseSource) => void; + onClearHistory?: () => void; + onDismissNotification?: () => void; + onMediaError: () => void; +} + /** * A single entry rendered in the chat transcript. User entries are agent * input, assistant entries wrap a `RealTimeAssistPayload` from the SDK, @@ -54,6 +129,10 @@ export interface AIAssistantComponentProps { restore: () => void; /** Toggle the fullscreen affordance; layout is host-owned. */ toggleFullScreen: () => void; + /** Clear the displayed assistant and wellness transcript. */ + clearContent?: () => void; + /** Whether the Clear control has displayed content to remove. */ + hasClearableContent?: boolean; /** Fire a no-context `GET_SUGGESTIONS` request. */ requestRealTimeAssist: () => void; /** Update the context input value. */ @@ -66,6 +145,54 @@ export interface AIAssistantComponentProps { logger?: ILogger; /** Extra class applied to the widget root. */ className?: string; + /** Viewport by default; may be scoped to the assistant or portalled into a host element. */ + wellnessBreakOverlayTarget?: WellnessBreakOverlayTarget; + /** Independent Agent Wellness Break cards and overlay. */ + wellness?: WellnessBreakViewModel; + /** Optional renderer supplied by the AI Assistant widget. */ + loadWellnessAnimation?: WellnessAnimationLoader; +} + +export interface WellnessBreakRequestCardProps { + phase: WellnessBreakPhase; + notice?: WellnessBreakNotice; + disabled: boolean; + actionText?: string; + onRequest: () => void; +} + +export interface WellnessBreakOfferCardProps { + event?: WellnessBreakEvent; + disabled: boolean; + showActions?: boolean; + onAccept: () => void; + onLater: () => void; +} + +export interface WellnessBreakHistoryProps { + entries: WellnessBreakHistoryEntry[]; + onAccept: () => void; + onLater: () => void; +} + +export interface WellnessBreakOfferToastProps extends WellnessBreakOfferCardProps { + visible: boolean; + onDismiss?: () => void; +} + +export interface WellnessBreakModalProps { + phase: Extract; + countdown?: number; + elapsedSeconds: number; + animationData?: unknown; + reducedMotion: boolean; + onMediaError: () => void; + overlayTarget?: WellnessBreakOverlayTarget; + loadAnimation?: WellnessAnimationLoader; +} + +export interface WellnessBreakErrorProps { + error?: WellnessBreakError; } export interface RealTimeAssistProps { diff --git a/packages/contact-center/cc-components/src/index.ts b/packages/contact-center/cc-components/src/index.ts index 96816fdef..98ed446b3 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -11,6 +11,11 @@ import CampaignTaskComponent from './components/task/CampaignTask/campaign-task' import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript'; import E911Modal from './components/StationLogin/E911Modal/e911-modal'; import AIAssistantComponent from './components/AIAssistant/ai-assistant'; +import WellnessBreakError from './components/AIAssistant/WellnessBreak/wellness-break-error'; +import WellnessBreakModal from './components/AIAssistant/WellnessBreak/wellness-break-modal'; +import WellnessBreakOfferCard from './components/AIAssistant/WellnessBreak/wellness-break-offer-card'; +import WellnessBreakOfferToast from './components/AIAssistant/WellnessBreak/wellness-break-offer-toast'; +import WellnessBreakRequestCard from './components/AIAssistant/WellnessBreak/wellness-break-request-card'; import TelephonyActionToast from './components/task/TelephonyActionToast/telephony-action-toast'; import WxAppOfferActionError from './components/task/WxAppOfferActionError/wxapp-offer-action-error'; @@ -28,6 +33,11 @@ export { RealTimeTranscriptComponent, E911Modal, AIAssistantComponent, + WellnessBreakRequestCard, + WellnessBreakOfferCard, + WellnessBreakOfferToast, + WellnessBreakModal, + WellnessBreakError, TelephonyActionToast, WxAppOfferActionError, }; diff --git a/packages/contact-center/cc-components/tests/components/AIAssistant/__snapshots__/ai-assistant.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/AIAssistant/__snapshots__/ai-assistant.snapshot.tsx.snap index efd6f9818..226b3168d 100644 --- a/packages/contact-center/cc-components/tests/components/AIAssistant/__snapshots__/ai-assistant.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/AIAssistant/__snapshots__/ai-assistant.snapshot.tsx.snap @@ -23,22 +23,46 @@ exports[`AIAssistantComponent snapshots matches a full-screen RealTimeAssist con
+ + + Clear + + + Minimize + + + Exit full screen + + + Close +
+ + + Clear + + + Minimize + + + Full screen + + + Close +
+ + Restore + + + Close +
@@ -801,22 +857,46 @@ exports[`AIAssistantComponent snapshots matches the open RealTimeAssist empty st
+ + + Clear + + + Minimize + + + Full screen + + + Close +
= {}): AIAssi requestRealTimeAssist: jest.fn(), setContextDraft: jest.fn(), submitContext: jest.fn(), + clearContent: jest.fn(), + hasClearableContent: false, ...overrides, }); @@ -79,6 +81,65 @@ describe('AIAssistantComponent', () => { expect(props.close).toHaveBeenCalledTimes(2); }); + it('renders Clear as the first header action and only enables it for assistant content', () => { + const props = createProps({hasClearableContent: true}); + render(); + + const actions = screen.getByTestId('ai-assistant:header-actions'); + expect(actions.firstElementChild).toBe(screen.getByTestId('ai-assistant:header-clear')); + expect(screen.getByText('Clear')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('ai-assistant:header-clear')); + expect(props.clearContent).toHaveBeenCalledTimes(1); + }); + + it('renders persistent wellness history across close and reopen until Clear is pressed', () => { + const history = [ + { + type: 'offer' as const, + id: 'offer-1', + createdAt: 1, + actionable: false, + event: { + agentId: 'agent-1', + orgId: 'org-1', + agentSessionId: 'notification-session', + actionEvent: 'PROVIDE_WELLNESS_BREAK' as const, + actionText: 'This break is pre-approved by your organization.', + }, + }, + {type: 'user-action' as const, id: 'action-1', createdAt: 2, action: 'take-break' as const}, + {type: 'acknowledgement' as const, id: 'ack-1', createdAt: 3, hasBlockingTasks: false}, + {type: 'notice' as const, id: 'done-1', createdAt: 4, notice: 'completed' as const}, + ]; + const wellness = { + enabled: true, + phase: 'idle' as const, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + history, + contentCleared: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onClearHistory: jest.fn(), + onMediaError: jest.fn(), + }; + const props = createProps({wellness, hasClearableContent: true}); + const {rerender} = render(); + + expect(screen.getByText('Well-being break scheduled')).toBeInTheDocument(); + expect(screen.getByText('Take a break')).toBeInTheDocument(); + expect(screen.getByText('Great. Your well-being break will begin shortly.')).toBeInTheDocument(); + expect(screen.getByText('Well-being break completed')).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('Well-being break completed')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText('Well-being break completed')).toBeInTheDocument(); + }); + it('renders the empty state and requests a suggestion', () => { const props = createProps(); render(); @@ -104,8 +165,16 @@ describe('AIAssistantComponent', () => { it('shows Real-time Assist on the landing page before an interaction starts', () => { render(); - expect(screen.getByTestId('ai-assistant:landing')).toBeInTheDocument(); - expect(screen.getByText('Real-time Assist')).toBeInTheDocument(); + const landing = screen.getByTestId('ai-assistant:landing'); + expect(landing).toBeInTheDocument(); + expect(landing).toHaveTextContent('✨Real-time Assist'); + expect(landing).toHaveTextContent('Real-time guidance to help you to respond to the customer'); + expect(landing).toHaveTextContent('🪷Wellness breaks'); + expect(landing).toHaveTextContent('Ensuring you get those well needed breaks'); + expect(landing).toHaveTextContent('✍🏻Smart summaries'); + expect(landing).toHaveTextContent( + 'Focus on the conversation while we capture the context - covering AI handoffs, transfers & consults, dropped conversations, and wrap-up.' + ); expect(screen.queryByTestId('ai-assistant:empty')).not.toBeInTheDocument(); }); @@ -256,4 +325,592 @@ describe('AIAssistantComponent', () => { expect(screen.getByTestId('ai-assistant:panel')).toHaveClass('ai-assistant__panel--full-screen'); expect(screen.getByTestId('ai-assistant:header-fullscreen')).toHaveAttribute('aria-label', 'Exit full screen'); }); + + it('renders wellness offer and eligible suggestion without stacking normal assistant content', () => { + const onRequest = jest.fn(); + const onAccept = jest.fn(); + const onLater = jest.fn(); + const wellness = { + enabled: true, + phase: 'offer-pending' as const, + event: { + agentId: 'agent-1', + orgId: 'org-1', + agentSessionId: 'session-1', + actionEvent: 'PROVIDE_WELLNESS_BREAK' as const, + }, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + onRequest, + onAccept, + onLater, + onMediaError: jest.fn(), + }; + const {rerender} = render(); + + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('wellness-break:accept')); + fireEvent.click(screen.getByTestId('wellness-break:later')); + expect(onAccept).toHaveBeenCalledTimes(1); + expect(onLater).toHaveBeenCalledTimes(1); + + rerender( + + ); + expect( + screen.getByText("Looks like it's a busy day. Here's how I can help you stay focussed and on top of your game") + ).toBeInTheDocument(); + expect(screen.getByText('This is your approved break.')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('wellness-break:request')); + expect(onRequest).toHaveBeenCalledTimes(1); + }); + + it('lets an eligible suggestion fill the assistant body while retaining prior wellness history', () => { + const history = [ + { + type: 'notice' as const, + id: 'completed-1', + createdAt: 1, + notice: 'completed' as const, + }, + ]; + const wellness = { + enabled: true, + phase: 'idle' as const, + event: { + agentId: 'agent-1', + orgId: 'org-1', + agentSessionId: 'notification-session', + actionEvent: 'SUGGEST_WELLNESS_BREAK' as const, + }, + requestAvailable: true, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + history, + contentCleared: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onClearHistory: jest.fn(), + onMediaError: jest.fn(), + }; + const props = createProps({isFeatureEnabled: false, wellness}); + const {rerender} = render(); + + expect(screen.getByTestId('ai-assistant:body')).toHaveClass( + 'ai-assistant__body--landing', + 'ai-assistant__body--wellness-suggestion' + ); + expect(screen.getByTestId('wellness-break:request-card')).toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:history')).not.toBeInTheDocument(); + expect(screen.queryByText('Well-being break completed')).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId('ai-assistant:body')).not.toHaveClass('ai-assistant__body--wellness-suggestion'); + expect(screen.getByTestId('wellness-break:history')).toBeInTheDocument(); + expect(screen.getByText('Well-being break completed')).toBeInTheDocument(); + }); + + it('keeps a suggestion hidden while interaction content has priority', () => { + render( + + ); + + expect(screen.queryByTestId('wellness-break:request')).not.toBeInTheDocument(); + expect(screen.getByTestId('ai-assistant:empty')).toBeInTheDocument(); + }); + + it('shows request results exclusively and does not restore the manual CTA for completion', () => { + const wellness = { + enabled: true, + phase: 'request-pending' as const, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + const {rerender} = render(); + + expect(screen.getByText('Take a break')).toBeInTheDocument(); + expect(screen.queryByText('Request pending')).not.toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:request')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + + const completedWellness = { + ...wellness, + phase: 'idle' as const, + notice: 'completed' as const, + requestAvailable: true, + }; + rerender(); + + expect(screen.getByText('Well-being break completed')).toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:request')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText('Well-being break completed')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Well-being break completed')).toBeInTheDocument(); + }); + + it('shows backend denial action text and falls back to the approved copy when it is blank', () => { + const wellness = { + enabled: true, + phase: 'idle' as const, + event: { + agentId: 'agent-1', + orgId: 'org-1', + agentSessionId: 'session-1', + actionEvent: 'WELLNESS_BREAK_NOT_ALLOWED' as const, + actionText: 'Sorry, you have already reached your limit for today.', + }, + notice: 'not-allowed' as const, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + const {rerender} = render(); + + expect(screen.getByText('Sorry, you have already reached your limit for today.')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:request')).not.toBeInTheDocument(); + + rerender( + + ); + + expect( + screen.getByText( + "I'm sorry, you've reached your well-being break limit today. Continue with your tasks, but remember to take care of yourself." + ) + ).toBeInTheDocument(); + }); + + it('keeps the accessible wellness overlay mounted while assistant chrome is closed', () => { + render( + + ); + + expect(screen.getByTestId('wellness-break:overlay')).toBeInTheDocument(); + expect(screen.getByRole('dialog', {name: 'Relax'})).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByLabelText('5 seconds')).toBeInTheDocument(); + expect(screen.getByTestId('wellness-break:surface')).toContainElement( + screen.getByTestId('wellness-break:animation') + ); + expect(screen.getByTestId('wellness-break:countdown')).toHaveTextContent('54321'); + expect(screen.getByText('5')).toHaveClass('wellness-break-modal__countdown-digit--active'); + }); + + it('uses an injected animation loader only during an animated break', async () => { + const animationData = {v: '5.0'}; + const animation = { + totalFrames: 20, + goToAndPlay: jest.fn(), + goToAndStop: jest.fn(), + destroy: jest.fn(), + }; + const loadWellnessAnimation = jest.fn().mockResolvedValue(animation); + const wellness = { + enabled: true, + phase: 'starting' as const, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + animationData, + reducedMotion: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + const {rerender, unmount} = render( + + ); + + await waitFor(() => expect(loadWellnessAnimation).toHaveBeenCalledWith(expect.any(HTMLDivElement), animationData)); + expect(animation.goToAndStop).toHaveBeenCalledWith(0, true); + + rerender( + + ); + expect(animation.goToAndPlay).toHaveBeenCalledWith(0, true); + + rerender( + + ); + expect(animation.goToAndStop).toHaveBeenCalledWith(19, true); + expect(loadWellnessAnimation).toHaveBeenCalledTimes(1); + unmount(); + expect(animation.destroy).toHaveBeenCalledTimes(1); + }); + + it('locks and restores document scrolling for the default viewport overlay', () => { + document.documentElement.style.overflow = 'auto'; + document.body.style.overflow = 'scroll'; + const transformedHost = document.createElement('div'); + transformedHost.style.transform = 'translateZ(0)'; + document.body.append(transformedHost); + + const {unmount} = render( + , + {container: transformedHost} + ); + + const overlay = screen.getByTestId('wellness-break:overlay'); + expect(overlay).toHaveClass('wellness-break-overlay--viewport'); + expect(overlay.parentElement).toBe(document.body); + expect(transformedHost).not.toContainElement(overlay); + expect(document.documentElement.style.overflow).toBe('hidden'); + expect(document.body.style.overflow).toBe('hidden'); + + unmount(); + expect(document.documentElement.style.overflow).toBe('auto'); + expect(document.body.style.overflow).toBe('scroll'); + document.documentElement.style.removeProperty('overflow'); + document.body.style.removeProperty('overflow'); + transformedHost.remove(); + }); + + it('keeps an assistant-scoped overlay canvas while chrome is closed or minimized', () => { + const wellness = { + enabled: true, + phase: 'playing' as const, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 10, + reducedMotion: true, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + const props = createProps({chrome: 'closed', wellnessBreakOverlayTarget: 'assistant', wellness}); + const {rerender} = render(); + + const root = screen.getByTestId('ai-assistant:root'); + const overlay = screen.getByTestId('wellness-break:overlay'); + expect(root).toHaveClass('ai-assistant--wellness-overlay'); + expect(overlay).toHaveClass('wellness-break-overlay--assistant'); + expect(overlay.parentElement).toBe(root); + + rerender(); + expect(root).toHaveClass('ai-assistant--wellness-overlay'); + expect(screen.getByTestId('wellness-break:overlay').parentElement).toBe(root); + + rerender(); + expect(root).not.toHaveClass('ai-assistant--wellness-overlay'); + expect(screen.queryByTestId('wellness-break:overlay')).not.toBeInTheDocument(); + }); + + it('can portal the wellness overlay into a custom container and restores its styles', () => { + const customTarget = document.createElement('section'); + customTarget.style.overflow = 'auto'; + document.body.append(customTarget); + + const {unmount} = render( + + ); + + const overlay = screen.getByTestId('wellness-break:overlay'); + expect(overlay).toHaveClass('wellness-break-overlay--custom'); + expect(overlay.parentElement).toBe(customTarget); + expect(customTarget.style.position).toBe('relative'); + expect(customTarget.style.overflow).toBe('hidden'); + + unmount(); + expect(customTarget.style.position).toBe(''); + expect(customTarget.style.overflow).toBe('auto'); + customTarget.remove(); + }); + + it('renders the ongoing Desktop-style message and progress over the full break surface', () => { + render( + + ); + + const surface = screen.getByTestId('wellness-break:surface'); + expect(surface).toContainElement(screen.getByText('This moment is yours.')); + expect(surface).toContainElement(screen.getByRole('progressbar', {name: 'Well-being break progress'})); + }); + + it('uses only the inline countdown message while the break is ending', () => { + render( + + ); + + expect(screen.getByText('Transitioning back to work mode in')).toBeInTheDocument(); + expect(screen.queryByRole('heading', {name: 'Transitioning back to work mode'})).not.toBeInTheDocument(); + expect(screen.getByTestId('wellness-break:countdown')).toHaveTextContent('54321'); + expect(screen.getByText('3')).toHaveClass('wellness-break-modal__countdown-digit--active'); + }); + + it('shows an actionable offer toast while the panel is closed', () => { + const onAccept = jest.fn(); + const onDismissNotification = jest.fn(); + const {rerender} = render( + + ); + + expect(screen.getByTestId('wellness-break:offer-toast')).toHaveTextContent('Your approved break is ready.'); + fireEvent.click(screen.getByTestId('wellness-break:toast-accept')); + expect(onAccept).toHaveBeenCalledTimes(1); + expect(onAccept).toHaveBeenCalledWith('notification'); + expect(screen.queryByTestId('wellness-break:offer-toast')).not.toBeInTheDocument(); + + // A newly delivered offer remounts the actionable notification state. + rerender( + + ); + expect(screen.getByTestId('wellness-break:offer-toast')).toHaveTextContent('Another approved break is ready.'); + const toast = screen.getByTestId('wellness-break:offer-toast').querySelector('mdc-toast'); + expect(toast).toBeTruthy(); + fireEvent(toast as Element, new CustomEvent('close')); + expect(onDismissNotification).toHaveBeenCalledTimes(1); + }); + + it('keeps the offer in the panel without duplicating the toast when open', () => { + render( + + ); + + expect(screen.getByTestId('wellness-break:offer-card')).toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:offer-toast')).not.toBeInTheDocument(); + }); + + it('only mentions current work when a task is actually blocking the break', () => { + const wellness = { + enabled: true, + phase: 'waiting-for-safe-state' as const, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + const {rerender} = render(); + + expect(screen.getByTestId('wellness-break:status')).toHaveTextContent('will begin shortly'); + expect(screen.queryByText(/current work/)).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId('wellness-break:status')).toHaveTextContent('right after your current work'); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx b/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx index c91712b58..4c4316c06 100644 --- a/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx +++ b/packages/contact-center/cc-components/tests/components/UserState/user-state.tsx @@ -144,6 +144,25 @@ describe('UserStateComponent', () => { const elapsedTimeElement = screen.getByTestId('elapsed-time'); expect(elapsedTimeElement).toHaveClass('elapsedTime elapsedTime-disabled'); }); + + it('renders WellbeingBreak with the running state timer when supplied as the current system code', async () => { + let screen; + await act(async () => { + screen = render( + + ); + }); + + expect(screen.getByTestId('state-name')).toHaveTextContent('WellbeingBreak'); + expect(screen.getByTestId('elapsed-time')).toHaveTextContent('30:00 / 01:01:01'); + }); }); describe('Utility Function Calls', () => { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index 64d68b9b2..3b2008649 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -886,7 +886,7 @@ describe('CallControl Utils', () => { describe('applyWxAppTelephonyControlVisibility', () => { const wxAppTask = { getWebexCallingCallId: () => 'call-123', - } as ITask; + } as unknown as ITask; const baseButtons = buildCallControlButtons( false, diff --git a/packages/contact-center/cc-components/webpack.config.js b/packages/contact-center/cc-components/webpack.config.js index 869bed63a..e80cc5935 100644 --- a/packages/contact-center/cc-components/webpack.config.js +++ b/packages/contact-center/cc-components/webpack.config.js @@ -23,7 +23,7 @@ module.exports = mergeWithCustomize({ path: path.resolve(__dirname, 'dist'), filename: '[name].js', libraryTarget: 'commonjs2', - publicPath: '', + publicPath: 'auto', }, externals: { react: 'react', 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 b0c4c4dbd..3e5db4587 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 @@ -11,7 +11,7 @@ | Doc kind | Module spec | | Coverage score | Pending coverage assessment | | Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | -| generated_by / approved_by / updated_at | generated_by `migration agent` / approved_by `pending` / updated_at `2026-06-29` | +| generated_by / approved_by / updated_at | generated_by `migration agent` / approved_by `pending` / updated_at `2026-09-04` | | Validation status | not-run | ## Evidence Rules @@ -31,9 +31,9 @@ as approved unknowns only when the human explicitly defers or does not know. ## Overview `cc-widgets` is the aggregator and distribution surface for the Webex Contact Center widget suite. It owns no widget UI or business logic of its own; instead it re-exports the React components produced by -the individual widget packages (`@webex/cc-station-login`, `@webex/cc-user-state`, `@webex/cc-task`, -`@webex/cc-digital-channels`) plus the shared MobX `store`, and it converts those same React components -into framework-agnostic custom elements via `@r2wc/react-to-web-component` (r2wc). +the individual widget packages (`@webex/cc-station-login`, `@webex/cc-user-state`, +`@webex/cc-task`, `@webex/cc-digital-channels`, `@webex/cc-ai-assistant`) plus the shared MobX `store`. +Its Web Component entry registers AI Assistant through `@r2wc/react-to-web-component` (r2wc). The package has exactly two source files. `src/index.ts` is the React entry point (`main` / `dist/index.js`): a re-export barrel that also imports Momentum UI base CSS so React consumers get @@ -73,7 +73,8 @@ packages/contact-center/cc-widgets/src/ ## Public Surface Two consumption modes share one package: React named imports from `@webex/cc-widgets`, and custom -elements registered by importing `@webex/cc-widgets/wc`. Widget prop/event contracts are owned by the +elements registered by importing `@webex/cc-widgets/wc`. React hosts can also import AI Assistant directly +from `@webex/cc-ai-assistant`; the existing WC entry still registers it. Widget prop/event contracts are owned by the upstream widget packages; this module only re-exports them and maps a subset across the Web Component boundary (see `src/wc.ts`). @@ -88,6 +89,7 @@ boundary (see `src/wc.ts`). | `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) | | `cc-widgets.DigitalChannels` | SDK | React export `DigitalChannels`; tag `widget-cc-digital-channels` (no mapped props; store-driven) | Digital channels UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `ai-assistant.AIAssistant` | React package and WC aggregate | React `AIAssistant` from `@webex/cc-widgets` or `@webex/cc-ai-assistant`; tag `widget-cc-ai-assistant` from `@webex/cc-widgets/wc` | Real-time Assist and Agent Wellness Break | Both React import paths and the WC tag remain available | `src/index.ts`, `packages/contact-center/ai-assistant/src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.store` | SDK | React export `store`; also re-exported from `src/wc.ts` | Shared MobX singleton (`@webex/cc-store`) callers init before mounting widgets | stable; the single shared store instance | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: @@ -99,7 +101,7 @@ Compatibility notes: ## Requires (dependencies) - Internal widget packages (workspace `*`): `@webex/cc-station-login`, `@webex/cc-user-state`, `@webex/cc-task` (provides `IncomingTask`, `TaskList`, `CallControl`, `CallControlCAD`, `OutdialCall`, - `RealTimeTranscript`), `@webex/cc-digital-channels`. Source: `package.json` dependencies, `src/index.ts`, + `RealTimeTranscript`), `@webex/cc-digital-channels`, and `@webex/cc-ai-assistant` for React export and WC registration. Source: `package.json` dependencies, `src/index.ts`, `src/wc.ts`. - `@webex/cc-store` (workspace `*`) — the shared MobX singleton re-exported to consumers. - `@r2wc/react-to-web-component` `2.0.3` — React→custom-element conversion (`src/wc.ts`). @@ -111,19 +113,19 @@ Compatibility notes: ## Requirements | ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | |---|---|---|---|---|---|---| -| `cc-widgets-R-001` | Re-export the React widgets `StationLogin`, `UserState`, `IncomingTask`, `CallControl`, `CallControlCAD`, `TaskList`, `OutdialCall`, `RealTimeTranscript`, `DigitalChannels`, and `store` from the package root. | Single-package install: consumers import the whole suite + store from `@webex/cc-widgets` without tracking each widget package. | `packages/contact-center/cc-widgets/src/index.ts` | None found | No unit test exists (`tests/` absent; `passWithNoTests` set). | WEAK | -| `cc-widgets-R-002` | Register each widget as a custom element under a `widget-cc-*` tag (`widget-cc-user-state`, `widget-cc-station-login`, `widget-cc-incoming-task`, `widget-cc-task-list`, `widget-cc-call-control`, `widget-cc-outdial-call`, `widget-cc-call-control-cad`, `widget-cc-realtime-transcript`, `widget-cc-digital-channels`) when `./wc` is loaded. | Framework-agnostic embedding: HTML/Angular/Vue/vanilla hosts use the suite without React. | `packages/contact-center/cc-widgets/src/wc.ts` | None found | No test verifies registration. | WEAK | +| `cc-widgets-R-001` | Re-export `StationLogin`, `UserState`, `IncomingTask`, `CallControl`, `CallControlCAD`, `TaskList`, `OutdialCall`, `RealTimeTranscript`, `DigitalChannels`, `AIAssistant`, and `store` from the package root. | Preserve the established React import path for existing hosts. | `packages/contact-center/cc-widgets/src/index.ts` | Production build and entry inspection | The aggregate loads AI Assistant from its package; wellness media stays in separate files. | PRESENT | +| `cc-widgets-R-002` | Register all `widget-cc-*` custom elements, including `widget-cc-ai-assistant`, when `./wc` is loaded. | Preserve the existing WC host import and tag registration. | `packages/contact-center/cc-widgets/src/wc.ts` | Web Component sample; build verification | No unit test verifies registration. | WEAK | | `cc-widgets-R-003` | Guard each `customElements.define` with `customElements.get(name)` so re-importing the WC bundle does not throw a duplicate-definition error. | Importing the bundle more than once (multiple micro-frontends/scripts) must be idempotent. | `packages/contact-center/cc-widgets/src/wc.ts` | None found | No regression test for double-import. | WEAK | | `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 | +| `cc-widgets-R-008` | Keep AI Assistant as a CommonJS package dependency in both aggregate entries. | A Webpack app must be able to emit the assistant's media and lazy chunks from its published package without copying files from another package. | `webpack.config.js`, `package.json` | Packed-package consumer build without asset rules or copy steps | Webpack sets the asset URL from its output configuration. | PRESENT | ## Design Overview The module is a pure composition/distribution layer with two entry points and no internal state. The React -path (`index.ts`) is a tree-shakeable re-export barrel: the host bundler pulls actual widget code from the -workspace packages, so the React bundle is small and uses the host's React instance. The only side effect -is the Momentum CSS import. +path (`index.ts`) re-exports the widgets, including AI Assistant, and imports Momentum CSS. The WC entry +continues to register the AI Assistant custom element. The assistant package owns the wellness files. The Web Component path (`wc.ts`) is self-contained. Each React widget is passed to `r2wc(Component, {props})` to produce a custom-element class. The `props` map tells r2wc how to bridge each prop: `function` props are @@ -150,6 +152,7 @@ graph LR US["@webex/cc-user-state"] TASK["@webex/cc-task"] DC["@webex/cc-digital-channels"] + AI["@webex/cc-ai-assistant"] STORE["@webex/cc-store"] end @@ -167,12 +170,14 @@ graph LR US --> IDX TASK --> IDX DC --> IDX + AI --> IDX STORE --> IDX SL --> WC US --> WC TASK --> WC DC --> WC + AI --> WC STORE --> WC IDX -->|named exports| REACT @@ -327,12 +332,17 @@ potentially breaking change for hosts. This module is the host-mount surface for the widget suite. - **Custom-element tags:** `widget-cc-user-state`, `widget-cc-station-login`, `widget-cc-incoming-task`, `widget-cc-task-list`, `widget-cc-call-control`, `widget-cc-call-control-cad`, `widget-cc-outdial-call`, - `widget-cc-realtime-transcript`, `widget-cc-digital-channels` (`src/wc.ts`). -- **Mount contract (WC mode):** load `@webex/cc-widgets/wc` (registers the elements at import time), + `widget-cc-realtime-transcript`, `widget-cc-digital-channels`, `widget-cc-ai-assistant` (`src/wc.ts`). +- **Mount contract (WC mode):** load `@webex/cc-widgets/wc` (registers all widget elements at import time), initialize the shared `store`, then place the custom elements in the DOM and assign callbacks/objects as element properties. -- **Mount contract (React mode):** import widgets + `store` from `@webex/cc-widgets`, init `store`, render - as JSX. The host provides React/ReactDOM (peers). +- **Mount contract (React mode):** import widgets, including `AIAssistant`, and `store` from + `@webex/cc-widgets` (or import `AIAssistant` directly from `@webex/cc-ai-assistant`), init `store`, then render as JSX. The host provides + React/ReactDOM (peers). +- **Compiled-package assets:** The aggregate keeps `@webex/cc-ai-assistant` as a CommonJS dependency. + The assistant package contains the MP3, animation data, and media loader. A Webpack consumer emits + them through its normal build without an asset rule or copy step. The browser requests the MP3, + animation data, and Lottie renderer only when the wellness UI needs them. - **Theming:** React consumers get Momentum base styling via the `@momentum-ui/core` CSS imported in `src/index.ts`; hosts also need the Momentum peer packages (`@momentum-ui/react-collaboration`, `@momentum-ui/web-components`). Do not assume the host's framework version beyond the declared peer diff --git a/packages/contact-center/cc-widgets/package.json b/packages/contact-center/cc-widgets/package.json index a64fb5984..92591173f 100644 --- a/packages/contact-center/cc-widgets/package.json +++ b/packages/contact-center/cc-widgets/package.json @@ -28,7 +28,7 @@ "clean": "rm -rf dist && rm -rf node_modules", "clean:dist": "rm -rf dist", "build": "yarn run -T tsc", - "build:src": "yarn run clean:dist && webpack", + "build:src": "yarn workspace @webex/cc-ai-assistant build:src && yarn run clean:dist && webpack", "build:watch": "webpack --watch", "test:unit": "NODE_ENV=test jest --coverage", "test:styles": "eslint", diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index 5265f1445..81f8479dd 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -74,6 +74,13 @@ const WebAIAssistant = r2wc(AIAssistant, { onClose: 'function', onFullScreenToggle: 'function', onRealTimeAssistReceived: 'function', + onWellnessBreakOffered: 'function', + onWellnessBreakAccepted: 'function', + onWellnessBreakStarted: 'function', + onWellnessBreakEnded: 'function', + onWellnessBreakError: 'function', + wellnessAudioUrl: 'string', + wellnessBreakOverlayTarget: 'string', className: 'string', }, }); diff --git a/packages/contact-center/cc-widgets/webpack.config.js b/packages/contact-center/cc-widgets/webpack.config.js index 58442ba5c..7b67eadc9 100644 --- a/packages/contact-center/cc-widgets/webpack.config.js +++ b/packages/contact-center/cc-widgets/webpack.config.js @@ -26,6 +26,7 @@ module.exports = merge(baseConfig, { path: path.resolve(__dirname, 'dist'), filename: '[name].js', // Set the output filename to index.js libraryTarget: 'commonjs2', + publicPath: 'auto', }, externals: { react: 'react', @@ -33,6 +34,7 @@ module.exports = merge(baseConfig, { 'react/jsx-runtime': 'react/jsx-runtime', 'react/jsx-dev-runtime': 'react/jsx-dev-runtime', '@webex/cc-store': '@webex/cc-store', + '@webex/cc-ai-assistant': 'commonjs @webex/cc-ai-assistant', '@momentum-ui/react-collaboration': '@momentum-ui/react-collaboration', '@momentum-ui/web-components': '@momentum-ui/web-components', }, diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 5afcf3acd..e0754dfd8 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -12,7 +12,7 @@ | Doc kind | Module spec | | Coverage score | Pending coverage assessment | | Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | -| generated_by / approved_by / updated_at | generated_by: migration agent / approved_by: pending / updated_at: 2026-06-29 | +| generated_by / approved_by / updated_at | generated_by: migration agent / approved_by: pending / updated_at: 2026-09-04 | | Validation status | not-run | Coverage score: `Pending coverage assessment` before the first report; after assessment, replace with @@ -48,7 +48,7 @@ Owns Contact Center client-side state and the SDK boundary: initialize/register ## 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.123. 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.131. 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 @@ -90,10 +90,11 @@ 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. - 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. +- Wellness constants and types come from the SDK package root. Do not add unpublished event or state-control surfaces to the store. ## Requires (dependencies) -- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.123`) — 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.131`) — 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. @@ -400,3 +401,26 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) - Coverage state & contracts baseline: `.sdd/manifest.json` + +## Agent Wellness Break addendum (WXCC-12423) + +| ID | Requirement | Source evidence | Test evidence | +|---|---|---|---| +| `STORE-R-032` | Project only the SDK's effective `Profile.isWellnessBreakEnabled`; never rebuild licensing or rollout rules from raw AI configuration. | `src/store.ts`, `src/util.ts` | `tests/wellness.ts` | +| `STORE-R-033` | Own the active station `agentSessionId`, `WellbeingBreak` system-code lookup, and one stable public wellness listener. Accept valid current-agent wellness notifications regardless of their diagnostic notification `agentSessionId`. Model the public action contract exactly: `requestWellnessBreak()` accepts no arguments and `respondToWellnessBreak({action})` accepts no identity override because the SDK supplies its active registration and login/relogin context. Never log notification payloads. | `src/store.ts`, `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/wellness.ts`, `tests/storeEventsWrapper.ts`, `packages/contact-center/ai-assistant/tests/wellness.orchestrator.ts` | +| `STORE-R-034` | Logout, session rotation, and current-session multi-login close reset session-owned wellness state. Keep the registration-level wellness listener active across logout and relogin. Active lifecycle/restoration remains state-owned during feature revocation. | `src/storeEventsWrapper.ts`, `src/store.ts` | `tests/wellness.ts`, `tests/storeEventsWrapper.ts` | +| `STORE-R-035` | Import the SDK's public wellness constants and types from the package root, project only legacy agent state, and ignore stale system-code lookup results after logout or feature revocation. All event and promise mutations use MobX actions. | `src/store.ts`, `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/wellness.ts`, declaration-surface check | +| `STORE-R-036` | Submit widgets behavioral events through `webex.internal.newMetrics` using explicit agent/target/verb taxonomy and flat bounded properties; missing or failed metrics transport is logged and never interrupts the wellness lifecycle. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/wellness.ts` | +| `STORE-R-037` | Project confirmed legacy `WellbeingBreak` state and backend timestamps through `currentState`, `legacyAgentState`, `legacyAuxCodeId`, `lastStateChangeTimestamp`, and `lastIdleCodeChangeTimestamp`, while keeping the system code out of ordinary idle-code choices. | `src/store.ts`, `src/storeEventsWrapper.ts` | `tests/wellness.ts` | + +On registration, the store reads the SDK-owned `Profile.agentSessionId` when present, derives the +legacy Available/Idle state from `lastStateAuxCodeId`, and clears any stale session when the profile +has no active session. This supports refresh recovery without using a notification's session ID. +The store also marks system-code lookup pending until its current request settles so User State +does not echo an unresolved system-owned state. Evidence: `src/store.ts`, +`src/storeEventsWrapper.ts`, and `tests/wellness.ts`. + +The store consumes the existing public Wellness Break symbols from `@webex/contact-center` 3.12.0-next.131. +The registration session projection requires the companion SDK change and a later published package. +It does not consume an AI Assistant RTD status event. See +[`agent-wellness-break-intake.md`](../../../../ai-docs/features/agent-wellness-break-intake.md). diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index df3f9f1ab..e4ab7cc87 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.131", + "@webex/contact-center": "3.12.0-next.133", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/constants.ts b/packages/contact-center/store/src/constants.ts index de95dbabb..1d2238153 100644 --- a/packages/contact-center/store/src/constants.ts +++ b/packages/contact-center/store/src/constants.ts @@ -19,6 +19,7 @@ export const EXCLUDED_PARTICIPANT_TYPES = [CUSTOMER, SUPERVISOR, VVA]; export const MEDIA_TYPE_CONSULT = 'consult'; export const AI_FEATURE_SUGGESTED_RESPONSES_KEY = 'isSuggestedResponsesEnabled'; +export const AI_FEATURE_WELLNESS_BREAK_KEY = 'isWellnessBreakEnabled'; export const TASK_MULTI_LOGIN_HYDRATE = 'task:multiLoginHydrate'; diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 58bc57f37..4b130ef5a 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -1,4 +1,4 @@ -import {makeAutoObservable, observable} from 'mobx'; +import {makeAutoObservable, observable, runInAction} from 'mobx'; import Webex, {ITask} from '@webex/contact-center'; import { IContactCenter, @@ -16,6 +16,7 @@ import { RealTimeTranscriptionData, RealTimeAssistPayload, OfferActionErrorDisplay, + WellnessBreakState, } from './store.types'; import {getFeatureFlags} from './util'; @@ -67,6 +68,15 @@ class Store implements IStore { isEmergencyModalAlreadyDisplayed: boolean = false; realTimeAssist: Record = {}; offerActionErrors: Record = {}; + isWellnessBreakEnabled = false; + wellnessAgentSessionId = ''; + wellbeingBreakIdleCode?: IdleCode; + wellnessIdleCodeLookupPending = false; + wellnessBreakState: WellnessBreakState = {phase: 'idle'}; + wellnessEventSequence = 0; + legacyAgentState = ''; + legacyAuxCodeId = ''; + private wellnessIdleCodeRequestGeneration = 0; constructor() { makeAutoObservable(this, { @@ -108,6 +118,22 @@ class Store implements IStore { }); // wire up logger into feature‐flag extraction this.featureFlags = getFeatureFlags(response); + const isWellnessBreakEnabled = response.isWellnessBreakEnabled === true; + const registeredSessionId = + response.isAgentLoggedIn && 'agentSessionId' in response && typeof response.agentSessionId === 'string' + ? response.agentSessionId + : ''; + runInAction(() => { + this.isWellnessBreakEnabled = isWellnessBreakEnabled; + this.wellnessIdleCodeLookupPending = isWellnessBreakEnabled && Boolean(response.isAgentLoggedIn); + if (this.wellnessAgentSessionId && this.wellnessAgentSessionId !== registeredSessionId) { + this.wellnessBreakState = {phase: 'idle'}; + this.wellnessEventSequence += 1; + } + this.wellnessAgentSessionId = registeredSessionId; + this.legacyAuxCodeId = registeredSessionId ? response.lastStateAuxCodeId || '' : ''; + this.legacyAgentState = this.legacyAuxCodeId ? (this.legacyAuxCodeId === '0' ? 'Available' : 'Idle') : ''; + }); //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 this.teams = response.teams; this.loginOptions = response.webRtcEnabled @@ -132,6 +158,25 @@ class Store implements IStore { this.agentProfile.isTimeoutDesktopInactivityEnabled = response.isTimeoutDesktopInactivityEnabled; this.agentProfile.timeoutDesktopInactivityMins = response.timeoutDesktopInactivityMins; this.dataCenter = (response as {environment?: string}).environment || ''; + if (!this.isWellnessBreakEnabled) { + const hasStateOwnedWellnessLifecycle = [ + 'changing-to-break', + 'waiting-for-safe-state', + 'starting', + 'playing', + 'ending', + 'restoring', + ].includes(this.wellnessBreakState.phase); + if (!hasStateOwnedWellnessLifecycle) { + runInAction(() => { + this.wellbeingBreakIdleCode = undefined; + this.wellnessIdleCodeLookupPending = false; + this.wellnessBreakState = {phase: 'idle'}; + }); + } + } else if (response.isAgentLoggedIn) { + void this.loadWellbeingBreakIdleCode(); + } }) .catch((error) => { this.logger.error(`CC-Widgets: Contact-center registerCC(): failed - ${error}`, { @@ -142,6 +187,71 @@ class Store implements IStore { }); } + async loadWellbeingBreakIdleCode(): Promise { + const requestGeneration = ++this.wellnessIdleCodeRequestGeneration; + + if (!this.isWellnessBreakEnabled || !this.isAgentLoggedIn) { + runInAction(() => { + this.wellbeingBreakIdleCode = undefined; + this.wellnessIdleCodeLookupPending = false; + }); + return; + } + + runInAction(() => { + this.wellnessIdleCodeLookupPending = true; + }); + + if (!this.cc?.getWellbeingBreakIdleCode) { + runInAction(() => { + this.wellbeingBreakIdleCode = undefined; + this.wellnessIdleCodeLookupPending = false; + this.wellnessBreakState = {phase: 'error', errorCode: 'SYSTEM_CODE_UNAVAILABLE'}; + }); + this.logger?.error('CC-Widgets: WellbeingBreak system code API is unavailable', { + module: 'cc-store#store.ts', + method: 'loadWellbeingBreakIdleCode', + }); + return; + } + + try { + const idleCode = await this.cc.getWellbeingBreakIdleCode(); + if (requestGeneration !== this.wellnessIdleCodeRequestGeneration) return; + + runInAction(() => { + this.wellnessIdleCodeLookupPending = false; + if (this.isWellnessBreakEnabled && this.isAgentLoggedIn) { + this.wellbeingBreakIdleCode = idleCode; + if (this.wellnessBreakState.errorCode === 'SYSTEM_CODE_UNAVAILABLE') { + this.wellnessBreakState = {phase: 'idle'}; + } + } + }); + } catch { + if ( + requestGeneration !== this.wellnessIdleCodeRequestGeneration || + !this.isWellnessBreakEnabled || + !this.isAgentLoggedIn + ) { + return; + } + + runInAction(() => { + this.wellbeingBreakIdleCode = undefined; + this.wellnessIdleCodeLookupPending = false; + this.wellnessBreakState = { + phase: 'error', + errorCode: 'SYSTEM_CODE_UNAVAILABLE', + }; + }); + this.logger?.error('CC-Widgets: WellbeingBreak system code is unavailable', { + module: 'cc-store#store.ts', + method: 'loadWellbeingBreakIdleCode', + }); + } + } + init(options: InitParams, setupEventListeners): Promise { if ('webexConfig' in options) { this.enableWxBetterTogether = options.webexConfig?.cc?.enableWxBetterTogether === true; diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 9ea9bafb5..0e55e6cea 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -24,6 +24,12 @@ import { TaskUILeg, getDefaultUIControls, TaskResponse, + WELLNESS_BREAK_NOTIFICATION_ACTIONS, + WELLNESS_BREAK_USER_ACTIONS, + WellnessBreakNotificationAction, + WellnessBreakUserAction, + WellnessBreakEvent, + RespondToWellnessBreakParams, } from '@webex/contact-center'; import type {RealTimeAssistanceParams} from 'node_modules/@webex/contact-center/dist/types/types'; import { @@ -69,6 +75,8 @@ interface IContactCenter { outdialANIId: string; }; setAgentState(data: StateChange): Promise; + /** Returns the system-owned `WellbeingBreak` idle code for the active registration. */ + getWellbeingBreakIdleCode(): Promise; getOutdialAniEntries(params: OutdialAniParams): Promise; getAccessToken(): Promise; startOutdial(destination: string, origin?: string): Promise; @@ -79,9 +87,52 @@ interface IContactCenter { apiAIAssistant?: { getRealTimeAssistance(params: RealTimeAssistRequestParams & {actionTimeStamp?: number}): Promise; sendRealTimeAssistanceUserAction(params: RealTimeAssistUserActionParams): Promise; + requestWellnessBreak(): Promise; + respondToWellnessBreak(params: RespondToWellnessBreakParams): Promise; + }; + /** SDK-owned transport for widgets behavioral metrics. */ + webex?: { + internal?: { + newMetrics?: { + submitBehavioralEvent: (event: { + product: 'wxcc-widgets'; + agent: WidgetsBehavioralMetricAgent; + target: string; + verb: WidgetsBehavioralMetricVerb; + payload?: Record; + }) => void; + }; + }; }; } +type WidgetsBehavioralMetricVerb = + | 'accept' + | 'display' + | 'dismiss' + | 'end' + | 'error' + | 'expire' + | 'fail' + | 'fire' + | 'ignore' + | 'load' + | 'receive' + | 'reject' + | 'request' + | 'retry' + | 'start'; + +type WidgetsBehavioralMetricAgent = 'browser' | 'service' | 'system' | 'user'; + +interface WidgetsBehavioralMetric { + name: string; + agent: WidgetsBehavioralMetricAgent; + target: string; + verb: WidgetsBehavioralMetricVerb; + properties?: Record; +} + type RealTimeAssistRequestParams = RealTimeAssistanceParams; type RealTimeAssistUserActionId = string; @@ -112,6 +163,44 @@ type RealTimeAssistPayload = { notifType?: string; orgId?: string; }; + +/** Public Agent Wellness Break lifecycle exposed by the widget package. */ +type WellnessBreakPhase = + | 'idle' + | 'offer-pending' + | 'request-pending' + | 'changing-to-break' + | 'waiting-for-safe-state' + | 'starting' + | 'playing' + | 'ending' + | 'restoring' + | 'error'; + +/** Stable, non-PII failure categories emitted to widget hosts. */ +type WellnessBreakErrorCode = + | 'FEATURE_DISABLED' + | 'SESSION_UNAVAILABLE' + | 'SYSTEM_CODE_UNAVAILABLE' + | 'ACTION_REQUEST_FAILED' + | 'STATE_CHANGE_FAILED' + | 'RESTORE_FAILED' + | 'INVALID_EVENT' + | 'MEDIA_UNAVAILABLE'; + +interface WellnessBreakError { + code: WellnessBreakErrorCode; + phase: WellnessBreakPhase; + recoverable: boolean; +} + +interface WellnessBreakState { + phase: WellnessBreakPhase; + event?: WellnessBreakEvent; + responseDeadline?: number; + errorCode?: WellnessBreakErrorCode; +} + // To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 type IWebex = { cc: IContactCenter; @@ -222,8 +311,17 @@ interface IStore { isEmergencyModalAlreadyDisplayed: boolean; realTimeAssist: Record; offerActionErrors: Record; + isWellnessBreakEnabled: boolean; + wellnessAgentSessionId: string; + wellbeingBreakIdleCode?: IdleCode; + wellnessIdleCodeLookupPending: boolean; + wellnessBreakState: WellnessBreakState; + wellnessEventSequence: number; + legacyAgentState: string; + legacyAuxCodeId: string; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; + loadWellbeingBreakIdleCode(): Promise; } interface IStoreWrapper extends IStore { @@ -269,6 +367,10 @@ interface IStoreWrapper extends IStore { setOfferActionError(interactionId: string, error: OfferActionErrorDisplay | null): void; clearOfferActionError(interactionId: string): void; pruneOfferActionErrors(activeInteractionIds: Set): void; + loadWellbeingBreakIdleCode(): Promise; + setWellnessBreakState(state: WellnessBreakState): void; + submitBehavioralMetric(metric: WidgetsBehavioralMetric): void; + resetWellnessSession(): void; } interface IWrapupCode { @@ -289,6 +391,7 @@ enum CC_EVENTS { AGENT_RELOGIN_SUCCESS = 'agent:reloginSuccess', AGENT_OFFER_CONSULT = 'AgentOfferConsult', REAL_TIME_TRANSCRIPTION = 'REAL_TIME_TRANSCRIPTION', + WELLNESS_BREAK = 'WellnessBreak', } interface ICustomStateSet { @@ -322,6 +425,9 @@ type AgentLoginProfile = { agentProfileID?: string; isTimeoutDesktopInactivityEnabled?: boolean; timeoutDesktopInactivityMins?: number; + agentSessionId?: string; + auxCodeId?: string; + subStatus?: string; }; // Generic pagination params for list-fetching APIs @@ -407,6 +513,17 @@ export type { RealTimeAssistUserActionId, RealTimeAssistUserActionParams, OfferActionErrorDisplay, + WellnessBreakNotificationAction, + WellnessBreakUserAction, + WellnessBreakEvent, + RespondToWellnessBreakParams, + WellnessBreakPhase, + WellnessBreakErrorCode, + WellnessBreakError, + WellnessBreakState, + WidgetsBehavioralMetric, + WidgetsBehavioralMetricAgent, + WidgetsBehavioralMetricVerb, }; export { @@ -426,6 +543,8 @@ export { LoginOptions, ERROR_TRIGGERING_IDLE_CODES, getDefaultUIControls, + WELLNESS_BREAK_NOTIFICATION_ACTIONS, + WELLNESS_BREAK_USER_ACTIONS, }; // ConsultStatus enum removed — use task.data.consultStatus from SDK instead diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index efaabaf5d..071070f7e 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -28,6 +28,10 @@ import { DesktopPreference, RealTimeAssistPayload, OfferActionErrorDisplay, + WellnessBreakEvent, + WellnessBreakState, + WidgetsBehavioralMetric, + WELLNESS_BREAK_NOTIFICATION_ACTIONS, } from './store.types'; import Store from './store'; import { @@ -48,6 +52,8 @@ const CONSULT_TRANSFER_CHANNELS = { email: 'EMAIL', } as const; +const WELLNESS_NOTIFICATION_ACTIONS = new Set(Object.values(WELLNESS_BREAK_NOTIFICATION_ACTIONS)); + const getSupportedMediaType = (mediaType?: string): keyof typeof CONSULT_TRANSFER_CHANNELS | undefined => { const normalizedMediaType = typeof mediaType === 'string' ? mediaType.toLowerCase() : ''; const channel = CONSULT_TRANSFER_CHANNELS[normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS]; @@ -209,6 +215,38 @@ class StoreWrapper implements IStoreWrapper { return this.store.realTimeAssist; } + get isWellnessBreakEnabled() { + return this.store.isWellnessBreakEnabled; + } + + get wellnessAgentSessionId() { + return this.store.wellnessAgentSessionId; + } + + get wellbeingBreakIdleCode() { + return this.store.wellbeingBreakIdleCode; + } + + get wellnessIdleCodeLookupPending() { + return this.store.wellnessIdleCodeLookupPending; + } + + get wellnessBreakState() { + return this.store.wellnessBreakState; + } + + get wellnessEventSequence() { + return this.store.wellnessEventSequence; + } + + get legacyAgentState() { + return this.store.legacyAgentState; + } + + get legacyAuxCodeId() { + return this.store.legacyAuxCodeId; + } + setDataCenter = (value: string): void => { this.store.dataCenter = value; }; @@ -332,6 +370,96 @@ class StoreWrapper implements IStoreWrapper { }); }; + loadWellbeingBreakIdleCode = (): Promise => this.store.loadWellbeingBreakIdleCode(); + + setWellnessBreakState = (state: WellnessBreakState): void => { + runInAction(() => { + this.store.wellnessBreakState = state; + }); + }; + + submitBehavioralMetric = ({name, agent, target, verb, properties}: WidgetsBehavioralMetric): void => { + const metrics = this.store.cc?.webex?.internal?.newMetrics; + if (!metrics) { + this.store.logger?.warn('CC-Widgets: behavioral metrics transport unavailable', { + module: 'storeEventsWrapper.ts', + method: 'submitBehavioralMetric', + }); + return; + } + + try { + metrics.submitBehavioralEvent({ + product: 'wxcc-widgets', + agent, + target, + verb, + payload: {name, ...properties}, + }); + } catch { + this.store.logger?.warn('CC-Widgets: behavioral metric submission failed', { + module: 'storeEventsWrapper.ts', + method: 'submitBehavioralMetric', + }); + } + }; + + resetWellnessSession = (): void => { + runInAction(() => { + this.store.wellnessAgentSessionId = ''; + this.store.wellbeingBreakIdleCode = undefined; + this.store.wellnessIdleCodeLookupPending = false; + this.store.wellnessBreakState = {phase: 'idle'}; + this.store.wellnessEventSequence = (this.store.wellnessEventSequence ?? 0) + 1; + this.store.legacyAgentState = ''; + this.store.legacyAuxCodeId = ''; + }); + }; + + private captureWellnessSession = (payload: AgentLoginProfile): void => { + const nextSessionId = payload?.agentSessionId; + if (!nextSessionId) return; + + runInAction(() => { + const sessionRotated = + Boolean(this.store.wellnessAgentSessionId) && this.store.wellnessAgentSessionId !== nextSessionId; + if (sessionRotated) { + this.store.wellnessBreakState = {phase: 'idle'}; + this.store.wellnessEventSequence = (this.store.wellnessEventSequence ?? 0) + 1; + } + + this.store.wellnessAgentSessionId = nextSessionId; + this.store.legacyAgentState = payload.subStatus || this.store.legacyAgentState; + this.store.legacyAuxCodeId = payload.auxCodeId?.trim() || this.store.legacyAuxCodeId || '0'; + }); + + void this.loadWellbeingBreakIdleCode(); + }; + + handleWellnessBreak = (payload: WellnessBreakEvent): void => { + if ( + !this.store.isWellnessBreakEnabled || + !this.store.isAgentLoggedIn || + !payload?.agentSessionId || + payload.agentId !== this.store.agentId || + !WELLNESS_NOTIFICATION_ACTIONS.has(payload.actionEvent) + ) { + this.store.logger?.warn('CC-Widgets: ignored invalid wellness notification', { + module: 'storeEventsWrapper.ts', + method: 'handleWellnessBreak', + }); + return; + } + + runInAction(() => { + this.store.wellnessBreakState = { + ...(this.store.wellnessBreakState ?? {phase: 'idle'}), + event: payload, + }; + this.store.wellnessEventSequence = (this.store.wellnessEventSequence ?? 0) + 1; + }); + }; + setCurrentTheme = (theme: string): void => { this.store.currentTheme = theme; }; @@ -479,7 +607,6 @@ class StoreWrapper implements IStoreWrapper { setOnError = (callback: (widgetName: string, error: Error) => void) => { this.onErrorCallback = (widgetName: string, error: Error) => { - // @ts-expect-error - test error boundary this.store.cc.webex.internal.newMetrics.submitBehavioralEvent({ product: 'wxcc-widgets', agent: 'browser', @@ -1310,11 +1437,26 @@ class StoreWrapper implements IStoreWrapper { method: 'handleStateChange', }); if (data && typeof data === 'object' && data.type === 'AgentStateChangeSuccess') { + if ( + data.agentSessionId && + this.store.wellnessAgentSessionId && + data.agentSessionId !== this.store.wellnessAgentSessionId + ) { + this.store.logger.warn('CC-Widgets: ignored stale legacy agent state update', { + module: 'storeEventsWrapper.ts', + method: 'handleStateChange', + }); + return; + } const DEFAULT_CODE = '0'; // Default code when no aux code is present this.setCurrentState(data.auxCodeId?.trim() !== '' ? data.auxCodeId : DEFAULT_CODE); this.setLastStateChangeTimestamp(data.lastStateChangeTimestamp); this.setLastIdleCodeChangeTimestamp(data.lastIdleCodeChangeTimestamp); + runInAction(() => { + this.store.legacyAgentState = data.subStatus || ''; + this.store.legacyAuxCodeId = data.auxCodeId?.trim() || DEFAULT_CODE; + }); } }; @@ -1324,6 +1466,9 @@ class StoreWrapper implements IStoreWrapper { method: 'handleMultiLoginCloseSession', }); if (data && typeof data === 'object' && data.type === 'AgentMultiLoginCloseSession') { + if (data.agentSessionId && data.agentSessionId === this.store.wellnessAgentSessionId) { + this.resetWellnessSession(); + } // Don't show the multi-login modal if there's an active task // The modal blocks UI interactions and should not interfere with task handling if (this.currentTask) { @@ -1604,6 +1749,7 @@ class StoreWrapper implements IStoreWrapper { this.setIsEmergencyModalAlreadyDisplayed(false); this.store.realTimeAssist = {}; this.realTimeAssistListeners = {}; + this.resetWellnessSession(); }); }; @@ -1670,6 +1816,7 @@ class StoreWrapper implements IStoreWrapper { // @ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 this.setTeamId(payload.teamId); }); + this.captureWellnessSession(payload as AgentLoginProfile); }; ccSDK.on(CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, handleLogin); @@ -1684,6 +1831,7 @@ class StoreWrapper implements IStoreWrapper { if (event === CC_EVENTS.AGENT_RELOGIN_SUCCESS) { this.setAgentProfile(payload); this.setTeamId(payload.teamId); + this.captureWellnessSession(payload); } }); if (!listenersAdded) { @@ -1692,6 +1840,13 @@ class StoreWrapper implements IStoreWrapper { } }); }); + + // Registration-level listeners are deliberately independent of task listeners. + // Keep these after the established registration order for backwards-compatible + // listener sequencing in host integrations, and replace exact callbacks so + // repeated init/register flows remain idempotent. + ccSDK.off(CC_EVENTS.WELLNESS_BREAK, this.handleWellnessBreak); + ccSDK.on(CC_EVENTS.WELLNESS_BREAK, this.handleWellnessBreak); }; } diff --git a/packages/contact-center/store/src/util.ts b/packages/contact-center/store/src/util.ts index 099732d53..01c67efb3 100644 --- a/packages/contact-center/store/src/util.ts +++ b/packages/contact-center/store/src/util.ts @@ -1,4 +1,4 @@ -import {AI_FEATURE_SUGGESTED_RESPONSES_KEY} from './constants'; +import {AI_FEATURE_SUGGESTED_RESPONSES_KEY, AI_FEATURE_WELLNESS_BREAK_KEY} from './constants'; import {Profile} from './store.types'; const getValueAtPath = (obj: unknown, path: string): unknown => { @@ -56,5 +56,10 @@ export function getFeatureFlags(agentProfile: Profile) { } } + const wellnessEnabled = getValueAtPath(agentProfile, AI_FEATURE_WELLNESS_BREAK_KEY); + if (typeof wellnessEnabled === 'boolean') { + keyValuePairs[AI_FEATURE_WELLNESS_BREAK_KEY] = wellnessEnabled; + } + return keyValuePairs; } diff --git a/packages/contact-center/store/tests/setupContactCenterMock.js b/packages/contact-center/store/tests/setupContactCenterMock.js index 325f5bf33..735d3a513 100644 --- a/packages/contact-center/store/tests/setupContactCenterMock.js +++ b/packages/contact-center/store/tests/setupContactCenterMock.js @@ -15,6 +15,17 @@ jest.mock('@webex/contact-center', () => ({ })), TASK_EVENTS: createEnumProxy(), CC_EVENTS: createEnumProxy(), + WELLNESS_BREAK_NOTIFICATION_ACTIONS: { + PROVIDE_WELLNESS_BREAK: 'PROVIDE_WELLNESS_BREAK', + SUGGEST_WELLNESS_BREAK: 'SUGGEST_WELLNESS_BREAK', + WELLNESS_BREAK_NOT_ALLOWED: 'WELLNESS_BREAK_NOT_ALLOWED', + }, + WELLNESS_BREAK_USER_ACTIONS: { + REQUESTED: 'REQUESTED', + ACCEPTED: 'ACCEPTED', + REJECTED: 'REJECTED', + NO_RESPONSE: 'NO_RESPONSE', + }, getDefaultUIControls: () => ({ activeLeg: 'main', main: {}, diff --git a/packages/contact-center/store/tests/store.ts b/packages/contact-center/store/tests/store.ts index dece8f265..7d172622d 100644 --- a/packages/contact-center/store/tests/store.ts +++ b/packages/contact-center/store/tests/store.ts @@ -11,6 +11,7 @@ console.log = jest.fn(); // Mock console.log jest.mock('mobx', () => ({ makeAutoObservable: jest.fn(), observable: {ref: jest.fn()}, + runInAction: (action: () => void) => action(), })); jest.mock('@webex/contact-center', () => ({ diff --git a/packages/contact-center/store/tests/util.ts b/packages/contact-center/store/tests/util.ts index 0aed0e8fa..702c051cb 100644 --- a/packages/contact-center/store/tests/util.ts +++ b/packages/contact-center/store/tests/util.ts @@ -14,6 +14,7 @@ describe('getFeatureFlags', () => { isOutboundEnabledForAgent: false, isOutboundEnabledForTenant: false, isTimeoutDesktopInactivityEnabled: false, + isWellnessBreakEnabled: false, }; const result = getFeatureFlags(mockProfile); diff --git a/packages/contact-center/store/tests/wellness.ts b/packages/contact-center/store/tests/wellness.ts new file mode 100644 index 000000000..465a4b596 --- /dev/null +++ b/packages/contact-center/store/tests/wellness.ts @@ -0,0 +1,226 @@ +import store, {CC_EVENTS, WellnessBreakEvent} from '../src'; +import {getFeatureFlags} from '../src/util'; +import {mockCC, mockProfile} from '@webex/test-fixtures'; + +const wellnessEvent: WellnessBreakEvent = { + agentId: 'agent-1', + orgId: 'org-1', + agentSessionId: 'session-1', + actionEvent: 'SUGGEST_WELLNESS_BREAK', +}; + +describe('Agent Wellness Break store projection', () => { + beforeEach(() => { + jest.clearAllMocks(); + store.store.cc = mockCC; + store.store.logger = mockCC.LoggerProxy; + store.store.agentId = 'agent-1'; + store.store.isAgentLoggedIn = true; + store.store.isWellnessBreakEnabled = true; + store.store.wellnessAgentSessionId = 'session-1'; + store.store.wellnessBreakState = {phase: 'idle'}; + store.store.wellnessEventSequence = 0; + store.store.wellbeingBreakIdleCode = undefined; + store.store.wellnessIdleCodeLookupPending = false; + store.store.currentState = '0'; + store.store.lastStateChangeTimestamp = undefined; + store.store.lastIdleCodeChangeTimestamp = undefined; + store.store.legacyAgentState = ''; + store.store.legacyAuxCodeId = ''; + mockCC.webex = undefined; + mockCC.getWellbeingBreakIdleCode.mockResolvedValue({ + id: 'wellbeing-break', + name: 'WellbeingBreak', + isSystem: true, + isDefault: false, + }); + }); + + it('projects only the SDK effective enablement flag', () => { + expect(getFeatureFlags({isWellnessBreakEnabled: true} as never)).toMatchObject({ + isWellnessBreakEnabled: true, + }); + expect( + getFeatureFlags({ + aiFeature: {agentWellbeing: {enable: true, wellnessBreakReminders: 'ENABLED'}}, + } as never) + ).not.toHaveProperty('isWellnessBreakEnabled'); + }); + + it('loads the system code without exposing it through normal idle codes', async () => { + store.store.idleCodes = [ + {id: 'ordinary', name: 'Lunch', isSystem: false, isDefault: true}, + {id: 'wellbeing-break', name: 'WellbeingBreak', isSystem: true, isDefault: false}, + ]; + + await store.loadWellbeingBreakIdleCode(); + + expect(store.wellbeingBreakIdleCode?.id).toBe('wellbeing-break'); + expect(store.idleCodes.map(({id}) => id)).toEqual(['ordinary']); + }); + + it('marks the system-code lookup pending until its current request settles', async () => { + let resolveIdleCode: ((value: Awaited>) => void) | undefined; + mockCC.getWellbeingBreakIdleCode.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveIdleCode = resolve; + }) + ); + + const pendingLoad = store.loadWellbeingBreakIdleCode(); + expect(store.wellnessIdleCodeLookupPending).toBe(true); + resolveIdleCode?.({id: 'wellbeing-break', name: 'WellbeingBreak', isSystem: true, isDefault: false}); + await pendingLoad; + + expect(store.wellnessIdleCodeLookupPending).toBe(false); + expect(store.wellbeingBreakIdleCode?.id).toBe('wellbeing-break'); + }); + + it('hydrates the registered station session and legacy state after page refresh', async () => { + let resolveIdleCode: ((value: Awaited>) => void) | undefined; + mockCC.getWellbeingBreakIdleCode.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveIdleCode = resolve; + }) + ); + mockCC.register.mockResolvedValueOnce({ + ...mockProfile, + isWellnessBreakEnabled: true, + isAgentLoggedIn: true, + agentSessionId: 'session-after-refresh', + lastStateAuxCodeId: 'meeting', + }); + store.store.wellnessAgentSessionId = 'stale-session'; + + await store.registerCC(); + + expect(store.wellnessAgentSessionId).toBe('session-after-refresh'); + expect(store.legacyAgentState).toBe('Idle'); + expect(store.legacyAuxCodeId).toBe('meeting'); + expect(store.wellnessBreakState).toEqual({phase: 'idle'}); + expect(store.wellnessIdleCodeLookupPending).toBe(true); + expect(mockCC.getWellbeingBreakIdleCode).toHaveBeenCalled(); + resolveIdleCode?.({id: 'wellbeing-break', name: 'WellbeingBreak', isSystem: true, isDefault: false}); + await Promise.resolve(); + expect(store.wellnessIdleCodeLookupPending).toBe(false); + }); + + it('clears a stale station session when registration has no active session', async () => { + mockCC.register.mockResolvedValueOnce({...mockProfile, isWellnessBreakEnabled: true, agentSessionId: undefined}); + store.store.wellnessAgentSessionId = 'stale-session'; + + await store.registerCC(); + + expect(store.wellnessAgentSessionId).toBe(''); + expect(store.legacyAgentState).toBe(''); + expect(store.legacyAuxCodeId).toBe(''); + }); + + it('ignores a stale system-code response after logout', async () => { + let resolveIdleCode: ((value: Awaited>) => void) | undefined; + mockCC.getWellbeingBreakIdleCode.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveIdleCode = resolve; + }) + ); + + const pendingLoad = store.loadWellbeingBreakIdleCode(); + store.store.isAgentLoggedIn = false; + resolveIdleCode?.({ + id: 'wellbeing-break', + name: 'WellbeingBreak', + isSystem: true, + isDefault: false, + }); + await pendingLoad; + + expect(store.wellbeingBreakIdleCode).toBeUndefined(); + expect(store.wellnessBreakState).toEqual({phase: 'idle'}); + }); + + it('accepts current-agent notifications regardless of their agent session id', () => { + store.handleWellnessBreak(wellnessEvent); + + expect(store.wellnessBreakState.event).toEqual(wellnessEvent); + expect(store.wellnessEventSequence).toBe(1); + + const mismatchedSessionEvent = {...wellnessEvent, agentSessionId: 'another-session'}; + store.handleWellnessBreak(mismatchedSessionEvent); + expect(store.wellnessBreakState.event).toEqual(mismatchedSessionEvent); + expect(store.wellnessEventSequence).toBe(2); + }); + + it('ignores wellness notifications while the agent is logged out', () => { + store.store.isAgentLoggedIn = false; + + store.handleWellnessBreak(wellnessEvent); + + expect(store.wellnessBreakState).toEqual({phase: 'idle'}); + expect(store.wellnessEventSequence).toBe(0); + }); + + it('projects the WellbeingBreak legacy state and timestamps used by the status timer', () => { + store.handleStateChange({ + type: 'AgentStateChangeSuccess', + agentSessionId: 'session-1', + subStatus: 'Idle', + auxCodeId: 'wellbeing-break', + lastStateChangeTimestamp: 2_000, + lastIdleCodeChangeTimestamp: 2_000, + lastStateChangeReason: 'WellbeingBreak', + }); + + expect(store.currentState).toBe('wellbeing-break'); + expect(store.legacyAgentState).toBe('Idle'); + expect(store.legacyAuxCodeId).toBe('wellbeing-break'); + expect(store.lastStateChangeTimestamp).toBe(2_000); + expect(store.lastIdleCodeChangeTimestamp).toBe(2_000); + }); + + it('registers one stable SDK wellness listener', () => { + store.setupIncomingTaskHandler(mockCC); + + expect(mockCC.off).toHaveBeenCalledWith('WellnessBreak', store.handleWellnessBreak); + expect(mockCC.on).toHaveBeenCalledWith('WellnessBreak', store.handleWellnessBreak); + }); + + it('submits a flat, non-identifying behavioral metric through the SDK metrics boundary', () => { + const submitBehavioralEvent = jest.fn(); + store.store.cc.webex = {internal: {newMetrics: {submitBehavioralEvent}}}; + + store.submitBehavioralMetric({ + name: 'Wellness Break CTA User Request', + agent: 'user', + target: 'wellness_break_cta', + verb: 'request', + }); + + expect(submitBehavioralEvent).toHaveBeenCalledWith({ + product: 'wxcc-widgets', + agent: 'user', + target: 'wellness_break_cta', + verb: 'request', + payload: {name: 'Wellness Break CTA User Request'}, + }); + }); + + it('keeps the registration-level wellness listener across logout and relogin', () => { + store.setupIncomingTaskHandler(mockCC); + const relogin = mockCC.on.mock.calls.find(([eventName]) => eventName === CC_EVENTS.AGENT_RELOGIN_SUCCESS)?.[1] as + | ((payload: {agentSessionId: string}) => void) + | undefined; + relogin?.({agentSessionId: 'session-1'}); + const logout = mockCC.on.mock.calls.find(([eventName]) => eventName === CC_EVENTS.AGENT_LOGOUT_SUCCESS)?.[1] as + | (() => void) + | undefined; + + expect(logout).toBeDefined(); + const wellnessOffCount = mockCC.off.mock.calls.filter(([eventName]) => eventName === 'WellnessBreak').length; + logout?.(); + + expect(mockCC.off.mock.calls.filter(([eventName]) => eventName === 'WellnessBreak')).toHaveLength(wellnessOffCount); + }); +}); diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index f0f1d1272..949db0614 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -69,6 +69,7 @@ const mockProfile: Profile = { lostConnectionRecoveryTimeout: 30000, maskSensitiveData: false, isAgentLoggedIn: true, + isWellnessBreakEnabled: false, lastStateAuxCodeId: 'auxCodeId', lastStateChangeTimestamp: 123456789, lastIdleCodeChangeTimestamp: 123456789, @@ -621,6 +622,12 @@ const mockCC: IContactCenter = { getEntryPoints: jest.fn().mockResolvedValue(mockEntryPointsResponse), addressBook: mockAddressBook, setAgentState: jest.fn().mockResolvedValue({}), + getWellbeingBreakIdleCode: jest.fn().mockResolvedValue({ + id: 'wellbeing-break', + name: 'WellbeingBreak', + isSystem: true, + isDefault: false, + }), getOutdialAniEntries: jest.fn().mockResolvedValue({entries: []}), getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), startOutdial: jest.fn().mockResolvedValue({}), @@ -630,6 +637,8 @@ const mockCC: IContactCenter = { apiAIAssistant: { getRealTimeAssistance: jest.fn().mockResolvedValue({}), sendRealTimeAssistanceUserAction: jest.fn().mockResolvedValue({}), + requestWellnessBreak: jest.fn().mockResolvedValue(undefined), + respondToWellnessBreak: jest.fn().mockResolvedValue(undefined), }, }; diff --git a/packages/contact-center/user-state/ai-docs/user-state-spec.md b/packages/contact-center/user-state/ai-docs/user-state-spec.md index 9ccdbb105..3ec8f4ab5 100644 --- a/packages/contact-center/user-state/ai-docs/user-state-spec.md +++ b/packages/contact-center/user-state/ai-docs/user-state-spec.md @@ -93,6 +93,7 @@ Compatibility notes: | `USER-STATE-R-009` | The Web Worker is terminated on unmount: post `stop` and `stopIdleCode`, call `terminate()`, null the ref. | Prevents leaked workers/intervals across widget remounts. | `src/helper.ts` (initial `useEffect` cleanup) | `tests/helper.ts` "should clean up on unmount" | none | PRESENT | | `USER-STATE-R-010` | Render errors are contained by an `ErrorBoundary` that renders an empty fragment and calls `store.onErrorCallback('UserState', error)`. | A widget crash must not take down the host desktop; host gets a single error hook. | `src/user-state/index.tsx` (`ErrorBoundary`) | `tests/user-state/index.tsx` "should render empty fragment when ErrorBoundary catches an error" | none | PRESENT | | `USER-STATE-R-011` | Every hook side effect (worker init, message handling, both state effects, `setAgentStatus`, `updateAgentState`, cleanup) is wrapped in try/catch and logs a scoped `CC-Widgets: UserState: ...` error via the injected logger without throwing out of the effect. | Defensive logging keeps a single failing path from cascading; aids field diagnosis. | `src/helper.ts` (try/catch in each block) | `tests/helper.ts` "Error Handling" suite (callOnStateChange, worker init, onmessage, currentState/customState/timestamp effects, setAgentStatus, updateAgentState, cleanup) | none | PRESENT | +| `USER-STATE-R-012` | `tests/user-state/index.tsx` "projects WellbeingBreak...", `tests/helper.ts` "does not echo externally managed wellness state transitions...", `cc-components/tests/components/UserState/user-state.tsx` "renders WellbeingBreak..." | none | ## Design Overview The widget is a near-pure adapter. `UserState` (`src/user-state/index.tsx`) wraps `UserStateInternal` in an `ErrorBoundary`; the internal component is an `observer` that destructures the store and forwards exactly the fields the hook needs, then spreads the hook's return plus `customState`/`logger` into `UserStateComponent`. No business logic lives in the widget file beyond that wiring and the error-boundary callback. @@ -318,6 +319,14 @@ Hook tests (`tests/helper.ts`) use `renderHook` with a mocked `Worker` (postMess | `USER-STATE-R-009` | `tests/helper.ts` "should clean up on unmount" | none | | `USER-STATE-R-010` | `tests/user-state/index.tsx` "should render empty fragment when ErrorBoundary catches an error" | none | | `USER-STATE-R-011` | `tests/helper.ts` "Error Handling" suite (8 cases) | none | +| `USER-STATE-R-012` | While the SDK reports the system-owned `WellbeingBreak` code as current, append it only to the rendered state data, keep the elapsed timer visible, and treat both entry into wellness and the following restoration as externally managed so neither transition is echoed through `cc.setAgentState`. | The agent must see Desktop-equivalent status/timing without exposing a manually selectable system code or creating duplicate backend state requests. | `src/user-state/index.tsx`, `src/helper.ts` | `tests/user-state/index.tsx`, `tests/helper.ts`, `packages/contact-center/cc-components/tests/components/UserState/user-state.tsx` | none | PRESENT | + +While the store resolves the system code after registration, User State records incoming SDK state +without calling `setAgentState`. An explicit dropdown selection made during lookup remains pending +and is sent after lookup completes, unless a newer SDK state replaces it. When the code resolves, +User State marks `WellbeingBreak` as externally managed before processing another change. This +prevents an SDK-owned state from being echoed during refresh without losing a user selection. +Evidence: `src/helper.ts`, `src/user-state/index.tsx`, `tests/helper.ts`. ## Traceability - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md` · Contracts: `../../../../ai-docs/CONTRACTS.md` diff --git a/packages/contact-center/user-state/src/helper.ts b/packages/contact-center/user-state/src/helper.ts index 63a06e946..8b1bc1d79 100644 --- a/packages/contact-center/user-state/src/helper.ts +++ b/packages/contact-center/user-state/src/helper.ts @@ -12,6 +12,8 @@ export const useUserState = ({ logger, onStateChange, lastIdleCodeChangeTimestamp, + isCurrentStateExternallyManaged = false, + wellnessIdleCodeLookupPending = false, }: UseUserStateProps) => { const [isSettingAgentStatus, setIsSettingAgentStatus] = useState(false); const [elapsedTime, setElapsedTime] = useState(0); @@ -19,6 +21,8 @@ export const useUserState = ({ const workerRef = useRef(null); const prevStateRef = useRef(currentState); + const externallyManagedStateRef = useRef(isCurrentStateExternallyManaged); + const pendingUserSelectionRef = useRef(); const callOnStateChange = () => { try { @@ -156,12 +160,34 @@ export const useUserState = ({ useEffect(() => { try { + if (isCurrentStateExternallyManaged) externallyManagedStateRef.current = true; + if (!wellnessIdleCodeLookupPending && pendingUserSelectionRef.current !== currentState) { + pendingUserSelectionRef.current = undefined; + } if (prevStateRef.current !== currentState) { + if (wellnessIdleCodeLookupPending) { + if (pendingUserSelectionRef.current !== currentState) { + pendingUserSelectionRef.current = undefined; + prevStateRef.current = currentState; + } + return; + } + pendingUserSelectionRef.current = undefined; logger.info(`CC-Widgets: State change action started: ${prevStateRef.current} -> ${currentState}`, { module: 'useUserState', method: 'useEffect - currentState', }); + // Wellness state is changed by the AI Assistant lifecycle. Reflect its + // SDK event (and the following restore event) without echoing either + // transition back through setAgentState. + if (isCurrentStateExternallyManaged || externallyManagedStateRef.current) { + prevStateRef.current = currentState; + externallyManagedStateRef.current = isCurrentStateExternallyManaged; + callOnStateChange(); + return; + } + // Call setAgentStatus and update prevStateRef after promise resolves updateAgentState(currentState) .then(() => { @@ -170,6 +196,7 @@ export const useUserState = ({ method: 'useEffect - currentState', }); prevStateRef.current = currentState; + externallyManagedStateRef.current = false; callOnStateChange(); }) .catch((error) => { @@ -185,7 +212,7 @@ export const useUserState = ({ method: 'useEffect - currentState', }); } - }, [currentState]); + }, [currentState, isCurrentStateExternallyManaged, wellnessIdleCodeLookupPending]); useEffect(() => { try { @@ -228,8 +255,10 @@ export const useUserState = ({ module: 'useUserState', method: 'setAgentStatus', }); + if (wellnessIdleCodeLookupPending) pendingUserSelectionRef.current = selectedCode; store.setCurrentState(selectedCode); } catch (error) { + pendingUserSelectionRef.current = undefined; logger?.error(`CC-Widgets: UserState: Error in setAgentStatus - ${error.message}`, { module: 'useUserState', method: 'setAgentStatus', diff --git a/packages/contact-center/user-state/src/user-state.types.ts b/packages/contact-center/user-state/src/user-state.types.ts index 56b97aa61..02dcd5d90 100644 --- a/packages/contact-center/user-state/src/user-state.types.ts +++ b/packages/contact-center/user-state/src/user-state.types.ts @@ -13,4 +13,9 @@ export type UseUserStateProps = Pick< | 'logger' | 'onStateChange' | 'lastIdleCodeChangeTimestamp' ->; +> & { + /** True when an SDK-owned lifecycle, rather than the dropdown, changed the current state. */ + isCurrentStateExternallyManaged?: boolean; + /** True until the SDK identifies the system-owned wellness idle code. */ + wellnessIdleCodeLookupPending?: boolean; +}; diff --git a/packages/contact-center/user-state/src/user-state/index.tsx b/packages/contact-center/user-state/src/user-state/index.tsx index aabdb26ef..9e38a4bab 100644 --- a/packages/contact-center/user-state/src/user-state/index.tsx +++ b/packages/contact-center/user-state/src/user-state/index.tsx @@ -17,10 +17,17 @@ const UserStateInternal: React.FunctionComponent = observer(({o lastIdleCodeChangeTimestamp, customState, logger, + wellbeingBreakIdleCode, + wellnessIdleCodeLookupPending, } = store; + const isWellnessBreakState = Boolean(wellbeingBreakIdleCode && currentState === wellbeingBreakIdleCode.id); + const displayedIdleCodes = + isWellnessBreakState && wellbeingBreakIdleCode && !idleCodes.some(({id}) => id === wellbeingBreakIdleCode.id) + ? [...idleCodes, wellbeingBreakIdleCode] + : idleCodes; const props: UserStateComponentsProps = { ...useUserState({ - idleCodes, + idleCodes: displayedIdleCodes, agentId, cc, currentState, @@ -29,6 +36,8 @@ const UserStateInternal: React.FunctionComponent = observer(({o logger, onStateChange, lastIdleCodeChangeTimestamp, + isCurrentStateExternallyManaged: isWellnessBreakState, + wellnessIdleCodeLookupPending, }), customState, logger, diff --git a/packages/contact-center/user-state/tests/helper.ts b/packages/contact-center/user-state/tests/helper.ts index 15f090f81..b95a7dc19 100644 --- a/packages/contact-center/user-state/tests/helper.ts +++ b/packages/contact-center/user-state/tests/helper.ts @@ -316,6 +316,117 @@ describe('useUserState Hook', () => { }); }); + it('does not echo externally managed wellness state transitions back to the SDK', async () => { + const wellnessIdleCodes = [ + ...idleCodes, + {id: 'wellness', name: 'WellbeingBreak', isSystem: true, isDefault: false}, + ]; + const {rerender} = renderHook( + ({currentState, isCurrentStateExternallyManaged, lastStateChangeTimestamp}) => + useUserState({ + idleCodes: wellnessIdleCodes, + agentId, + cc: mockCC, + currentState, + customState: null, + lastStateChangeTimestamp, + lastIdleCodeChangeTimestamp: lastStateChangeTimestamp, + isCurrentStateExternallyManaged, + logger, + onStateChange, + }), + { + initialProps: { + currentState: '1', + isCurrentStateExternallyManaged: false, + lastStateChangeTimestamp: 1_000, + }, + } + ); + + rerender({ + currentState: 'wellness', + isCurrentStateExternallyManaged: true, + lastStateChangeTimestamp: 2_000, + }); + rerender({ + currentState: '1', + isCurrentStateExternallyManaged: false, + lastStateChangeTimestamp: 3_000, + }); + + await waitFor(() => expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({name: 'WellbeingBreak'}))); + expect(mockCC.setAgentState).not.toHaveBeenCalled(); + expect(workerMock.postMessage).toHaveBeenCalledWith({type: 'reset', startTime: 2_000}); + expect(workerMock.postMessage).toHaveBeenCalledWith({type: 'reset', startTime: 3_000}); + }); + + it('waits for wellness-code resolution before sending a state change', async () => { + const wellnessIdleCodes = [ + ...idleCodes, + {id: 'wellness', name: 'WellbeingBreak', isSystem: true, isDefault: false}, + ]; + const {rerender} = renderHook( + ({currentState, wellnessIdleCodeLookupPending, isCurrentStateExternallyManaged}) => + useUserState({ + idleCodes: wellnessIdleCodes, + agentId, + cc: mockCC, + currentState, + customState: null, + lastStateChangeTimestamp: 1_000, + lastIdleCodeChangeTimestamp: 1_000, + wellnessIdleCodeLookupPending, + isCurrentStateExternallyManaged, + logger, + onStateChange, + }), + { + initialProps: { + currentState: '1', + wellnessIdleCodeLookupPending: true, + isCurrentStateExternallyManaged: false, + }, + } + ); + + rerender({currentState: 'wellness', wellnessIdleCodeLookupPending: true, isCurrentStateExternallyManaged: false}); + expect(mockCC.setAgentState).not.toHaveBeenCalled(); + + rerender({currentState: 'wellness', wellnessIdleCodeLookupPending: false, isCurrentStateExternallyManaged: true}); + rerender({currentState: '1', wellnessIdleCodeLookupPending: false, isCurrentStateExternallyManaged: false}); + expect(mockCC.setAgentState).not.toHaveBeenCalled(); + + rerender({currentState: '2', wellnessIdleCodeLookupPending: false, isCurrentStateExternallyManaged: false}); + await waitFor(() => expect(mockCC.setAgentState).toHaveBeenCalledWith(expect.objectContaining({auxCodeId: '2'}))); + }); + + it('sends an explicit user selection after wellness-code lookup completes', async () => { + const {result, rerender} = renderHook( + ({currentState, wellnessIdleCodeLookupPending}) => + useUserState({ + idleCodes, + agentId, + cc: mockCC, + currentState, + customState: null, + lastStateChangeTimestamp: 1_000, + lastIdleCodeChangeTimestamp: 1_000, + wellnessIdleCodeLookupPending, + logger, + onStateChange, + }), + {initialProps: {currentState: '1', wellnessIdleCodeLookupPending: true}} + ); + + act(() => result.current.setAgentStatus('2')); + rerender({currentState: '2', wellnessIdleCodeLookupPending: true}); + expect(mockCC.setAgentState).not.toHaveBeenCalled(); + + rerender({currentState: '2', wellnessIdleCodeLookupPending: false}); + await waitFor(() => expect(mockCC.setAgentState).toHaveBeenCalledWith(expect.objectContaining({auxCodeId: '2'}))); + }); + it('should handle errors from setAgentState and revert state', async () => { ccSetAgentStateSpy.mockRejectedValueOnce(new Error('Error setting agent status')); const {rerender} = renderHook( diff --git a/packages/contact-center/user-state/tests/user-state/index.tsx b/packages/contact-center/user-state/tests/user-state/index.tsx index 9fde381e8..7fd05a106 100644 --- a/packages/contact-center/user-state/tests/user-state/index.tsx +++ b/packages/contact-center/user-state/tests/user-state/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {render} from '@testing-library/react'; +import {render, screen} from '@testing-library/react'; import {UserState} from '../../src'; import * as helper from '../../src/helper'; import store from '@webex/cc-store'; @@ -24,16 +24,27 @@ jest.mock('@webex/cc-store', () => { lastIdleCodeChangeTimestamp: undefined, customState: null, currentState: '0', + wellbeingBreakIdleCode: undefined, + wellnessIdleCodeLookupPending: false, onErrorCallback: jest.fn(), }; }); +const mutableStore = store as unknown as { + currentState: string; + wellbeingBreakIdleCode?: {id: string; name: string; isSystem: boolean; isDefault: boolean}; + wellnessIdleCodeLookupPending: boolean; +}; + describe('UserState Component', () => { let workerMock; const onStateChange = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mutableStore.currentState = '0'; + mutableStore.wellbeingBreakIdleCode = undefined; + mutableStore.wellnessIdleCodeLookupPending = false; // Suppress console.error for error boundary tests jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -71,6 +82,8 @@ describe('UserState Component', () => { customState: null, lastStateChangeTimestamp: expect.any(Number), lastIdleCodeChangeTimestamp: undefined, + isCurrentStateExternallyManaged: false, + wellnessIdleCodeLookupPending: false, logger: { log: expect.any(Function), info: expect.any(Function), @@ -81,6 +94,29 @@ describe('UserState Component', () => { }); }); + it('projects WellbeingBreak as an externally managed state', () => { + const useUserStateSpy = jest.spyOn(helper, 'useUserState'); + mutableStore.currentState = 'wellness'; + mutableStore.wellbeingBreakIdleCode = { + id: 'wellness', + name: 'WellbeingBreak', + isSystem: true, + isDefault: false, + }; + + render(); + + expect(useUserStateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + currentState: 'wellness', + idleCodes: [expect.objectContaining({id: 'wellness', name: 'WellbeingBreak'})], + isCurrentStateExternallyManaged: true, + wellnessIdleCodeLookupPending: false, + }) + ); + expect(screen.getByTestId('elapsed-time')).toBeInTheDocument(); + }); + describe('ErrorBoundary Tests', () => { it('should render empty fragment when ErrorBoundary catches an error', () => { const mockOnErrorCallback = 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 6ac3ec5ff..d3b564de1 100644 --- a/widgets-samples/cc/samples-cc-react-app/package.json +++ b/widgets-samples/cc/samples-cc-react-app/package.json @@ -22,7 +22,9 @@ "@momentum-ui/tokens": "1.7.1", "@momentum-ui/utils": "6.2.15", "@momentum-ui/web-components": "^2.26.20", + "@webex/cc-ai-assistant": "workspace:*", "@webex/cc-widgets": "workspace:*", + "@webex/contact-center": "3.12.0-next.133", "babel-loader": "^9.2.1", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.6.3", @@ -32,7 +34,7 @@ "react-dom": "18.3.1", "ts-loader": "^9.5.1", "typescript": "^5.6.3", - "webex": "3.12.0-next.216", + "webex": "3.12.0-next.220", "webpack": "^5.94.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.1.0", diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.scss b/widgets-samples/cc/samples-cc-react-app/src/App.scss index d5b3347a7..6c338f22d 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.scss +++ b/widgets-samples/cc/samples-cc-react-app/src/App.scss @@ -225,3 +225,31 @@ iframe { min-height: 12rem; width: 100%; } + +.wellness-overlay-config { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0.75rem 0.5rem 1rem; + max-width: 24rem; +} + +.wellness-overlay-config__description { + color: var(--mds-color-theme-text-secondary-normal, #545454); + font-size: 0.875rem; + line-height: 1.25rem; + margin: 0; +} + +.wellness-overlay-demo-target { + box-sizing: border-box; + position: relative; +} + +.wellness-overlay-demo-target--active { + border: 0.125rem dashed var(--mds-color-theme-outline-secondary-normal, #929292); + border-radius: 0.5rem; + min-height: 36rem; + padding: 0.5rem; +} diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 4280c0453..379cf6bc6 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -9,8 +9,8 @@ import { store, OutdialCall, RealTimeTranscript, - AIAssistant, } from '@webex/cc-widgets'; +import {AIAssistant, type WellnessBreakOverlayTarget} from '@webex/cc-ai-assistant'; import {StationLogoutResponse} from '@webex/contact-center'; import {ERROR_TRIGGERING_IDLE_CODES} from '@webex/cc-store'; import Webex from 'webex'; @@ -42,6 +42,14 @@ const defaultWidgets = { aiAssistant: false, }; +type WellnessBreakOverlayMode = 'viewport' | 'assistant' | 'custom'; + +const wellnessBreakOverlayDescriptions: Record = { + viewport: 'Covers the browser viewport and prevents document scrolling during the break.', + assistant: 'Covers only the AI Assistant widget root.', + custom: 'Covers the bordered custom host container below.', +}; + function App() { const [isSdkReady, setIsSdkReady] = useState(false); const [selectedWidgets, setSelectedWidgets] = useState(() => { @@ -108,6 +116,12 @@ function App() { // AI Assistant fullscreen state — the widget reports toggles, the host decides the layout. const [isAIAssistantFullScreen, setIsAIAssistantFullScreen] = useState(false); + const [wellnessBreakOverlayMode, setWellnessBreakOverlayMode] = useState('viewport'); + const [wellnessBreakCustomContainer, setWellnessBreakCustomContainer] = useState(null); + const wellnessBreakOverlayTarget: WellnessBreakOverlayTarget = + wellnessBreakOverlayMode === 'custom' + ? wellnessBreakCustomContainer ?? 'assistant' + : wellnessBreakOverlayMode; const handleSaveStart = () => { setShowLoader(true); @@ -706,6 +720,38 @@ function App() {
+
+
+  AI Assistant Settings  +
+ +

+ {wellnessBreakOverlayDescriptions[wellnessBreakOverlayMode]} +

+
+
+
+
 SDK Toggles  @@ -1181,21 +1227,39 @@ function App() {
AI Assistant - console.log('AIAssistant opened')} - onMinimize={() => console.log('AIAssistant minimized')} - onRestore={() => console.log('AIAssistant restored')} - onClose={() => { - setIsAIAssistantFullScreen(false); - console.log('AIAssistant closed'); - }} - onFullScreenToggle={(isFs) => { - setIsAIAssistantFullScreen(isFs); - console.log('AIAssistant fullScreen', isFs); - }} - onRealTimeAssistReceived={(payload) => console.log('AIAssistant suggestion', payload)} - /> +
+ console.log('AIAssistant opened')} + onMinimize={() => console.log('AIAssistant minimized')} + onRestore={() => console.log('AIAssistant restored')} + onClose={() => { + setIsAIAssistantFullScreen(false); + console.log('AIAssistant closed'); + }} + onFullScreenToggle={(isFs) => { + setIsAIAssistantFullScreen(isFs); + console.log('AIAssistant fullScreen', isFs); + }} + onRealTimeAssistReceived={(payload) => console.log('AIAssistant suggestion', payload)} + onWellnessBreakOffered={() => console.log('AIAssistant wellness offered')} + onWellnessBreakAccepted={() => console.log('AIAssistant wellness accepted')} + onWellnessBreakStarted={() => console.log('AIAssistant wellness started')} + onWellnessBreakEnded={() => console.log('AIAssistant wellness ended')} + onWellnessBreakError={(error) => + console.error('AIAssistant wellness error', error.code) + } + /> +
diff --git a/widgets-samples/cc/samples-cc-react-app/webpack.config.js b/widgets-samples/cc/samples-cc-react-app/webpack.config.js index 5f713dd6d..ac724b137 100644 --- a/widgets-samples/cc/samples-cc-react-app/webpack.config.js +++ b/widgets-samples/cc/samples-cc-react-app/webpack.config.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const {HotModuleReplacementPlugin, ProvidePlugin} = require('webpack'); @@ -8,6 +9,7 @@ const resolveMonorepoRoot = (...segments) => path.resolve(__dirname, '../../../' const PKG_SRC = [ 'packages/contact-center/store/src', 'packages/contact-center/cc-widgets/src', + 'packages/contact-center/ai-assistant/src', 'packages/contact-center/station-login/src', 'packages/contact-center/user-state/src', 'packages/contact-center/task/src', @@ -15,6 +17,14 @@ const PKG_SRC = [ 'packages/contact-center/ui-logging/src', 'packages/contact-center/cc-digital-channels/src', ].map((p) => resolveMonorepoRoot(p)); +const contactCenterSdkRoot = process.env.WEBEX_CONTACT_CENTER_SDK_PATH; +const contactCenterSdkEntry = contactCenterSdkRoot + ? path.resolve(contactCenterSdkRoot, 'packages/@webex/contact-center/dist/webex.js') + : undefined; + +if (contactCenterSdkEntry && !fs.existsSync(contactCenterSdkEntry)) { + throw new Error(`WEBEX_CONTACT_CENTER_SDK_PATH does not contain ${contactCenterSdkEntry}`); +} module.exports = { mode: process.env.NODE_ENV || 'development', @@ -44,12 +54,14 @@ module.exports = { alias: { '@webex/cc-store': path.resolve(__dirname, '../../../packages/contact-center/store/src'), '@webex/cc-widgets': path.resolve(__dirname, '../../../packages/contact-center/cc-widgets/src'), + '@webex/cc-ai-assistant': path.resolve(__dirname, '../../../packages/contact-center/ai-assistant/src'), '@webex/cc-station-login': path.resolve(__dirname, '../../../packages/contact-center/station-login/src'), '@webex/cc-user-state': path.resolve(__dirname, '../../../packages/contact-center/user-state/src'), '@webex/cc-task': path.resolve(__dirname, '../../../packages/contact-center/task/src'), '@webex/cc-components': path.resolve(__dirname, '../../../packages/contact-center/cc-components/src'), '@webex/cc-ui-logging': path.resolve(__dirname, '../../../packages/contact-center/ui-logging/src'), '@webex/cc-digital-channels': path.resolve(__dirname, '../../../packages/contact-center/cc-digital-channels/src'), + ...(contactCenterSdkEntry ? {'@webex/contact-center$': contactCenterSdkEntry} : {}), // Ensure single React instance across all packages react: resolveMonorepoRoot('node_modules/react'), 'react-dom': resolveMonorepoRoot('node_modules/react-dom'), @@ -127,6 +139,8 @@ module.exports = { path: path.resolve(__dirname, '../../../docs/samples-cc-react-app'), // Output directory filename: 'bundle.js', // Output bundle file name clean: true, // Clean dist folder before each build + publicPath: 'auto', + chunkFilename: 'assets/[name].[contenthash:8].js', }, plugins: [ new HtmlWebpackPlugin({ diff --git a/widgets-samples/cc/samples-cc-wc-app/app.js b/widgets-samples/cc/samples-cc-wc-app/app.js index a558f2c12..c5c4a74dd 100644 --- a/widgets-samples/cc/samples-cc-wc-app/app.js +++ b/widgets-samples/cc/samples-cc-wc-app/app.js @@ -15,6 +15,7 @@ const ccTaskList = document.createElement('widget-cc-task-list'); const ccCallControl = document.createElement('widget-cc-call-control'); const ccCallControlCAD = document.createElement('widget-cc-call-control-cad'); const ccOutdial = document.createElement('widget-cc-outdial-call'); +const ccAiAssistant = document.createElement('widget-cc-ai-assistant'); const initWidgetsButton = document.getElementById('init-widgets'); const themeProviderElem = document.getElementById('theme-provider-elem'); @@ -26,6 +27,7 @@ const taskListCheckbox = document.getElementById('taskListCheckbox'); const callControlCheckbox = document.getElementById('callControlCheckbox'); const callControlCADCheckbox = document.getElementById('callControlCADCheckbox'); const outdialCallCheckbox = document.getElementById('outdialCallCheckbox'); +const aiAssistantCheckbox = document.getElementById('aiAssistantCheckbox'); let isMultiLoginEnabled = false; let hasCampaignPreviewEnabled = true; let integrationEnv = false; @@ -205,6 +207,14 @@ function initWidgets() { ccCallControlCAD.onHoldResume = onHoldResume; ccCallControlCAD.onEnd = onEnd; ccCallControlCAD.onWrapUp = onWrapUp; + ccAiAssistant.onWellnessBreakOffered = () => console.log('AIAssistant wellness offered'); + ccAiAssistant.onWellnessBreakAccepted = () => console.log('AIAssistant wellness accepted'); + ccAiAssistant.onWellnessBreakStarted = () => console.log('AIAssistant wellness started'); + ccAiAssistant.onWellnessBreakEnded = () => console.log('AIAssistant wellness ended'); + ccAiAssistant.onWellnessBreakError = (error) => console.error('AIAssistant wellness error', error.code); + ccAiAssistant.onFullScreenToggle = (isFullScreen) => { + ccAiAssistant.classList.toggle('ai-assistant-fullscreen-host', isFullScreen); + }; if (stationLoginCheckbox.checked) { ccStationLogin.classList.remove('disabled'); @@ -294,6 +304,20 @@ function loginSuccess() { callControlCADContainer.querySelector('fieldset').appendChild(ccCallControlCAD); widgetsContainer.appendChild(callControlCADContainer); } + if (aiAssistantCheckbox.checked && !ccAiAssistant.isConnected) { + ccAiAssistant.classList.remove('disabled'); + const aiAssistantContainer = document.createElement('div'); + aiAssistantContainer.className = 'box'; + aiAssistantContainer.innerHTML = ` +
+
+ AI Assistant +
+
+ `; + aiAssistantContainer.querySelector('fieldset').appendChild(ccAiAssistant); + widgetsContainer.appendChild(aiAssistantContainer); + } } function logoutSuccess() { @@ -304,6 +328,7 @@ function logoutSuccess() { ccCallControl.classList.add('disabled'); ccCallControlCAD.classList.add('disabled'); ccOutdial.classList.add('disabled'); + ccAiAssistant.classList.add('disabled'); } function onStateChange(status) { diff --git a/widgets-samples/cc/samples-cc-wc-app/index.html b/widgets-samples/cc/samples-cc-wc-app/index.html index 4f5d5d035..bb4282918 100644 --- a/widgets-samples/cc/samples-cc-wc-app/index.html +++ b/widgets-samples/cc/samples-cc-wc-app/index.html @@ -25,6 +25,13 @@ color: var(--mds-color-theme-button-primary-normal); background-color: var(--mds-color-theme-inverted-text-primary-normal); } + + widget-cc-ai-assistant.ai-assistant-fullscreen-host { + background: var(--mds-color-theme-background-primary-normal, white); + inset: 0; + position: fixed; + z-index: 999; + } @@ -68,6 +75,7 @@

Contact Center widgets as web-component

+
diff --git a/yarn.lock b/yarn.lock index 7229b436b..cd92c0bb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9616,6 +9616,7 @@ __metadata: globals: "npm:^16.0.0" jest: "npm:29.7.0" jest-environment-jsdom: "npm:29.7.0" + lottie-web: "npm:5.13.0" mobx-react-lite: "npm:^4.1.0" prettier: "npm:^3.5.1" react-error-boundary: "npm:^6.0.0" @@ -9791,7 +9792,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.131" + "@webex/contact-center": "npm:3.12.0-next.133" "@webex/event-dictionary-ts": "npm:^1.0.2191" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" @@ -10124,15 +10125,15 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-next.130": - version: 3.12.0-next.130 - resolution: "@webex/contact-center@npm:3.12.0-next.130" +"@webex/contact-center@npm:3.12.0-next.133": + version: 3.12.0-next.133 + resolution: "@webex/contact-center@npm:3.12.0-next.133" dependencies: "@types/platform": "npm:1.3.4" "@webex/calling": "npm:3.12.0-next.102" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/internal-plugin-metrics": "npm:3.12.0-next.49" - "@webex/internal-plugin-support": "npm:3.12.0-next.57" + "@webex/internal-plugin-support": "npm:3.12.0-next.58" "@webex/plugin-authorization": "npm:3.12.0-next.49" "@webex/plugin-logger": "npm:3.12.0-next.49" "@webex/webex-core": "npm:3.12.0-next.49" @@ -10140,27 +10141,7 @@ __metadata: lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" xstate: "npm:5.24.0" - checksum: 10c0/390403e37fbac2e07f3fa066bca2f07e34bf9a6502fb1e5d935a92fedaa6f5b5e9701e3c55a438a0608ab2d7544b0e9c1ce7dc27dac9351bf79b9c19e7b4b972 - languageName: node - linkType: hard - -"@webex/contact-center@npm:3.12.0-next.131": - version: 3.12.0-next.131 - resolution: "@webex/contact-center@npm:3.12.0-next.131" - dependencies: - "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.102" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.49" - "@webex/internal-plugin-support": "npm:3.12.0-next.57" - "@webex/plugin-authorization": "npm:3.12.0-next.49" - "@webex/plugin-logger": "npm:3.12.0-next.49" - "@webex/webex-core": "npm:3.12.0-next.49" - jest-html-reporters: "npm:3.0.11" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - xstate: "npm:5.24.0" - checksum: 10c0/e47c250186662fb9f3d32e9fd2c5b90294ccb5dc43c031f6c167b6e9d96258b726fbfdc8c9676887dca2ee30e8c8261e91948e03aca0186741fc17afbea7a5f1 + checksum: 10c0/181a14c92513ae419f12ceea9bae7b97f5348b2189fc2b1b40f940d4419af625e79c637675f5384b0ab3fe2123071b08de3126a0f4e8dd3822d34d72dbdb849a languageName: node linkType: hard @@ -10409,17 +10390,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-calendar@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/internal-plugin-calendar@npm:3.12.0-next.57" +"@webex/internal-plugin-calendar@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/internal-plugin-calendar@npm:3.12.0-next.58" dependencies: - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-device": "npm:3.12.0-next.49" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.55" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.56" "@webex/webex-core": "npm:3.12.0-next.49" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/847eb91b319063036b4d1676e7b713d85f2e2b5afc14f60f15503f456bb6532e917092c87a2ead34be52e26e4cfc316023b51f646abb3e8b791e49ee1ee73fe5 + checksum: 10c0/53170eef2c813255f6dbb09e7a688da25e5c53fe5b5514180d37c86e183b57cf48eb9e3d241ee90161932dc803c3dfffb51a01d0311ca228bf12f56201c0aa5a languageName: node linkType: hard @@ -10459,21 +10440,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.57" +"@webex/internal-plugin-conversation@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" "@webex/helper-html": "npm:3.12.0-next.8" "@webex/helper-image": "npm:3.12.0-next.7" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.55" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.56" "@webex/internal-plugin-user": "npm:3.12.0-next.50" "@webex/webex-core": "npm:3.12.0-next.49" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/bbd37bfd16f3de72d946e7052c1664fa8cb3240941c397d49cb33a0bc5da4cb9ebcd12147ef864cd874d5187edc7b97d4ac4de822a3bb22385a6ac5ae8d9980a + checksum: 10c0/15a3ae52f72ecbab6b473f69bb44767824e4e8835cb9665b92b555ef4250fd5850864ff41ca7ffe565a7b29441b724f9c6b8f1c70bc1622972ad4083ca3030df languageName: node linkType: hard @@ -10593,9 +10574,9 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.12.0-next.55": - version: 3.12.0-next.55 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.55" +"@webex/internal-plugin-encryption@npm:3.12.0-next.56": + version: 3.12.0-next.56 + resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.56" dependencies: "@webex/common": "npm:3.12.0-next.7" "@webex/common-timers": "npm:3.12.0-next.7" @@ -10615,7 +10596,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/10bdd5371130febd65e81333d75fcc4ab2aecc5afca84ae187031edea6ccf52ec6d90182acc3a343be307219ee71ffa1faf66fae34b9bbc534fd54cdb28d3185 + checksum: 10c0/68c9041866cdb0293342dd58a7c89abcaf4cacf7ef55a0c7ff199c7dfc34784b137840c3c574021d44379b3593195675a6ab1f757b35f0098cdde86ca42080ea languageName: node linkType: hard @@ -10707,20 +10688,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-lyra@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/internal-plugin-lyra@npm:3.12.0-next.57" +"@webex/internal-plugin-lyra@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/internal-plugin-lyra@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.55" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.56" "@webex/internal-plugin-feature": "npm:3.12.0-next.49" "@webex/internal-plugin-locus": "npm:3.12.0-next.53" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/webex-core": "npm:3.12.0-next.49" bowser: "npm:^2.11.0" uuid: "npm:^3.3.2" - checksum: 10c0/59a3c5d88e266e3b9b743ce34958b33fdd4232fda3b1c26bdfa3b26270c164956dbaa4cf9692c30c2d1487de6977cd8713c6865dfae733033999e9de3fdaa471 + checksum: 10c0/307513e40d1588e1505137409c134e5ee65186fa00b1fdd0812d7a292716b29bb8c3cf0705cbf1b1e01f64e5f420837a2bbe3970a38b61a573d5facadcbb7ef5 languageName: node linkType: hard @@ -10890,18 +10871,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.57" +"@webex/internal-plugin-search@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/internal-plugin-search@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-device": "npm:3.12.0-next.49" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.55" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.56" "@webex/webex-core": "npm:3.12.0-next.49" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e94c0ff0d9a9a284fc6211431ca9dd76d6f4218b46774fa62de554e965f5783471e4ae5cc388ef89ac056cb929343893a848ac53985755a94a87e336c974107c + checksum: 10c0/d9babcb4dc35c5b43e4c4a6d43f901041e1cde3ab61d2e3b74e6f977eec04cdd98374ed6c0734acfd4b11a884fee6dcb9bc640f940d2c68e9ab334e98b6fe638 languageName: node linkType: hard @@ -10922,12 +10903,12 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.57" +"@webex/internal-plugin-support@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/internal-plugin-support@npm:3.12.0-next.58" dependencies: "@webex/internal-plugin-device": "npm:3.12.0-next.49" - "@webex/internal-plugin-search": "npm:3.12.0-next.57" + "@webex/internal-plugin-search": "npm:3.12.0-next.58" "@webex/test-helper-chai": "npm:3.12.0-next.7" "@webex/test-helper-file": "npm:3.12.0-next.7" "@webex/test-helper-mock-webex": "npm:3.12.0-next.7" @@ -10935,11 +10916,11 @@ __metadata: "@webex/webex-core": "npm:3.12.0-next.49" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/39b5ff7880191cae398dd89129b863532f9fbf304214f9be6521abd57a3e2dee381fe7c552c552d2c40c638d6f183a1aeca4932256a42036bfd7e73e05bc967d + checksum: 10c0/401fbf4d437f66df131256e259954836cd765e374e3de438ee3c117637be4b5a78213a67e8e62bb25a42089fc0006e4f125d876183b4a85bfdb67a1be9d422a9 languageName: node linkType: hard -"@webex/internal-plugin-task@npm:^3.12.0-next.57": +"@webex/internal-plugin-task@npm:^3.12.0-next.58": version: 3.12.0 resolution: "@webex/internal-plugin-task@npm:3.12.0" dependencies: @@ -11074,27 +11055,27 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-attachment-actions@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-attachment-actions@npm:3.12.0-next.57" +"@webex/plugin-attachment-actions@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/plugin-attachment-actions@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/webex-core": "npm:3.12.0-next.49" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: "@webex/plugin-logger": 3.12.0-next.49 - "@webex/plugin-messages": 3.12.0-next.57 + "@webex/plugin-messages": 3.12.0-next.58 "@webex/plugin-people": 3.12.0-next.53 - checksum: 10c0/0014a034e3534eed5906a6a862f5ae99a0b1a8152202a8b4718faa793d92bcd93f91fa780d3f4f54d7ee889277a606b44bd36f4d692f890e780aa63923b35fa7 + checksum: 10c0/1443ad46bfa3c03fc84f07ddaf3bde6fc1f56171a804007061b7839585d40f77fe641897eafedbfb40352d955f4be97a940f1eeaf895a81fa45ad35acd33eff3 languageName: node linkType: hard -"@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.51": - version: 3.12.0-next.51 - resolution: "@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.51" +"@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.52": + version: 3.12.0-next.52 + resolution: "@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.52" dependencies: "@webex/common": "npm:3.12.0-next.7" "@webex/storage-adapter-local-storage": "npm:3.12.0-next.49" @@ -11107,7 +11088,7 @@ __metadata: crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/83516e1c1b27b9d4f5ad45d255dc63c4d7cc8241a31cacecaf6d133339f67f3c20a4092370df4fb85e6b05a42b79efbeedf7a393c96f45d4876c79da8a7ba08c + checksum: 10c0/2f24f079cba03946b0b454ffa178a83e266800e040d27db6f6d94909cb1fb0f7a8aa246de4895796eaee95ce0777f727318adab20dc04297f8b8ee70433e98e0 languageName: node linkType: hard @@ -11209,31 +11190,31 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-device-manager@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-device-manager@npm:3.12.0-next.57" +"@webex/plugin-device-manager@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/plugin-device-manager@npm:3.12.0-next.58" dependencies: - "@webex/internal-plugin-calendar": "npm:3.12.0-next.57" + "@webex/internal-plugin-calendar": "npm:3.12.0-next.58" "@webex/internal-plugin-device": "npm:3.12.0-next.49" - "@webex/internal-plugin-lyra": "npm:3.12.0-next.57" - "@webex/internal-plugin-search": "npm:3.12.0-next.57" + "@webex/internal-plugin-lyra": "npm:3.12.0-next.58" + "@webex/internal-plugin-search": "npm:3.12.0-next.58" "@webex/plugin-authorization": "npm:3.12.0-next.49" "@webex/plugin-logger": "npm:3.12.0-next.49" "@webex/test-helper-chai": "npm:3.12.0-next.7" "@webex/webex-core": "npm:3.12.0-next.49" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/95e8597183fd8bff25da2e2a6a0c94ed128fc8748087731f3e2cdce1e8190f8cb1a7f7ba429091720588e4c88c5c17b12c1bc79a5126a2a63b2e9bcb8bbd8bdd + checksum: 10c0/5fbcc3f74bc1d7949f94184971ccedfc07835c09d69efc49eb96f83e9f42e42e9607f9325e140a5603f80987b787c8b4298e4e5eb1828d4edf5e655c42878316 languageName: node linkType: hard -"@webex/plugin-encryption@npm:3.12.0-next.55": - version: 3.12.0-next.55 - resolution: "@webex/plugin-encryption@npm:3.12.0-next.55" +"@webex/plugin-encryption@npm:3.12.0-next.56": + version: 3.12.0-next.56 + resolution: "@webex/plugin-encryption@npm:3.12.0-next.56" dependencies: - "@webex/internal-plugin-encryption": "npm:3.12.0-next.55" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.56" "@webex/webex-core": "npm:3.12.0-next.49" - checksum: 10c0/9987639d563ae28afd2c5fe3f80abc572c0f71fed93711100442000183f92f326017103eb596511519697726bcabd502d3f78f51db79bc0cfdcbf361b75a8fb1 + checksum: 10c0/f8d89c36dea75aa28989dc4453dd076bb69596404feafbbfc16c38bdb73150e16ba74f3c31095bdcb521afa7a0c8fa39452b39d83abe252a89f62bfe8be1720b languageName: node linkType: hard @@ -11292,24 +11273,24 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-meetings@npm:3.12.0-next.149": - version: 3.12.0-next.149 - resolution: "@webex/plugin-meetings@npm:3.12.0-next.149" +"@webex/plugin-meetings@npm:3.12.0-next.150": + version: 3.12.0-next.150 + resolution: "@webex/plugin-meetings@npm:3.12.0-next.150" dependencies: "@webex/common": "npm:3.12.0-next.7" "@webex/event-dictionary-ts": "npm:^1.0.2215" "@webex/internal-media-core": "npm:2.30.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-device": "npm:3.12.0-next.49" "@webex/internal-plugin-llm": "npm:3.12.0-next.56" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/internal-plugin-metrics": "npm:3.12.0-next.49" - "@webex/internal-plugin-support": "npm:3.12.0-next.57" + "@webex/internal-plugin-support": "npm:3.12.0-next.58" "@webex/internal-plugin-user": "npm:3.12.0-next.50" "@webex/internal-plugin-voicea": "npm:3.12.0-next.59" "@webex/media-helpers": "npm:3.12.0-next.15" "@webex/plugin-people": "npm:3.12.0-next.53" - "@webex/plugin-rooms": "npm:3.12.0-next.57" + "@webex/plugin-rooms": "npm:3.12.0-next.58" "@webex/ts-sdp": "npm:^1.8.1" "@webex/web-capabilities": "npm:^1.13.1" "@webex/webex-core": "npm:3.12.0-next.49" @@ -11326,7 +11307,7 @@ __metadata: uuid: "npm:^3.3.2" webrtc-adapter: "npm:^8.1.2" xxh3-ts: "npm:^2.0.1" - checksum: 10c0/1bc15fe5566a2597257603d6d1ad2d93a220deef819b3167064110bda72e04824c06146057248da0453b8d272d67481745f4dfd8cde06a5020c319f7da81bffd + checksum: 10c0/983c0044dce531cf177e6730c6e095bb9f3381cb0d5b9fd1575f541408038f11f5a8c4bfde94470164ab82a6e97f7251f8bb3bd6f9b9672f0ab0421e71df18bc languageName: node linkType: hard @@ -11348,22 +11329,22 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-memberships@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-memberships@npm:3.12.0-next.57" +"@webex/plugin-memberships@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/plugin-memberships@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/webex-core": "npm:3.12.0-next.49" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: "@webex/plugin-logger": 3.12.0-next.49 - "@webex/plugin-messages": 3.12.0-next.57 + "@webex/plugin-messages": 3.12.0-next.58 "@webex/plugin-people": 3.12.0-next.53 - "@webex/plugin-rooms": 3.12.0-next.57 - checksum: 10c0/df1c4cce3342804e35df521e721be5c7a3af26c49e856aa504ce766b8fa6f4857639519fdc2303053a27e46074d1b388a623dc202d896bb58188eca7f308bed4 + "@webex/plugin-rooms": 3.12.0-next.58 + checksum: 10c0/288aed3ee117399002026de3dad268060f374853f4cce555cbb38d70441d908771c325bd8e0c511285139dd3c1dae4732d284bb5e64071ccebef1adadbf6d666 languageName: node linkType: hard @@ -11385,12 +11366,12 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-messages@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-messages@npm:3.12.0-next.57" +"@webex/plugin-messages@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/plugin-messages@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-device": "npm:3.12.0-next.49" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/webex-core": "npm:3.12.0-next.49" @@ -11399,8 +11380,8 @@ __metadata: peerDependencies: "@webex/plugin-logger": 3.12.0-next.49 "@webex/plugin-people": 3.12.0-next.53 - "@webex/plugin-rooms": 3.12.0-next.57 - checksum: 10c0/864e0cb48dec4e1b2f19dd9ba6a1eafdd42aeadfccc710b01d3f6c6848a95600762e23b65e74c1e1ebb6acf247be56ae8e7e7ae72c449ca97580c3f2b00f7ca1 + "@webex/plugin-rooms": 3.12.0-next.58 + checksum: 10c0/94c2fe13fe2da020943e21e9b71bb72af8321ada260409bdd091c1acd16d2d609ef4c01484960b439a870c43503c7657d7940f66e69153796c6e7dd8d6a96357 languageName: node linkType: hard @@ -11444,12 +11425,12 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-rooms@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-rooms@npm:3.12.0-next.57" +"@webex/plugin-rooms@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/plugin-rooms@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.7" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.58" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/webex-core": "npm:3.12.0-next.49" debug: "npm:^4.3.4" @@ -11457,7 +11438,7 @@ __metadata: peerDependencies: "@webex/plugin-logger": 3.12.0-next.49 "@webex/plugin-people": 3.12.0-next.53 - checksum: 10c0/c36d6ad22f9e404d87529400aababf0086e96650841bbdd0468ac0bbd7bb642ebb4ca724d8ffb5efe6042242cc266714c14768a3e640c9e50feb8d904f176026 + checksum: 10c0/cca33e05dfba876b026d5fde01f139634d923a902a29d007a6ec681ddf9e8f65be117ecfbc8c034d078804f13ac382c0525c56f8c94a0fe60661ce1234ead7ed languageName: node linkType: hard @@ -24988,7 +24969,7 @@ __metadata: languageName: node linkType: hard -"lottie-web@npm:^5.12.2": +"lottie-web@npm:5.13.0, lottie-web@npm:^5.12.2": version: 5.13.0 resolution: "lottie-web@npm:5.13.0" checksum: 10c0/b463ad462169df3f7e04b7f3716274e269842554f1d72b8303ca7245628f2512982a59ae31698b3d57fbcda886228ae160fcb2aa518c6cecd8a95974db2458ee @@ -30767,7 +30748,9 @@ __metadata: "@momentum-ui/tokens": "npm:1.7.1" "@momentum-ui/utils": "npm:6.2.15" "@momentum-ui/web-components": "npm:^2.26.20" + "@webex/cc-ai-assistant": "workspace:*" "@webex/cc-widgets": "workspace:*" + "@webex/contact-center": "npm:3.12.0-next.133" babel-loader: "npm:^9.2.1" eslint: "npm:^9.20.1" eslint-config-prettier: "npm:^10.0.1" @@ -30788,7 +30771,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.216" + webex: "npm:3.12.0-next.220" webpack: "npm:^5.94.0" webpack-cli: "npm:^5.1.4" webpack-dev-server: "npm:^5.1.0" @@ -34922,35 +34905,35 @@ __metadata: languageName: node linkType: hard -"webex@npm:3.12.0-next.216": - version: 3.12.0-next.216 - resolution: "webex@npm:3.12.0-next.216" +"webex@npm:3.12.0-next.220": + version: 3.12.0-next.220 + resolution: "webex@npm:3.12.0-next.220" dependencies: "@babel/polyfill": "npm:^7.12.1" "@babel/runtime-corejs2": "npm:^7.14.8" "@webex/calling": "npm:3.12.0-next.102" "@webex/common": "npm:3.12.0-next.7" - "@webex/contact-center": "npm:3.12.0-next.130" - "@webex/internal-plugin-calendar": "npm:3.12.0-next.57" + "@webex/contact-center": "npm:3.12.0-next.133" + "@webex/internal-plugin-calendar": "npm:3.12.0-next.58" "@webex/internal-plugin-device": "npm:3.12.0-next.49" "@webex/internal-plugin-dss": "npm:3.12.0-next.53" "@webex/internal-plugin-llm": "npm:3.12.0-next.56" "@webex/internal-plugin-mercury": "npm:3.12.0-next.53" "@webex/internal-plugin-presence": "npm:3.12.0-next.53" - "@webex/internal-plugin-support": "npm:3.12.0-next.57" - "@webex/internal-plugin-task": "npm:^3.12.0-next.57" + "@webex/internal-plugin-support": "npm:3.12.0-next.58" + "@webex/internal-plugin-task": "npm:^3.12.0-next.58" "@webex/internal-plugin-voicea": "npm:3.12.0-next.59" - "@webex/plugin-attachment-actions": "npm:3.12.0-next.57" + "@webex/plugin-attachment-actions": "npm:3.12.0-next.58" "@webex/plugin-authorization": "npm:3.12.0-next.49" - "@webex/plugin-authorization-browser-first-party": "npm:3.12.0-next.51" - "@webex/plugin-device-manager": "npm:3.12.0-next.57" - "@webex/plugin-encryption": "npm:3.12.0-next.55" + "@webex/plugin-authorization-browser-first-party": "npm:3.12.0-next.52" + "@webex/plugin-device-manager": "npm:3.12.0-next.58" + "@webex/plugin-encryption": "npm:3.12.0-next.56" "@webex/plugin-logger": "npm:3.12.0-next.49" - "@webex/plugin-meetings": "npm:3.12.0-next.149" - "@webex/plugin-memberships": "npm:3.12.0-next.57" - "@webex/plugin-messages": "npm:3.12.0-next.57" + "@webex/plugin-meetings": "npm:3.12.0-next.150" + "@webex/plugin-memberships": "npm:3.12.0-next.58" + "@webex/plugin-messages": "npm:3.12.0-next.58" "@webex/plugin-people": "npm:3.12.0-next.53" - "@webex/plugin-rooms": "npm:3.12.0-next.57" + "@webex/plugin-rooms": "npm:3.12.0-next.58" "@webex/plugin-team-memberships": "npm:3.12.0-next.49" "@webex/plugin-teams": "npm:3.12.0-next.49" "@webex/plugin-webhooks": "npm:3.12.0-next.49" @@ -34958,7 +34941,7 @@ __metadata: "@webex/webex-core": "npm:3.12.0-next.49" lodash: "npm:^4.17.21" safe-buffer: "npm:^5.2.0" - checksum: 10c0/734cd1594bfb1311e688b40e7ccfffe5cdffae4b81c992098b44cef62e16dfc105f61dc2fa833b2ebed7ba88f4eb96926719738a255f8113bb5bcd8e04c8347c + checksum: 10c0/92bb44ff4d423646a04e16dbc649c0a17bbc086880c16ae7064513b86d8d28314270b947db0fd44d7cc25aa13349f8568eb403555155362fab70daa492bcea28 languageName: node linkType: hard