From 1a05551d00453484e323799b10a6f9eb70d35904 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 4 Feb 2026 11:11:55 +0800 Subject: [PATCH] feat(a2ui): enhance A2UI notification handling and multi-select support --- .../src/components/a2ui/A2UIPopupCard.tsx | 133 ++++++++++++++++++ ccw/frontend/src/components/a2ui/index.ts | 1 + .../src/components/layout/AppShell.tsx | 24 +++- ccw/frontend/src/components/layout/Header.tsx | 2 +- .../notification/NotificationPanel.tsx | 27 +++- .../packages/a2ui-runtime/core/A2UITypes.ts | 6 + .../__tests__/notificationStore.test.ts | 88 +++++++----- ccw/frontend/src/stores/index.ts | 1 + ccw/frontend/src/stores/notificationStore.ts | 78 ++++++++-- ccw/frontend/src/types/store.ts | 8 +- ccw/src/core/a2ui/A2UIWebSocketHandler.ts | 86 ++++++++++- ccw/src/core/routes/system-routes.ts | 77 ++++++++++ ccw/src/core/websocket.ts | 74 ++++++---- ccw/src/tools/ask-question.ts | 28 ++++ 14 files changed, 539 insertions(+), 94 deletions(-) create mode 100644 ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx diff --git a/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx b/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx new file mode 100644 index 00000000..725d8fbd --- /dev/null +++ b/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx @@ -0,0 +1,133 @@ +// ======================================== +// A2UIPopupCard Component +// ======================================== +// Centered popup dialog for A2UI surfaces with minimalist design +// Used for displayMode: 'popup' surfaces (e.g., ask_question) + +import { useCallback, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/Dialog'; +import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer'; +import { useNotificationStore } from '@/stores'; +import type { SurfaceUpdate } 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; +} + +// ========== Component ========== + +export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { + const { formatMessage } = useIntl(); + const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); + + // Extract title and description from surface components if available + const titleComponent = surface.components.find( + (c) => c.id === 'title' && 'Text' in c.component + ); + const descriptionComponent = surface.components.find( + (c) => c.id === 'description' && 'Text' in c.component + ); + const messageComponent = surface.components.find( + (c) => c.id === 'message' && 'Text' in c.component + ); + + // Get text content from component + const getTextContent = (component: any): string => { + if (!component?.component?.Text?.text) return ''; + const text = component.component.Text.text; + if ('literalString' in text) return text.literalString; + return ''; + }; + + const title = getTextContent(titleComponent) || + formatMessage({ id: 'askQuestion.defaultTitle' }) || + 'Question'; + const description = getTextContent(descriptionComponent) || getTextContent(messageComponent); + + // Filter out title/description components for body rendering + const bodyComponents = surface.components.filter( + (c) => c.id !== 'title' && c.id !== 'description' && c.id !== 'message' + ); + + // Create a surface subset for body rendering + const bodySurface: SurfaceUpdate = { + ...surface, + components: bodyComponents, + }; + + // Handle A2UI actions + const handleAction = useCallback( + (actionId: string, params?: Record) => { + // Send action to backend via WebSocket + sendA2UIAction(actionId, surface.surfaceId, params); + + // Check if this action should close the dialog + // (confirm, cancel, submit, answer actions typically resolve the question) + 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) { + // Send cancel action when closing via ESC or overlay + sendA2UIAction('cancel', surface.surfaceId, { + questionId: (surface.initialState as any)?.questionId, + }); + onClose(); + } + }, + [sendA2UIAction, surface.surfaceId, onClose] + ); + + return ( + + + + {title} + {description && ( + + {description} + + )} + + + {/* A2UI Surface Body */} +
+ +
+
+
+ ); +} + +export default A2UIPopupCard; diff --git a/ccw/frontend/src/components/a2ui/index.ts b/ccw/frontend/src/components/a2ui/index.ts index 60f8e92d..8205655b 100644 --- a/ccw/frontend/src/components/a2ui/index.ts +++ b/ccw/frontend/src/components/a2ui/index.ts @@ -4,3 +4,4 @@ // Export all A2UI-related components export { AskQuestionDialog } from './AskQuestionDialog'; +export { A2UIPopupCard } from './A2UIPopupCard'; diff --git a/ccw/frontend/src/components/layout/AppShell.tsx b/ccw/frontend/src/components/layout/AppShell.tsx index 2982672b..f2da571b 100644 --- a/ccw/frontend/src/components/layout/AppShell.tsx +++ b/ccw/frontend/src/components/layout/AppShell.tsx @@ -11,8 +11,8 @@ import { Sidebar } from './Sidebar'; import { MainContent } from './MainContent'; import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor'; import { NotificationPanel } from '@/components/notification'; -import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog'; -import { useNotificationStore, selectCurrentQuestion } from '@/stores'; +import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui'; +import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores'; import { useWorkflowStore } from '@/stores/workflowStore'; import { useWebSocketNotifications, useWebSocket } from '@/hooks'; @@ -99,10 +99,14 @@ export function AppShell({ (state) => state.loadPersistentNotifications ); - // Current question dialog state + // Current question dialog state (legacy) const currentQuestion = useNotificationStore(selectCurrentQuestion); const setCurrentQuestion = useNotificationStore((state) => state.setCurrentQuestion); + // Current popup card state (for A2UI displayMode: 'popup') + const currentPopupCard = useNotificationStore(selectCurrentPopupCard); + const setCurrentPopupCard = useNotificationStore((state) => state.setCurrentPopupCard); + // Initialize WebSocket connection and notifications handler useWebSocket(); useWebSocketNotifications(); @@ -157,6 +161,10 @@ export function AppShell({ setCurrentQuestion(null); }, [setCurrentQuestion]); + const handlePopupCardClose = useCallback(() => { + setCurrentPopupCard(null); + }, [setCurrentPopupCard]); + return (
{/* Header - fixed at top */} @@ -201,13 +209,21 @@ export function AppShell({ onClose={handleNotificationPanelClose} /> - {/* Ask Question Dialog - For ask_question MCP tool */} + {/* Ask Question Dialog - For ask_question MCP tool (legacy) */} {currentQuestion && ( )} + + {/* A2UI Popup Card - For A2UI surfaces with displayMode: 'popup' */} + {currentPopupCard && ( + + )}
); } diff --git a/ccw/frontend/src/components/layout/Header.tsx b/ccw/frontend/src/components/layout/Header.tsx index d610e00d..6687e0d9 100644 --- a/ccw/frontend/src/components/layout/Header.tsx +++ b/ccw/frontend/src/components/layout/Header.tsx @@ -121,7 +121,7 @@ export function Header({ > {unreadCount > 0 && ( - + {unreadCount > 9 ? '9+' : unreadCount} )} diff --git a/ccw/frontend/src/components/notification/NotificationPanel.tsx b/ccw/frontend/src/components/notification/NotificationPanel.tsx index b7e91527..c7e4e053 100644 --- a/ccw/frontend/src/components/notification/NotificationPanel.tsx +++ b/ccw/frontend/src/components/notification/NotificationPanel.tsx @@ -29,7 +29,7 @@ import { import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; -import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer/A2UIRenderer'; +import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer'; import { useNotificationStore, selectPersistentNotifications } from '@/stores'; import type { Toast, NotificationAttachment, NotificationAction, ActionStateType, NotificationSource } from '@/types/store'; @@ -470,6 +470,7 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification const hasActions = notification.actions && notification.actions.length > 0; const hasLegacyAction = notification.action && !hasActions; const hasAttachments = notification.attachments && notification.attachments.length > 0; + const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); // Check if this is an A2UI notification const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface; @@ -486,10 +487,6 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification isRead && 'opacity-70' )} > - {/* Unread dot indicator */} - {!isRead && ( - - )}
{/* Icon */}
{getNotificationIcon(notification.type)}
@@ -519,7 +516,7 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification {/* Read/Unread status badge */} {!isRead && ( {formatMessage({ id: 'notifications.unread' }) || '未读'} @@ -580,7 +577,23 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification {/* A2UI Surface Content */} {isA2UI && notification.a2uiSurface ? (
- + { + // Send A2UI action back to backend via WebSocket + sendA2UIAction(actionId, notification.a2uiSurface!.surfaceId, params); + + // ask_question surfaces should disappear after the user answers + const maybeQuestionId = (notification.a2uiSurface?.initialState as Record | undefined) + ?.questionId; + const isAskQuestionSurface = typeof maybeQuestionId === 'string'; + const resolvesQuestion = actionId === 'confirm' || actionId === 'cancel' || actionId === 'submit' || actionId === 'answer'; + + if (isAskQuestionSurface && resolvesQuestion) { + onDelete(notification.id); + } + }} + />
) : ( <> diff --git a/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts b/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts index 680aba62..e7ad91da 100644 --- a/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts +++ b/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts @@ -175,11 +175,16 @@ export const SurfaceComponentSchema = z.object({ component: ComponentSchema, }); +/** Display mode for A2UI surfaces */ +export const DisplayModeSchema = z.enum(['popup', 'panel']); + /** Surface update message */ export const SurfaceUpdateSchema = z.object({ surfaceId: z.string(), components: z.array(SurfaceComponentSchema), initialState: z.record(z.unknown()).optional(), + /** Display mode: 'popup' for centered dialog, 'panel' for notification panel */ + displayMode: DisplayModeSchema.optional(), }); // ========== TypeScript Types ========== @@ -206,6 +211,7 @@ export type DateTimeInputComponent = z.infer; export type SurfaceComponent = z.infer; export type SurfaceUpdate = z.infer; +export type DisplayMode = z.infer; // ========== Helper Types ========== diff --git a/ccw/frontend/src/stores/__tests__/notificationStore.test.ts b/ccw/frontend/src/stores/__tests__/notificationStore.test.ts index b9179519..143afc96 100644 --- a/ccw/frontend/src/stores/__tests__/notificationStore.test.ts +++ b/ccw/frontend/src/stores/__tests__/notificationStore.test.ts @@ -16,7 +16,9 @@ describe('NotificationStore A2UI Methods', () => { a2uiSurfaces: new Map(), currentQuestion: null, persistentNotifications: [], + isPanelVisible: false, }); + localStorage.removeItem('ccw_notifications'); vi.clearAllMocks(); }); @@ -26,7 +28,7 @@ describe('NotificationStore A2UI Methods', () => { }); describe('addA2UINotification()', () => { - it('should add A2UI notification to toasts array', () => { + it('should add A2UI notification to persistentNotifications array', () => { const surface: SurfaceUpdate = { surfaceId: 'test-surface', components: [ @@ -44,14 +46,17 @@ describe('NotificationStore A2UI Methods', () => { result.current.addA2UINotification(surface, 'Test Surface'); }); - expect(result.current.toasts).toHaveLength(1); - expect(result.current.toasts[0]).toMatchObject({ + expect(result.current.toasts).toHaveLength(0); + expect(result.current.persistentNotifications).toHaveLength(1); + expect(result.current.isPanelVisible).toBe(true); + expect(result.current.persistentNotifications[0]).toMatchObject({ type: 'a2ui', title: 'Test Surface', a2uiSurface: surface, a2uiState: { key: 'value' }, dismissible: true, duration: 0, // Persistent by default + read: false, }); }); @@ -76,7 +81,7 @@ describe('NotificationStore A2UI Methods', () => { expect(result.current.a2uiSurfaces.get('surface-123')).toEqual(surface); }); - it('should respect maxToasts limit for A2UI notifications', () => { + it('should not be constrained by maxToasts (A2UI uses persistentNotifications)', () => { const { result } = renderHook(() => useNotificationStore()); // Set max toasts to 3 @@ -94,10 +99,10 @@ describe('NotificationStore A2UI Methods', () => { }); } - // Should only keep last 3 - expect(result.current.toasts).toHaveLength(3); - expect(result.current.toasts[0].a2uiSurface?.surfaceId).toBe('surface-1'); - expect(result.current.toasts[2].a2uiSurface?.surfaceId).toBe('surface-3'); + expect(result.current.toasts).toHaveLength(0); + expect(result.current.persistentNotifications).toHaveLength(4); + expect(result.current.persistentNotifications[0].a2uiSurface?.surfaceId).toBe('surface-3'); + expect(result.current.persistentNotifications[3].a2uiSurface?.surfaceId).toBe('surface-0'); }); it('should use default title when not provided', () => { @@ -112,10 +117,10 @@ describe('NotificationStore A2UI Methods', () => { result.current.addA2UINotification(surface); }); - expect(result.current.toasts[0].title).toBe('A2UI Surface'); + expect(result.current.persistentNotifications[0].title).toBe('A2UI Surface'); }); - it('should return toast ID', () => { + it('should return notification ID', () => { const surface: SurfaceUpdate = { surfaceId: 'test', components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }], @@ -123,14 +128,14 @@ describe('NotificationStore A2UI Methods', () => { const { result } = renderHook(() => useNotificationStore()); - let toastId: string; + let notificationId: string; act(() => { - toastId = result.current.addA2UINotification(surface); + notificationId = result.current.addA2UINotification(surface); }); - expect(toastId).toBeDefined(); - expect(typeof toastId).toBe('string'); - expect(result.current.toasts[0].id).toBe(toastId); + expect(notificationId).toBeDefined(); + expect(typeof notificationId).toBe('string'); + expect(result.current.persistentNotifications[0].id).toBe(notificationId); }); it('should include initialState in a2uiState', () => { @@ -146,7 +151,7 @@ describe('NotificationStore A2UI Methods', () => { result.current.addA2UINotification(surface); }); - expect(result.current.toasts[0].a2uiState).toEqual({ counter: 0, user: 'Alice' }); + expect(result.current.persistentNotifications[0].a2uiState).toEqual({ counter: 0, user: 'Alice' }); }); it('should default to empty a2uiState when initialState is not provided', () => { @@ -161,12 +166,12 @@ describe('NotificationStore A2UI Methods', () => { result.current.addA2UINotification(surface); }); - expect(result.current.toasts[0].a2uiState).toEqual({}); + expect(result.current.persistentNotifications[0].a2uiState).toEqual({}); }); }); describe('updateA2UIState()', () => { - it('should update a2uiState for matching toast', () => { + it('should update a2uiState for matching notification', () => { const surface: SurfaceUpdate = { surfaceId: 'test-surface', components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }], @@ -183,7 +188,7 @@ describe('NotificationStore A2UI Methods', () => { result.current.updateA2UIState('test-surface', { count: 5, newField: 'value' }); }); - expect(result.current.toasts[0].a2uiState).toEqual({ count: 5, newField: 'value' }); + expect(result.current.persistentNotifications[0].a2uiState).toEqual({ count: 5, newField: 'value' }); }); it('should update surface initialState in a2uiSurfaces Map', () => { @@ -207,7 +212,7 @@ describe('NotificationStore A2UI Methods', () => { expect(updatedSurface?.initialState).toEqual({ value: 'updated' }); }); - it('should not affect other toasts with different surface IDs', () => { + it('should not affect other notifications with different surface IDs', () => { const { result } = renderHook(() => useNotificationStore()); act(() => { @@ -227,8 +232,9 @@ describe('NotificationStore A2UI Methods', () => { result.current.updateA2UIState('surface-1', { value: 'A-updated' }); }); - expect(result.current.toasts[0].a2uiState).toEqual({ value: 'A-updated' }); - expect(result.current.toasts[1].a2uiState).toEqual({ value: 'B' }); + // addA2UINotification prepends, so surface-2 is index 0 and surface-1 is index 1 + expect(result.current.persistentNotifications[0].a2uiState).toEqual({ value: 'B' }); + expect(result.current.persistentNotifications[1].a2uiState).toEqual({ value: 'A-updated' }); }); it('should handle updates for non-existent surface gracefully', () => { @@ -338,8 +344,8 @@ describe('NotificationStore A2UI Methods', () => { }); }); - describe('Integration with toast actions', () => { - it('should allow removing A2UI toast via removeToast', () => { + describe('Integration with persistent notification actions', () => { + it('should allow removing A2UI notification via removePersistentNotification', () => { const surface: SurfaceUpdate = { surfaceId: 'test', components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }], @@ -347,21 +353,21 @@ describe('NotificationStore A2UI Methods', () => { const { result } = renderHook(() => useNotificationStore()); - let toastId: string; + let notificationId: string; act(() => { - toastId = result.current.addA2UINotification(surface); + notificationId = result.current.addA2UINotification(surface); }); - expect(result.current.toasts).toHaveLength(1); + expect(result.current.persistentNotifications).toHaveLength(1); act(() => { - result.current.removeToast(toastId); + result.current.removePersistentNotification(notificationId); }); - expect(result.current.toasts).toHaveLength(0); + expect(result.current.persistentNotifications).toHaveLength(0); }); - it('should clear all A2UI toasts with clearAllToasts', () => { + it('should clear all A2UI notifications with clearPersistentNotifications', () => { const { result } = renderHook(() => useNotificationStore()); act(() => { @@ -369,25 +375,28 @@ describe('NotificationStore A2UI Methods', () => { surfaceId: 's1', components: [{ id: 'c1', component: { Text: { text: { literalString: 'A' } } } }], }); - result.current.addToast({ type: 'info', title: 'Regular toast' }); + // Duration 0 avoids auto-timeout side effects in tests + result.current.addToast({ type: 'info', title: 'Regular toast', duration: 0 }); result.current.addA2UINotification({ surfaceId: 's2', components: [{ id: 'c2', component: { Text: { text: { literalString: 'B' } } } }], }); }); - expect(result.current.toasts).toHaveLength(3); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.persistentNotifications).toHaveLength(2); act(() => { - result.current.clearAllToasts(); + result.current.clearPersistentNotifications(); }); - expect(result.current.toasts).toHaveLength(0); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.persistentNotifications).toHaveLength(0); }); }); describe('A2UI surfaces Map management', () => { - it('should maintain separate surfaces Map from toasts', () => { + it('should maintain separate surfaces Map from persistent notifications', () => { const { result } = renderHook(() => useNotificationStore()); act(() => { @@ -398,15 +407,16 @@ describe('NotificationStore A2UI Methods', () => { }); expect(result.current.a2uiSurfaces.size).toBe(1); - expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts).toHaveLength(0); + expect(result.current.persistentNotifications).toHaveLength(1); act(() => { - result.current.removeToast(result.current.toasts[0].id); + result.current.removePersistentNotification(result.current.persistentNotifications[0].id); }); - // Surface should remain in Map even after toast is removed + // Surface should remain in Map even after notification is removed (cleanup happens in NotificationPanel) expect(result.current.a2uiSurfaces.size).toBe(1); - expect(result.current.toasts).toHaveLength(0); + expect(result.current.persistentNotifications).toHaveLength(0); }); }); }); diff --git a/ccw/frontend/src/stores/index.ts b/ccw/frontend/src/stores/index.ts index d84bd788..4ff4323b 100644 --- a/ccw/frontend/src/stores/index.ts +++ b/ccw/frontend/src/stores/index.ts @@ -46,6 +46,7 @@ export { selectIsPanelVisible, selectPersistentNotifications, selectCurrentQuestion, + selectCurrentPopupCard, toast, } from './notificationStore'; diff --git a/ccw/frontend/src/stores/notificationStore.ts b/ccw/frontend/src/stores/notificationStore.ts index 17fb3d21..3dac520a 100644 --- a/ccw/frontend/src/stores/notificationStore.ts +++ b/ccw/frontend/src/stores/notificationStore.ts @@ -77,9 +77,12 @@ const initialState: NotificationState = { // A2UI surfaces a2uiSurfaces: new Map(), - // Current question dialog state + // Current question dialog state (legacy) currentQuestion: null, + // Current popup card surface (for displayMode: 'popup') + currentPopupCard: null, + // Action state tracking actionStates: new Map(), }; @@ -365,8 +368,30 @@ export const useNotificationStore = create()( // ========== A2UI Actions ========== addA2UINotification: (surface: SurfaceUpdate, title = 'A2UI Surface') => { + // Route based on displayMode + if (surface.displayMode === 'popup') { + // Popup mode: show as centered dialog + set( + (state) => { + // Store surface in a2uiSurfaces Map + const newSurfaces = new Map(state.a2uiSurfaces); + newSurfaces.set(surface.surfaceId, surface); + + return { + currentPopupCard: surface, + a2uiSurfaces: newSurfaces, + }; + }, + false, + 'addA2UINotification (popup)' + ); + + return surface.surfaceId; + } + + // Panel mode (default): show in notification panel const id = generateId(); - const newToast: Toast = { + const newNotification: Toast = { id, type: 'a2ui', title, @@ -375,30 +400,32 @@ export const useNotificationStore = create()( duration: 0, // Persistent by default a2uiSurface: surface, a2uiState: surface.initialState || {}, + read: false, }; set( (state) => { - // Add to toasts array - const { maxToasts } = state; - let newToasts = [...state.toasts, newToast]; - if (newToasts.length > maxToasts) { - newToasts = newToasts.slice(-maxToasts); - } - // Store surface in a2uiSurfaces Map const newSurfaces = new Map(state.a2uiSurfaces); newSurfaces.set(surface.surfaceId, surface); return { - toasts: newToasts, + // A2UI surfaces should be visible in the NotificationPanel (which reads persistentNotifications) + // and should also bump the unread badge in the header. + persistentNotifications: [newNotification, ...state.persistentNotifications], a2uiSurfaces: newSurfaces, + // Auto-open panel for interactive A2UI surfaces + isPanelVisible: true, }; }, false, - 'addA2UINotification' + 'addA2UINotification (panel)' ); + // Persist to localStorage (same behavior as addPersistentNotification) + const state = get(); + saveToStorage(state.persistentNotifications); + return id; }, @@ -416,19 +443,39 @@ export const useNotificationStore = create()( }); } - // Update notification's a2uiState + // Update notification's a2uiState (both toast queue and persistent panel list) const newToasts = state.toasts.map((toast) => { if (toast.a2uiSurface && toast.a2uiSurface.surfaceId === surfaceId) { return { ...toast, a2uiState: { ...toast.a2uiState, ...updates }, + a2uiSurface: surface + ? { ...toast.a2uiSurface, initialState: { ...toast.a2uiSurface.initialState, ...updates } } + : toast.a2uiSurface, }; } return toast; }); + const newPersistentNotifications = state.persistentNotifications.map((notification) => { + if (notification.a2uiSurface && notification.a2uiSurface.surfaceId === surfaceId) { + return { + ...notification, + a2uiState: { ...notification.a2uiState, ...updates }, + a2uiSurface: surface + ? { + ...notification.a2uiSurface, + initialState: { ...notification.a2uiSurface.initialState, ...updates }, + } + : notification.a2uiSurface, + }; + } + return notification; + }); + return { toasts: newToasts, + persistentNotifications: newPersistentNotifications, a2uiSurfaces: newSurfaces, }; }, @@ -457,6 +504,12 @@ export const useNotificationStore = create()( setCurrentQuestion: (question: any) => { set({ currentQuestion: question }, false, 'setCurrentQuestion'); }, + + // ========== Current Popup Card Actions ========== + + setCurrentPopupCard: (surface: SurfaceUpdate | null) => { + set({ currentPopupCard: surface }, false, 'setCurrentPopupCard'); + }, }), { name: 'NotificationStore' } ) @@ -478,6 +531,7 @@ export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelV export const selectPersistentNotifications = (state: NotificationStore) => state.persistentNotifications; export const selectCurrentQuestion = (state: NotificationStore) => state.currentQuestion; +export const selectCurrentPopupCard = (state: NotificationStore) => state.currentPopupCard; // Helper to create toast shortcuts export const toast = { diff --git a/ccw/frontend/src/types/store.ts b/ccw/frontend/src/types/store.ts index 5b9db1cf..f268225a 100644 --- a/ccw/frontend/src/types/store.ts +++ b/ccw/frontend/src/types/store.ts @@ -501,9 +501,12 @@ export interface NotificationState { // A2UI surfaces (Map of surfaceId to SurfaceUpdate) a2uiSurfaces: Map; - // Current question dialog state + // Current question dialog state (legacy) currentQuestion: AskQuestionPayload | null; + // Current popup card surface (for displayMode: 'popup') + currentPopupCard: SurfaceUpdate | null; + // Action state tracking (Map of actionKey to ActionState) actionStates: Map; } @@ -547,6 +550,9 @@ export interface NotificationActions { // Current question actions setCurrentQuestion: (question: AskQuestionPayload | null) => void; + + // Current popup card actions (for displayMode: 'popup') + setCurrentPopupCard: (surface: SurfaceUpdate | null) => void; } export type NotificationStore = NotificationState & NotificationActions; diff --git a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts index 1510c9af..4d3117fc 100644 --- a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts +++ b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts @@ -58,6 +58,8 @@ export class A2UIWebSocketHandler { timestamp: number; }>(); + private multiSelectSelections = new Map>(); + private answerCallback?: (answer: QuestionAnswer) => boolean; /** @@ -84,6 +86,7 @@ export class A2UIWebSocketHandler { surfaceId: string; components: unknown[]; initialState: Record; + displayMode?: 'popup' | 'panel'; }): number { const message = { type: 'a2ui-surface', @@ -93,12 +96,18 @@ export class A2UIWebSocketHandler { // Track active surface const questionId = surfaceUpdate.initialState?.questionId as string | undefined; + const questionType = surfaceUpdate.initialState?.questionType as string | undefined; if (questionId) { this.activeSurfaces.set(questionId, { surfaceId: surfaceUpdate.surfaceId, questionId, timestamp: Date.now(), }); + + if (questionType === 'multi-select') { + // Selection state is updated via a2ui-action messages ("toggle") and resolved on "submit" + this.multiSelectSelections.set(questionId, new Set()); + } } // Broadcast to all clients @@ -130,6 +139,7 @@ export class A2UIWebSocketHandler { surfaceId: string; components: unknown[]; initialState: Record; + displayMode?: 'popup' | 'panel'; } ): boolean { const message = { @@ -188,11 +198,78 @@ export class A2UIWebSocketHandler { // Remove from active surfaces if answered/cancelled if (handled) { this.activeSurfaces.delete(answer.questionId); + this.multiSelectSelections.delete(answer.questionId); } return handled; } + /** + * Try to interpret a2ui-action messages as ask_question answers. + * This keeps the frontend generic: it only sends actions; the backend resolves question answers. + */ + handleQuestionAction( + action: A2UIActionMessage, + answerCallback: (answer: QuestionAnswer) => boolean + ): boolean { + const params = action.parameters ?? {}; + const questionId = typeof params.questionId === 'string' ? params.questionId : undefined; + if (!questionId) { + return false; + } + + const resolveAndCleanup = (answer: QuestionAnswer): boolean => { + const handled = answerCallback(answer); + if (handled) { + this.activeSurfaces.delete(questionId); + this.multiSelectSelections.delete(questionId); + } + return handled; + }; + + switch (action.actionId) { + case 'confirm': + return resolveAndCleanup({ questionId, value: true, cancelled: false }); + + case 'cancel': + return resolveAndCleanup({ questionId, value: false, cancelled: true }); + + case 'answer': { + const value = params.value; + if (typeof value !== 'string' && typeof value !== 'boolean' && !Array.isArray(value)) { + return false; + } + return resolveAndCleanup({ questionId, value: value as string | boolean | string[], cancelled: false }); + } + + case 'toggle': { + const value = params.value; + const checked = params.checked; + + if (typeof value !== 'string' || typeof checked !== 'boolean') { + return false; + } + + const selected = this.multiSelectSelections.get(questionId) ?? new Set(); + if (checked) { + selected.add(value); + } else { + selected.delete(value); + } + this.multiSelectSelections.set(questionId, selected); + return true; + } + + case 'submit': { + const selected = this.multiSelectSelections.get(questionId) ?? new Set(); + return resolveAndCleanup({ questionId, value: Array.from(selected), cancelled: false }); + } + + default: + return false; + } + } + /** * Cancel an active surface * @param questionId - Question ID to cancel @@ -222,6 +299,7 @@ export class A2UIWebSocketHandler { } this.activeSurfaces.delete(questionId); + this.multiSelectSelections.delete(questionId); return true; } @@ -284,7 +362,13 @@ export function handleA2UIMessage( // Handle A2UI action messages if (data.type === 'a2ui-action') { - a2uiHandler.handleAction(data as A2UIActionMessage); + const action = data as A2UIActionMessage; + a2uiHandler.handleAction(action); + + // If this action belongs to an ask_question surface, interpret it as an answer update/submit. + if (answerCallback) { + a2uiHandler.handleQuestionAction(action, answerCallback); + } return true; } diff --git a/ccw/src/core/routes/system-routes.ts b/ccw/src/core/routes/system-routes.ts index 68d257af..4371f55e 100644 --- a/ccw/src/core/routes/system-routes.ts +++ b/ccw/src/core/routes/system-routes.ts @@ -571,5 +571,82 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise { + // Buffers may contain partial frames or multiple frames; accumulate and parse in a loop. + pendingBuffer = Buffer.concat([pendingBuffer, buffer]); + try { - const frame = parseWebSocketFrame(buffer); - if (!frame) return; + while (true) { + const frame = parseWebSocketFrame(pendingBuffer); + if (!frame) return; - const { opcode, payload } = frame; + const { opcode, payload, frameLength } = frame; + pendingBuffer = pendingBuffer.slice(frameLength); - switch (opcode) { - case 0x1: // Text frame - if (payload) { - console.log('[WS] Received:', payload); - // Try to handle as A2UI message - const handledAsA2UI = handleA2UIMessage(payload, a2uiWebSocketHandler, handleAnswer); - if (handledAsA2UI) { - console.log('[WS] Handled as A2UI message'); + switch (opcode) { + case 0x1: // Text frame + if (payload) { + console.log('[WS] Received:', payload); + // Try to handle as A2UI message + const handledAsA2UI = handleA2UIMessage(payload, a2uiWebSocketHandler, handleAnswer); + if (handledAsA2UI) { + console.log('[WS] Handled as A2UI message'); + } } + break; + case 0x8: // Close frame + socket.end(); + return; + case 0x9: { // Ping frame - respond with Pong + const pongFrame = Buffer.alloc(2); + pongFrame[0] = 0x8A; // Pong opcode with FIN bit + pongFrame[1] = 0x00; // No payload + socket.write(pongFrame); + break; } - break; - case 0x8: // Close frame - socket.end(); - break; - case 0x9: // Ping frame - respond with Pong - const pongFrame = Buffer.alloc(2); - pongFrame[0] = 0x8A; // Pong opcode with FIN bit - pongFrame[1] = 0x00; // No payload - socket.write(pongFrame); - break; - case 0xA: // Pong frame - ignore - break; - default: - // Ignore other frame types (binary, continuation) - break; + case 0xA: // Pong frame - ignore + break; + default: + // Ignore other frame types (binary, continuation) + break; + } } } catch (e) { - // Ignore parse errors + // On parse error, drop the buffered data to avoid unbounded growth. + pendingBuffer = Buffer.alloc(0); } }); @@ -218,7 +228,7 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he * Parse WebSocket frame (simplified) * Returns { opcode, payload } or null */ -export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload: string } | null { +export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload: string; frameLength: number } | null { if (buffer.length < 2) return null; const firstByte = buffer[0]; @@ -234,19 +244,25 @@ export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload: let offset = 2; if (payloadLength === 126) { + if (buffer.length < 4) return null; payloadLength = buffer.readUInt16BE(2); offset = 4; } else if (payloadLength === 127) { + if (buffer.length < 10) return null; payloadLength = Number(buffer.readBigUInt64BE(2)); offset = 10; } let mask: Buffer | null = null; if (isMasked) { + if (buffer.length < offset + 4) return null; mask = buffer.slice(offset, offset + 4); offset += 4; } + const frameLength = offset + payloadLength; + if (buffer.length < frameLength) return null; + const payload = buffer.slice(offset, offset + payloadLength); if (isMasked && mask) { @@ -255,7 +271,7 @@ export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload: } } - return { opcode, payload: payload.toString('utf8') }; + return { opcode, payload: payload.toString('utf8'), frameLength }; } /** diff --git a/ccw/src/tools/ask-question.ts b/ccw/src/tools/ask-question.ts index 67cb7b7e..a19dd314 100644 --- a/ccw/src/tools/ask-question.ts +++ b/ccw/src/tools/ask-question.ts @@ -255,6 +255,32 @@ function generateQuestionSurface(question: Question, surfaceId: string): { }, }, }); + + // Submit/cancel actions for multi-select so users can choose multiple options before resolving + components.push({ + id: 'submit-btn', + component: { + Button: { + onClick: { actionId: 'submit', parameters: { questionId: question.id } }, + content: { + Text: { text: { literalString: 'Submit' } }, + }, + variant: 'primary', + }, + }, + }); + components.push({ + id: 'cancel-btn', + component: { + Button: { + onClick: { actionId: 'cancel', parameters: { questionId: question.id } }, + content: { + Text: { text: { literalString: 'Cancel' } }, + }, + variant: 'secondary', + }, + }, + }); break; } @@ -284,6 +310,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): { options: question.options, required: question.required, }, + /** Display mode: 'popup' for centered dialog (interactive questions) */ + displayMode: 'popup' as const, }, }; }