diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index d160f4f90..6f076c255 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -26,6 +26,7 @@ The React entry of `@webex/cc-widgets` re-exports the widgets, including `AIAssi | 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.ai-summary | `@webex/cc-store` | `aiSummaries`, summary request/edit/copy/feedback methods, `getAISummarySurface`, `onAISummaryStatusChange` | Entries keyed by interaction and role retain SDK `AISummary` in `content` and `AISummaryResponse` in `response`. Store actions update SDK response fields; senders forward that record. Consumers read feedback and counts from `entry.response` and enablement directly from `task.aiSummaryCapabilities`; `task:featureEnablement` refreshes the task view. Components choose the display format. Capture the post-call response before wrap-up, then call `sendPostCallSummaryResponse(response, originalTask)` with the required captured task. Status callbacks contain no summary text. | additive; uses published `@webex/contact-center` `3.12.0-next.148` | `packages/contact-center/store/ai-docs/store-spec.md` (AI summaries) | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/store/src/storeEventsWrapper.ts` | | 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` | | cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action`, `availableDestinations`, and optional list-item `presence` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list/category order, passes buddy availability to Momentum Avatar as semantic presence, shows `AddressBookEntry.number` and `EntryPointRecord.number` subtitles, and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts; optional presentation prop is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md`; `ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | @@ -37,6 +38,13 @@ The React entry of `@webex/cc-widgets` re-exports the widgets, including `AIAssi | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | | ui-logging.WidgetMetrics | `@webex/cc-ui-logging` | `WidgetMetrics` (type) | `{ widgetName; event: 'WIDGET_MOUNTED' \| 'ERROR' \| 'WIDGET_UNMOUNTED' \| 'PROPS_UPDATED'; props?; timestamp; additionalContext? }` | stable semver; narrowing the `event` union or removing a field is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | +## AI summary surfaces + +| Contract ID | Package | Surface | Purpose | Compatibility | Detail | +| --- | --- | --- | --- | --- | --- | +| task.ai-summary-status | `@webex/cc-task`, `@webex/cc-widgets` | `CallControl.onAISummaryStatusChange`, `AISummaryStatusDetail` | Optional content-free status callback; the CallControl custom element accepts a function property, with inert callback attributes | additive optional callback and type | `packages/contact-center/task/ai-docs/task-spec.md`, `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | +| cc-components.ai-summary-props | `@webex/cc-components` | `CallControlComponentProps.aiSummary`, `AIAssistantComponentProps.receiverSummary` | Optional summary views and callbacks; the shared AISummary component is internal | additive optional props; wrapupCall supports void or Promise | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | + ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| diff --git a/package.json b/package.json index 52fa13a51..d3fa462ca 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,11 @@ }, "resolutions": { "@momentum-design/components": "0.133.23", - "@momentum-design/icons": "0.17.0" + "@momentum-design/icons": "0.17.0", + "@webex/contact-center": "3.12.0-next.148", + "@webex/internal-plugin-device@npm:3.12.0-next.55": "3.12.0-next.54", + "@webex/internal-plugin-metrics@npm:3.12.0-next.56": "3.12.0-next.55", + "@webex/internal-plugin-mercury@npm:3.12.0-next.62": "3.12.0-next.61" }, "scripts": { "clean": "yarn workspaces foreach --all --topological --parallel run clean && rm -rf node_modules", 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 17beac66b..65571d0dc 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 @@ -271,6 +271,23 @@ views and the ErrorBoundary path. `tests/ai-assistant/feedback.tsx` covers like/ and the missing-`adaptiveCardId` warning. UI-level rendering (spinner, error text, snapshots) is covered in `cc-components/tests/components/AIAssistant/`. +## Receiving-agent AI summaries + +`getReceiverSummary` in `src/helper.ts` reads the current interaction's receiver entry +from `store.aiSummaries`, maps its surface with `getAISummarySurface`, and passes the +raw SDK content, revision, confirmed feedback from `entry.response.feedback` and +pending state to the presentation component. Its copy and feedback callbacks pass the +captured Task and displayed revision to the store. The widget makes no summary SDK calls. + +The receiver branch is independent of Real-time Assist enablement. A missing receiver +entry supplies no branch. SDK content with `areTranscriptsAvailable: false` supplies +the unavailable surface, and a rejected request supplies the generic error surface. +Card rendering and unsupported-card fallback belong to the presentation component. +Summary changes are observed by `AIAssistantInternal`. + +Evidence: `src/helper.ts`, `src/ai-assistant/index.tsx`; +tests: receiver summary cases in `tests/ai-assistant/index.tsx`. + ## Traceability - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md` 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 0f979e7ae..7fae47fa9 100644 --- a/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx +++ b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx @@ -4,7 +4,7 @@ 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 {getReceiverSummary, useAiAssistant, REAL_TIME_ASSIST_FLAG} from '../helper'; import {useWellnessBreak} from '../wellness/useWellnessBreak'; import {loadWellnessAnimation} from '../wellness/animation'; import {IAIAssistantProps} from '../ai-assistant.types'; @@ -71,6 +71,7 @@ const AIAssistantInternal: React.FunctionComponent = observer return ( { return useMemo(() => ({...chrome, ...realTimeAssist}), [chrome, realTimeAssist]); }; +/** The receiving agent's consult/transfer summary, pushed by the backend for the task's interaction. */ +export const getReceiverSummary = (task?: ITask): AIAssistantReceiverSummary | undefined => { + const interactionId = task?.data.interactionId; + const entry = interactionId ? store.aiSummaries[interactionId]?.receiver : undefined; + const surface = getAISummarySurface(entry); + const branchKey = `mid-call:receiver:${interactionId}`; + if (surface === 'content' && entry.content.adaptiveCard) { + return { + surface, + branchKey, + content: entry.content, + contentRevision: entry.revision, + selectedFeedback: entry.response.feedback, + feedbackPending: Boolean(entry.feedbackPending), + recordReceiverSummaryCopied: (expectedRevision) => + store.recordAISummaryCopied('receiver', expectedRevision, task), + setReceiverSummaryFeedback: (feedback, expectedRevision) => + void store.setReceiverSummaryFeedback(feedback, expectedRevision, task), + }; + } + return surface === 'unavailable' || surface === 'generic-error' ? {surface, branchKey} : undefined; +}; + export {REAL_TIME_ASSIST_FLAG}; diff --git a/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx b/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx index e318d59eb..de6b4c5cf 100644 --- a/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx +++ b/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx @@ -59,6 +59,7 @@ jest.mock('@webex/cc-components', () => { jest.mock('@webex/cc-store', () => ({ __esModule: true, + getAISummarySurface: jest.requireActual('@webex/cc-store').getAISummarySurface, default: { cc: { apiAIAssistant: { @@ -70,6 +71,7 @@ jest.mock('@webex/cc-store', () => ({ agentId: 'agent-1', featureFlags: {isSuggestedResponsesEnabled: true}, realTimeAssist: {'interaction-1': []}, + aiSummaries: {}, clearRealTimeAssist: jest.fn(), logger: {error: jest.fn(), warn: jest.fn()}, }, diff --git a/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx index 2a405ab35..10899db7d 100644 --- a/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx +++ b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx @@ -1,27 +1,48 @@ import React from 'react'; -import {fireEvent, render, screen, within} from '@testing-library/react'; +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import {runInAction} from 'mobx'; import {AIAssistant} from '../../src'; import * as helper from '../../src/helper'; import store from '@webex/cc-store'; +import type {AISummaryEntry} from '@webex/cc-store'; import '@testing-library/jest-dom'; jest.mock('@webex/cc-store', () => { - return { - __esModule: true, - default: { - cc: { - apiAIAssistant: { - getRealTimeAssistance: jest.fn(), - sendRealTimeAssistanceUserAction: jest.fn(), - }, + const {observable} = jest.requireActual('mobx'); + const storeMock = { + cc: { + apiAIAssistant: { + getRealTimeAssistance: jest.fn(), + sendRealTimeAssistanceUserAction: jest.fn(), }, - currentTask: {data: {interactionId: 'interaction-1'}}, - agentId: 'agent-1', - featureFlags: {isSuggestedResponsesEnabled: true}, - realTimeAssist: {}, - onErrorCallback: undefined, - clearRealTimeAssist: jest.fn(), }, + currentTask: {data: {interactionId: 'interaction-1'}}, + agentId: 'agent-1', + agentProfile: {}, + featureFlags: {isSuggestedResponsesEnabled: true}, + realTimeAssist: {}, + aiSummaries: {}, + onErrorCallback: undefined, + clearRealTimeAssist: jest.fn(), + recordAISummaryCopied: jest.fn(), + setReceiverSummaryFeedback: jest.fn(), + }; + + return { + __esModule: true, + getAISummarySurface: jest.requireActual('@webex/cc-store').getAISummarySurface, + default: observable.object(storeMock, { + cc: observable.ref, + currentTask: observable.ref, + agentProfile: observable.ref, + featureFlags: observable.ref, + realTimeAssist: observable.ref, + aiSummaries: observable.ref, + onErrorCallback: observable.ref, + clearRealTimeAssist: false, + recordAISummaryCopied: false, + setReceiverSummaryFeedback: false, + }), }; }); @@ -34,23 +55,98 @@ type StoreMock = { }; currentTask?: {data: {interactionId: string}}; agentId: string; + agentProfile: Record; featureFlags: {isSuggestedResponsesEnabled: boolean}; realTimeAssist: Record; + aiSummaries: Record; onErrorCallback?: jest.Mock; clearRealTimeAssist: jest.Mock; + recordAISummaryCopied: jest.Mock; + setReceiverSummaryFeedback: jest.Mock; }; const storeMock = store as unknown as StoreMock; +const receiverEntry = (overrides: Partial = {}): AISummaryEntry => ({ + status: 'ready', + revision: 7, + response: { + summary: 'Receiver summary', + feedback: 'none', + state: 'DEFAULT', + numberOfTimesViewed: 1, + numberOfTimesEdited: 0, + numberOfTimesCopied: 0, + }, + action: 'TRANSFER', + content: { + conversationId: 'interaction-1', + adaptiveCard: { + type: 'AdaptiveCard', + version: '1.5', + body: [{type: 'TextBlock', text: 'Receiver summary'}], + }, + }, + ...overrides, +}); + +const setReceiverEntry = (receiver?: AISummaryEntry): void => { + runInAction(() => { + storeMock.aiSummaries = receiver ? {'interaction-1': {receiver}} : {}; + }); +}; + +const deferred = (): { + promise: Promise; + resolve: (value: T) => void; +} => { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return {promise, resolve}; +}; + describe('AIAssistant widget', () => { + const innerText = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText'); + + beforeAll(() => { + // Adaptive Cards writes plain text through innerText, which jsdom does not implement. + Object.defineProperty(HTMLElement.prototype, 'innerText', { + configurable: true, + get() { + return this.textContent; + }, + set(value: string) { + this.textContent = value; + }, + }); + }); + + afterAll(() => { + if (innerText) { + Object.defineProperty(HTMLElement.prototype, 'innerText', innerText); + } else { + delete (HTMLElement.prototype as Partial).innerText; + } + }); + beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); jest.spyOn(console, 'error').mockImplementation(() => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {writeText: jest.fn().mockResolvedValue(undefined)}, + }); storeMock.currentTask = {data: {interactionId: 'interaction-1'}}; + storeMock.agentProfile = {}; storeMock.featureFlags = {isSuggestedResponsesEnabled: true}; storeMock.realTimeAssist = {}; + storeMock.aiSummaries = {}; storeMock.onErrorCallback = undefined; + storeMock.recordAISummaryCopied.mockReturnValue(true); storeMock.cc.apiAIAssistant.sendRealTimeAssistanceUserAction.mockResolvedValue(undefined); + storeMock.setReceiverSummaryFeedback.mockResolvedValue(undefined); }); it('renders launcher when chrome is closed', () => { @@ -106,6 +202,101 @@ describe('AIAssistant widget', () => { expect(container.querySelector('.my-host-class')).toBeInTheDocument(); }); + it('should omit the View summary trigger until a receiver summary arrives for the current interaction', () => { + runInAction(() => { + storeMock.aiSummaries = {'other-interaction': {receiver: receiverEntry()}}; + }); + render(); + expect(screen.queryByTestId('ai-assistant:view-summary')).not.toBeInTheDocument(); + }); + + it('re-renders the receiver summary branch when the observable surface changes', async () => { + setReceiverEntry(receiverEntry({content: {conversationId: 'interaction-1', areTranscriptsAvailable: false}})); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + expect(screen.getByTestId('ai-summary:unavailable')).toBeInTheDocument(); + + setReceiverEntry(receiverEntry({status: 'error', content: undefined})); + + await waitFor(() => expect(screen.getByTestId('ai-summary:error')).toBeInTheDocument()); + expect(screen.queryByTestId('ai-summary:unavailable')).not.toBeInTheDocument(); + }); + + it('should open the receiver summary from the View summary trigger', async () => { + setReceiverEntry(receiverEntry()); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + }); + + it('opens a receiver summary even when Real-time Assist is disabled', async () => { + storeMock.featureFlags = {isSuggestedResponsesEnabled: false}; + setReceiverEntry(receiverEntry()); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + }); + + it('routes receiver copy and feedback through the store with the current revision', async () => { + setReceiverEntry(receiverEntry()); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + fireEvent.click(await screen.findByRole('button', {name: 'Copy Summary'})); + + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Receiver summary')); + expect(storeMock.recordAISummaryCopied).toHaveBeenCalledWith('receiver', 7, storeMock.currentTask); + + fireEvent.click(screen.getByRole('button', {name: 'This is helpful'})); + + await waitFor(() => + expect(storeMock.setReceiverSummaryFeedback).toHaveBeenCalledWith('thumbs_up', 7, storeMock.currentTask) + ); + }); + + it('projects receiver feedback pending state as disabled controls without painting a selection', async () => { + setReceiverEntry(receiverEntry({feedbackPending: true})); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + const like = await screen.findByRole('button', {name: 'This is helpful'}); + expect(like).toBeDisabled(); + expect(like).toHaveAttribute('aria-pressed', 'false'); + }); + + it('replaces receiver feedback projections only after the store confirms the selection', async () => { + const feedbackSend = deferred(); + setReceiverEntry(receiverEntry()); + storeMock.setReceiverSummaryFeedback.mockImplementationOnce(() => { + setReceiverEntry(receiverEntry({feedbackPending: true})); + return feedbackSend.promise; + }); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + const like = await screen.findByRole('button', {name: 'This is helpful'}); + fireEvent.click(like); + + await waitFor(() => expect(like).toBeDisabled()); + expect(like).toHaveAttribute('aria-pressed', 'false'); + + feedbackSend.resolve(); + const confirmed = receiverEntry(); + confirmed.response.feedback = 'thumbs_up'; + setReceiverEntry(confirmed); + + await waitFor(() => + expect(screen.getByRole('button', {name: 'This is helpful'})).toHaveAttribute('aria-pressed', 'true') + ); + }); + it('routes errors thrown in the hook to store.onErrorCallback', () => { const onErrorCallback = jest.fn(); storeMock.onErrorCallback = onErrorCallback; 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 7551a10d5..ea5e0ccb9 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 @@ -355,6 +355,70 @@ Each component is tested in isolation with React Testing Library: render from a | `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 | +## AI summary presentation + +The internal `components/AISummary` surface renders generating, unavailable, error, +and content states from props. Initiator and post-call summaries support keyed text +edits, copy and feedback. Post-call supports Retry and a read-only Outcome from SDK +`resolution`; the display labels never become SDK section keys. Receiver summaries +use copy and feedback actions around a display-only Adaptive Card. + +Summary content props retain the store's SDK `AISummary` shape, including the required +`conversationId`; feedback and lifecycle values are supplied by the widget from the +store entry. Generating and error states accept absent content so widgets can pass +the store value directly without constructing an empty SDK payload. Component tests +use that same SDK shape. Receiver rendering and focus +handlers live in the assistant component alongside its chrome and summary branch. +The mode-specific prop union separates editable summaries from receiver actions and +requires callbacks only when receiver content is available. Section ordering, +labels, plain-text clipboard formatting, bullet presentation and auto-sizing native +editors belong to this component; edits, copy counts, feedback and submission belong +to the store's actions and separate SDK response record. + +Summary loading, Retry and tooltip presentation use the same Momentum primitives as +Real-time Assist and Wellness Break. Tooltip visibility remains controlled to keep +hovered content reachable and consume Escape before the containing panel handles it; +button labels and the pending-feedback description remain unchanged. Keyed editing, +clipboard formatting, revision acceptance and focus recovery stay summary-specific. + +`AIAssistantComponent.receiverSummary` adds View summary to closed/minimized chrome +and opens the receiver branch independently of Real-time Assist enablement. Copy uses +rendered card text through the existing Adaptive Card text extractor. The renderer +uses Adaptive Cards' `supportsInteractivity: false` for receiver content, keeping +card-provided actions, select actions and inputs inert without rewriting the SDK +card. Its render callback controls whether the separate copy/feedback controls are +shown; parsing or rendering failure hides them. Valid replacement cards preserve +those controls and their focus. The receiver content revision resets the renderer's +error boundary when replacement content arrives. Summary components recover focus +when a focused summary control or branch is removed and no connected control has +taken focus. + +These components receive data and callbacks through props and make no SDK calls. +`AISummary` remains internal; it is not exported by the package's root React barrel. + +Evidence: `components/AISummary/` and `components/AIAssistant/`; tests: +`tests/components/AISummary/ai-summary.tsx`, +`tests/components/AIAssistant/ai-assistant.tsx`, and +`tests/components/AIAssistant/adaptive-card-renderer.test.tsx`. + +## AI summaries in CallControl + +Consult/transfer and wrap-up panels forward their summary view props to `AISummary`. +The wrap-up panel owns reason search and selection; Complete Wrap-Up uses the same +Momentum button as the existing CallControl form and Wellness Break. It stays enabled +while summary generation is pending, and disables completion and summary editing while +wrap-up itself is pending. Failed completion retains the selected reason and draft. + +Panels recover focus after a focused summary control is removed only when no connected +control has taken focus. Consult/transfer retains its summary layout until the popover +closes after capability revocation so its focus target and close action stay stable. +React-generated heading IDs label each panel without a shared counter. Summary data, +actions, retry and submission remain owned by the widget/store layers. + +Evidence: `components/task/CallControl/call-control.tsx` and `CallControlCustom/`; +tests: `tests/components/task/CallControl/call-control.tsx`, +`CallControlCustom/consult-transfer-popover.tsx`, and `CallControlCustom/wrap-up-summary.tsx`. + ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx index 95eb76108..4ca5a6451 100644 --- a/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx @@ -34,6 +34,8 @@ const AdaptiveCardFallback: React.FC = ({ card, + displayOnly = false, + onRender, assistantTitle, publishTimestamp, suggestionText, @@ -51,17 +53,23 @@ const AdaptiveCardRendererBody: React.FC = ({ const onActionRef = useRef(onAction); const suggestionTextRef = useRef(suggestionText); const loggerRef = useRef(logger); + const onRenderRef = useRef(onRender); useEffect(() => { onUserActionRef.current = onUserAction; onActionRef.current = onAction; suggestionTextRef.current = suggestionText; loggerRef.current = logger; + onRenderRef.current = onRender; }); useEffect(() => { preloadIcons(); const container = containerRef.current; - if (!container || !card) return undefined; + if (!container) return undefined; + if (!card) { + onRenderRef.current?.(false); + return undefined; + } resetBoundary(); container.innerHTML = ''; @@ -71,7 +79,10 @@ const AdaptiveCardRendererBody: React.FC = ({ const getControl = (kind: AIAssistantActionKind): Element | undefined => Array.from(controls.entries()).find(([, controlKind]) => controlKind === kind)?.[0]; const adaptiveCard = new AdaptiveCards.AdaptiveCard(); - adaptiveCard.hostConfig = new AdaptiveCards.HostConfig(buildHostConfig()); + adaptiveCard.hostConfig = new AdaptiveCards.HostConfig({ + ...buildHostConfig(), + supportsInteractivity: !displayOnly, + }); // `onSent` runs once the host confirms the action reached the backend, so // the control never shows a selection the SDK didn't record. Hosts that // return nothing are treated as immediate success. @@ -106,6 +117,7 @@ const AdaptiveCardRendererBody: React.FC = ({ if (rendered) { container.appendChild(rendered); addImageFallbacks(container); + onRenderRef.current?.(true); // Visual-state wiring for the like/dislike/copy controls. Adaptive // Cards owns the rendered buttons, so ask it which element belongs to @@ -123,9 +135,10 @@ const AdaptiveCardRendererBody: React.FC = ({ } }); } else { - showBoundary(new Error('Adaptive card render returned no DOM output.')); + throw new Error('Adaptive card render returned no DOM output.'); } } catch (error) { + onRenderRef.current?.(false); showBoundary(error instanceof Error ? error : new Error('Adaptive card rendering failed.')); } @@ -134,13 +147,16 @@ const AdaptiveCardRendererBody: React.FC = ({ container.innerHTML = ''; } }; - }, [assistantTitle, card, publishTimestamp, resetBoundary, showBoundary]); + }, [assistantTitle, card, displayOnly, publishTimestamp, resetBoundary, showBoundary]); return

; }; const AdaptiveCardRenderer: React.FC = ({ card, + displayOnly = false, + onRender, + contentRevision, assistantTitle, fallbackText, publishTimestamp, @@ -156,9 +172,14 @@ const AdaptiveCardRenderer: React.FC = ({ className={`ai-assistant__card${isCustomerStatement ? ' ai-assistant__card--customer-statement' : ''}`} data-testid="ai-assistant:adaptive-card" > - }> + } + resetKeys={contentRevision === undefined ? undefined : [contentRevision]} + > + Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => + !element.hasAttribute('disabled') && element.getAttribute('aria-disabled') !== 'true' && element.tabIndex >= 0 + ); + const AIAssistantComponent: React.FC = ({ chrome, isFullScreen, @@ -43,6 +54,7 @@ const AIAssistantComponent: React.FC = ({ setContextDraft, submitContext, onRealTimeAssistAction, + receiverSummary, logger, className, wellnessBreakOverlayTarget, @@ -53,6 +65,15 @@ const AIAssistantComponent: React.FC = ({ assistantHeaderSequence += 1; return `ai-assistant-header-${assistantHeaderSequence}`; }); + // The receiver summary branch the agent opened; real-time assist shows otherwise. + const [viewingReceiverKey, setViewingReceiverKey] = useState(null); + const [receiverCardReady, setReceiverCardReady] = useState(false); + const rootRef = useRef(null); + const panelRef = useRef(null); + const receiverBranchRef = useRef(null); + const lastFocusedElementRef = useRef(null); + const lastFocusSuccessorRef = useRef(null); + const pendingReceiverActivationFocusRef = useRef(false); // Fullscreen is consumer-owned: we emit onFullScreenToggle; the host owns layout. const wellnessOverlayPhase = wellness && isWellnessOverlayPhase(wellness.phase) ? wellness.phase : undefined; const rootClass = [ @@ -65,7 +86,16 @@ const AIAssistantComponent: React.FC = ({ const panelClass = ['ai-assistant__panel', isFullScreen ? 'ai-assistant__panel--full-screen' : ''] .filter(Boolean) .join(' '); - const showLanding = !hasActiveInteraction || !isFeatureEnabled; + const canViewReceiverSummary = Boolean(receiverSummary && hasActiveInteraction); + const showReceiverSummary = Boolean( + receiverSummary && hasActiveInteraction && viewingReceiverKey === receiverSummary.branchKey + ); + const showLanding = !showReceiverSummary && (!hasActiveInteraction || !isFeatureEnabled); + const receiverBranchKey = receiverSummary?.branchKey; + const receiverActionsVisible = Boolean( + showReceiverSummary && receiverSummary?.surface === 'content' && receiverCardReady + ); + 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 @@ -108,20 +138,221 @@ const AIAssistantComponent: React.FC = ({ ? Boolean(!showWellnessSuggestion && !showWellnessOverlay && isFeatureEnabled) : !showLanding; + useEffect(() => { + if (viewingReceiverKey !== receiverBranchKey) { + setViewingReceiverKey(null); + pendingReceiverActivationFocusRef.current = false; + } + }, [viewingReceiverKey, receiverBranchKey]); + + const focusTarget = (target: HTMLElement | null | undefined): boolean => { + if ( + !target || + !target.isConnected || + target.hasAttribute('disabled') || + target.getAttribute('aria-disabled') === 'true' + ) { + return false; + } + // Momentum reflects its default tab stop asynchronously after connection. + // A newly rendered launcher must already be focusable during layout repair. + if (target.tagName === 'MDC-BUTTON' && !target.hasAttribute('tabindex')) { + target.setAttribute('tabindex', '0'); + } + target.focus(); + HTMLElement.prototype.focus.call(target); + return document.activeElement === target; + }; + + const clearReceiverFocusSnapshot = () => { + lastFocusedElementRef.current = null; + lastFocusSuccessorRef.current = null; + }; + + const recoverReceiverFocus = (preferHeader: boolean) => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement && activeElement !== document.body && activeElement.isConnected) { + clearReceiverFocusSnapshot(); + return; + } + const lastFocusedElement = lastFocusedElementRef.current; + if (lastFocusedElement?.isConnected && lastFocusedElement !== document.body) { + clearReceiverFocusSnapshot(); + return; + } + + if (focusTarget(lastFocusSuccessorRef.current)) { + clearReceiverFocusSnapshot(); + return; + } + + if (preferHeader) { + const headerMinimize = panelRef.current?.querySelector( + '[data-testid="ai-assistant:header-minimize"]' + ); + if (focusTarget(headerMinimize)) { + clearReceiverFocusSnapshot(); + return; + } + } + + focusTarget(panelRef.current); + clearReceiverFocusSnapshot(); + }; + + // Recover only when the previously focused control was removed. Connected + // controls and focus outside the assistant keep their current focus. + useLayoutEffect(() => { + if (pendingReceiverActivationFocusRef.current && chrome === 'open' && showReceiverSummary) { + pendingReceiverActivationFocusRef.current = false; + recoverReceiverFocus(true); + return; + } + const focused = lastFocusedElementRef.current; + if (!focused || focused.isConnected) { + return; + } + if (chrome === 'open') { + recoverReceiverFocus(false); + return; + } + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement && activeElement !== document.body && activeElement.isConnected) { + clearReceiverFocusSnapshot(); + return; + } + const fallbackSelector = + chrome === 'closed' ? '[data-testid="ai-assistant:launcher"]' : '[data-testid="ai-assistant:minimized-restore"]'; + if (!focusTarget(lastFocusSuccessorRef.current)) { + focusTarget(rootRef.current?.querySelector(fallbackSelector)); + } + clearReceiverFocusSnapshot(); + }); + + const handleRootFocusCapture = (event: React.FocusEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const controls = rootRef.current ? getEnabledFocusTargets(rootRef.current) : []; + const index = controls.indexOf(target); + lastFocusedElementRef.current = target; + lastFocusSuccessorRef.current = index >= 0 ? (controls[index + 1] ?? null) : null; + }; + + const openRealTimeAssist = () => { + setViewingReceiverKey(null); + open(); + }; + + const restoreRealTimeAssist = () => { + setViewingReceiverKey(null); + restore(); + }; + + const closePanel = () => { + setViewingReceiverKey(null); + close(); + }; + + const openReceiverSummary = () => { + if (!receiverSummary) { + return; + } + setViewingReceiverKey(receiverSummary.branchKey); + pendingReceiverActivationFocusRef.current = true; + if (chrome === 'minimized') { + restore(); + return; + } + if (chrome === 'closed') { + open(); + } + }; + + const renderReceiverSummary = () => { + if (!receiverSummary) { + return null; + } + + return ( +
+ {receiverSummary.surface === 'content' ? ( +
+ +
+ ) : null} + {receiverSummary.surface === 'content' && receiverActionsVisible ? ( + (receiverBranchRef.current ? extractCardText(receiverBranchRef.current) : '')} + onCopy={receiverSummary.recordReceiverSummaryCopied} + onFeedback={receiverSummary.setReceiverSummaryFeedback} + /> + ) : null} + {receiverSummary.surface === 'unavailable' || receiverSummary.surface === 'generic-error' ? ( + + ) : null} +
+ ); + }; + + const renderReceiverSummaryTrigger = (classNameSuffix = '') => + canViewReceiverSummary ? ( + + ) : null; + + const renderLauncherButton = () => ( + + ); + return ( -
+
{chrome === 'closed' ? ( - + canViewReceiverSummary ? ( +
+ {renderReceiverSummaryTrigger()} + {renderLauncherButton()} +
+ ) : ( + renderLauncherButton() + ) ) : chrome === 'minimized' ? (
@@ -129,6 +360,7 @@ const AIAssistantComponent: React.FC = ({ {AI_ASSISTANT_TITLE}
+ {renderReceiverSummaryTrigger(' ai-assistant__summary-trigger--minimized')}
) : ( -
+
{AI_ASSISTANT_TITLE} @@ -218,7 +457,7 @@ const AIAssistantComponent: React.FC = ({ prefix-icon="cancel-bold" aria-label="Close" data-testid="ai-assistant:header-close" - onClick={close} + onClick={closePanel} /> Close @@ -238,6 +477,27 @@ const AIAssistantComponent: React.FC = ({ onLater={() => wellness.onLater('card')} /> ) : null} + {!showWellnessContent ? ( + showReceiverSummary ? ( + renderReceiverSummary() + ) : showLanding ? ( + + ) : ( + + ) + ) : null} {(showWellnessSuggestion || (showWellnessRequestState && !showWellnessHistory)) && wellness ? ( = ({ ) : null} {showWellnessError && wellness ? : null} - {!showWellnessContent ? ( - showLanding ? ( - - ) : ( - - ) - ) : null}
{showFooter ? (