diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 6a76ae085..7865687ca 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -25,6 +25,8 @@ The React entry of `@webex/cc-widgets` re-exports the widgets, including `AIAssi | ai-assistant.AIAssistant | `@webex/cc-widgets` and `@webex/cc-ai-assistant` (React); `@webex/cc-widgets/wc` (custom element) | `AIAssistant`; `widget-cc-ai-assistant` | React component and existing custom element; wellness callbacks `onWellnessBreakOffered`, `onWellnessBreakAccepted`, `onWellnessBreakStarted`, `onWellnessBreakEnded`, `onWellnessBreakError`; optional `wellnessAudioUrl` and `wellnessBreakOverlayTarget` | Both React import paths remain available; WC entry retains the tag | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | `packages/contact-center/ai-assistant/src/ai-assistant.types.ts`, `packages/contact-center/cc-widgets/src/index.ts`, `packages/contact-center/cc-widgets/src/wc.ts` | | ai-assistant.wellness-types | `@webex/cc-ai-assistant` | `WellnessBreakPhase`, `WellnessBreakErrorCode`, `WellnessBreakError`, `WellnessBreakOverlayTarget` | Exported TypeScript lifecycle, error, and layout contracts | additive minor surface; literals are stable | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | `packages/contact-center/ai-assistant/src/ai-assistant.types.ts` | | cc-components.WellnessBreak | `@webex/cc-components` | `WellnessBreakRequestCard`, `WellnessBreakOfferCard`, `WellnessBreakModal`, `WellnessBreakError` | Props-only Agent Wellness Break presentation; modal accepts an optional animation loader supplied by AI Assistant and owns no wellness media | monorepo component surface; no SDK/store or Lottie dependency | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/` | +| cc-components.AIAssistantReceiverSummary | `@webex/cc-components` | `AIAssistantComponentProps.receiverSummary`, `AIAssistantReceiverSummary` | Props-only receiver view with a stable interaction `branchKey`, SDK `content`, feedback selection/pending state, and copy/feedback callbacks bound by the widget to that displayed summary. Callbacks carry no numeric revision. The component renders a display-only card and copies only visible card text; SDK requests and submission remain in the store. | additive presentation surface; consumed by the widget integration | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` (AI summary presentation) | `packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts` | +| cc-components.CallControlAISummary | `@webex/cc-components` | `CallControlComponentProps.aiSummary`, `CallControlAISummaryProps`, `ConsultTransferSummaryView`, `WrapUpSummaryView` | Optional props-only consult/transfer and wrap-up summary views; raw SDK content and local `editedSummary` are separate. Callbacks carry no numeric revision. Requests, edits and SDK submission are supplied by the widget/store. | additive optional props; `wrapupCall` supports void or Promise; the internal AISummary component remains private | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` (AI summaries in CallControl) | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | | store.ai-summary | `@webex/cc-store` | `cc.aiSummaries`, summary request/receiver-feedback/send methods, `AISummaryEntry`, `AISummaryRole`, `AISummaryResponseSubmissionResult` | Entries keyed by interaction and role retain the unchanged SDK `AISummary` in `content` and `AISummaryResponse` in `response`. Requests return `AISummary` or `undefined`; senders return `AISummaryResponseSubmissionResult`. Widget helpers own local editing, copy tracking and deferred feedback through response fields. `setReceiverSummaryFeedback(feedback, expectedSummary, task)` uses the displayed SDK payload to guard its asynchronous acknowledgement. Consumers read enablement from `task.aiSummaryCapabilities`; `task:featureEnablement` refreshes the task view. Wrap-up sends `sendPostCallSummaryResponse(wrapUpCode, originalTask)` before `task.wrapup()`. | additive; uses published `@webex/contact-center` `3.12.0-next.148` | `packages/contact-center/store/ai-docs/store-spec.md` (AI summaries) | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/store/src/storeEventsWrapper.ts` | | store.host-init.enableWxBetterTogether | `@webex/cc-store` | `webexConfig.cc.enableWxBetterTogether` → `store.enableWxBetterTogether` | Host init boolean (default `false`), set **before** `store.init()`: read from `webexConfig.cc` on `{ webexConfig, access_token }` init or `webex.config.cc` on `{ webex }` init; read-only observable after init (Phase 1: re-init to change). Gates wxApp Mute/Keypad visibility and wxApp mute seed; SDK owns mute/DTMF API routing. | stable semver; renaming or removing the init key or store getter is breaking | `packages/contact-center/store/ai-docs/store-spec.md` (`STORE-R-030`); `packages/contact-center/task/ai-docs/task-spec.md` (WXCC-6026) | `packages/contact-center/store/src/store.ts`, `packages/contact-center/store/src/store.types.ts` (`WebexCcInitConfig`) | diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 7551a10d5..83d9d8e88 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -355,6 +355,111 @@ 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 Assistant summary presentation + +The internal `components/AIAssistant/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. Receiver content +without an Adaptive Card renders as unavailable and exposes no summary actions. + +Summary content props retain the store's SDK `AISummary` shape, including the required +`conversationId`; feedback and lifecycle values are supplied by the widget from the +store entry. Generating and error states accept absent content so widgets can pass +the store value directly without constructing an empty SDK payload. An editable +content state without a payload falls back to unavailable and exposes no actions. +Edit callbacks notify the owner without requiring a return value. Component tests +use the same SDK shape. Receiver rendering and focus +handlers live in the assistant component alongside its chrome and summary branch. +The mode-specific prop union separates editable summaries from receiver actions and +requires content and callbacks only when receiver content is available. The shared +action surface uses that SDK payload only as identity; the assistant card branch +still owns receiver rendering. Callbacks are bound by the widget to the displayed +SDK summary and accept no numeric revision. Section ordering, labels, plain-text +clipboard formatting, bullet presentation and auto-sizing native editors belong to +this component. Widget helpers keep local edits, copy counts and +deferred feedback in the SDK response record; SDK submission stays in the store. +The presentation-state union is declared locally rather than derived from a store +selector. Pending clipboard completions cannot update confirmation after the +displayed SDK payload changes. + +Summary loading, Retry, editable section activation and tooltip presentation use the +same Momentum primitives as Real-time Assist and Wellness Break. Tooltip visibility +remains controlled to keep hovered content reachable and consume Escape before the +containing panel handles it; button labels and the pending-feedback description +remain unchanged. Keyed editing, clipboard formatting, confirmation state and focus +recovery stay summary-specific. + +Structured section previews size the Momentum button host to their full wrapped +content and allow wrapping through its exposed `button-text` part. Each section +reserves its actual text height, including bullet lists, in consult/transfer and +post-call views. Disabled previews retain readable text and keyboard focus styling +targets the host; activating an enabled preview still opens its keyed editor. +Empty or whitespace-only section values render no preview label or row in either +editable summary mode. A focused editor stays mounted when its text is cleared; +the empty row disappears after editing ends. Values and cleared section keys remain +unchanged in the SDK payload and the owner's local response, and copy omits blank sections. + +`AIAssistantComponent.receiverSummary` adds View summary to closed/minimized chrome +and opens the receiver branch independently of Real-time Assist enablement. Copy uses +visible rendered card text through an opt-in filter in the shared Adaptive Card +text extractor. Hidden receiver text blocks and descendants of hidden card +containers are excluded from summary copying; ordinary Real-time Assist copying +keeps its existing behavior. Visible nested blocks keep their text and line breaks. +The renderer uses Adaptive Cards' `supportsInteractivity: false` for receiver content, keeping +card-provided actions, select actions and inputs inert without rewriting the SDK +card. Its render callback controls whether the separate copy/feedback controls are +shown; parsing or rendering failure hides them. Valid replacement cards preserve +those controls and their focus. Replacing a display-only receiver card resets its +error boundary; ordinary Real-time Assist cards keep their existing recovery +behavior. Closing either chrome clears the receiver selection before invoking the +host callback, so external reopening returns to the ordinary assistant view. +An explicitly selected receiver summary takes precedence over retained wellness +history; an active wellness offer, transition, error or overlay retains priority. +Wellness history remains available when returning to the ordinary assistant view. +Focus capture is limited to the receiver branch and View summary trigger. It is +cleared when focus moves to an ordinary assistant or host control. Summary +components recover focus only when a focused summary control or branch is removed +and no connected control has taken focus; ordinary Real-time Assist and wellness +controls retain their existing focus behavior. + +Summary presentation constants use the `AI_ASSISTANT_SUMMARY_` prefix. These components +receive data and callbacks through props and make no SDK calls. +`ai-summary.tsx` owns the rendered UI; `ai-summary.hooks.ts` owns summary interaction, +clipboard, tooltip, and focus behavior. +`AISummary` remains internal; it is not exported by the package's root React barrel. + +Evidence: `components/AIAssistant/AISummary/` and `components/AIAssistant/`; tests: +`tests/components/AIAssistant/AISummary/ai-summary.tsx`, +`tests/components/AIAssistant/ai-assistant.tsx`, and +`tests/components/AIAssistant/adaptive-card-renderer.test.tsx`. + +## AI summaries in CallControl + +Consult/transfer and wrap-up panels forward their summary view props to the shared +`AIAssistant/AISummary` component. The widget binds callbacks to the displayed SDK +content and supplies local response edits separately through `editedSummary`, so +raw content retains its identity. These components perform no SDK operations. + +The wrap-up panel owns reason search and selection. Complete Wrap-Up stays enabled +while summary generation is pending and disables completion and summary editing +while wrap-up itself is pending. Failed completion retains the selected reason and +draft. An absent summary prop preserves the existing CallControl form. + +Each CallControl panel stylesheet owns its constrained flex and scroll layout; +the shared summary stylesheet owns the editor, status, and action presentation. + +Summary focus recovery applies only after a focused summary control is removed and +no connected control has taken focus. Consult/transfer retains its summary layout +until the popover closes after capability revocation, preserving its focus target +and close action. React-generated heading IDs label the panels. Requests, local +response changes, retry and submission remain in the widget/store layers. + +Evidence: `components/task/CallControl/call-control.tsx` and `CallControlCustom/`; +tests: `tests/components/task/CallControl/call-control.tsx`, +`CallControlCustom/consult-transfer-popover.tsx`, and `CallControlCustom/wrap-up-summary.tsx`. + ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.constants.ts b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.constants.ts new file mode 100644 index 000000000..59b7cc86a --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.constants.ts @@ -0,0 +1,69 @@ +import type {AISummaryFeedbackButton, AISummarySectionKey} from './ai-summary.types'; + +export const AI_ASSISTANT_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; + +/** Presentation order for SDK sections and the read-only resolution. */ +export const AI_ASSISTANT_SUMMARY_SECTION_ORDER = [ + 'reasonForTransferOrConsult', + 'initialContactReason', + 'additionalContactReasons', + 'additionalContext', + 'keyActionsTaken', + 'resolution', + 'nextSteps', +] as const satisfies readonly (keyof typeof AI_ASSISTANT_SUMMARY_MESSAGES.sectionLabels)[]; + +export const AI_ASSISTANT_SUMMARY_FEEDBACK_BUTTONS = [ + {feedback: 'thumbs_up', icon: 'like', label: AI_ASSISTANT_SUMMARY_MESSAGES.like}, + {feedback: 'thumbs_down', icon: 'dislike', label: AI_ASSISTANT_SUMMARY_MESSAGES.dislike}, +] as const satisfies readonly AISummaryFeedbackButton[]; + +export const AI_ASSISTANT_SUMMARY_BULLETED_SECTION_KEYS: ReadonlySet = new Set([ + 'additionalContactReasons', + 'keyActionsTaken', + 'nextSteps', +]); diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.hooks.ts b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.hooks.ts new file mode 100644 index 000000000..d70eb10f8 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.hooks.ts @@ -0,0 +1,235 @@ +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; +import type {FocusEvent} from 'react'; +import type {AISummaryEntry, AISummaryFeedback} from '@webex/cc-store'; +import {AI_ASSISTANT_SUMMARY_MESSAGES, AI_ASSISTANT_SUMMARY_SECTION_ORDER} from './ai-summary.constants'; +import {COPIED_FEEDBACK_MS} from '../constants'; +import type {AISummaryDisplaySection, AISummaryProps, AISummaryTooltipControl} from './ai-summary.types'; + +const useReactId = (React as typeof React & {useId: () => string}).useId; + +const isEnabledFocusTarget = (element: HTMLElement): boolean => + element.isConnected && + !element.hasAttribute('disabled') && + element.getAttribute('aria-disabled') !== 'true' && + element.tabIndex >= 0; + +const getEnabledControls = (root: HTMLElement): HTMLElement[] => + Array.from(root.querySelectorAll('button, [href], input, textarea, select, [tabindex]')).filter( + isEnabledFocusTarget + ); + +/** The summary's sections in display order, with the read-only post-call resolution among them. */ +export const getDisplaySections = (content: NonNullable): AISummaryDisplaySection[] => + AI_ASSISTANT_SUMMARY_SECTION_ORDER.flatMap((key): AISummaryDisplaySection[] => { + 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_ASSISTANT_SUMMARY_MESSAGES.sectionLabels[section.key]}: ${section.value}`) + .join('\n\n'); +}; + +// Local edits are a presentation overlay; retain the SDK payload as the action identity. +const getDisplayContent = (props: AISummaryProps): AISummaryEntry['content'] => { + const {content} = props; + if (!content || props.mode === 'mid-call-receiver' || props.editedSummary === undefined) { + return content; + } + return typeof props.editedSummary === 'string' + ? {...content, summaryText: props.editedSummary} + : {...content, sections: {...content.sections, ...props.editedSummary}}; +}; + +export const useAISummaryInteractions = (props: AISummaryProps) => { + const rootRef = useRef(null); + const displayContent = getDisplayContent(props); + // Compare visible values, so response bookkeeping does not reset copy feedback. + const displayValue = + props.mode === 'mid-call-receiver' || !displayContent + ? undefined + : JSON.stringify( + displayContent.sections ? getDisplaySections(displayContent) : (displayContent.summaryText ?? '') + ); + const latestPropsRef = useRef({props, displayValue}); + useLayoutEffect(() => { + latestPropsRef.current = {props, displayValue}; + }); + const focusedControlRef = useRef(null); + const successorRef = useRef(null); + const [copied, setCopied] = useState(false); + const [hoveredTooltip, setHoveredTooltip] = useState(null); + const [focusRevision, requestFocusRestoration] = useState(0); + const summaryId = useReactId(); + const feedbackDescriptionId = `ai-summary-feedback-${summaryId}`; + const disabled = Boolean(props.controlsDisabled || props.requestPending); + const selectedFeedback = props.selectedFeedback ?? 'none'; + + const handleLocalControlRemoval = () => { + requestFocusRestoration((revision) => revision + 1); + }; + + useEffect(() => { + setCopied(false); + }, [props.content, props.mode, props.state, displayValue]); + + 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); + } + }; + document.addEventListener('keydown', dismissTooltip, true); + return () => document.removeEventListener('keydown', dismissTooltip, true); + }, [hoveredTooltip]); + + useLayoutEffect(() => { + const focused = focusedControlRef.current; + if (!focused || focused.isConnected) { + return; + } + const successor = successorRef.current; + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLElement && + activeElement !== document.body && + activeElement.isConnected && + !(successor !== null && activeElement === successor && !isEnabledFocusTarget(successor)) + ) { + focusedControlRef.current = null; + successorRef.current = null; + return; + } + if (successor && isEnabledFocusTarget(successor)) { + successor.focus(); + if (document.activeElement === successor && isEnabledFocusTarget(successor)) { + focusedControlRef.current = null; + successorRef.current = null; + return; + } + } + props.containingPanelFocusTarget.current?.focus(); + focusedControlRef.current = null; + successorRef.current = null; + }, [disabled, displayValue, focusRevision, props.content, props.containingPanelFocusTarget, props.mode, props.state]); + + const handleFocusCapture = (event: 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[index + 1] ?? null) : null; + }; + + const handleBlurCapture = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget; + const root = rootRef.current; + if ( + relatedTarget instanceof HTMLElement && + relatedTarget !== document.body && + relatedTarget.isConnected && + root && + !root.contains(relatedTarget) + ) { + focusedControlRef.current = null; + successorRef.current = null; + } + }; + + const handleCopy = async () => { + if (disabled || props.state !== 'content') { + return; + } + setHoveredTooltip(null); + setCopied(false); + try { + const text = + props.mode === 'mid-call-receiver' + ? props.getReceiverCopyText() + : displayContent + ? flattenContentForCopy(displayContent) + : ''; + if (text.trim().length === 0) { + return; + } + await navigator.clipboard.writeText(text); + } catch { + return; + } + // Clipboard writes can finish after another summary or local edit is displayed. + // Keep completion local to the content and visible values that initiated the copy. + const {props: current, displayValue: currentDisplayValue} = latestPropsRef.current; + if ( + !rootRef.current || + current.state !== 'content' || + current.mode !== props.mode || + current.content !== props.content || + currentDisplayValue !== displayValue + ) { + return; + } + setCopied(props.onCopy()); + }; + + const handleFeedback = (feedback: Exclude) => { + if (!disabled && props.state === 'content') { + props.onFeedback(feedback); + } + }; + + const handleActionMouseEnter = (control: AISummaryTooltipControl) => { + if (!disabled && !(control === 'copy' && copied)) { + setHoveredTooltip(control); + } + }; + + const handleActionMouseLeave = (control: AISummaryTooltipControl) => { + if (hoveredTooltip === control) { + setHoveredTooltip(null); + } + }; + + return { + rootRef, + displayContent, + copied, + setCopied, + hoveredTooltip, + setHoveredTooltip, + summaryId, + feedbackDescriptionId, + disabled, + selectedFeedback, + handleLocalControlRemoval, + handleFocusCapture, + handleBlurCapture, + handleCopy, + handleFeedback, + handleActionMouseEnter, + handleActionMouseLeave, + }; +}; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.styles.scss b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.styles.scss new file mode 100644 index 000000000..4ddcd1d7f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.styles.scss @@ -0,0 +1,336 @@ +.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; +} + +.ai-summary__retry-button { + justify-self: center; +} + +.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 { + box-sizing: border-box; + display: block; + inline-size: 100%; + block-size: auto; + min-block-size: 1.5rem; + border: 0; + margin: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: start; + white-space: normal; + overflow-wrap: anywhere; + cursor: text; + + // Momentum puts the fixed height on the host and exposes only its text slot. + &::part(button-text) { + display: block; + white-space: normal; + overflow: visible; + text-overflow: clip; + } + + > .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__button { + min-inline-size: 2rem; + border: 0.0625rem solid var(--mds-color-theme-outline-primary-normal, #d6d8dc); + border-radius: 0.375rem; + 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: 0.25rem; + inline-size: 2.75rem; + block-size: 1.25rem; + flex: 0 0 2.75rem; +} + +.ai-summary__feedback-action { + inline-size: 1.25rem; + block-size: 1.25rem; +} + +.ai-summary__feedback-button { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.25rem; + block-size: 1.25rem; + min-inline-size: 1.25rem; + min-block-size: 1.25rem; + border: 0; + border-radius: 50%; + background: transparent; + padding: 0.125rem; +} + +.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: 0.25rem; + block-size: 0.25rem; + flex: 0 0 0.25rem; + border-radius: 50%; + background: var(--mds-color-theme-text-secondary-normal, #54585c); + opacity: 0.46; +} + +.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: 0.125rem solid var(--mds-color-theme-outline-accent-normal, #0b5cad); + outline-offset: 0.125rem; +} + +@media (forced-colors: active) { + .ai-summary__button, + .ai-summary__textarea { + border-color: ButtonText; + } + + .ai-summary__button:focus-visible, + .ai-summary__textarea:focus-visible { + outline-color: Highlight; + } +} diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.tsx new file mode 100644 index 000000000..a3e8c79ee --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.tsx @@ -0,0 +1,357 @@ +import React, {useLayoutEffect, useRef, useState} from 'react'; +import {Button, Icon, Spinner, Tooltip} from '@momentum-design/components/dist/react'; +import { + AI_ASSISTANT_SUMMARY_BULLETED_SECTION_KEYS, + AI_ASSISTANT_SUMMARY_FEEDBACK_BUTTONS, + AI_ASSISTANT_SUMMARY_MESSAGES, +} from './ai-summary.constants'; +import {getDisplaySections, useAISummaryInteractions} from './ai-summary.hooks'; +import type { + AISummaryDisplaySection, + AISummaryEditorProps, + AISummaryProps, + AISummarySectionEditorProps, + AISummaryTooltipControl, +} from './ai-summary.types'; +import './ai-summary.styles.scss'; + +const renderReadonlyValue = (section: AISummaryDisplaySection, inline = false) => { + const lines = section.value.split('\n').filter((line) => line.trim().length > 0); + if (AI_ASSISTANT_SUMMARY_BULLETED_SECTION_KEYS.has(section.key) && lines.length > 1) { + const List = inline ? 'span' : 'ul'; + const Item = inline ? 'span' : 'li'; + return ( + + {lines.map((line, index) => ( + + {line.trim().replace(/^(?:\u2022|[-*])\s*/, '')} + + ))} + + ); + } + + 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