diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md
index d0899d3de..6da699ab8 100644
--- a/ai-docs/CONTRACTS.md
+++ b/ai-docs/CONTRACTS.md
@@ -34,6 +34,14 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto
| 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` |
+| store.ai-summary | `@webex/cc-store` | `aiSummaries`, SDK summary types, `getAISummarySurface`, and StoreWrapper summary actions | SDK requests/responses and per-interaction UI state, edits, copies and feedback | additive SDK boundary | `packages/contact-center/store/ai-docs/store-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..148751b06 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,8 @@
},
"resolutions": {
"@momentum-design/components": "0.133.23",
- "@momentum-design/icons": "0.17.0"
+ "@momentum-design/icons": "0.17.0",
+ "@webex/contact-center": "file:./vendor/contact-center-cc-summaries.tgz"
},
"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 4b49fc76b..0dc62b28f 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
@@ -204,6 +204,21 @@ 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
+SDK-shaped content, revision, confirmed 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; failed or unsupported summaries supply the matching status
+surface. 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`
- Coverage state & contracts baseline: `.sdd/manifest.json`
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..26aaa71ed 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 {IAIAssistantProps} from '../ai-assistant.types';
const AIAssistantInternal: React.FunctionComponent = observer((props) => {
@@ -24,6 +24,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.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..8f4e5ae45 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('../../../store/src/ai-summary').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..e7826d15e 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('../../../store/src/ai-summary').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,72 @@ 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,
+ copied: 0,
+ edited: false,
+ feedback: 'none',
+ action: 'TRANSFER',
+ content: {
+ adaptiveCard: {
+ type: 'AdaptiveCard',
+ version: '1.5',
+ body: [{type: 'TextBlock', text: 'Receiver summary'}],
+ },
+ },
+ ...overrides,
+});
+
+const receiverErrorEntry = (error: AISummaryEntry['error']): AISummaryEntry =>
+ receiverEntry({status: 'error', error, content: undefined});
+
+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', () => {
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 +176,99 @@ 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(receiverErrorEntry('unsupported'));
+ render( );
+
+ fireEvent.click(screen.getByTestId('ai-assistant:view-summary'));
+ expect(screen.getByTestId('ai-summary:unavailable')).toBeInTheDocument();
+
+ setReceiverEntry(receiverErrorEntry('failed'));
+
+ 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).toHaveBeenCalled());
+ 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();
+ setReceiverEntry(receiverEntry({feedback: 'thumbs_up'}));
+
+ 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 1ce7f9136..534ce5fb9 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
@@ -353,6 +353,37 @@ 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.
+
+CallControl's optional `aiSummary` prop contains the views and request callbacks.
+For eligible voice tasks, opening consult/transfer requests a summary and renders
+destination radios with the summary below the existing results. That layout stays
+stable while the open popover loses summary eligibility; voice tasks without summary
+eligibility retain the existing category buttons, reload control and list layout.
+`WrapUpSummary` shows searchable reasons and the post-call summary. Complete Wrap-Up
+remains available during generation; while completion is pending, summary edits and
+completion are disabled. The existing wrap-up form keeps its previous submission behavior.
+
+`AIAssistantComponent.receiverSummary` adds View summary to closed/minimized chrome
+and opens the receiver branch independently of Real-time Assist enablement. Copy uses
+visible card text; card actions remain inert. The receiver content revision resets
+its 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/`, `components/AIAssistant/`, and
+`components/task/CallControl/`; tests: the corresponding `tests/components/` suites,
+including `AISummary/ai-summary.tsx`, `AIAssistant/ai-assistant.tsx`,
+`CallControl/call-control.tsx`, `consult-transfer-popover.tsx`, and `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..afa03d8ca 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
@@ -141,6 +141,7 @@ const AdaptiveCardRendererBody: React.FC = ({
const AdaptiveCardRenderer: React.FC = ({
card,
+ contentRevision,
assistantTitle,
fallbackText,
publishTimestamp,
@@ -156,7 +157,10 @@ 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]}
+ >
+ !element.hasAttribute('disabled') &&
+ element.getAttribute('aria-disabled') !== 'true' &&
+ element.tabIndex >= 0 &&
+ element.isConnected;
+
+const isConnectedFocusTarget = (element: HTMLElement): boolean =>
+ !element.hasAttribute('disabled') && element.getAttribute('aria-disabled') !== 'true' && element.isConnected;
+
+const getEnabledFocusTargets = (root: HTMLElement): HTMLElement[] =>
+ Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isEnabledFocusTarget);
+
+const isReceiverDisplayActionNode = (value: unknown): boolean => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false;
+ }
+ const type = (value as Record).type;
+ return typeof type === 'string' && (type === 'ActionSet' || type.startsWith('Action.'));
+};
+
+const projectReceiverDisplayCard = (card: unknown): unknown => {
+ if (Array.isArray(card)) {
+ return card
+ .map(projectReceiverDisplayCard)
+ .filter((item) => item !== undefined && !isReceiverDisplayActionNode(item));
+ }
+ if (!card || typeof card !== 'object') {
+ return card;
+ }
+ if (isReceiverDisplayActionNode(card)) {
+ return undefined;
+ }
+ const projected: Record = {};
+ Object.entries(card as Record).forEach(([key, value]) => {
+ if (key === 'actions' || key === 'selectAction') {
+ return;
+ }
+ const nextValue = projectReceiverDisplayCard(value);
+ if (nextValue !== undefined) {
+ projected[key] = nextValue;
+ }
+ });
+ return projected;
+};
+
+const pushNonEmptyText = (texts: string[], value: unknown) => {
+ if (typeof value !== 'string') {
+ return;
+ }
+ const trimmed = value.trim();
+ if (trimmed) {
+ texts.push(trimmed);
+ }
+};
+
+const extractReceiverDisplayCardText = (node: unknown): string => {
+ const texts: string[] = [];
+
+ const visit = (value: unknown) => {
+ if (typeof value === 'string') {
+ pushNonEmptyText(texts, value);
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ return;
+ }
+ if (!value || typeof value !== 'object') {
+ return;
+ }
+
+ const record = value as Record;
+ switch (record.type) {
+ case 'TextBlock':
+ case 'TextRun':
+ pushNonEmptyText(texts, record.text);
+ break;
+ case 'RichTextBlock':
+ visit(record.inlines);
+ break;
+ case 'FactSet':
+ if (Array.isArray(record.facts)) {
+ record.facts.forEach((fact) => {
+ if (!fact || typeof fact !== 'object') {
+ return;
+ }
+ const factRecord = fact as Record;
+ pushNonEmptyText(texts, factRecord.title);
+ pushNonEmptyText(texts, factRecord.value);
+ });
+ }
+ break;
+ default:
+ break;
+ }
+
+ ['body', 'items', 'columns', 'rows', 'cells'].forEach((key) => visit(record[key]));
+ };
+
+ visit(node);
+ return texts.join('\n');
+};
+
const AIAssistantComponent: React.FC = ({
chrome,
isFullScreen,
@@ -29,30 +139,298 @@ const AIAssistantComponent: React.FC = ({
setContextDraft,
submitContext,
onRealTimeAssistAction,
+ receiverSummary,
logger,
className,
}) => {
+ // The receiver summary branch the agent opened; real-time assist shows otherwise.
+ const [viewingReceiverKey, setViewingReceiverKey] = useState(null);
+ const [receiverCardRenderState, setReceiverCardRenderState] = useState('pending');
+ const rootRef = useRef(null);
+ const panelRef = useRef(null);
+ const receiverBranchRef = useRef(null);
+ const lastFocusedElementRef = useRef(null);
+ const lastFocusSuccessorRef = useRef(null);
+ const pendingReceiverActivationFocusRef = useRef(false);
+ const previousShowReceiverSummaryRef = useRef(false);
+ const previousReceiverActionsVisibleRef = useRef(false);
// Fullscreen is consumer-owned: we emit onFullScreenToggle; the host owns layout.
const rootClass = ['ai-assistant', className || ''].filter(Boolean).join(' ');
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 receiverContentRevision = receiverSummary?.surface === 'content' ? receiverSummary.contentRevision : undefined;
+ const receiverAdaptiveCard =
+ receiverSummary?.surface === 'content' ? receiverSummary.content.adaptiveCard : undefined;
+ const receiverDisplayCard = useMemo(
+ () => (receiverAdaptiveCard === undefined ? undefined : projectReceiverDisplayCard(receiverAdaptiveCard)),
+ [receiverAdaptiveCard]
+ );
+ const receiverActionsVisible = Boolean(
+ showReceiverSummary && receiverSummary?.surface === 'content' && receiverCardRenderState === 'ready'
+ );
+
+ useEffect(() => {
+ if (viewingReceiverKey !== receiverBranchKey) {
+ setViewingReceiverKey(null);
+ pendingReceiverActivationFocusRef.current = false;
+ }
+ }, [viewingReceiverKey, receiverBranchKey]);
+
+ const focusTarget = (target: HTMLElement | null | undefined): boolean => {
+ if (!target || !isConnectedFocusTarget(target)) {
+ 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();
+ };
+
+ useLayoutEffect(() => {
+ if (!pendingReceiverActivationFocusRef.current || chrome !== 'open' || !showReceiverSummary) {
+ return;
+ }
+ pendingReceiverActivationFocusRef.current = false;
+ recoverReceiverFocus(true);
+ });
+
+ useLayoutEffect(() => {
+ const previousShowReceiverSummary = previousShowReceiverSummaryRef.current;
+ previousShowReceiverSummaryRef.current = showReceiverSummary;
+ if (previousShowReceiverSummary && !showReceiverSummary) {
+ recoverReceiverFocus(false);
+ }
+ });
+
+ useLayoutEffect(() => {
+ const previousReceiverActionsVisible = previousReceiverActionsVisibleRef.current;
+ previousReceiverActionsVisibleRef.current = receiverActionsVisible;
+ if (previousReceiverActionsVisible && !receiverActionsVisible) {
+ recoverReceiverFocus(false);
+ }
+ });
+
+ useLayoutEffect(() => {
+ const focused = lastFocusedElementRef.current;
+ if (chrome === 'open' || !focused || focused.isConnected) {
+ 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();
+ });
+
+ useLayoutEffect(() => {
+ if (!showReceiverSummary || receiverSummary?.surface !== 'content') {
+ return undefined;
+ }
+ const branch = receiverBranchRef.current;
+ if (!branch) {
+ return undefined;
+ }
+
+ const updateReceiverCardRenderState = () => {
+ const hasFallback = Boolean(branch.querySelector('[data-testid="ai-assistant:adaptive-card-fallback"]'));
+ const cardHost = branch.querySelector('.ai-assistant__card-host');
+ const hasRenderedContent = Boolean(cardHost && cardHost.childElementCount > 0);
+ const nextState: ReceiverCardRenderState = hasFallback ? 'fallback' : hasRenderedContent ? 'ready' : 'pending';
+ setReceiverCardRenderState((current) => (current === nextState ? current : nextState));
+ };
+
+ updateReceiverCardRenderState();
+ if (typeof MutationObserver === 'undefined') {
+ return undefined;
+ }
+ const observer = new MutationObserver(updateReceiverCardRenderState);
+ observer.observe(branch, {childList: true, subtree: true});
+ return () => observer.disconnect();
+ }, [chrome, receiverContentRevision, receiverSummary?.surface, showReceiverSummary]);
+
+ 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.slice(index + 1).find((control) => control !== target) ?? 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 ? (
+
{
+ const renderedText = receiverBranchRef.current ? extractCardText(receiverBranchRef.current).trim() : '';
+ const text = renderedText || extractReceiverDisplayCardText(receiverDisplayCard).trim();
+ if (!text) {
+ throw new Error('Receiver summary has no visible copy text.');
+ }
+ return text;
+ }}
+ onCopy={receiverSummary.recordReceiverSummaryCopied}
+ onFeedback={receiverSummary.setReceiverSummaryFeedback}
+ />
+ ) : null}
+ {receiverSummary.surface === 'unavailable' || receiverSummary.surface === 'generic-error' ? (
+
+ ) : null}
+
+ );
+ };
+
+ const renderReceiverSummaryTrigger = (classNameSuffix = '') =>
+ canViewReceiverSummary ? (
+
+ {AI_SUMMARY_MESSAGES.viewSummary}
+
+ ) : null;
+
+ const renderLauncherButton = () => (
+
+
+
+ );
return (
-
+
{chrome === 'closed' ? (
-
-
-
+ canViewReceiverSummary ? (
+
+ {renderReceiverSummaryTrigger()}
+ {renderLauncherButton()}
+
+ ) : (
+ renderLauncherButton()
+ )
) : chrome === 'minimized' ? (
@@ -60,6 +438,7 @@ const AIAssistantComponent: React.FC
= ({
{AI_ASSISTANT_TITLE}
+ {renderReceiverSummaryTrigger(' ai-assistant__summary-trigger--minimized')}
= ({
prefix-icon="arrow-up-bold"
aria-label="Restore"
data-testid="ai-assistant:minimized-restore"
- onClick={restore}
+ onClick={restoreRealTimeAssist}
/>
= ({
prefix-icon="cancel-bold"
aria-label="Close"
data-testid="ai-assistant:minimized-close"
- onClick={close}
+ onClick={closePanel}
/>
) : (
-
+
{AI_ASSISTANT_TITLE}
@@ -113,7 +499,7 @@ const AIAssistantComponent: React.FC = ({
prefix-icon="cancel-bold"
aria-label="Close"
data-testid="ai-assistant:header-close"
- onClick={close}
+ onClick={closePanel}
/>
@@ -121,7 +507,9 @@ const AIAssistantComponent: React.FC
= ({
className={`ai-assistant__body${showLanding ? ' ai-assistant__body--landing' : ''}`}
data-testid="ai-assistant:body"
>
- {showLanding ? (
+ {showReceiverSummary ? (
+ renderReceiverSummary()
+ ) : showLanding ? (
) : (
;
+ contentRevision: number;
+ selectedFeedback: AISummaryFeedback;
+ feedbackPending: boolean;
+ recordReceiverSummaryCopied: (expectedRevision: number) => boolean;
+ setReceiverSummaryFeedback: (feedback: Exclude, expectedRevision: number) => void;
+ }
+ | {
+ surface: 'unavailable' | 'generic-error';
+ branchKey: string;
+ };
+
/** Props for the top-level AIAssistant presentational component. */
export interface AIAssistantComponentProps {
/** Current chrome visibility state. */
@@ -44,6 +60,8 @@ export interface AIAssistantComponentProps {
agentName?: string;
/** Whether the first real-time assist request has completed successfully. */
hasInitialRequestSucceeded: boolean;
+ /** Optional receiving-agent summary branch. */
+ receiverSummary?: AIAssistantReceiverSummary;
/** Transition the chrome to `open`. */
open: () => void;
/** Transition the chrome to `closed`; preserves chat state. */
@@ -95,6 +113,8 @@ export interface AIAssistantActionEvent {
export interface AdaptiveCardRendererProps {
card: unknown;
+ /** Receiver summary revision; resets a failed card when a replacement summary arrives. */
+ contentRevision?: number;
/** Title already rendered by RealTimeAssist; removes the matching header embedded in backend cards. */
assistantTitle?: string;
/** Plain-text fallback rendered when the Adaptive Card cannot be parsed/rendered. */
diff --git a/packages/contact-center/cc-components/src/components/AISummary/ai-summary.constants.ts b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.constants.ts
new file mode 100644
index 000000000..b0bf52728
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.constants.ts
@@ -0,0 +1,56 @@
+export const AI_SUMMARY_MESSAGES = {
+ viewSummary: 'View summary',
+ generatingTitle: 'Generating summary...',
+ generatingDescription: 'Just a sec—the details are coming together.',
+ unavailable: 'The summary is not available',
+ generationError: 'Having trouble generating summary',
+ generationErrorDescription:
+ 'It could be a lost connection or something else. Could you check your connection or try again later?',
+ copySummary: 'Copy Summary',
+ copiedSummary: 'Copied',
+ attribution: 'AI-generated',
+ plainSummary: 'Summary',
+ like: 'This is helpful',
+ dislike: "This isn't helpful",
+ retry: 'Retry',
+ editSectionLabel: (label: string) => `Edit ${label}`,
+ sectionLabels: {
+ initialContactReason: 'Initial contact reason',
+ additionalContactReasons: 'Additional contact reason(s)',
+ additionalContext: 'Additional context',
+ keyActionsTaken: 'Key Actions Taken',
+ nextSteps: 'Next Steps',
+ reasonForTransferOrConsult: 'Reason for transfer or consult',
+ resolution: 'Outcome',
+ },
+ midCall: {
+ consultHeading: 'Here’s a consult summary—they’ll get a copy',
+ transferHeading: 'Here’s a transfer summary—they’ll get a copy.',
+ searchPlaceholder: 'Search by name, queue, entry point or phone number',
+ destinationCategory: 'Destination category',
+ searchDestinations: 'Search destinations',
+ },
+ postCall: {
+ summaryHeading: 'Summary of your conversation',
+ searchPlaceholder: 'Search topic',
+ completeAction: 'Complete Wrap-Up',
+ instructions: 'Choose a code, and click the summary to edit it if needed.',
+ chooseReason: 'Choose a reason to wrap up',
+ reasonSearchLabel: 'Search wrap-up reasons',
+ noReasonMatches: 'No wrap-up reasons match your search.',
+ },
+ feedback: {
+ pendingSubmission: 'Pending submission',
+ },
+} as const;
+
+/** Display order of summary sections; the store keeps only the sections each summary renders. */
+export const AI_SUMMARY_SECTION_ORDER = [
+ 'reasonForTransferOrConsult',
+ 'initialContactReason',
+ 'additionalContactReasons',
+ 'additionalContext',
+ 'keyActionsTaken',
+ 'resolution',
+ 'nextSteps',
+] as const satisfies readonly (keyof typeof AI_SUMMARY_MESSAGES.sectionLabels)[];
diff --git a/packages/contact-center/cc-components/src/components/AISummary/ai-summary.styles.scss b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.styles.scss
new file mode 100644
index 000000000..26a34bb55
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.styles.scss
@@ -0,0 +1,430 @@
+.ai-summary {
+ color: var(--mds-color-theme-text-primary-normal, #1b1c1d);
+ min-inline-size: 0;
+ font-size: 0.875rem;
+ line-height: 1.4286;
+}
+
+.ai-summary__status,
+.ai-summary__content,
+.ai-summary__actions {
+ min-inline-size: 0;
+}
+
+.ai-summary__status {
+ display: grid;
+ gap: 0.5rem;
+ min-block-size: 13rem;
+ place-content: center;
+ text-align: center;
+ flex: 1 1 auto;
+}
+
+.ai-summary__spinner {
+ margin-block-start: 1.25rem;
+ justify-self: center;
+ inline-size: 1.75rem;
+ block-size: 1.75rem;
+ border: 0.1875rem solid var(--mds-color-theme-outline-secondary-normal, #d7d7d8);
+ border-block-start-color: var(--mds-color-theme-text-primary-normal, #1b1c1d);
+ border-radius: 50%;
+ animation: ai-summary-spin 1s linear infinite;
+}
+
+.ai-summary__error-icon {
+ justify-self: center;
+ inline-size: 2.25rem;
+ block-size: 2.25rem;
+ color: var(--mds-color-theme-text-error-normal, #b4232a);
+}
+
+.ai-summary__status-title {
+ margin: 0;
+ font-weight: 700;
+ line-height: 1.4;
+}
+
+.ai-summary__status-title--generating {
+ font-size: 1rem;
+}
+
+.ai-summary__status-title--error {
+ font-size: 0.875rem;
+}
+
+.ai-summary__description,
+.ai-summary__feedback-status {
+ margin: 0;
+ color: var(--mds-color-theme-text-secondary-normal, #54585c);
+ font-size: 0.875rem;
+ line-height: 1.4;
+ overflow-wrap: anywhere;
+}
+
+.ai-summary__content {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.ai-summary__summary-heading {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ margin-block: 0 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.35;
+}
+
+.ai-summary__summary-sparkle {
+ inline-size: 1rem;
+ block-size: 1rem;
+ color: var(--mds-color-theme-text-accent-normal, #0b5cad);
+}
+
+.ai-summary__summary-info {
+ inline-size: 0.875rem;
+ block-size: 0.875rem;
+ color: var(--mds-color-theme-text-secondary-normal, #54585c);
+}
+
+.ai-summary__editor-surface {
+ display: flex;
+ flex-direction: column;
+ border: 0.0625rem solid var(--mds-color-theme-outline-primary-normal);
+ border-radius: 0.5rem;
+ padding: 0.625rem 0.75rem;
+}
+
+.ai-summary__sr-only {
+ position: absolute;
+ inline-size: 0.0625rem;
+ block-size: 0.0625rem;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.ai-summary__section {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.ai-summary__section--structured {
+ gap: 0.125rem;
+}
+
+.ai-summary .ai-summary__section-preview {
+ display: block;
+ inline-size: 100%;
+ min-block-size: 1.5rem;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: start;
+ white-space: normal;
+ cursor: text;
+
+ > .ai-summary__label {
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: 700;
+ }
+
+ &:disabled {
+ opacity: 1;
+ cursor: default;
+ }
+}
+
+.ai-summary__label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.ai-summary__textarea {
+ box-sizing: border-box;
+ inline-size: 100%;
+ min-block-size: 1.35em;
+ resize: none;
+ overflow: hidden;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.ai-summary__readonly {
+ display: inline;
+ margin: 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.ai-summary .ai-summary__readonly > .ai-summary__label {
+ display: inline;
+ margin-inline-end: 0.25rem;
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: 700;
+}
+
+.ai-summary__readonly > .ai-summary__label::after {
+ content: ':';
+}
+
+.ai-summary__readonly-value {
+ white-space: pre-wrap;
+}
+
+.ai-summary__readonly-list {
+ display: block;
+ margin: 0;
+ padding-inline-start: 1rem;
+ white-space: normal;
+}
+
+.ai-summary__readonly-list > span {
+ display: list-item;
+ list-style-type: disc;
+}
+
+.ai-summary__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ align-items: center;
+ margin-block-start: auto;
+ padding-block-start: 0.5rem;
+}
+
+.ai-summary__action {
+ position: relative;
+ display: inline-flex;
+}
+
+.ai-summary__action--tooltip-open::before {
+ position: absolute;
+ inset-block-end: 100%;
+ inset-inline-start: 50%;
+ inline-size: 4rem;
+ min-inline-size: 100%;
+ block-size: 0.75rem;
+ content: '';
+ transform: translateX(-50%);
+}
+
+.ai-summary__button {
+ min-inline-size: 2rem;
+ border: 1px solid var(--mds-color-theme-outline-primary-normal, #d6d8dc);
+ border-radius: 6px;
+ background: var(--mds-color-theme-background-solid-primary-normal, #fff);
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ padding: 0.375rem 0.625rem;
+}
+
+.ai-summary__copy-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ min-inline-size: 1.5rem;
+ min-block-size: 1.5rem;
+ border-color: transparent;
+ padding: 0.25rem;
+}
+
+.ai-summary__feedback-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ inline-size: 44px;
+ block-size: 20px;
+ flex: 0 0 44px;
+}
+
+.ai-summary__feedback-action {
+ inline-size: 20px;
+ block-size: 20px;
+}
+
+.ai-summary__feedback-button {
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 20px;
+ block-size: 20px;
+ min-inline-size: 20px;
+ min-block-size: 20px;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ padding: 2px;
+}
+
+.ai-summary__status .ai-summary__button {
+ display: inline-flex;
+ align-items: center;
+ justify-self: center;
+ gap: 0.375rem;
+ border-radius: 999px;
+}
+
+.ai-summary__actions mdc-icon {
+ inline-size: 1rem;
+ block-size: 1rem;
+}
+
+.ai-summary__actions-spacer {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+}
+
+.ai-summary__attribution {
+ color: var(--mds-color-theme-text-secondary-normal, #54585c);
+ white-space: nowrap;
+}
+
+.ai-summary__action-separator {
+ display: inline-block;
+ inline-size: 4px;
+ block-size: 4px;
+ flex: 0 0 4px;
+ border-radius: 50%;
+ background: var(--mds-color-theme-text-secondary-normal, #54585c);
+ opacity: 0.46;
+}
+
+.ai-summary__tooltip {
+ position: absolute;
+ inset-block-end: calc(100% + 0.75rem);
+ inset-inline-start: 50%;
+ z-index: 1;
+ max-inline-size: 14rem;
+ transform: translateX(-50%);
+ border: 1px solid var(--mds-color-theme-outline-secondary-normal, #d7d7d8);
+ border-radius: 8px;
+ background: var(--mds-color-theme-background-solid-primary-normal);
+ color: var(--mds-color-theme-text-primary-normal);
+ box-shadow: 0 0.125rem 0.5rem var(--mds-color-theme-outline-secondary-normal);
+ font-size: 0.875rem;
+ line-height: 1.3;
+ padding: 0.75rem;
+ pointer-events: auto;
+ white-space: nowrap;
+}
+
+.ai-summary__tooltip::after {
+ position: absolute;
+ inset-block-start: 100%;
+ inset-inline-start: 50%;
+ inline-size: 0.75rem;
+ block-size: 0.75rem;
+ border-inline-end: 1px solid var(--mds-color-theme-outline-secondary-normal, #d7d7d8);
+ border-block-end: 1px solid var(--mds-color-theme-outline-secondary-normal, #d7d7d8);
+ background: inherit;
+ content: '';
+ transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.ai-summary__status .ai-summary__button:hover {
+ background: var(--mds-color-theme-background-primary-hover, #eef4ff);
+ border-color: var(--mds-color-theme-outline-accent-normal, #0b5cad);
+}
+
+.ai-summary__copy-button:hover,
+.ai-summary__copy-button--confirmed,
+.ai-summary__feedback-button:hover {
+ background: var(--mds-color-theme-background-secondary-normal, #e9eaed);
+ border-color: transparent;
+ border-radius: 50%;
+}
+
+.ai-summary__feedback-button--selected,
+.ai-summary__feedback-button--selected:hover {
+ background: transparent;
+ border-color: transparent;
+}
+
+.ai-summary__button:disabled,
+.ai-summary__button[aria-disabled='true'] {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.ai-summary__button:focus-visible,
+.ai-summary__section-preview:focus-visible,
+.ai-summary__textarea:focus-visible {
+ outline: 2px solid var(--mds-color-theme-outline-accent-normal, #0b5cad);
+ outline-offset: 2px;
+}
+
+@keyframes ai-summary-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+// Constrained CallControl panels keep action rows outside the content scroll
+// region. No screenshot-only override: this is also the production layout.
+.wrap-up-summary__body,
+.consult-transfer-summary {
+ > .ai-summary {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-block-size: 0;
+ }
+
+ .ai-summary__editor-surface {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-block-size: 0;
+ }
+
+ .ai-summary__content {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-block-size: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ gap: 0;
+ }
+
+ .ai-summary__content > *,
+ .ai-summary__actions {
+ flex-shrink: 0;
+ }
+}
+
+@media (forced-colors: active) {
+ .ai-summary__button,
+ .ai-summary__textarea,
+ .ai-summary__tooltip {
+ border-color: ButtonText;
+ }
+
+ .ai-summary__tooltip {
+ background: CanvasText;
+ color: Canvas;
+ }
+
+ .ai-summary__button:focus-visible,
+ .ai-summary__textarea:focus-visible {
+ outline-color: Highlight;
+ }
+}
diff --git a/packages/contact-center/cc-components/src/components/AISummary/ai-summary.tsx b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.tsx
new file mode 100644
index 000000000..a02b1b4aa
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.tsx
@@ -0,0 +1,514 @@
+import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
+import {Icon} from '@momentum-design/components/dist/react';
+import type {AISummaryEntry, AISummaryFeedback} from '@webex/cc-store';
+import {AI_SUMMARY_MESSAGES, AI_SUMMARY_SECTION_ORDER} from './ai-summary.constants';
+import {COPIED_FEEDBACK_MS} from '../AIAssistant/constants';
+import type {AISummaryEditorProps, AISummaryProps} from './ai-summary.types';
+import './ai-summary.styles.scss';
+
+type DisplaySection = {key: (typeof AI_SUMMARY_SECTION_ORDER)[number]; value: string};
+
+const FEEDBACK_BUTTONS = [
+ {feedback: 'thumbs_up', icon: 'like', label: AI_SUMMARY_MESSAGES.like},
+ {feedback: 'thumbs_down', icon: 'dislike', label: AI_SUMMARY_MESSAGES.dislike},
+] as const;
+
+type AISummaryTooltipControl = 'copy' | Exclude;
+
+const useReactId = (React as typeof React & {useId: () => string}).useId;
+
+const BULLETED_SECTION_KEYS = new Set([
+ 'additionalContactReasons',
+ 'keyActionsTaken',
+ 'nextSteps',
+]);
+
+/** The summary's sections in display order, with the read-only post-call resolution among them. */
+export const getDisplaySections = (content: NonNullable): DisplaySection[] =>
+ AI_SUMMARY_SECTION_ORDER.flatMap((key): DisplaySection[] => {
+ if (key === 'resolution') {
+ return content.resolution?.trim() ? [{key, value: content.resolution}] : [];
+ }
+ const value = content.sections?.[key];
+ return value === undefined ? [] : [{key, value}];
+ });
+
+const flattenContentForCopy = (content: NonNullable): string => {
+ if (!content.sections) {
+ return content.summaryText ?? '';
+ }
+ return getDisplaySections(content)
+ .filter((section) => section.value.trim().length > 0)
+ .map((section) => `${AI_SUMMARY_MESSAGES.sectionLabels[section.key]}: ${section.value}`)
+ .join('\n\n');
+};
+
+const splitDisplayLines = (value: string): string[] => value.split('\n').filter((line) => line.trim().length > 0);
+
+const stripBulletMarker = (line: string): string => line.trim().replace(/^(?:\u2022|[-*])\s*/, '');
+
+const renderReadonlyValue = (section: DisplaySection, inline = false) => {
+ const lines = splitDisplayLines(section.value);
+ if (BULLETED_SECTION_KEYS.has(section.key) && lines.length > 1) {
+ const List = inline ? 'span' : 'ul';
+ const Item = inline ? 'span' : 'li';
+ return (
+
+ {lines.map((line, index) => (
+ -
+ {stripBulletMarker(line)}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {section.value}
+
+ );
+};
+
+// Keep keyed, controlled edits inside one visual surface. Never parse edited labels
+// back into SDK keys or introduce a second scrollbar inside the containing panel.
+const SummaryEditor: React.FC = ({value, ...props}) => {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const editor = ref.current;
+ if (!editor) return;
+ const resize = () => {
+ if (!editor.isConnected) return;
+ editor.style.height = 'auto';
+ editor.style.height = `${editor.scrollHeight}px`;
+ };
+ resize();
+ void document.fonts?.ready.then(resize, () => undefined);
+ document.fonts?.addEventListener('loadingdone', resize);
+ let width = editor.clientWidth;
+ const observer =
+ typeof ResizeObserver === 'undefined'
+ ? undefined
+ : new ResizeObserver(() => {
+ if (editor.clientWidth !== width) {
+ width = editor.clientWidth;
+ resize();
+ }
+ });
+ observer?.observe(editor);
+ return () => {
+ observer?.disconnect();
+ document.fonts?.removeEventListener('loadingdone', resize);
+ };
+ }, [value]);
+ return ;
+};
+
+const getEnabledControls = (root: HTMLElement): HTMLElement[] =>
+ Array.from(root.querySelectorAll('button, [href], input, textarea, select, [tabindex]')).filter(
+ (element) =>
+ !element.hasAttribute('disabled') &&
+ element.getAttribute('aria-disabled') !== 'true' &&
+ element.tabIndex >= 0 &&
+ element.isConnected
+ );
+
+// Display the structured document compactly; activate the original keyed native
+// editor on click/keyboard activation. SDK values remain plain text and unchanged.
+const SummarySectionEditor: React.FC<{
+ section: DisplaySection;
+ disabled: boolean;
+ onChange: (value: string) => void;
+ onEditorBlur: () => void;
+}> = ({section, disabled, onChange, onEditorBlur}) => {
+ const [editing, setEditing] = useState(false);
+ const label = AI_SUMMARY_MESSAGES.sectionLabels[section.key];
+ if (editing && !disabled) {
+ return (
+
+
+ {label}
+
+ onChange(event.currentTarget.value)}
+ onBlur={() => {
+ setEditing(false);
+ onEditorBlur();
+ }}
+ />
+
+ );
+ }
+ return (
+ setEditing(true)}
+ >
+
+ {label}:
+ {' '}
+ {renderReadonlyValue(section, true)}
+
+ );
+};
+
+const AISummary = (props: AISummaryProps): React.ReactElement => {
+ const rootRef = useRef(null);
+ const focusedControlRef = useRef(null);
+ const successorRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+ const [hoveredTooltip, setHoveredTooltip] = useState(null);
+ const [, scheduleFocusRestoration] = useState(0);
+ const feedbackDescriptionId = `ai-summary-feedback-${useReactId()}`;
+
+ const contentRevision = 'contentRevision' in props ? props.contentRevision : undefined;
+ const disabled = Boolean(props.controlsDisabled || props.requestPending);
+ const selectedFeedback = props.selectedFeedback ?? 'none';
+
+ const clearFocusSnapshot = () => {
+ focusedControlRef.current = null;
+ successorRef.current = null;
+ };
+
+ const handleLocalControlRemoval = () => {
+ scheduleFocusRestoration((revision) => revision + 1);
+ };
+
+ // A new summary starts without the previous copy confirmation.
+ useEffect(() => {
+ setCopied(false);
+ }, [contentRevision]);
+
+ useEffect(() => {
+ if (!copied) {
+ return undefined;
+ }
+ const timer = setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
+ return () => clearTimeout(timer);
+ }, [copied]);
+
+ useEffect(() => {
+ if (hoveredTooltip === null) {
+ return undefined;
+ }
+ const dismissTooltip = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ setHoveredTooltip(null);
+ }
+ };
+ // Hover does not move focus into the summary. Capture Escape at the document
+ // while a tooltip is visible so the same dismissal works from any focus target.
+ document.addEventListener('keydown', dismissTooltip, true);
+ return () => document.removeEventListener('keydown', dismissTooltip, true);
+ }, [hoveredTooltip]);
+
+ useLayoutEffect(() => {
+ const focused = focusedControlRef.current;
+ if (!focused || focused.isConnected) {
+ return;
+ }
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement && activeElement !== document.body && activeElement.isConnected) {
+ clearFocusSnapshot();
+ return;
+ }
+ const successor = successorRef.current;
+ if (successor?.isConnected) {
+ successor.focus();
+ clearFocusSnapshot();
+ return;
+ }
+ props.containingPanelFocusTarget.current?.focus();
+ clearFocusSnapshot();
+ });
+
+ const copyText = () => {
+ if (props.mode === 'mid-call-receiver' && props.state === 'content') {
+ return props.getReceiverCopyText();
+ }
+ if (props.mode === 'mid-call-receiver') {
+ return '';
+ }
+ return flattenContentForCopy(props.content);
+ };
+
+ const handleFocusCapture = (event: React.FocusEvent) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ const controls = rootRef.current ? getEnabledControls(rootRef.current) : [];
+ const index = controls.indexOf(target);
+ focusedControlRef.current = target;
+ successorRef.current =
+ index >= 0 ? (controls.slice(index + 1).find((control) => control !== target) ?? null) : null;
+ };
+
+ const handleBlurCapture = (event: React.FocusEvent) => {
+ const relatedTarget = event.relatedTarget;
+ const root = rootRef.current;
+ if (
+ relatedTarget instanceof HTMLElement &&
+ relatedTarget !== document.body &&
+ relatedTarget.isConnected &&
+ root &&
+ !root.contains(relatedTarget)
+ ) {
+ clearFocusSnapshot();
+ }
+ };
+
+ const handleCopy = async () => {
+ if (disabled || contentRevision === undefined) {
+ return;
+ }
+ setHoveredTooltip(null);
+ setCopied(false);
+ try {
+ const text = copyText();
+ if (text.trim().length === 0) {
+ return;
+ }
+ await navigator.clipboard.writeText(text);
+ } catch {
+ // Without copy text or clipboard access, the button shows no confirmation.
+ return;
+ }
+ // The store rejects the copy when a newer summary replaced this one meanwhile.
+ setCopied(props.onCopy(contentRevision));
+ };
+
+ const handleFeedback = (feedback: Exclude) => {
+ if (!disabled && contentRevision !== undefined) {
+ props.onFeedback?.(feedback, contentRevision);
+ }
+ };
+
+ const handleRetry = () => {
+ if (props.mode === 'post-call' && !disabled) {
+ void props.onRetry();
+ }
+ };
+
+ const renderStatus = () => {
+ if (props.state === 'generating') {
+ return (
+
+
+ {AI_SUMMARY_MESSAGES.generatingTitle}
+
+
{AI_SUMMARY_MESSAGES.generatingDescription}
+
+
+ );
+ }
+ if (props.state === 'unavailable') {
+ return (
+
+
{AI_SUMMARY_MESSAGES.unavailable}
+
+ );
+ }
+ if (props.state === 'generic-error') {
+ return (
+
+
+
+ {AI_SUMMARY_MESSAGES.generationError}
+
+
{AI_SUMMARY_MESSAGES.generationErrorDescription}
+ {props.mode === 'post-call' ? (
+
+
+ {AI_SUMMARY_MESSAGES.retry}
+
+ ) : null}
+
+ );
+ }
+ return null;
+ };
+
+ const renderContent = () => {
+ if (props.state !== 'content' || props.mode === 'mid-call-receiver') {
+ return null;
+ }
+ if (!props.content.sections) {
+ return (
+
+
+ {AI_SUMMARY_MESSAGES.plainSummary}
+ props.onEdit('summaryText', event.currentTarget.value, props.contentRevision)}
+ />
+
+
+ );
+ }
+ return (
+
+ {getDisplaySections(props.content).map((section) => {
+ const {key} = section;
+ return key !== 'resolution' ? (
+
props.onEdit(key, value, props.contentRevision)}
+ onEditorBlur={handleLocalControlRemoval}
+ />
+ ) : (
+
+
+
+ {AI_SUMMARY_MESSAGES.sectionLabels[section.key]}
+
+ {renderReadonlyValue(section)}
+
+
+ );
+ })}
+
+ );
+ };
+
+ // Post-call feedback is only submitted with the final response after wrap-up.
+ const feedbackDescription =
+ props.mode === 'post-call' && selectedFeedback !== 'none'
+ ? AI_SUMMARY_MESSAGES.feedback.pendingSubmission
+ : undefined;
+
+ const showActions = props.state === 'content';
+ const showSummaryHeading = props.mode === 'post-call' && props.state === 'content';
+ const copyLabel = copied ? AI_SUMMARY_MESSAGES.copiedSummary : AI_SUMMARY_MESSAGES.copySummary;
+ const renderTooltip = (control: AISummaryTooltipControl, label: string) =>
+ hoveredTooltip === control ? (
+
+ {label}
+
+ ) : null;
+ const getActionClassName = (control: AISummaryTooltipControl, extraClassName?: string) =>
+ ['ai-summary__action', hoveredTooltip === control ? 'ai-summary__action--tooltip-open' : undefined, extraClassName]
+ .filter(Boolean)
+ .join(' ');
+ const handleActionMouseEnter = (control: AISummaryTooltipControl) => {
+ // Confirmation changes the copy button width and can retrigger pointer entry.
+ if (!disabled && !(control === 'copy' && copied)) {
+ setHoveredTooltip(control);
+ }
+ };
+ const handleActionMouseLeave = (control: AISummaryTooltipControl) => {
+ if (hoveredTooltip === control) {
+ setHoveredTooltip(null);
+ }
+ };
+
+ return (
+
+ {renderStatus()}
+ {showSummaryHeading ? (
+
+
+ {AI_SUMMARY_MESSAGES.postCall.summaryHeading}
+
+
+ ) : null}
+
+ {renderContent()}
+ {showActions ? (
+
+ handleActionMouseEnter('copy')}
+ onMouseLeave={() => handleActionMouseLeave('copy')}
+ >
+ setHoveredTooltip('copy')}
+ onBlur={() => {
+ setHoveredTooltip(null);
+ setCopied(false);
+ }}
+ onClick={() => void handleCopy()}
+ aria-label={AI_SUMMARY_MESSAGES.copySummary}
+ >
+
+ {copyLabel}
+
+ {renderTooltip('copy', AI_SUMMARY_MESSAGES.copySummary)}
+
+ {props.mode !== 'mid-call-receiver' ? (
+ <>
+
+ {AI_SUMMARY_MESSAGES.attribution}
+
+ >
+ ) : null}
+
+ {FEEDBACK_BUTTONS.map(({feedback, icon, label: feedbackLabel}) => {
+ const selected = selectedFeedback === feedback;
+
+ return (
+ handleActionMouseEnter(feedback)}
+ onMouseLeave={() => handleActionMouseLeave(feedback)}
+ >
+ setHoveredTooltip(feedback)}
+ onBlur={() => setHoveredTooltip(null)}
+ onClick={() => handleFeedback(feedback)}
+ aria-label={feedbackLabel}
+ aria-pressed={selected}
+ aria-describedby={feedbackDescription ? feedbackDescriptionId : undefined}
+ >
+
+
+ {renderTooltip(feedback, feedbackLabel)}
+
+ );
+ })}
+
+
+ ) : null}
+
+ {feedbackDescription ? (
+
+ {feedbackDescription}
+
+ ) : null}
+
+ );
+};
+
+export default AISummary;
diff --git a/packages/contact-center/cc-components/src/components/AISummary/ai-summary.types.ts b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.types.ts
new file mode 100644
index 000000000..517050917
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/AISummary/ai-summary.types.ts
@@ -0,0 +1,77 @@
+import type {RefObject, TextareaHTMLAttributes} from 'react';
+import type {AISummaryEntry, AISummaryFeedback, AISummarySections, getAISummarySurface} from '@webex/cc-store';
+
+export type AISummaryPresentationState = Exclude, 'omitted'>;
+
+export type AISummaryEditorProps = Omit, 'value'> & {
+ value: string;
+};
+
+type AISummaryFeedbackHandler = (feedback: Exclude, expectedRevision: number) => void;
+
+type AISummaryBaseProps = {
+ state: AISummaryPresentationState;
+ requestPending?: boolean;
+ controlsDisabled?: boolean;
+ selectedFeedback?: AISummaryFeedback;
+ containingPanelFocusTarget: RefObject;
+};
+
+type AISummaryFocusFallbackProps = {
+ containingPanelFocusTarget: RefObject;
+};
+
+type AISummaryEditableActions = {
+ content: NonNullable;
+ contentRevision: number;
+ onEdit: (key: keyof AISummarySections | 'summaryText', value: string, expectedRevision: number) => boolean;
+ onCopy: (expectedRevision: number) => boolean;
+ onFeedback: AISummaryFeedbackHandler;
+};
+
+export type AISummaryMidCallInitiatorProps = AISummaryBaseProps &
+ AISummaryEditableActions & {
+ mode: 'mid-call-initiator';
+ onRetry?: never;
+ };
+
+export type AISummaryMidCallReceiverReadyProps = AISummaryFocusFallbackProps & {
+ mode: 'mid-call-receiver';
+ state: 'content';
+ requestPending?: boolean;
+ controlsDisabled?: boolean;
+ selectedFeedback?: AISummaryFeedback;
+ contentRevision: number;
+ getReceiverCopyText: () => string;
+ onCopy: (expectedRevision: number) => boolean;
+ onFeedback: AISummaryFeedbackHandler;
+ content?: never;
+ onEdit?: never;
+ onRetry?: never;
+};
+
+export type AISummaryMidCallReceiverNonReadyProps = AISummaryFocusFallbackProps & {
+ mode: 'mid-call-receiver';
+ state: Exclude;
+ requestPending?: never;
+ controlsDisabled?: never;
+ selectedFeedback?: never;
+ contentRevision?: never;
+ getReceiverCopyText?: never;
+ onCopy?: never;
+ onFeedback?: never;
+ content?: never;
+ onEdit?: never;
+ onRetry?: never;
+};
+
+export type AISummaryMidCallReceiverProps = AISummaryMidCallReceiverReadyProps | AISummaryMidCallReceiverNonReadyProps;
+
+export type AISummaryPostCallProps = AISummaryBaseProps &
+ AISummaryEditableActions & {
+ mode: 'post-call';
+ onRetry: () => Promise;
+ getReceiverCopyText?: never;
+ };
+
+export type AISummaryProps = AISummaryMidCallInitiatorProps | AISummaryMidCallReceiverProps | AISummaryPostCallProps;
diff --git a/packages/contact-center/cc-components/src/components/AISummary/index.ts b/packages/contact-center/cc-components/src/components/AISummary/index.ts
new file mode 100644
index 000000000..0dba44307
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/AISummary/index.ts
@@ -0,0 +1,4 @@
+export {default} from './ai-summary';
+export {getDisplaySections} from './ai-summary';
+export * from './ai-summary.constants';
+export * from './ai-summary.types';
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx
index e21ee42d3..3e1cfbb59 100644
--- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx
@@ -6,7 +6,7 @@ import {ConsultTransferListComponentProps} from '../../task.types';
import {createInitials, handleListItemPress} from './call-control-custom.utils';
const ConsultTransferListComponent: React.FC = (props) => {
- const {title, subtitle, presence, buttonIcon, onButtonPress, className, logger} = props;
+ const {title, subtitle, presence, buttonIcon, buttonAriaLabel, onButtonPress, className, logger} = props;
const initials = createInitials(title);
@@ -31,7 +31,7 @@ const ConsultTransferListComponent: React.FC
-
+
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx
index b1a0edb55..603828a09 100644
--- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx
@@ -1,6 +1,15 @@
-import React, {useMemo, useState} from 'react';
-import {Text, ListNext, TextInput, Button, ButtonCircle, TooltipNext} from '@momentum-ui/react-collaboration';
+import React, {useLayoutEffect, useMemo, useRef, useState} from 'react';
+import {
+ Text,
+ ListNext,
+ TextInput,
+ Button,
+ ButtonCircle,
+ TooltipNext,
+ RadioGroupNext as RadioGroup,
+} from '@momentum-ui/react-collaboration';
import {Icon, Checkbox, Spinner} from '@momentum-design/components/dist/react';
+import AISummary, {AI_SUMMARY_MESSAGES} from '../../../AISummary';
import ConsultTransferListComponent from './consult-transfer-list-item';
import {
CategoryType,
@@ -24,6 +33,7 @@ import {
SCROLL_TO_LOAD_MORE,
NO_DATA_AVAILABLE_CONSULT_TRANSFER,
} from '../../constants';
+import './consult-transfer-summary.styles.scss';
const DESTINATION_CATEGORY = {
agent: CATEGORY_AGENTS,
@@ -32,6 +42,20 @@ const DESTINATION_CATEGORY = {
entryPoint: CATEGORY_ENTRY_POINT,
} as const;
+const VOICE_DESTINATION_CATEGORY_LABELS: Record = {
+ Agents: 'Agent',
+ Queues: 'Queues',
+ 'Dial Number': 'Dial number',
+ 'Entry Point': 'Entry point',
+};
+
+let headingInstanceCounter = 0;
+
+const createHeadingId = (): string => {
+ headingInstanceCounter += 1;
+ return `consult-transfer-popover-heading-${headingInstanceCounter}`;
+};
+
const ConsultTransferPopoverComponent: React.FC = ({
heading,
buttonIcon,
@@ -49,8 +73,16 @@ const ConsultTransferPopoverComponent: React.FC {
+ const headingId = useMemo(createHeadingId, []);
+ const consultTransferPanelRef = useRef(null);
+ const summaryFocusedControlRef = useRef(null);
+ const previousSummaryVisibleRef = useRef(false);
+ const summaryVisible = Boolean(summary && summary.state !== 'omitted');
const {showDialNumberTab = true, showEntryPointTab = true} = consultTransferOptions || {};
const availableCategories = useMemo(
() =>
@@ -88,32 +120,86 @@ const ConsultTransferPopoverComponent: React.FC(false);
+ const [hasSummaryFocusFallback, setHasSummaryFocusFallback] = useState(summaryVisible);
+ // Keep the summary layout stable until this popover closes after summary eligibility is revoked.
+ const isVoiceDestinationLayout = isTelephony && (Boolean(summary) || hasSummaryFocusFallback);
+ const panelFocusEnabled = isVoiceDestinationLayout || hasSummaryFocusFallback;
+
+ const clearSummaryFocusSnapshot = () => {
+ summaryFocusedControlRef.current = null;
+ };
+
+ const restoreSummaryRemovalFocus = () => {
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement && activeElement !== document.body && activeElement.isConnected) {
+ clearSummaryFocusSnapshot();
+ return;
+ }
+ const focusedSummaryControl = summaryFocusedControlRef.current;
+ if (focusedSummaryControl && !focusedSummaryControl.isConnected) {
+ consultTransferPanelRef.current?.focus();
+ }
+ clearSummaryFocusSnapshot();
+ };
+
+ useLayoutEffect(() => {
+ if (summaryVisible && !hasSummaryFocusFallback) {
+ setHasSummaryFocusFallback(true);
+ }
+ const previousSummaryVisible = previousSummaryVisibleRef.current;
+ previousSummaryVisibleRef.current = summaryVisible;
+ if (previousSummaryVisible && !summaryVisible) {
+ restoreSummaryRemovalFocus();
+ }
+ }, [hasSummaryFocusFallback, summaryVisible]);
+
+ const handleSummaryFocusCapture = (event: React.FocusEvent) => {
+ const target = event.target;
+ if (target instanceof HTMLElement) {
+ summaryFocusedControlRef.current = target;
+ }
+ };
+
const renderList = (
items: T[],
onButtonPress: (item: T) => void
) => (
- {items.map((item) => (
- e.stopPropagation()}
- className="consult-list-item-wrapper"
- >
+ {items.map((item) => {
+ const key = `${item.id ?? item.name}-${item.number ?? ''}`;
+ const listItem = (
onButtonPress(item)}
logger={logger}
/>
-
- ))}
- {items.length === 0 && (
-
- No {selectedCategory.toLowerCase()} found
-
- )}
+ );
+ return isVoiceDestinationLayout ? (
+ listItem
+ ) : (
+ event.stopPropagation()} className="consult-list-item-wrapper">
+ {listItem}
+
+ );
+ })}
+ {items.length === 0 &&
+ (isVoiceDestinationLayout ? (
+
+
+ No {selectedCategory.toLowerCase()} found
+
+
+ ) : (
+
+ No {selectedCategory.toLowerCase()} found
+
+ ))}
);
@@ -131,53 +217,100 @@ const ConsultTransferPopoverComponent: React.FC
+ availableCategories.map((category) => ({
+ label: VOICE_DESTINATION_CATEGORY_LABELS[category],
+ value: category,
+ })),
+ [availableCategories]
+ );
+
return (
-
-
+
+
{heading}
+ {isVoiceDestinationLayout && onClose && (
+
+
+
+ )}
+
+ {isVoiceDestinationLayout ? (
+
{
+ const category = availableCategories.find((candidate) => candidate === value);
+ if (category) handleCategoryChange(category);
+ }}
+ />
+ ) : null}
+
+ {isVoiceDestinationLayout &&
}
handleSearchChange(value)}
clearAriaLabel={CLEAR_SEARCH}
- aria-labelledby="consult-search-label"
+ aria-label={isVoiceDestinationLayout ? AI_SUMMARY_MESSAGES.midCall.searchDestinations : undefined}
+ aria-labelledby={isVoiceDestinationLayout ? undefined : 'consult-search-label'}
className="consult-search-input"
/>
-
- {
- if (selectedCategory === CATEGORY_AGENTS && loadBuddyAgents) {
- loadBuddyAgents(action);
- } else {
- handleReload();
- }
- }}
- disabled={loadingBuddyAgents || loadingDialNumbers || loadingEntryPoints || loadingQueues}
- >
-
-
- }
- color="secondary"
- delay={[0, 0]}
- placement="bottom-start"
- type="description"
- variant="small"
- className="tooltip"
- >
- {`Reload ${selectedCategory}`}
-
-
+ {!isVoiceDestinationLayout && (
+
+ {
+ if (selectedCategory === CATEGORY_AGENTS && loadBuddyAgents) {
+ loadBuddyAgents(action);
+ } else {
+ handleReload();
+ }
+ }}
+ disabled={loadingBuddyAgents || loadingDialNumbers || loadingEntryPoints || loadingQueues}
+ >
+
+
+ }
+ color="secondary"
+ delay={[0, 0]}
+ placement="bottom-start"
+ type="description"
+ variant="small"
+ className="tooltip"
+ >
+ {`Reload ${selectedCategory}`}
+
+
+ )}
{consultTransferManualAction.visible && (
-
- {availableCategories.map((category: CategoryType) => {
- const isWide = category === CATEGORY_DIAL_NUMBER || category === CATEGORY_ENTRY_POINT;
+ {!isVoiceDestinationLayout ? (
+
+ {availableCategories.map((category: CategoryType) => {
+ const isWide = category === CATEGORY_DIAL_NUMBER || category === CATEGORY_ENTRY_POINT;
- return (
- handleCategoryChange(category)}
- className={`${isWide ? 'consult-category-button-wide' : 'consult-category-button-standard'} ${
- selectedCategory === category ? 'consult-category-button-active' : ''
- }`}
- >
- {category}
-
- );
- })}
-
+ return (
+
handleCategoryChange(category)}
+ className={`${isWide ? 'consult-category-button-wide' : 'consult-category-button-standard'} ${
+ selectedCategory === category ? 'consult-category-button-active' : ''
+ }`}
+ >
+ {category}
+
+ );
+ })}
+
+ ) : null}
-
+
event.stopPropagation() : undefined}
+ >
{isAgentsTabVisible &&
selectedCategory === CATEGORY_AGENTS &&
(loadingBuddyAgents ? (
@@ -238,6 +377,7 @@ const ConsultTransferPopoverComponent: React.FC ({
id: agent.agentId,
name: agent.agentName,
+ number: isVoiceDestinationLayout ? agent.dn : undefined,
presence: agent.state?.toLowerCase() === 'available' ? ('active' as const) : ('away' as const),
})),
(item) => handleAgentSelection(item.id, item.name, allowParticipantsToInteract, onAgentSelect, logger)
@@ -336,6 +476,30 @@ const ConsultTransferPopoverComponent: React.FC }
+ {summary && summary.state !== 'omitted' && (
+
+
+
+ {summaryHeading}
+
+
+
+ )}
{isConferenceInProgress && (
.md-radio-wrapper > .md-radio-label {
+ font-size: 0.875rem;
+ margin-inline: 0;
+ }
+}
+
+// Bound the voice panel; long destination lists and summary content scroll
+// independently without pushing the heading or summary actions off screen.
+.agent-popover.md-modal-container-wrapper:has(.agent-popover-content--voice) {
+ padding: 0;
+}
+
+.agent-popover .agent-popover-content.agent-popover-content--voice {
+ min-width: 0;
+ width: min(var(--cc-summary-panel-width, 30rem), calc(100vw - 2rem));
+ block-size: 80vh;
+ max-block-size: 80vh;
+ min-block-size: 0;
+ min-height: 0;
+ padding: 1rem;
+ overflow: visible;
+ font-family: var(--mds-font-family-primary, Inter, CiscoSans, 'Helvetica Neue', Arial, sans-serif);
+
+ .agent-popover-title::part(text) {
+ margin: 0;
+ }
+
+ .agent-popover-title {
+ padding-inline-end: 2rem;
+ }
+
+ .consult-close-button {
+ position: absolute;
+ inset-block-start: 1rem;
+ inset-inline-end: 1rem;
+ inline-size: 1.25rem;
+ block-size: 1.25rem;
+
+ mdc-icon {
+ inline-size: 1rem;
+ block-size: 1rem;
+ }
+ }
+
+ .consult-search-row {
+ position: relative;
+ inline-size: 100%;
+ }
+
+ .consult-search-icon {
+ position: absolute;
+ z-index: 1;
+ inset-inline-start: 0.75rem;
+ inline-size: 1rem;
+ block-size: 1rem;
+ pointer-events: none;
+ }
+
+ .consult-search-input input {
+ padding-inline-start: 2.25rem;
+ font-size: 0.875rem;
+ }
+
+ .consult-search-input {
+ flex-basis: 100%;
+ }
+
+ > :not(.consult-list-container, .consult-transfer-summary) {
+ flex-shrink: 0;
+ }
+
+ .consult-list-container {
+ flex: 1 1 35%;
+ min-block-size: 0;
+ max-block-size: none;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ }
+
+ .agent-list {
+ min-inline-size: 0;
+ }
+
+ .call-control-list-item:focus-within .hover-button {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ .call-control-list-item {
+ min-block-size: 3.5rem;
+ }
+
+ .call-control-list-item .hover-button button {
+ inline-size: 1.75rem;
+ block-size: 1.75rem;
+ background: transparent;
+ color: var(--mds-color-theme-text-primary-normal);
+ border: 1px solid var(--mds-color-theme-outline-primary-normal);
+ }
+
+ .call-control-list-item .hover-button mdc-icon {
+ inline-size: 1.25rem;
+ block-size: 1.25rem;
+ }
+
+ .consult-transfer-summary {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 45%;
+ min-block-size: 8rem;
+ }
+
+ .consult-transfer-summary__heading {
+ flex-shrink: 0;
+ }
+
+ .consult-transfer-summary > .ai-summary > .ai-summary__status {
+ min-block-size: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ }
+}
+
+@media (max-height: 480px) {
+ .agent-popover .agent-popover-content.agent-popover-content--voice {
+ padding: 0.5rem;
+ gap: 0.125rem;
+
+ .agent-popover-title {
+ margin-block-end: 0;
+ }
+
+ .consult-category-radios.md-radio-group {
+ margin-block-end: 0.125rem;
+ }
+
+ .consult-list-container {
+ flex: 1 1 0;
+ min-block-size: 1.5rem;
+ }
+
+ .consult-transfer-summary {
+ flex: 2 1 0;
+ min-block-size: 6.5rem;
+ margin-block-start: 0.25rem;
+ padding-block-start: 0.25rem;
+ }
+
+ .consult-transfer-summary__heading {
+ margin-block-end: 0.125rem;
+ }
+
+ .ai-summary__editor-surface {
+ padding: 0.25rem 0.5rem;
+ }
+ }
+}
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/wrap-up-summary.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/wrap-up-summary.styles.scss
new file mode 100644
index 000000000..9a5f8649d
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/wrap-up-summary.styles.scss
@@ -0,0 +1,257 @@
+.wrap-up-summary {
+ font-size: 0.875rem;
+ line-height: 1.4286;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ inline-size: min(var(--cc-summary-panel-width, 30rem), calc(100vw - 2rem));
+ block-size: 80vh;
+ max-block-size: 80vh;
+ min-block-size: 0;
+ overflow: visible;
+ overscroll-behavior: contain;
+ padding: 1rem;
+}
+
+.wrap-up-summary__header,
+.wrap-up-summary__search-field,
+.wrap-up-summary__title,
+.wrap-up-summary__actions {
+ min-inline-size: 0;
+ flex-shrink: 0;
+}
+
+.wrap-up-summary__scroll-content {
+ display: contents;
+}
+
+.wrap-up-summary__reason-group,
+.wrap-up-summary__reasons,
+.wrap-up-summary__body {
+ min-inline-size: 0;
+ min-block-size: 0;
+}
+
+.wrap-up-summary__header {
+ display: grid;
+ gap: 0.5rem;
+ margin-block-end: 0.5rem;
+}
+
+.wrap-up-summary__eyebrow {
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.35;
+ margin: 0;
+}
+
+.wrap-up-summary__intro {
+ color: var(--mds-color-theme-text-secondary-normal, #54585c);
+ font-size: 0.875rem;
+ line-height: 1.35;
+ margin: 0;
+}
+
+.wrap-up-summary__title {
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.35;
+ margin: 0;
+}
+
+.wrap-up-summary__search-field {
+ inline-size: 100%;
+ position: relative;
+}
+
+.wrap-up-summary__search-field-icon {
+ block-size: 1rem;
+ color: var(--mds-color-theme-text-secondary-normal, #54585c);
+ inline-size: 1rem;
+ inset-block-start: 50%;
+ inset-inline-start: 0.75rem;
+ pointer-events: none;
+ position: absolute;
+ transform: translateY(-50%);
+ z-index: 1;
+}
+
+.wrap-up-summary__search-control {
+ inline-size: 100%;
+}
+
+.wrap-up-summary__search-control .wrap-up-summary__search {
+ font-size: 0.875rem;
+}
+
+.wrap-up-summary__search-field .wrap-up-summary__search {
+ padding-inline-start: 2.25rem;
+}
+
+.wrap-up-summary__search-control .clear-icon {
+ min-inline-size: 1.5rem;
+ min-block-size: 1.5rem;
+}
+
+.wrap-up-summary__reason-group {
+ display: grid;
+ gap: 0.25rem;
+ flex: 1 1 30%;
+ min-block-size: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.wrap-up-summary__reasons {
+ inline-size: 100%;
+}
+
+.wrap-up-summary__reasons > span:empty {
+ display: none;
+}
+
+.wrap-up-summary__reason.md-radio-wrapper {
+ align-items: center;
+ display: flex;
+ min-inline-size: 0;
+ overflow-wrap: anywhere;
+ min-block-size: 2.75rem;
+ margin: 0;
+ padding-inline: 0.75rem;
+}
+
+.wrap-up-summary__reason.md-radio-wrapper > .md-radio-label {
+ block-size: auto;
+ font-size: 0.875rem;
+ line-height: 1.35;
+ margin: 0;
+}
+
+.wrap-up-summary__empty {
+ margin: 0;
+}
+
+.wrap-up-summary__body {
+ display: flex;
+ flex-direction: column;
+ flex: 2 1 55%;
+ min-block-size: 0;
+ border-block-start: 1px solid var(--mds-color-theme-outline-secondary-normal, #d7d7d8);
+ padding-block-start: 0.625rem;
+}
+
+.wrap-up-summary__body--content {
+ border-block-start: 0;
+ padding-block-start: 0;
+}
+
+.wrap-up-summary__body:not(.wrap-up-summary__body--content) {
+ border-block-end: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal);
+ padding-block-end: 0.625rem;
+}
+
+// The existing popover owns its border. Avoid double padding and allow the
+// bounded summary panel to allocate space to its scrollable content.
+.wrapup-popover.md-modal-container-wrapper:has(.wrap-up-summary) {
+ padding: 0;
+}
+
+.wrap-up-summary__body .ai-summary__summary-heading,
+.wrap-up-summary__body .ai-summary__actions {
+ flex-shrink: 0;
+}
+
+.wrap-up-summary__body > .ai-summary,
+.wrap-up-summary__body .ai-summary__editor-surface,
+.wrap-up-summary__body .ai-summary__content {
+ flex: 1 1 auto;
+ min-block-size: 0;
+}
+
+.wrap-up-summary__body .ai-summary__content,
+.wrap-up-summary__body .ai-summary__status {
+ min-block-size: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.wrap-up-summary__actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-block-start: auto;
+}
+
+.wrap-up-summary__complete {
+ background: var(--mds-color-theme-background-inverted-normal, #1b1c1d);
+ border: 0;
+ border-radius: 999px;
+ color: var(--mds-color-theme-text-inverted-normal, #fff);
+ cursor: pointer;
+ font: inherit;
+ font-size: 1rem;
+ line-height: 1.5;
+ padding: 0.25rem 0.75rem;
+}
+
+.wrap-up-summary__complete:disabled {
+ background: var(--mds-color-theme-background-secondary-normal, #e9eaed);
+ color: var(--mds-color-theme-text-disabled-normal, #6f7378);
+ cursor: not-allowed;
+}
+
+@media (max-height: 480px) {
+ .wrap-up-summary {
+ gap: 0.25rem;
+ padding: 0.5rem;
+ }
+
+ .wrap-up-summary__header {
+ gap: 0.125rem;
+ margin-block-end: 0;
+ }
+
+ .wrap-up-summary__intro {
+ font-size: 0.75rem;
+ }
+
+ .wrap-up-summary__reason-group {
+ flex: 1 1 0;
+ min-block-size: 1.75rem;
+ }
+
+ .wrap-up-summary__body {
+ flex: 3 1 0;
+ min-block-size: 5.25rem;
+ padding-block-start: 0;
+ }
+
+ .wrap-up-summary__body .ai-summary__editor-surface {
+ padding: 0.25rem 0.5rem;
+ }
+
+ .wrap-up-summary__body .ai-summary__summary-heading {
+ margin-block-end: 0.125rem;
+ }
+}
+
+// At very short heights the fixed chrome itself exceeds the available space.
+// Scroll the content above the footer, keeping completion inside the 80vh shell.
+@media (max-height: 330px) {
+ .wrap-up-summary__scroll-content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: inherit;
+ min-block-size: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ }
+
+ .wrap-up-summary__scroll-content > .wrap-up-summary__reason-group,
+ .wrap-up-summary__scroll-content > .wrap-up-summary__body {
+ flex-shrink: 0;
+ }
+}
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/wrap-up-summary.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/wrap-up-summary.tsx
new file mode 100644
index 000000000..87f62dd12
--- /dev/null
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/wrap-up-summary.tsx
@@ -0,0 +1,157 @@
+import React, {useLayoutEffect, useMemo, useRef, useState} from 'react';
+import {RadioGroupNext as RadioGroup, TextInput} from '@momentum-ui/react-collaboration';
+import {Icon} from '@momentum-design/components/dist/react';
+import AISummary, {AI_SUMMARY_MESSAGES} from '../../../AISummary';
+import {CLEAR_SEARCH, WRAP_UP_INTERACTION} from '../../constants';
+import {WrapUpSummaryProps, WrapUpSummaryReason} from '../../task.types';
+import './wrap-up-summary.styles.scss';
+
+const useReactId = (React as typeof React & {useId: () => string}).useId;
+
+const reasonMatchesQuery = (reason: WrapUpSummaryReason, query: string): boolean =>
+ reason.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase());
+
+const WrapUpSummary: React.FC = ({
+ reasons,
+ summary,
+ selectedReasonId,
+ completionPending = false,
+ onReasonChange,
+ onComplete,
+}) => {
+ const headingId = useReactId();
+ const [query, setQuery] = useState('');
+ const wrapUpPanelRef = useRef(null);
+ const summaryFocusedControlRef = useRef(null);
+ const previousSummaryVisibleRef = useRef(false);
+ const selectedReason = reasons.find((reason) => reason.id === selectedReasonId);
+ const filteredReasons = useMemo(
+ () => reasons.filter((reason) => reasonMatchesQuery(reason, query)),
+ [query, reasons]
+ );
+ const summaryVisible = Boolean(summary && summary.state !== 'omitted');
+
+ const clearSummaryFocusSnapshot = () => {
+ summaryFocusedControlRef.current = null;
+ };
+
+ const restoreSummaryRemovalFocus = () => {
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement && activeElement !== document.body && activeElement.isConnected) {
+ clearSummaryFocusSnapshot();
+ return;
+ }
+ const focusedSummaryControl = summaryFocusedControlRef.current;
+ if (focusedSummaryControl && !focusedSummaryControl.isConnected) {
+ wrapUpPanelRef.current?.focus();
+ }
+ clearSummaryFocusSnapshot();
+ };
+
+ useLayoutEffect(() => {
+ const previousSummaryVisible = previousSummaryVisibleRef.current;
+ previousSummaryVisibleRef.current = summaryVisible;
+ if (previousSummaryVisible && !summaryVisible) {
+ restoreSummaryRemovalFocus();
+ }
+ }, [summaryVisible]);
+
+ const handleSummaryFocusCapture = (event: React.FocusEvent) => {
+ const target = event.target;
+ if (target instanceof HTMLElement) {
+ summaryFocusedControlRef.current = target;
+ }
+ };
+
+ return (
+
+
+
+
+ {AI_SUMMARY_MESSAGES.postCall.chooseReason}
+
+
+
+
+
+
+
{
+ const reason = reasons.find((candidate) => candidate.id === reasonId);
+ if (reason) {
+ onReasonChange(reason);
+ }
+ }}
+ options={filteredReasons.map((reason) => ({
+ className: 'wrap-up-summary__reason',
+ label: reason.name,
+ value: reason.id,
+ }))}
+ value={selectedReason?.id ?? ''}
+ />
+ {filteredReasons.length === 0 ? (
+ {AI_SUMMARY_MESSAGES.postCall.noReasonMatches}
+ ) : null}
+
+ {summary && summary.state !== 'omitted' ? (
+
+ ) : null}
+
+
+ {/* As in Agent Desktop, a summary that is still generating does not hold back wrap-up. */}
+ selectedReason && onComplete(selectedReason)}
+ >
+ {AI_SUMMARY_MESSAGES.postCall.completeAction}
+
+
+
+ );
+};
+
+export default WrapUpSummary;
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss
index 4d51436d8..b0acf458e 100644
--- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss
@@ -92,6 +92,11 @@
margin-top: 1rem;
}
+.call-control-wrapup-panel {
+ display: flex;
+ flex-direction: column;
+}
+
.wrapup-name {
color: var(--mds-color-theme-text-primary-normal);
}
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx
index 554283a04..c7916c8f2 100644
--- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx
@@ -1,10 +1,12 @@
-import React, {useEffect, useState} from 'react';
+import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {CallControlComponentProps, CallControlMenuType} from '../task.types';
import './call-control.styles.scss';
import {PopoverNext, TooltipNext, Text, ButtonCircle} from '@momentum-ui/react-collaboration';
+import type {PopoverInstance} from '@momentum-ui/react-collaboration';
import {Icon, Button, Select, Option} from '@momentum-design/components/dist/react';
import ConsultTransferPopoverComponent from './CallControlCustom/consult-transfer-popover';
+import WrapUpSummary from './CallControlCustom/wrap-up-summary';
import CallControlDtmfKeypad from './call-control-dtmf-keypad';
import AutoWrapupTimer from '../AutoWrapupTimer/AutoWrapupTimer';
import type {MEDIA_CHANNEL as MediaChannelType} from '../task.types';
@@ -35,6 +37,11 @@ function CallControlComponent(props: CallControlComponentProps) {
const [showAgentMenu, setShowAgentMenu] = useState(false);
const [agentMenuType, setAgentMenuType] = useState(null);
const [isMuteButtonDisabled, setIsMuteButtonDisabled] = useState(false);
+ const [isWrapupCompletionPending, setIsWrapupCompletionPending] = useState(false);
+ const [isSummaryPopoverLayout, setIsSummaryPopoverLayout] = useState(false);
+ const agentPopovers = useRef>({});
+ const wrapUpSurfaceRef = useRef(null);
+ const focusedWrapUpControlRef = useRef(null);
const {
currentTask,
@@ -69,11 +76,23 @@ function CallControlComponent(props: CallControlComponentProps) {
getEntryPoints,
getQueuesFetcher,
consultTransferOptions,
+ aiSummary,
conferenceEnabled = true,
enableWxBetterTogether = false,
agentDeviceType,
} = props;
+ useLayoutEffect(() => {
+ const previous = focusedWrapUpControlRef.current;
+ const active = document.activeElement;
+ // Capability revocation replaces the entire summary with the legacy form.
+ // Its stable parent owns focus recovery when the focused child is removed.
+ if (previous && !previous.isConnected && (!active || active === document.body || !active.isConnected)) {
+ wrapUpSurfaceRef.current?.focus();
+ }
+ if (previous && !previous.isConnected) focusedWrapUpControlRef.current = null;
+ }, [aiSummary?.postCall]);
+
useEffect(() => {
updateCallStateFromTask(currentTask, setIsRecording, logger);
}, [currentTask, logger]);
@@ -81,6 +100,7 @@ function CallControlComponent(props: CallControlComponentProps) {
useEffect(() => {
setShowAgentMenu(false);
setAgentMenuType(null);
+ setIsWrapupCompletionPending(false);
}, [currentTask?.data?.interactionId]);
const handletoggleHold = () => {
@@ -91,6 +111,28 @@ function CallControlComponent(props: CallControlComponentProps) {
handleMuteToggleUtil(toggleMute, setIsMuteButtonDisabled, logger);
};
+ // Freeze summary edits and prevent a second completion while its wrap-up is pending.
+ const runWrapupCompletion = async (wrapupReason: string | null, wrapupId: string | null) => {
+ if (isWrapupCompletionPending || !wrapupReason || !wrapupId) {
+ return;
+ }
+ setIsWrapupCompletionPending(true);
+ try {
+ const succeeded = await wrapupCall(wrapupReason, wrapupId);
+ if (succeeded !== false) {
+ setSelectedWrapupReason(null);
+ setSelectedWrapupId(null);
+ }
+ } catch (error) {
+ logger.error(`CC-Widgets: CallControl: wrap-up failed: ${error}`, {
+ module: 'call-control.tsx',
+ method: 'runWrapupCompletion',
+ });
+ } finally {
+ setIsWrapupCompletionPending(false);
+ }
+ };
+
const handleWrapupCallLocal = () => {
handleWrapupCallUtil(
selectedWrapupReason,
@@ -194,6 +236,7 @@ function CallControlComponent(props: CallControlComponentProps) {
{
+ setIsSummaryPopoverLayout(Boolean(aiSummary?.midCall));
logger.info(`CC-Widgets: CallControl: showing ${button.menuType} popover`, {
module: 'call-control.tsx',
method: 'onShowPopover',
@@ -203,6 +246,9 @@ function CallControlComponent(props: CallControlComponentProps) {
if (button.menuType !== 'Keypad' && availableDestinations.includes('agent')) {
loadBuddyAgents(action);
}
+ if (button.menuType !== 'Keypad' && isTelephony) {
+ void aiSummary?.requestMidCallSummary?.(action === 'Transfer' ? 'TRANSFER' : 'CONSULT');
+ }
}}
onHide={() => {
setShowAgentMenu(false);
@@ -217,7 +263,13 @@ function CallControlComponent(props: CallControlComponentProps) {
offsetDistance={2}
className="agent-popover"
trigger="click"
- closeButtonPlacement="top-right"
+ setInstance={(instance) => {
+ agentPopovers.current[button.menuType] =
+ typeof instance === 'function' ? instance(agentPopovers.current[button.menuType]) : instance;
+ }}
+ closeButtonPlacement={
+ isTelephony && isSummaryPopoverLayout && button.menuType !== 'Keypad' ? 'none' : 'top-right'
+ }
closeButtonProps={{
'aria-label': 'Close popover',
onPress: () => handleCloseButtonPress(setShowAgentMenu, setAgentMenuType, logger),
@@ -256,6 +308,11 @@ function CallControlComponent(props: CallControlComponentProps) {
/>
) : showAgentMenu && agentMenuType === button.menuType ? (
{
+ agentPopovers.current[button.menuType]?.hide();
+ handleCloseButtonPress(setShowAgentMenu, setAgentMenuType, logger);
+ }}
+ isTelephony={isTelephony}
heading={button.menuType}
buttonIcon={button.icon}
buddyAgents={buddyAgents}
@@ -279,6 +336,7 @@ function CallControlComponent(props: CallControlComponentProps) {
action={action}
availableDestinations={availableDestinations}
consultTransferOptions={consultTransferOptions}
+ summary={aiSummary?.midCall}
isConferenceInProgress={controls?.main?.exitConference?.isVisible ?? false}
logger={logger}
/>
@@ -316,6 +374,7 @@ function CallControlComponent(props: CallControlComponentProps) {
{controls?.main?.wrapup?.isVisible && (
aiSummary?.requestPostCallSummary?.()}
color="primary"
delay={[0, 0]}
placement="bottom-start"
@@ -340,53 +399,76 @@ function CallControlComponent(props: CallControlComponentProps) {
offsetDistance={2}
className="wrapup-popover"
>
- {currentTask.autoWrapup && (
-
- )}
-
-
- {WRAP_UP_INTERACTION}
-
-
- handleWrapupReasonChange(event, wrapupCodes, handleWrapupChange, logger)
- }
+ {
+ focusedWrapUpControlRef.current = event.target as HTMLElement;
+ }}
>
- {wrapupCodes?.map((code) => (
-
- {code.name}
-
- ))}
-
-
- {SUBMIT_WRAP_UP}
-
+ {currentTask.autoWrapup && (
+
+ )}
+
+ {aiSummary?.postCall ? (
+
handleWrapupChange(reason.name, reason.id)}
+ onComplete={(reason) => runWrapupCompletion(reason.name, reason.id)}
+ />
+ ) : (
+ <>
+
+ {WRAP_UP_INTERACTION}
+
+
+ handleWrapupReasonChange(event, wrapupCodes, handleWrapupChange, logger)
+ }
+ >
+ {wrapupCodes?.map((code) => (
+
+ {code.name}
+
+ ))}
+
+
+ {SUBMIT_WRAP_UP}
+
+ >
+ )}
+
)}
diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts
index 071573b0a..b19c90ffc 100644
--- a/packages/contact-center/cc-components/src/components/task/task.types.ts
+++ b/packages/contact-center/cc-components/src/components/task/task.types.ts
@@ -1,4 +1,5 @@
import {
+ AISummaryAction,
ILogger,
ITask,
IContactCenter,
@@ -15,8 +16,10 @@ import {
AddressBookEntrySearchParams,
AddressBookEntriesResponse,
TaskUIControls,
+ type getAISummarySurface,
} from '@webex/cc-store';
import {CampaignErrorType} from './CampaignErrorDialog/campaign-error-dialog.types';
+import type {AISummaryMidCallInitiatorProps, AISummaryPostCallProps} from '../AISummary/ai-summary.types';
type Enum> = T[keyof T];
@@ -60,6 +63,43 @@ export type ParticipantDropAnnouncement = {
message: string;
};
+type AISummaryViewProps =
+ | 'content'
+ | 'contentRevision'
+ | 'selectedFeedback'
+ | 'requestPending'
+ | 'onEdit'
+ | 'onCopy'
+ | 'onFeedback';
+
+/** The initiating agent's consult/transfer summary shown in the popover. */
+export type ConsultTransferSummaryView = Pick & {
+ state: ReturnType;
+};
+
+/** The summary shown in the wrap-up panel. */
+export type WrapUpSummaryView = Pick & {
+ state: ReturnType;
+};
+
+export type WrapUpSummaryReason = Pick;
+
+export type WrapUpSummaryProps = {
+ reasons: readonly WrapUpSummaryReason[];
+ summary?: WrapUpSummaryView;
+ selectedReasonId?: string;
+ completionPending?: boolean;
+ onReasonChange: (reason: WrapUpSummaryReason) => void;
+ onComplete: (reason: WrapUpSummaryReason) => void;
+};
+
+export type CallControlAISummaryProps = {
+ midCall?: ConsultTransferSummaryView;
+ postCall?: WrapUpSummaryView;
+ requestMidCallSummary?: (action: AISummaryAction) => Promise;
+ requestPostCallSummary?: () => void;
+};
+
/**
* Interface representing the TaskProps of a user.
*/
@@ -321,7 +361,7 @@ export interface ControlProps {
* @param wrapupReason - The reason for wrapping up the call.
* @param wrapupId - The ID associated with the wrap-up reason.
*/
- wrapupCall: (wrapupReason: string, wrapupId: string) => void;
+ wrapupCall: (wrapupReason: string, wrapupId: string) => void | Promise;
/**
* Flag to determine if the task is held
@@ -558,6 +598,11 @@ export interface ControlProps {
*/
consultTransferOptions?: ConsultTransferOptions;
+ /**
+ * AI summary presentation/adapters supplied by the task package.
+ */
+ aiSummary?: CallControlAISummaryProps;
+
/**
* Agent ID of the logged-in user
*/
@@ -623,6 +668,7 @@ export type CallControlComponentProps = Pick<
| 'getEntryPoints'
| 'getQueuesFetcher'
| 'consultTransferOptions'
+ | 'aiSummary'
| 'conferenceEnabled'
> &
Partial<
@@ -732,6 +778,7 @@ export interface ConsultTransferListComponentProps {
subtitle?: string;
presence?: 'active' | 'away';
buttonIcon: string;
+ buttonAriaLabel?: string;
onButtonPress: () => void;
className?: string;
logger: ILogger;
@@ -767,6 +814,10 @@ export interface ConsultTransferPopoverComponentProps {
onDialNumberSelect: (dialNumber: string, allowParticipantsToInteract: boolean) => void;
action: 'Consult' | 'Transfer';
availableDestinations: TaskUIControls['consultTransferDestinations']['consult'];
+ /** Eligible telephony tasks use the summary layout with destination radios and a close button. */
+ isTelephony?: boolean;
+ summary?: ConsultTransferSummaryView;
+ onClose?: () => void;
/** Options governing popover visibility/behavior */
consultTransferOptions?: ConsultTransferOptions;
isConferenceInProgress?: boolean;
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..3fe6b5fdb 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
@@ -11,6 +11,7 @@ exports[`AIAssistantComponent snapshots matches a full-screen RealTimeAssist con
class="ai-assistant__panel ai-assistant__panel--full-screen"
data-testid="ai-assistant:panel"
role="dialog"
+ tabindex="-1"
>