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 ? ( + + ) : 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')}
) : ( -
+
{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