mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +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 { AskQuestionDialog } from './AskQuestionDialog';
|
||||
export { A2UIPopupCard } from './A2UIPopupCard';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user