mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-13 02:41:50 +08:00
feat(a2ui): enhance A2UI notification handling and multi-select support
This commit is contained in:
133
ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx
Normal file
133
ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||||
|
// 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 (
|
||||||
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
// Minimalist style: no heavy borders, light shadow, rounded corners
|
||||||
|
'sm:max-w-[420px] max-h-[80vh] overflow-y-auto',
|
||||||
|
'bg-card p-6 rounded-xl shadow-md border-0',
|
||||||
|
// Animation classes
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||||
|
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
|
||||||
|
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader className="space-y-1.5 pb-4">
|
||||||
|
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
|
||||||
|
{description && (
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* A2UI Surface Body */}
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default A2UIPopupCard;
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
// Export all A2UI-related components
|
// Export all A2UI-related components
|
||||||
|
|
||||||
export { AskQuestionDialog } from './AskQuestionDialog';
|
export { AskQuestionDialog } from './AskQuestionDialog';
|
||||||
|
export { A2UIPopupCard } from './A2UIPopupCard';
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { Sidebar } from './Sidebar';
|
|||||||
import { MainContent } from './MainContent';
|
import { MainContent } from './MainContent';
|
||||||
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
|
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
|
||||||
import { NotificationPanel } from '@/components/notification';
|
import { NotificationPanel } from '@/components/notification';
|
||||||
import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog';
|
import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui';
|
||||||
import { useNotificationStore, selectCurrentQuestion } from '@/stores';
|
import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores';
|
||||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||||
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
|
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
|
||||||
|
|
||||||
@@ -99,10 +99,14 @@ export function AppShell({
|
|||||||
(state) => state.loadPersistentNotifications
|
(state) => state.loadPersistentNotifications
|
||||||
);
|
);
|
||||||
|
|
||||||
// Current question dialog state
|
// Current question dialog state (legacy)
|
||||||
const currentQuestion = useNotificationStore(selectCurrentQuestion);
|
const currentQuestion = useNotificationStore(selectCurrentQuestion);
|
||||||
const setCurrentQuestion = useNotificationStore((state) => state.setCurrentQuestion);
|
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
|
// Initialize WebSocket connection and notifications handler
|
||||||
useWebSocket();
|
useWebSocket();
|
||||||
useWebSocketNotifications();
|
useWebSocketNotifications();
|
||||||
@@ -157,6 +161,10 @@ export function AppShell({
|
|||||||
setCurrentQuestion(null);
|
setCurrentQuestion(null);
|
||||||
}, [setCurrentQuestion]);
|
}, [setCurrentQuestion]);
|
||||||
|
|
||||||
|
const handlePopupCardClose = useCallback(() => {
|
||||||
|
setCurrentPopupCard(null);
|
||||||
|
}, [setCurrentPopupCard]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-background">
|
<div className="flex flex-col min-h-screen bg-background">
|
||||||
{/* Header - fixed at top */}
|
{/* Header - fixed at top */}
|
||||||
@@ -201,13 +209,21 @@ export function AppShell({
|
|||||||
onClose={handleNotificationPanelClose}
|
onClose={handleNotificationPanelClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ask Question Dialog - For ask_question MCP tool */}
|
{/* Ask Question Dialog - For ask_question MCP tool (legacy) */}
|
||||||
{currentQuestion && (
|
{currentQuestion && (
|
||||||
<AskQuestionDialog
|
<AskQuestionDialog
|
||||||
payload={currentQuestion}
|
payload={currentQuestion}
|
||||||
onClose={handleQuestionDialogClose}
|
onClose={handleQuestionDialogClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* A2UI Popup Card - For A2UI surfaces with displayMode: 'popup' */}
|
||||||
|
{currentPopupCard && (
|
||||||
|
<A2UIPopupCard
|
||||||
|
surface={currentPopupCard}
|
||||||
|
onClose={handlePopupCardClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function Header({
|
|||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 min-h-4 min-w-4 px-1 rounded-full bg-destructive text-[10px] text-destructive-foreground flex items-center justify-center font-medium">
|
<span className="absolute -top-1 -right-1 min-h-4 min-w-4 px-1 rounded-full bg-primary text-[10px] text-primary-foreground flex items-center justify-center font-medium">
|
||||||
{unreadCount > 9 ? '9+' : unreadCount}
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
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 { useNotificationStore, selectPersistentNotifications } from '@/stores';
|
||||||
import type { Toast, NotificationAttachment, NotificationAction, ActionStateType, NotificationSource } from '@/types/store';
|
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 hasActions = notification.actions && notification.actions.length > 0;
|
||||||
const hasLegacyAction = notification.action && !hasActions;
|
const hasLegacyAction = notification.action && !hasActions;
|
||||||
const hasAttachments = notification.attachments && notification.attachments.length > 0;
|
const hasAttachments = notification.attachments && notification.attachments.length > 0;
|
||||||
|
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||||
|
|
||||||
// Check if this is an A2UI notification
|
// Check if this is an A2UI notification
|
||||||
const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface;
|
const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface;
|
||||||
@@ -486,10 +487,6 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
|
|||||||
isRead && 'opacity-70'
|
isRead && 'opacity-70'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Unread dot indicator */}
|
|
||||||
{!isRead && (
|
|
||||||
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-destructive" />
|
|
||||||
)}
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
|
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
|
||||||
@@ -519,7 +516,7 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
|
|||||||
{/* Read/Unread status badge */}
|
{/* Read/Unread status badge */}
|
||||||
{!isRead && (
|
{!isRead && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="default"
|
||||||
className="h-5 px-1.5 text-[10px] font-medium shrink-0"
|
className="h-5 px-1.5 text-[10px] font-medium shrink-0"
|
||||||
>
|
>
|
||||||
{formatMessage({ id: 'notifications.unread' }) || '未读'}
|
{formatMessage({ id: 'notifications.unread' }) || '未读'}
|
||||||
@@ -580,7 +577,23 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
|
|||||||
{/* A2UI Surface Content */}
|
{/* A2UI Surface Content */}
|
||||||
{isA2UI && notification.a2uiSurface ? (
|
{isA2UI && notification.a2uiSurface ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<A2UIRenderer surface={notification.a2uiSurface} />
|
<A2UIRenderer
|
||||||
|
surface={notification.a2uiSurface}
|
||||||
|
onAction={(actionId, params) => {
|
||||||
|
// 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<string, unknown> | undefined)
|
||||||
|
?.questionId;
|
||||||
|
const isAskQuestionSurface = typeof maybeQuestionId === 'string';
|
||||||
|
const resolvesQuestion = actionId === 'confirm' || actionId === 'cancel' || actionId === 'submit' || actionId === 'answer';
|
||||||
|
|
||||||
|
if (isAskQuestionSurface && resolvesQuestion) {
|
||||||
|
onDelete(notification.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -175,11 +175,16 @@ export const SurfaceComponentSchema = z.object({
|
|||||||
component: ComponentSchema,
|
component: ComponentSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Display mode for A2UI surfaces */
|
||||||
|
export const DisplayModeSchema = z.enum(['popup', 'panel']);
|
||||||
|
|
||||||
/** Surface update message */
|
/** Surface update message */
|
||||||
export const SurfaceUpdateSchema = z.object({
|
export const SurfaceUpdateSchema = z.object({
|
||||||
surfaceId: z.string(),
|
surfaceId: z.string(),
|
||||||
components: z.array(SurfaceComponentSchema),
|
components: z.array(SurfaceComponentSchema),
|
||||||
initialState: z.record(z.unknown()).optional(),
|
initialState: z.record(z.unknown()).optional(),
|
||||||
|
/** Display mode: 'popup' for centered dialog, 'panel' for notification panel */
|
||||||
|
displayMode: DisplayModeSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== TypeScript Types ==========
|
// ========== TypeScript Types ==========
|
||||||
@@ -206,6 +211,7 @@ export type DateTimeInputComponent = z.infer<typeof DateTimeInputComponentSchema
|
|||||||
export type A2UIComponent = z.infer<typeof ComponentSchema>;
|
export type A2UIComponent = z.infer<typeof ComponentSchema>;
|
||||||
export type SurfaceComponent = z.infer<typeof SurfaceComponentSchema>;
|
export type SurfaceComponent = z.infer<typeof SurfaceComponentSchema>;
|
||||||
export type SurfaceUpdate = z.infer<typeof SurfaceUpdateSchema>;
|
export type SurfaceUpdate = z.infer<typeof SurfaceUpdateSchema>;
|
||||||
|
export type DisplayMode = z.infer<typeof DisplayModeSchema>;
|
||||||
|
|
||||||
// ========== Helper Types ==========
|
// ========== Helper Types ==========
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
a2uiSurfaces: new Map(),
|
a2uiSurfaces: new Map(),
|
||||||
currentQuestion: null,
|
currentQuestion: null,
|
||||||
persistentNotifications: [],
|
persistentNotifications: [],
|
||||||
|
isPanelVisible: false,
|
||||||
});
|
});
|
||||||
|
localStorage.removeItem('ccw_notifications');
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('addA2UINotification()', () => {
|
describe('addA2UINotification()', () => {
|
||||||
it('should add A2UI notification to toasts array', () => {
|
it('should add A2UI notification to persistentNotifications array', () => {
|
||||||
const surface: SurfaceUpdate = {
|
const surface: SurfaceUpdate = {
|
||||||
surfaceId: 'test-surface',
|
surfaceId: 'test-surface',
|
||||||
components: [
|
components: [
|
||||||
@@ -44,14 +46,17 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
result.current.addA2UINotification(surface, 'Test Surface');
|
result.current.addA2UINotification(surface, 'Test Surface');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.toasts).toHaveLength(1);
|
expect(result.current.toasts).toHaveLength(0);
|
||||||
expect(result.current.toasts[0]).toMatchObject({
|
expect(result.current.persistentNotifications).toHaveLength(1);
|
||||||
|
expect(result.current.isPanelVisible).toBe(true);
|
||||||
|
expect(result.current.persistentNotifications[0]).toMatchObject({
|
||||||
type: 'a2ui',
|
type: 'a2ui',
|
||||||
title: 'Test Surface',
|
title: 'Test Surface',
|
||||||
a2uiSurface: surface,
|
a2uiSurface: surface,
|
||||||
a2uiState: { key: 'value' },
|
a2uiState: { key: 'value' },
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
duration: 0, // Persistent by default
|
duration: 0, // Persistent by default
|
||||||
|
read: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
expect(result.current.a2uiSurfaces.get('surface-123')).toEqual(surface);
|
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());
|
const { result } = renderHook(() => useNotificationStore());
|
||||||
|
|
||||||
// Set max toasts to 3
|
// Set max toasts to 3
|
||||||
@@ -94,10 +99,10 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should only keep last 3
|
expect(result.current.toasts).toHaveLength(0);
|
||||||
expect(result.current.toasts).toHaveLength(3);
|
expect(result.current.persistentNotifications).toHaveLength(4);
|
||||||
expect(result.current.toasts[0].a2uiSurface?.surfaceId).toBe('surface-1');
|
expect(result.current.persistentNotifications[0].a2uiSurface?.surfaceId).toBe('surface-3');
|
||||||
expect(result.current.toasts[2].a2uiSurface?.surfaceId).toBe('surface-3');
|
expect(result.current.persistentNotifications[3].a2uiSurface?.surfaceId).toBe('surface-0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default title when not provided', () => {
|
it('should use default title when not provided', () => {
|
||||||
@@ -112,10 +117,10 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
result.current.addA2UINotification(surface);
|
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 = {
|
const surface: SurfaceUpdate = {
|
||||||
surfaceId: 'test',
|
surfaceId: 'test',
|
||||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||||
@@ -123,14 +128,14 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useNotificationStore());
|
const { result } = renderHook(() => useNotificationStore());
|
||||||
|
|
||||||
let toastId: string;
|
let notificationId: string;
|
||||||
act(() => {
|
act(() => {
|
||||||
toastId = result.current.addA2UINotification(surface);
|
notificationId = result.current.addA2UINotification(surface);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(toastId).toBeDefined();
|
expect(notificationId).toBeDefined();
|
||||||
expect(typeof toastId).toBe('string');
|
expect(typeof notificationId).toBe('string');
|
||||||
expect(result.current.toasts[0].id).toBe(toastId);
|
expect(result.current.persistentNotifications[0].id).toBe(notificationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include initialState in a2uiState', () => {
|
it('should include initialState in a2uiState', () => {
|
||||||
@@ -146,7 +151,7 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
result.current.addA2UINotification(surface);
|
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', () => {
|
it('should default to empty a2uiState when initialState is not provided', () => {
|
||||||
@@ -161,12 +166,12 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
result.current.addA2UINotification(surface);
|
result.current.addA2UINotification(surface);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.toasts[0].a2uiState).toEqual({});
|
expect(result.current.persistentNotifications[0].a2uiState).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateA2UIState()', () => {
|
describe('updateA2UIState()', () => {
|
||||||
it('should update a2uiState for matching toast', () => {
|
it('should update a2uiState for matching notification', () => {
|
||||||
const surface: SurfaceUpdate = {
|
const surface: SurfaceUpdate = {
|
||||||
surfaceId: 'test-surface',
|
surfaceId: 'test-surface',
|
||||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
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' });
|
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', () => {
|
it('should update surface initialState in a2uiSurfaces Map', () => {
|
||||||
@@ -207,7 +212,7 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
expect(updatedSurface?.initialState).toEqual({ value: 'updated' });
|
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());
|
const { result } = renderHook(() => useNotificationStore());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -227,8 +232,9 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
result.current.updateA2UIState('surface-1', { value: 'A-updated' });
|
result.current.updateA2UIState('surface-1', { value: 'A-updated' });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.toasts[0].a2uiState).toEqual({ value: 'A-updated' });
|
// addA2UINotification prepends, so surface-2 is index 0 and surface-1 is index 1
|
||||||
expect(result.current.toasts[1].a2uiState).toEqual({ value: 'B' });
|
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', () => {
|
it('should handle updates for non-existent surface gracefully', () => {
|
||||||
@@ -338,8 +344,8 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Integration with toast actions', () => {
|
describe('Integration with persistent notification actions', () => {
|
||||||
it('should allow removing A2UI toast via removeToast', () => {
|
it('should allow removing A2UI notification via removePersistentNotification', () => {
|
||||||
const surface: SurfaceUpdate = {
|
const surface: SurfaceUpdate = {
|
||||||
surfaceId: 'test',
|
surfaceId: 'test',
|
||||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||||
@@ -347,21 +353,21 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useNotificationStore());
|
const { result } = renderHook(() => useNotificationStore());
|
||||||
|
|
||||||
let toastId: string;
|
let notificationId: string;
|
||||||
act(() => {
|
act(() => {
|
||||||
toastId = result.current.addA2UINotification(surface);
|
notificationId = result.current.addA2UINotification(surface);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.toasts).toHaveLength(1);
|
expect(result.current.persistentNotifications).toHaveLength(1);
|
||||||
|
|
||||||
act(() => {
|
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());
|
const { result } = renderHook(() => useNotificationStore());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -369,25 +375,28 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
surfaceId: 's1',
|
surfaceId: 's1',
|
||||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'A' } } } }],
|
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({
|
result.current.addA2UINotification({
|
||||||
surfaceId: 's2',
|
surfaceId: 's2',
|
||||||
components: [{ id: 'c2', component: { Text: { text: { literalString: 'B' } } } }],
|
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(() => {
|
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', () => {
|
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());
|
const { result } = renderHook(() => useNotificationStore());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -398,15 +407,16 @@ describe('NotificationStore A2UI Methods', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.a2uiSurfaces.size).toBe(1);
|
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(() => {
|
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.a2uiSurfaces.size).toBe(1);
|
||||||
expect(result.current.toasts).toHaveLength(0);
|
expect(result.current.persistentNotifications).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export {
|
|||||||
selectIsPanelVisible,
|
selectIsPanelVisible,
|
||||||
selectPersistentNotifications,
|
selectPersistentNotifications,
|
||||||
selectCurrentQuestion,
|
selectCurrentQuestion,
|
||||||
|
selectCurrentPopupCard,
|
||||||
toast,
|
toast,
|
||||||
} from './notificationStore';
|
} from './notificationStore';
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,12 @@ const initialState: NotificationState = {
|
|||||||
// A2UI surfaces
|
// A2UI surfaces
|
||||||
a2uiSurfaces: new Map<string, SurfaceUpdate>(),
|
a2uiSurfaces: new Map<string, SurfaceUpdate>(),
|
||||||
|
|
||||||
// Current question dialog state
|
// Current question dialog state (legacy)
|
||||||
currentQuestion: null,
|
currentQuestion: null,
|
||||||
|
|
||||||
|
// Current popup card surface (for displayMode: 'popup')
|
||||||
|
currentPopupCard: null,
|
||||||
|
|
||||||
// Action state tracking
|
// Action state tracking
|
||||||
actionStates: new Map<string, ActionState>(),
|
actionStates: new Map<string, ActionState>(),
|
||||||
};
|
};
|
||||||
@@ -365,8 +368,30 @@ export const useNotificationStore = create<NotificationStore>()(
|
|||||||
// ========== A2UI Actions ==========
|
// ========== A2UI Actions ==========
|
||||||
|
|
||||||
addA2UINotification: (surface: SurfaceUpdate, title = 'A2UI Surface') => {
|
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 id = generateId();
|
||||||
const newToast: Toast = {
|
const newNotification: Toast = {
|
||||||
id,
|
id,
|
||||||
type: 'a2ui',
|
type: 'a2ui',
|
||||||
title,
|
title,
|
||||||
@@ -375,30 +400,32 @@ export const useNotificationStore = create<NotificationStore>()(
|
|||||||
duration: 0, // Persistent by default
|
duration: 0, // Persistent by default
|
||||||
a2uiSurface: surface,
|
a2uiSurface: surface,
|
||||||
a2uiState: surface.initialState || {},
|
a2uiState: surface.initialState || {},
|
||||||
|
read: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
set(
|
set(
|
||||||
(state) => {
|
(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
|
// Store surface in a2uiSurfaces Map
|
||||||
const newSurfaces = new Map(state.a2uiSurfaces);
|
const newSurfaces = new Map(state.a2uiSurfaces);
|
||||||
newSurfaces.set(surface.surfaceId, surface);
|
newSurfaces.set(surface.surfaceId, surface);
|
||||||
|
|
||||||
return {
|
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,
|
a2uiSurfaces: newSurfaces,
|
||||||
|
// Auto-open panel for interactive A2UI surfaces
|
||||||
|
isPanelVisible: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
'addA2UINotification'
|
'addA2UINotification (panel)'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Persist to localStorage (same behavior as addPersistentNotification)
|
||||||
|
const state = get();
|
||||||
|
saveToStorage(state.persistentNotifications);
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -416,19 +443,39 @@ export const useNotificationStore = create<NotificationStore>()(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update notification's a2uiState
|
// Update notification's a2uiState (both toast queue and persistent panel list)
|
||||||
const newToasts = state.toasts.map((toast) => {
|
const newToasts = state.toasts.map((toast) => {
|
||||||
if (toast.a2uiSurface && toast.a2uiSurface.surfaceId === surfaceId) {
|
if (toast.a2uiSurface && toast.a2uiSurface.surfaceId === surfaceId) {
|
||||||
return {
|
return {
|
||||||
...toast,
|
...toast,
|
||||||
a2uiState: { ...toast.a2uiState, ...updates },
|
a2uiState: { ...toast.a2uiState, ...updates },
|
||||||
|
a2uiSurface: surface
|
||||||
|
? { ...toast.a2uiSurface, initialState: { ...toast.a2uiSurface.initialState, ...updates } }
|
||||||
|
: toast.a2uiSurface,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return toast;
|
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 {
|
return {
|
||||||
toasts: newToasts,
|
toasts: newToasts,
|
||||||
|
persistentNotifications: newPersistentNotifications,
|
||||||
a2uiSurfaces: newSurfaces,
|
a2uiSurfaces: newSurfaces,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -457,6 +504,12 @@ export const useNotificationStore = create<NotificationStore>()(
|
|||||||
setCurrentQuestion: (question: any) => {
|
setCurrentQuestion: (question: any) => {
|
||||||
set({ currentQuestion: question }, false, 'setCurrentQuestion');
|
set({ currentQuestion: question }, false, 'setCurrentQuestion');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========== Current Popup Card Actions ==========
|
||||||
|
|
||||||
|
setCurrentPopupCard: (surface: SurfaceUpdate | null) => {
|
||||||
|
set({ currentPopupCard: surface }, false, 'setCurrentPopupCard');
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ name: 'NotificationStore' }
|
{ name: 'NotificationStore' }
|
||||||
)
|
)
|
||||||
@@ -478,6 +531,7 @@ export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelV
|
|||||||
export const selectPersistentNotifications = (state: NotificationStore) =>
|
export const selectPersistentNotifications = (state: NotificationStore) =>
|
||||||
state.persistentNotifications;
|
state.persistentNotifications;
|
||||||
export const selectCurrentQuestion = (state: NotificationStore) => state.currentQuestion;
|
export const selectCurrentQuestion = (state: NotificationStore) => state.currentQuestion;
|
||||||
|
export const selectCurrentPopupCard = (state: NotificationStore) => state.currentPopupCard;
|
||||||
|
|
||||||
// Helper to create toast shortcuts
|
// Helper to create toast shortcuts
|
||||||
export const toast = {
|
export const toast = {
|
||||||
|
|||||||
@@ -501,9 +501,12 @@ export interface NotificationState {
|
|||||||
// A2UI surfaces (Map of surfaceId to SurfaceUpdate)
|
// A2UI surfaces (Map of surfaceId to SurfaceUpdate)
|
||||||
a2uiSurfaces: Map<string, SurfaceUpdate>;
|
a2uiSurfaces: Map<string, SurfaceUpdate>;
|
||||||
|
|
||||||
// Current question dialog state
|
// Current question dialog state (legacy)
|
||||||
currentQuestion: AskQuestionPayload | null;
|
currentQuestion: AskQuestionPayload | null;
|
||||||
|
|
||||||
|
// Current popup card surface (for displayMode: 'popup')
|
||||||
|
currentPopupCard: SurfaceUpdate | null;
|
||||||
|
|
||||||
// Action state tracking (Map of actionKey to ActionState)
|
// Action state tracking (Map of actionKey to ActionState)
|
||||||
actionStates: Map<string, ActionState>;
|
actionStates: Map<string, ActionState>;
|
||||||
}
|
}
|
||||||
@@ -547,6 +550,9 @@ export interface NotificationActions {
|
|||||||
|
|
||||||
// Current question actions
|
// Current question actions
|
||||||
setCurrentQuestion: (question: AskQuestionPayload | null) => void;
|
setCurrentQuestion: (question: AskQuestionPayload | null) => void;
|
||||||
|
|
||||||
|
// Current popup card actions (for displayMode: 'popup')
|
||||||
|
setCurrentPopupCard: (surface: SurfaceUpdate | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationStore = NotificationState & NotificationActions;
|
export type NotificationStore = NotificationState & NotificationActions;
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export class A2UIWebSocketHandler {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
private multiSelectSelections = new Map<string, Set<string>>();
|
||||||
|
|
||||||
private answerCallback?: (answer: QuestionAnswer) => boolean;
|
private answerCallback?: (answer: QuestionAnswer) => boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +86,7 @@ export class A2UIWebSocketHandler {
|
|||||||
surfaceId: string;
|
surfaceId: string;
|
||||||
components: unknown[];
|
components: unknown[];
|
||||||
initialState: Record<string, unknown>;
|
initialState: Record<string, unknown>;
|
||||||
|
displayMode?: 'popup' | 'panel';
|
||||||
}): number {
|
}): number {
|
||||||
const message = {
|
const message = {
|
||||||
type: 'a2ui-surface',
|
type: 'a2ui-surface',
|
||||||
@@ -93,12 +96,18 @@ export class A2UIWebSocketHandler {
|
|||||||
|
|
||||||
// Track active surface
|
// Track active surface
|
||||||
const questionId = surfaceUpdate.initialState?.questionId as string | undefined;
|
const questionId = surfaceUpdate.initialState?.questionId as string | undefined;
|
||||||
|
const questionType = surfaceUpdate.initialState?.questionType as string | undefined;
|
||||||
if (questionId) {
|
if (questionId) {
|
||||||
this.activeSurfaces.set(questionId, {
|
this.activeSurfaces.set(questionId, {
|
||||||
surfaceId: surfaceUpdate.surfaceId,
|
surfaceId: surfaceUpdate.surfaceId,
|
||||||
questionId,
|
questionId,
|
||||||
timestamp: Date.now(),
|
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<string>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to all clients
|
// Broadcast to all clients
|
||||||
@@ -130,6 +139,7 @@ export class A2UIWebSocketHandler {
|
|||||||
surfaceId: string;
|
surfaceId: string;
|
||||||
components: unknown[];
|
components: unknown[];
|
||||||
initialState: Record<string, unknown>;
|
initialState: Record<string, unknown>;
|
||||||
|
displayMode?: 'popup' | 'panel';
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
const message = {
|
const message = {
|
||||||
@@ -188,11 +198,78 @@ export class A2UIWebSocketHandler {
|
|||||||
// Remove from active surfaces if answered/cancelled
|
// Remove from active surfaces if answered/cancelled
|
||||||
if (handled) {
|
if (handled) {
|
||||||
this.activeSurfaces.delete(answer.questionId);
|
this.activeSurfaces.delete(answer.questionId);
|
||||||
|
this.multiSelectSelections.delete(answer.questionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handled;
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
return resolveAndCleanup({ questionId, value: Array.from(selected), cancelled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel an active surface
|
* Cancel an active surface
|
||||||
* @param questionId - Question ID to cancel
|
* @param questionId - Question ID to cancel
|
||||||
@@ -222,6 +299,7 @@ export class A2UIWebSocketHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeSurfaces.delete(questionId);
|
this.activeSurfaces.delete(questionId);
|
||||||
|
this.multiSelectSelections.delete(questionId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +362,13 @@ export function handleA2UIMessage(
|
|||||||
|
|
||||||
// Handle A2UI action messages
|
// Handle A2UI action messages
|
||||||
if (data.type === 'a2ui-action') {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -571,5 +571,82 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: Test ask_question popup (for development testing)
|
||||||
|
if (pathname === '/api/test/ask-question' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
// Import the A2UI handler
|
||||||
|
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
|
||||||
|
|
||||||
|
// Create a test surface with displayMode: 'popup'
|
||||||
|
const testSurface = {
|
||||||
|
surfaceId: `test-question-${Date.now()}`,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
component: {
|
||||||
|
Text: {
|
||||||
|
text: { literalString: 'Test Popup Question' },
|
||||||
|
usageHint: 'h3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message',
|
||||||
|
component: {
|
||||||
|
Text: {
|
||||||
|
text: { literalString: 'This is a test popup card. Does it appear in the center?' },
|
||||||
|
usageHint: 'p',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'confirm-btn',
|
||||||
|
component: {
|
||||||
|
Button: {
|
||||||
|
onClick: { actionId: 'confirm', parameters: { questionId: 'test-q-1' } },
|
||||||
|
content: {
|
||||||
|
Text: { text: { literalString: 'Confirm' } },
|
||||||
|
},
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cancel-btn',
|
||||||
|
component: {
|
||||||
|
Button: {
|
||||||
|
onClick: { actionId: 'cancel', parameters: { questionId: 'test-q-1' } },
|
||||||
|
content: {
|
||||||
|
Text: { text: { literalString: 'Cancel' } },
|
||||||
|
},
|
||||||
|
variant: 'secondary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialState: {
|
||||||
|
questionId: 'test-q-1',
|
||||||
|
questionType: 'confirm',
|
||||||
|
},
|
||||||
|
displayMode: 'popup' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send the surface via WebSocket
|
||||||
|
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Test popup sent',
|
||||||
|
sentToClients: sentCount,
|
||||||
|
surfaceId: testSurface.surfaceId
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,41 +165,51 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
|
|||||||
console.log(`[WS] Client connected (${wsClients.size} total)`);
|
console.log(`[WS] Client connected (${wsClients.size} total)`);
|
||||||
|
|
||||||
// Handle incoming messages
|
// Handle incoming messages
|
||||||
|
let pendingBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
socket.on('data', (buffer: Buffer) => {
|
socket.on('data', (buffer: Buffer) => {
|
||||||
|
// Buffers may contain partial frames or multiple frames; accumulate and parse in a loop.
|
||||||
|
pendingBuffer = Buffer.concat([pendingBuffer, buffer]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frame = parseWebSocketFrame(buffer);
|
while (true) {
|
||||||
if (!frame) return;
|
const frame = parseWebSocketFrame(pendingBuffer);
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
const { opcode, payload } = frame;
|
const { opcode, payload, frameLength } = frame;
|
||||||
|
pendingBuffer = pendingBuffer.slice(frameLength);
|
||||||
|
|
||||||
switch (opcode) {
|
switch (opcode) {
|
||||||
case 0x1: // Text frame
|
case 0x1: // Text frame
|
||||||
if (payload) {
|
if (payload) {
|
||||||
console.log('[WS] Received:', payload);
|
console.log('[WS] Received:', payload);
|
||||||
// Try to handle as A2UI message
|
// Try to handle as A2UI message
|
||||||
const handledAsA2UI = handleA2UIMessage(payload, a2uiWebSocketHandler, handleAnswer);
|
const handledAsA2UI = handleA2UIMessage(payload, a2uiWebSocketHandler, handleAnswer);
|
||||||
if (handledAsA2UI) {
|
if (handledAsA2UI) {
|
||||||
console.log('[WS] Handled as A2UI message');
|
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 0xA: // Pong frame - ignore
|
||||||
case 0x8: // Close frame
|
break;
|
||||||
socket.end();
|
default:
|
||||||
break;
|
// Ignore other frame types (binary, continuation)
|
||||||
case 0x9: // Ping frame - respond with Pong
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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)
|
* Parse WebSocket frame (simplified)
|
||||||
* Returns { opcode, payload } or null
|
* 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;
|
if (buffer.length < 2) return null;
|
||||||
|
|
||||||
const firstByte = buffer[0];
|
const firstByte = buffer[0];
|
||||||
@@ -234,19 +244,25 @@ export function parseWebSocketFrame(buffer: Buffer): { opcode: number; payload:
|
|||||||
|
|
||||||
let offset = 2;
|
let offset = 2;
|
||||||
if (payloadLength === 126) {
|
if (payloadLength === 126) {
|
||||||
|
if (buffer.length < 4) return null;
|
||||||
payloadLength = buffer.readUInt16BE(2);
|
payloadLength = buffer.readUInt16BE(2);
|
||||||
offset = 4;
|
offset = 4;
|
||||||
} else if (payloadLength === 127) {
|
} else if (payloadLength === 127) {
|
||||||
|
if (buffer.length < 10) return null;
|
||||||
payloadLength = Number(buffer.readBigUInt64BE(2));
|
payloadLength = Number(buffer.readBigUInt64BE(2));
|
||||||
offset = 10;
|
offset = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mask: Buffer | null = null;
|
let mask: Buffer | null = null;
|
||||||
if (isMasked) {
|
if (isMasked) {
|
||||||
|
if (buffer.length < offset + 4) return null;
|
||||||
mask = buffer.slice(offset, offset + 4);
|
mask = buffer.slice(offset, offset + 4);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const frameLength = offset + payloadLength;
|
||||||
|
if (buffer.length < frameLength) return null;
|
||||||
|
|
||||||
const payload = buffer.slice(offset, offset + payloadLength);
|
const payload = buffer.slice(offset, offset + payloadLength);
|
||||||
|
|
||||||
if (isMasked && mask) {
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +310,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
|||||||
options: question.options,
|
options: question.options,
|
||||||
required: question.required,
|
required: question.required,
|
||||||
},
|
},
|
||||||
|
/** Display mode: 'popup' for centered dialog (interactive questions) */
|
||||||
|
displayMode: 'popup' as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user