// ======================================== // A2UIPopupCard Component // ======================================== // Centered popup dialog for A2UI surfaces with minimalist design // Used for displayMode: 'popup' surfaces (e.g., ask_question) // Supports markdown content parsing and multi-page navigation import { useState, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/Dialog'; import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer'; import { useNotificationStore } from '@/stores'; import type { SurfaceUpdate, SurfaceComponent } from '@/packages/a2ui-runtime/core/A2UITypes'; import { cn } from '@/lib/utils'; // ========== Types ========== interface A2UIPopupCardProps { /** A2UI Surface to render */ surface: SurfaceUpdate; /** Callback when dialog is closed */ onClose: () => void; } type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'multi-question' | 'unknown'; interface PageMeta { index: number; questionId: string; title: string; type: string; } // ========== Helpers ========== /** Get text content from A2UI Text component */ function getTextContent(component: SurfaceComponent | undefined): string { if (!component?.component) return ''; const comp = component.component as any; if (!comp?.Text?.text) return ''; const text = comp.Text.text; if ('literalString' in text) return text.literalString; return ''; } /** Detect question type from surface */ function detectQuestionType(surface: SurfaceUpdate): QuestionType { const state = surface.initialState as Record | undefined; if (state?.questionType) { return state.questionType as QuestionType; } // Fallback: detect from components const hasCheckbox = surface.components.some((c) => 'Checkbox' in (c.component as any)); const hasRadioGroup = surface.components.some((c) => 'RadioGroup' in (c.component as any)); const hasDropdown = surface.components.some((c) => 'Dropdown' in (c.component as any)); const hasTextField = surface.components.some((c) => 'TextField' in (c.component as any)); const hasConfirmCancel = surface.components.some( (c) => c.id === 'confirm-btn' || c.id === 'cancel-btn' ); if (hasCheckbox) return 'multi-select'; if (hasRadioGroup) return 'select'; if (hasDropdown) return 'select'; if (hasTextField) return 'input'; if (hasConfirmCancel) return 'confirm'; return 'unknown'; } /** Check if component is an action button */ function isActionButton(component: SurfaceComponent): boolean { const comp = component.component as any; return 'Button' in comp; } // ========== "Other" Text Input Component ========== interface OtherInputProps { visible: boolean; value: string; onChange: (value: string) => void; placeholder?: string; } function OtherInput({ visible, value, onChange, placeholder }: OtherInputProps) { if (!visible) return null; return (
onChange(e.target.value)} placeholder={placeholder || 'Enter your answer...'} className={cn( 'w-full px-3 py-2 text-sm rounded-md border border-border', 'bg-background text-foreground placeholder:text-muted-foreground', 'focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent', 'transition-colors' )} autoFocus />
); } // ========== Markdown Component ========== interface MarkdownContentProps { content: string; className?: string; } function MarkdownContent({ content, className }: MarkdownContentProps) { return (

{children}

, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , code: ({ children, className }) => { const isInline = !className; return isInline ? ( {children} ) : ( {children} ); }, a: ({ href, children }) => ( {children} ), }} > {content}
    ); } // ========== Single-Page Popup (Legacy) ========== function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) { const { formatMessage } = useIntl(); const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); // Detect question type const questionType = useMemo(() => detectQuestionType(surface), [surface]); // "Other" option state const [otherSelected, setOtherSelected] = useState(false); const [otherText, setOtherText] = useState(''); const questionId = (surface.initialState as any)?.questionId as string | undefined; // Extract title, message, and description from surface components const titleComponent = surface.components.find( (c) => c.id === 'title' && 'Text' in (c.component as any) ); const messageComponent = surface.components.find( (c) => c.id === 'message' && 'Text' in (c.component as any) ); const descriptionComponent = surface.components.find( (c) => c.id === 'description' && 'Text' in (c.component as any) ); const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' }); const message = getTextContent(messageComponent); const description = getTextContent(descriptionComponent); // Separate body components (interactive elements) from action buttons const { bodyComponents, actionButtons } = useMemo(() => { const body: SurfaceComponent[] = []; const actions: SurfaceComponent[] = []; for (const comp of surface.components) { // Skip title, message, description if (['title', 'message', 'description'].includes(comp.id)) continue; // Separate action buttons (confirm, cancel, submit) if (isActionButton(comp) && ['confirm-btn', 'cancel-btn', 'submit-btn'].includes(comp.id)) { actions.push(comp); } else { body.push(comp); } } return { bodyComponents: body, actionButtons: actions }; }, [surface.components]); // Create surfaces for body and actions const bodySurface: SurfaceUpdate = useMemo( () => ({ ...surface, components: bodyComponents }), [surface, bodyComponents] ); const actionsSurface: SurfaceUpdate = useMemo( () => ({ ...surface, components: actionButtons }), [surface, actionButtons] ); // Handle "Other" text change const handleOtherTextChange = useCallback( (value: string) => { setOtherText(value); if (questionId) { sendA2UIAction('input-change', surface.surfaceId, { questionId: `__other__:${questionId}`, value, }); } }, [sendA2UIAction, surface.surfaceId, questionId] ); // Handle A2UI actions const handleAction = useCallback( (actionId: string, params?: Record) => { // Track "Other" selection state if (actionId === 'select' && params?.value === '__other__') { setOtherSelected(true); } else if (actionId === 'select' && params?.value !== '__other__') { setOtherSelected(false); } if (actionId === 'toggle' && params?.value === '__other__') { setOtherSelected((prev) => !prev); } // Send action to backend via WebSocket sendA2UIAction(actionId, surface.surfaceId, params); // Check if this action should close the dialog const resolvingActions = ['confirm', 'cancel', 'submit', 'answer']; if (resolvingActions.includes(actionId)) { onClose(); } }, [sendA2UIAction, surface.surfaceId, onClose] ); // Handle dialog close (ESC key or overlay click) const handleOpenChange = useCallback( (open: boolean) => { if (!open) { sendA2UIAction('cancel', surface.surfaceId, { questionId: (surface.initialState as any)?.questionId, }); onClose(); } }, [sendA2UIAction, surface.surfaceId, onClose] ); // Determine dialog width based on question type const dialogWidth = useMemo(() => { switch (questionType) { case 'multi-select': return 'sm:max-w-[480px]'; case 'input': return 'sm:max-w-[500px]'; default: return 'sm:max-w-[420px]'; } }, [questionType]); // Check if this question type supports "Other" input const hasOtherOption = questionType === 'select' || questionType === 'multi-select'; return ( { // Prevent closing when clicking outside e.preventDefault(); }} onEscapeKeyDown={(e) => { // Prevent closing with ESC key e.preventDefault(); }} > {/* Header */} {title} {message && (
    )} {description && (
    )}
    {/* Body - Interactive elements */} {bodyComponents.length > 0 && (
    {questionType === 'multi-select' ? ( // Render each checkbox individually for better control bodyComponents.map((comp) => (
    )) ) : ( )} {/* "Other" text input — shown when Other is selected */} {hasOtherOption && ( )}
    )} {/* Footer - Action buttons */} {actionButtons.length > 0 && (
    {actionButtons.map((comp) => ( ))}
    )}
    ); } // ========== Multi-Page Popup ========== function MultiPagePopup({ surface, onClose }: A2UIPopupCardProps) { const { formatMessage } = useIntl(); const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); const state = surface.initialState as Record; const pages = state.pages as PageMeta[]; const totalPages = state.totalPages as number; const compositeId = state.questionId as string; const [currentPage, setCurrentPage] = useState(0); // "Other" per-page state const [otherSelectedPages, setOtherSelectedPages] = useState>(new Set()); const [otherTexts, setOtherTexts] = useState>(new Map()); // Group components by page const pageComponentGroups = useMemo(() => { const groups: SurfaceComponent[][] = []; for (let i = 0; i < totalPages; i++) { groups.push( surface.components.filter((c) => (c as any).page === i) ); } return groups; }, [surface.components, totalPages]); // Extract current page title and body components const currentPageData = useMemo(() => { const comps = pageComponentGroups[currentPage] || []; const titleComp = comps.find((c) => c.id.endsWith('-title')); const messageComp = comps.find((c) => c.id.endsWith('-message')); const descComp = comps.find((c) => c.id.endsWith('-description')); const bodyComps = comps.filter( (c) => !c.id.endsWith('-title') && !c.id.endsWith('-message') && !c.id.endsWith('-description') ); return { title: getTextContent(titleComp), message: getTextContent(messageComp), description: getTextContent(descComp), bodyComponents: bodyComps, pageMeta: pages[currentPage], }; }, [pageComponentGroups, currentPage, pages]); // Handle "Other" text change for a specific page const handleOtherTextChange = useCallback( (pageIdx: number, value: string) => { setOtherTexts((prev) => { const next = new Map(prev); next.set(pageIdx, value); return next; }); // Send input-change to backend with __other__:{questionId} const qId = pages[pageIdx]?.questionId; if (qId) { sendA2UIAction('input-change', surface.surfaceId, { questionId: `__other__:${qId}`, value, }); } }, [sendA2UIAction, surface.surfaceId, pages] ); // Handle A2UI actions (pass through to backend without closing dialog) const handleAction = useCallback( (actionId: string, params?: Record) => { // Track "Other" selection state per page if (actionId === 'select' && params?.value === '__other__') { setOtherSelectedPages((prev) => new Set(prev).add(currentPage)); } else if (actionId === 'select' && params?.value !== '__other__') { setOtherSelectedPages((prev) => { const next = new Set(prev); next.delete(currentPage); return next; }); } if (actionId === 'toggle' && params?.value === '__other__') { setOtherSelectedPages((prev) => { const next = new Set(prev); if (next.has(currentPage)) { next.delete(currentPage); } else { next.add(currentPage); } return next; }); } sendA2UIAction(actionId, surface.surfaceId, params); }, [sendA2UIAction, surface.surfaceId, currentPage] ); // Handle Cancel const handleCancel = useCallback(() => { sendA2UIAction('cancel', surface.surfaceId, { questionId: compositeId }); onClose(); }, [sendA2UIAction, surface.surfaceId, compositeId, onClose]); // Handle Submit All const handleSubmitAll = useCallback(() => { sendA2UIAction('submit-all', surface.surfaceId, { compositeId, questionIds: pages.map((p) => p.questionId), }); onClose(); }, [sendA2UIAction, surface.surfaceId, compositeId, pages, onClose]); // Handle dialog close const handleOpenChange = useCallback( (open: boolean) => { if (!open) { handleCancel(); } }, [handleCancel] ); // Navigation const goNext = useCallback(() => { setCurrentPage((p) => Math.min(p + 1, totalPages - 1)); }, [totalPages]); const goPrev = useCallback(() => { setCurrentPage((p) => Math.max(p - 1, 0)); }, []); const isFirstPage = currentPage === 0; const isLastPage = currentPage === totalPages - 1; return ( e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} > {/* Header with current page title */} {currentPageData.title || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' })} {currentPageData.message && (
    )} {currentPageData.description && (
    )}
    {/* Page content with slide animation */}
    {pageComponentGroups.map((pageComps, pageIdx) => { const bodyComps = pageComps.filter( (c) => !c.id.endsWith('-title') && !c.id.endsWith('-message') && !c.id.endsWith('-description') ); const pageType = pages[pageIdx]?.type || 'unknown'; const hasOther = pageType === 'select' || pageType === 'multi-select'; const isOtherSelected = otherSelectedPages.has(pageIdx); return (
    {bodyComps.length > 0 && (
    {pageType === 'multi-select' ? ( bodyComps.map((comp) => (
    )) ) : ( )} {/* "Other" text input */} {hasOther && ( handleOtherTextChange(pageIdx, v)} /> )}
    )}
    ); })}
    {/* Dot indicator */}
    {pages.map((_, i) => (
    {/* Footer - Navigation buttons */}
    {/* Left: Cancel */} {/* Right: Prev / Next / Submit */}
    {!isFirstPage && ( )} {isLastPage ? ( ) : ( )}
    ); } // ========== Main Component ========== export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { const state = surface.initialState as Record | undefined; const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1; if (isMultiPage) { return ; } return ; } export default A2UIPopupCard;