feat(a2ui): enhance A2UI notification handling and multi-select support

This commit is contained in:
catlog22
2026-02-04 11:11:55 +08:00
parent c6093ef741
commit 1a05551d00
14 changed files with 539 additions and 94 deletions

View 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;

View File

@@ -4,3 +4,4 @@
// Export all A2UI-related components
export { AskQuestionDialog } from './AskQuestionDialog';
export { A2UIPopupCard } from './A2UIPopupCard';

View File

@@ -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 (
<div className="flex flex-col min-h-screen bg-background">
{/* 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 && (
<AskQuestionDialog
payload={currentQuestion}
onClose={handleQuestionDialogClose}
/>
)}
{/* A2UI Popup Card - For A2UI surfaces with displayMode: 'popup' */}
{currentPopupCard && (
<A2UIPopupCard
surface={currentPopupCard}
onClose={handlePopupCardClose}
/>
)}
</div>
);
}

View File

@@ -121,7 +121,7 @@ export function Header({
>
<Bell className="w-5 h-5" />
{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}
</span>
)}

View File

@@ -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 && (
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-destructive" />
)}
<div className="flex gap-3">
{/* Icon */}
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
@@ -519,7 +516,7 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
{/* Read/Unread status badge */}
{!isRead && (
<Badge
variant="destructive"
variant="default"
className="h-5 px-1.5 text-[10px] font-medium shrink-0"
>
{formatMessage({ id: 'notifications.unread' }) || '未读'}
@@ -580,7 +577,23 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
{/* A2UI Surface Content */}
{isA2UI && notification.a2uiSurface ? (
<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>
) : (
<>

View File

@@ -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<typeof DateTimeInputComponentSchema
export type A2UIComponent = z.infer<typeof ComponentSchema>;
export type SurfaceComponent = z.infer<typeof SurfaceComponentSchema>;
export type SurfaceUpdate = z.infer<typeof SurfaceUpdateSchema>;
export type DisplayMode = z.infer<typeof DisplayModeSchema>;
// ========== Helper Types ==========

View File

@@ -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);
});
});
});

View File

@@ -46,6 +46,7 @@ export {
selectIsPanelVisible,
selectPersistentNotifications,
selectCurrentQuestion,
selectCurrentPopupCard,
toast,
} from './notificationStore';

View File

@@ -77,9 +77,12 @@ const initialState: NotificationState = {
// A2UI surfaces
a2uiSurfaces: new Map<string, SurfaceUpdate>(),
// 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<string, ActionState>(),
};
@@ -365,8 +368,30 @@ export const useNotificationStore = create<NotificationStore>()(
// ========== 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<NotificationStore>()(
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<NotificationStore>()(
});
}
// 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<NotificationStore>()(
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 = {

View File

@@ -501,9 +501,12 @@ export interface NotificationState {
// A2UI surfaces (Map of surfaceId to SurfaceUpdate)
a2uiSurfaces: Map<string, SurfaceUpdate>;
// 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<string, ActionState>;
}
@@ -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;