Add end-to-end tests for workspace switching and backend tests for ask_question tool

- Implemented E2E tests for workspace switching functionality, covering scenarios such as switching workspaces, data isolation, language preference maintenance, and UI updates.
- Added tests to ensure workspace data is cleared on logout and handles unsaved changes during workspace switches.
- Created comprehensive backend tests for the ask_question tool, validating question creation, execution, answer handling, cancellation, and timeout scenarios.
- Included edge case tests to ensure robustness against duplicate questions and invalid answers.
This commit is contained in:
catlog22
2026-01-31 16:02:20 +08:00
parent 715ef12c92
commit 345437415f
33 changed files with 7049 additions and 105 deletions

View File

@@ -0,0 +1,325 @@
// ========================================
// AskQuestionDialog Component
// ========================================
// Dialog for handling ask_question MCP tool with all question types
import { useState, useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useNotificationStore } from '@/stores';
import type { AskQuestionPayload, Question, QuestionType } from '@/types/store';
// ========== Types ==========
interface AskQuestionDialogProps {
/** Question payload from ask_question tool */
payload: AskQuestionPayload;
/** Callback when dialog is closed (cancelled or confirmed) */
onClose: () => void;
}
/** Answer value per question */
type AnswerValue = string | string[];
/** Answers record keyed by question ID */
interface Answers {
[questionId: string]: AnswerValue;
}
// ========== Component ==========
export function AskQuestionDialog({ payload, onClose }: AskQuestionDialogProps) {
const { formatMessage } = useIntl();
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
// Initialize answers with default values
const [answers, setAnswers] = useState<Answers>(() => {
const initial: Answers = {};
for (const question of payload.questions) {
if (question.default !== undefined) {
initial[question.id] = question.default;
} else if (question.type === 'multi') {
initial[question.id] = [];
} else if (question.type === 'yes_no') {
initial[question.id] = 'yes';
} else {
initial[question.id] = '';
}
}
return initial;
});
// Validation error state
const [errors, setErrors] = useState<Record<string, string>>({});
const [hasValidationError, setHasValidationError] = useState(false);
// Clear validation error when answers change
useEffect(() => {
if (hasValidationError) {
setHasValidationError(false);
setErrors({});
}
}, [answers, hasValidationError]);
// ========== Question Renderers ==========
const renderQuestion = useCallback((question: Question) => {
const value = answers[question.id] || '';
const setValue = (newValue: AnswerValue) => {
setAnswers((prev) => ({ ...prev, [question.id]: newValue }));
};
const error = errors[question.id];
switch (question.type) {
case 'single':
return (
<div key={question.id} className="space-y-3">
<Label className={cn('text-sm font-medium', question.required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
{question.question}
</Label>
<RadioGroup
value={String(value)}
onValueChange={(v) => setValue(v)}
className="space-y-2"
>
{question.options?.map((option) => (
<div key={option} className="flex items-center space-x-2">
<RadioGroupItem value={option} id={`${question.id}-${option}`} />
<Label
htmlFor={`${question.id}-${option}`}
className="cursor-pointer text-sm font-normal"
>
{option}
</Label>
</div>
))}
</RadioGroup>
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
</div>
);
case 'multi':
return (
<div key={question.id} className="space-y-3">
<Label className={cn('text-sm font-medium', question.required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
{question.question}
</Label>
<div className="space-y-2">
{question.options?.map((option) => {
const checked = Array.isArray(value) && value.includes(option);
return (
<div key={option} className="flex items-center space-x-2">
<Checkbox
id={`${question.id}-${option}`}
checked={checked}
onCheckedChange={(checked) => {
const currentArray = Array.isArray(value) ? value : [];
if (checked) {
setValue([...currentArray, option]);
} else {
setValue(currentArray.filter((v) => v !== option));
}
}}
/>
<Label
htmlFor={`${question.id}-${option}`}
className="cursor-pointer text-sm font-normal"
>
{option}
</Label>
</div>
);
})}
</div>
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
</div>
);
case 'text':
return (
<div key={question.id} className="space-y-2">
<Label className={cn('text-sm font-medium', question.required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
{question.question}
</Label>
<Textarea
value={String(value)}
onChange={(e) => setValue(e.target.value)}
placeholder={formatMessage({ id: 'askQuestion.textPlaceholder' }) || 'Enter your answer...'}
rows={3}
className={cn(error && 'border-destructive')}
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
</div>
);
case 'yes_no':
return (
<div key={question.id} className="space-y-3">
<Label className={cn('text-sm font-medium', question.required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
{question.question}
</Label>
<RadioGroup
value={String(value)}
onValueChange={(v) => setValue(v)}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="yes" id={`${question.id}-yes`} />
<Label
htmlFor={`${question.id}-yes`}
className="cursor-pointer text-sm font-normal"
>
{formatMessage({ id: 'askQuestion.yes' }) || 'Yes'}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="no" id={`${question.id}-no`} />
<Label
htmlFor={`${question.id}-no`}
className="cursor-pointer text-sm font-normal"
>
{formatMessage({ id: 'askQuestion.no' }) || 'No'}
</Label>
</div>
</RadioGroup>
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
</div>
);
default:
return null;
}
}, [answers, errors, formatMessage]);
// ========== Handlers ==========
const validate = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
let isValid = true;
for (const question of payload.questions) {
if (question.required) {
const answer = answers[question.id];
// Check if answer is empty
const isEmpty = (
answer === undefined ||
answer === null ||
answer === '' ||
(Array.isArray(answer) && answer.length === 0)
);
if (isEmpty) {
newErrors[question.id] = formatMessage({ id: 'askQuestion.required' }) || 'This question is required';
isValid = false;
}
}
}
setErrors(newErrors);
return isValid;
}, [answers, payload.questions, formatMessage]);
const handleConfirm = useCallback(() => {
if (!validate()) {
setHasValidationError(true);
return;
}
// Send answer via notificationStore
sendA2UIAction('submit-answer', payload.surfaceId, {
type: 'a2ui-answer',
cancelled: false,
answers,
});
onClose();
}, [validate, sendA2UIAction, payload.surfaceId, answers, onClose]);
const handleCancel = useCallback(() => {
// Send cancellation via notificationStore
sendA2UIAction('cancel-question', payload.surfaceId, {
type: 'a2ui-answer',
cancelled: true,
answers: {},
});
onClose();
}, [sendA2UIAction, payload.surfaceId, onClose]);
// ========== Render ==========
const title = payload.title || formatMessage({ id: 'askQuestion.defaultTitle' }) || 'Questions';
return (
<Dialog open onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[500px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'askQuestion.description' }) || 'Please answer the following questions'}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{payload.questions.map(renderQuestion)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={handleCancel}
className="w-full sm:w-auto"
>
{formatMessage({ id: 'common.actions.cancel' }) || 'Cancel'}
</Button>
<Button
variant="default"
onClick={handleConfirm}
className="w-full sm:w-auto"
>
{formatMessage({ id: 'common.actions.confirm' }) || 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default AskQuestionDialog;

View File

@@ -0,0 +1,7 @@
// ========================================
// A2UI Components Barrel Export
// ========================================
// Export all A2UI-related components
export { AskQuestionDialog } from './AskQuestionDialog';
export { default as AskQuestionDialog } from './AskQuestionDialog';

View File

@@ -10,7 +10,8 @@ import { Sidebar } from './Sidebar';
import { MainContent } from './MainContent';
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
import { NotificationPanel } from '@/components/notification';
import { useNotificationStore } from '@/stores';
import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog';
import { useNotificationStore, selectCurrentQuestion } from '@/stores';
import { useWebSocketNotifications } from '@/hooks';
export interface AppShellProps {
@@ -57,6 +58,10 @@ export function AppShell({
(state) => state.loadPersistentNotifications
);
// Current question dialog state
const currentQuestion = useNotificationStore(selectCurrentQuestion);
const setCurrentQuestion = useNotificationStore((state) => state.setCurrentQuestion);
// Initialize WebSocket notifications handler
useWebSocketNotifications();
@@ -106,6 +111,10 @@ export function AppShell({
useNotificationStore.getState().setPanelVisible(false);
}, []);
const handleQuestionDialogClose = useCallback(() => {
setCurrentQuestion(null);
}, [setCurrentQuestion]);
return (
<div className="flex flex-col min-h-screen bg-background">
{/* Header - fixed at top */}
@@ -150,6 +159,14 @@ export function AppShell({
isOpen={isNotificationPanelVisible}
onClose={handleNotificationPanelClose}
/>
{/* Ask Question Dialog - For ask_question MCP tool */}
{currentQuestion && (
<AskQuestionDialog
payload={currentQuestion}
onClose={handleQuestionDialogClose}
/>
)}
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
User,
LogOut,
Terminal,
Bell,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -24,6 +25,7 @@ import { useTheme } from '@/hooks';
import { LanguageSwitcher } from './LanguageSwitcher';
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
import { useNotificationStore } from '@/stores';
export interface HeaderProps {
/** Callback to toggle mobile sidebar */
@@ -49,6 +51,13 @@ export function Header({
const { isDark, toggleTheme } = useTheme();
const activeCliCount = useCliStreamStore(selectActiveExecutionCount);
// Notification state for badge
const persistentNotifications = useNotificationStore((state) => state.persistentNotifications);
const togglePanel = useNotificationStore((state) => state.togglePanel);
// Calculate unread count
const unreadCount = persistentNotifications.filter((n) => !n.read).length;
const handleRefresh = useCallback(() => {
if (onRefresh && !isRefreshing) {
onRefresh();
@@ -105,6 +114,23 @@ export function Header({
{/* Workspace selector */}
{projectPath && <WorkspaceSelector />}
{/* Notification badge */}
<Button
variant="ghost"
size="icon"
onClick={togglePanel}
aria-label={formatMessage({ id: 'common.aria.notifications' }) || 'Notifications'}
title={formatMessage({ id: 'common.aria.notifications' }) || 'Notifications'}
className="relative"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-[10px] text-destructive-foreground flex items-center justify-center font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</Button>
{/* Refresh button */}
{onRefresh && (
<Button

View File

@@ -20,6 +20,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 { useNotificationStore, selectPersistentNotifications } from '@/stores';
import type { Toast } from '@/types/store';
@@ -161,6 +162,9 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
const hasDetails = notification.message && notification.message.length > 100;
const { formatMessage } = useIntl();
// Check if this is an A2UI notification
const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface;
return (
<div
className={cn(
@@ -194,44 +198,54 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
</div>
</div>
{notification.message && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{isExpanded || !hasDetails
? notification.message
: notification.message.slice(0, 100) + '...'}
</p>
)}
{/* Expand toggle */}
{hasDetails && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showLess' }) || 'Show less'}
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showMore' }) || 'Show more'}
</>
{/* A2UI Surface Content */}
{isA2UI && notification.a2uiSurface ? (
<div className="mt-2">
<A2UIRenderer surface={notification.a2uiSurface} />
</div>
) : (
<>
{/* Regular message content */}
{notification.message && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{isExpanded || !hasDetails
? notification.message
: notification.message.slice(0, 100) + '...'}
</p>
)}
</button>
)}
{/* Action button */}
{notification.action && (
<Button
variant="outline"
size="sm"
onClick={notification.action.onClick}
className="mt-2 h-7 text-xs"
>
{notification.action.label}
</Button>
{/* Expand toggle */}
{hasDetails && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showLess' }) || 'Show less'}
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showMore' }) || 'Show more'}
</>
)}
</button>
)}
{/* Action button */}
{notification.action && (
<Button
variant="outline"
size="sm"
onClick={notification.action.onClick}
className="mt-2 h-7 text-xs"
>
{notification.action.label}
</Button>
)}
</>
)}
</div>
</div>
@@ -316,9 +330,21 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
// Delete handler
const handleDelete = useCallback(
(id: string) => {
// Find the notification being deleted
const notification = persistentNotifications.find((n) => n.id === id);
// If it's an A2UI notification, also remove from a2uiSurfaces Map
if (notification?.type === 'a2ui' && notification.a2uiSurface) {
const store = useNotificationStore.getState();
const newSurfaces = new Map(store.a2uiSurfaces);
newSurfaces.delete(notification.a2uiSurface.surfaceId);
// Update the store's a2uiSurfaces directly
useNotificationStore.setState({ a2uiSurfaces: newSurfaces });
}
removePersistentNotification(id);
},
[removePersistentNotification]
[removePersistentNotification, persistentNotifications]
);
// Mark all read handler

View File

@@ -0,0 +1,41 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -219,6 +219,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
completeExecution,
updateNode,
addOutput,
addA2UINotification,
onMessage,
]
);
@@ -314,7 +315,20 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
connect();
}
// Listen for A2UI action events and send via WebSocket
const handleA2UIAction = (event: CustomEvent) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(event.detail));
} else {
console.warn('[WebSocket] Cannot send A2UI action: not connected');
}
};
// Type the event listener properly
window.addEventListener('a2ui-action', handleA2UIAction as EventListener);
return () => {
window.removeEventListener('a2ui-action', handleA2UIAction as EventListener);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}

View File

@@ -5,7 +5,8 @@
"switchToLightMode": "Switch to light mode",
"switchToDarkMode": "Switch to dark mode",
"userMenu": "User menu",
"actions": "Actions"
"actions": "Actions",
"notifications": "Notifications"
},
"actions": {
"save": "Save",
@@ -179,5 +180,13 @@
"documentation": "Documentation",
"tutorials": "Tutorials"
}
},
"askQuestion": {
"defaultTitle": "Questions",
"description": "Please answer the following questions",
"textPlaceholder": "Enter your answer...",
"yes": "Yes",
"no": "No",
"required": "This question is required"
}
}

View File

@@ -5,7 +5,8 @@
"switchToLightMode": "切换到浅色模式",
"switchToDarkMode": "切换到深色模式",
"userMenu": "用户菜单",
"actions": "操作"
"actions": "操作",
"notifications": "通知"
},
"actions": {
"save": "保存",
@@ -179,5 +180,13 @@
"documentation": "文档",
"tutorials": "教程"
}
},
"askQuestion": {
"defaultTitle": "问题",
"description": "请回答以下问题",
"textPlaceholder": "输入您的答案...",
"yes": "是",
"no": "否",
"required": "此问题为必填项"
}
}

View File

@@ -0,0 +1,467 @@
# A2UI Runtime
A lightweight, framework-agnostic runtime for rendering A2UI (AI-to-UI) surfaces in React applications.
## Overview
A2UI Runtime enables AI agents to generate and update interactive UI components dynamically through a structured protocol. Based on Google's A2UI specification, this runtime provides:
- **Dynamic Surface Rendering**: Create and update UI components in real-time
- **Component Registry**: Extensible system for custom component renderers
- **Type Safety**: Full TypeScript support with Zod validation
- **State Management**: Built-in state binding and action handling
- **Protocol Validation**: Parse and validate A2UI surface updates
## Installation
Located at `src/packages/a2ui-runtime/`, this runtime is included as part of the CCW frontend codebase.
## Core Components
### 1. A2UIParser
Parse and validate A2UI surface updates from JSON.
```typescript
import { a2uiParser } from '@/packages/a2ui-runtime/core';
// Parse JSON string
const surfaceUpdate = a2uiParser.parse(jsonString);
// Validate object
const isValid = a2uiParser.validate(surfaceUpdate);
// Safe parse with result
const result = a2uiParser.safeParse(jsonString);
if (result.success) {
console.log(result.data);
}
```
### 2. A2UIComponentRegistry
Register and manage component renderers.
```typescript
import { a2uiRegistry } from '@/packages/a2ui-runtime/core';
import { CustomRenderer } from './renderers/CustomRenderer';
// Register a component
a2uiRegistry.register('CustomComponent', CustomRenderer);
// Check if registered
if (a2uiRegistry.has('CustomComponent')) {
const renderer = a2uiRegistry.get('CustomComponent');
}
// Get all registered types
const types = a2uiRegistry.getRegisteredTypes();
```
### 3. A2UIRenderer
Render A2UI surfaces as React components.
```typescript
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer';
function MyComponent() {
const [surface, setSurface] = useState<SurfaceUpdate | null>(null);
const handleAction = async (actionId: string, params: Record<string, unknown>) => {
console.log(`Action: ${actionId}`, params);
// Send action back to AI agent
};
return surface ? (
<A2UIRenderer
surface={surface}
onAction={handleAction}
className="my-surface"
/>
) : null;
}
```
## Built-in Components
### Standard Components
| Component | Props | Description |
|-----------|-------|-------------|
| `Text` | `text`, `usageHint` | Text with semantic hint (h1-h6, p, span, code, small) |
| `Button` | `onClick`, `content`, `variant`, `disabled` | Button with variants (primary, secondary, destructive, ghost, outline) |
| `Dropdown` | `options`, `selectedValue`, `onChange`, `placeholder` | Select dropdown with options |
| `TextField` | `value`, `onChange`, `placeholder`, `type` | Text input (text, email, password, number, url) |
| `TextArea` | `value`, `onChange`, `placeholder`, `rows` | Multi-line text input |
| `Checkbox` | `checked`, `onChange`, `label` | Checkbox with label |
| `Progress` | `value`, `max` | Progress bar |
| `Card` | `title`, `description`, `content` | Container with title and nested content |
### Custom Components
| Component | Props | Description |
|-----------|-------|-------------|
| `CLIOutput` | `output`, `language`, `streaming`, `maxLines` | CLI output with syntax highlighting |
| `DateTimeInput` | `value`, `onChange`, `placeholder`, `includeTime` | Date/time picker input |
## A2UI Protocol
### Surface Update Structure
```typescript
interface SurfaceUpdate {
surfaceId: string;
components: SurfaceComponent[];
initialState?: Record<string, unknown>;
}
interface SurfaceComponent {
id: string;
component: A2UIComponent;
}
```
### Component Types
All components use a discriminated union structure:
```typescript
// Text Component
{
Text: {
text: { literalString: "Hello" } | { path: "state.key" };
usageHint?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "code" | "small";
};
}
// Button Component
{
Button: {
onClick: { actionId: "submit", parameters?: {} };
content: A2UIComponent; // Nested component (usually Text)
variant?: "primary" | "secondary" | "destructive" | "ghost" | "outline";
disabled?: { literalBoolean: true } | { path: "state.disabled" };
};
}
```
### Content Bindings
Values can be literal or bound to state:
```typescript
// Literal string
{ literalString: "Hello, World!" }
// Bound to state
{ path: "user.name" }
```
### Actions
Actions are triggered by user interactions:
```typescript
{
actionId: "save-form",
parameters: {
formId: "contact-form",
validate: true
}
}
```
## Creating Custom Components
### 1. Define the Renderer
```typescript
import type { ComponentRenderer } from '@/packages/a2ui-runtime/core/A2UIComponentRegistry';
import type { A2UIComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
interface CustomComponentConfig {
title: { literalString: string } | { path: string };
items: Array<{ label: string; value: string }>;
onSelect: { actionId: string; parameters?: Record<string, unknown> };
}
export const CustomRenderer: ComponentRenderer = ({
component,
state,
onAction,
resolveBinding
}) => {
const customComp = component as { Custom: CustomComponentConfig };
const { Custom: config } = customComp;
const handleSelect = (value: string) => {
onAction(config.onSelect.actionId, {
...config.onSelect.parameters,
selectedValue: value,
});
};
return (
<div className="custom-component">
{/* Your rendering logic */}
</div>
);
};
```
### 2. Register the Component
```typescript
import { a2uiRegistry } from '@/packages/a2ui-runtime/core/A2UIComponentRegistry';
import { CustomRenderer } from './CustomRenderer';
a2uiRegistry.register('Custom', CustomRenderer);
```
### 3. Add Type Definition (Optional)
For type safety, extend the A2UI types:
```typescript
// In A2UITypes.ts
export const CustomComponentSchema = z.object({
Custom: z.object({
title: TextContentSchema,
items: z.array(z.object({
label: z.string(),
value: z.string(),
})),
onSelect: ActionSchema,
}),
});
// Add to ComponentSchema union
export const ComponentSchema = z.union([
// ... existing components
CustomComponentSchema,
]);
// Add to A2UIComponentType
export type A2UIComponentType =
| 'Text' | 'Button' | // ... existing types
| 'Custom';
```
## Usage Examples
### Rendering a Simple Form
```typescript
const formSurface: SurfaceUpdate = {
surfaceId: 'contact-form',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'Contact Form' },
usageHint: 'h2',
},
},
},
{
id: 'name',
component: {
TextField: {
value: { literalString: '' },
onChange: { actionId: 'update-name', parameters: { field: 'name' } },
placeholder: 'Your Name',
type: 'text',
},
},
},
{
id: 'email',
component: {
TextField: {
value: { literalString: '' },
onChange: { actionId: 'update-email', parameters: { field: 'email' } },
placeholder: 'your@email.com',
type: 'email',
},
},
},
{
id: 'submit',
component: {
Button: {
onClick: { actionId: 'submit-form' },
content: { Text: { text: { literalString: 'Submit' } } },
variant: 'primary',
},
},
},
],
initialState: {
name: '',
email: '',
},
};
function ContactForm() {
const [surface] = useState(formSurface);
const handleAction = async (actionId: string, params: any) => {
if (actionId === 'submit-form') {
// Submit logic
} else {
// Update local state
console.log(`${actionId}:`, params);
}
};
return <A2UIRenderer surface={surface} onAction={handleAction} />;
}
```
### Using CLIOutput Component
```typescript
const cliOutputSurface: SurfaceUpdate = {
surfaceId: 'build-output',
components: [
{
id: 'output',
component: {
CLIOutput: {
output: { literalString: '$ npm run build\nBuilding...\nDone!' },
language: 'bash',
streaming: false,
},
},
},
],
};
```
### Using DateTimeInput Component
```typescript
const datetimeSurface: SurfaceUpdate = {
surfaceId: 'appointment-picker',
components: [
{
id: 'datetime',
component: {
DateTimeInput: {
value: { literalString: '2024-01-15T10:30:00Z' },
onChange: { actionId: 'update-date' },
placeholder: 'Select appointment time',
includeTime: true,
},
},
},
],
};
```
## State Management
### Binding Resolution
State is resolved through the `resolveBinding` function:
```typescript
// In your component renderer
const value = resolveBinding({ path: 'user.name' });
```
### State Updates
Send state updates through actions:
```typescript
const handleAction = (actionId: string, params: any) => {
// Update local state
setLocalState(prev => ({ ...prev, ...params }));
// Send action to backend
sendA2UIAction(actionId, surfaceId, params);
};
```
## API Reference
### A2UIParser
| Method | Parameters | Returns | Description |
|--------|------------|---------|-------------|
| `parse()` | `json: string` | `SurfaceUpdate` | Parse and validate JSON string |
| `parseObject()` | `data: unknown` | `SurfaceUpdate` | Validate object |
| `validate()` | `value: unknown` | `boolean` | Type guard check |
| `safeParse()` | `json: string` | `SafeParseResult` | Parse without throwing |
| `safeParseObject()` | `data: unknown` | `SafeParseResult` | Validate without throwing |
| `validateComponent()` | `component: unknown` | `boolean` | Check if valid component |
### A2UIComponentRegistry
| Method | Parameters | Returns | Description |
|--------|------------|---------|-------------|
| `register()` | `type, renderer` | `void` | Register component renderer |
| `unregister()` | `type` | `void` | Remove component renderer |
| `get()` | `type` | `ComponentRenderer \| undefined` | Get renderer |
| `has()` | `type` | `boolean` | Check if registered |
| `getRegisteredTypes()` | - | `A2UIComponentType[]` | List all types |
| `clear()` | - | `void` | Remove all renderers |
| `size` | - | `number` | Count of renderers |
## Error Handling
### A2UIParseError
Custom error class for parsing failures:
```typescript
import { A2UIParseError } from '@/packages/a2ui-runtime/core/A2UIParser';
try {
a2uiParser.parse(invalidJson);
} catch (error) {
if (error instanceof A2UIParseError) {
console.error(error.message);
console.error(error.getDetails()); // Detailed Zod errors
console.error(error.originalError);
}
}
```
## Best Practices
1. **Always validate surfaces** before rendering:
```typescript
if (a2uiParser.validate(surfaceUpdate)) {
return <A2UIRenderer surface={surfaceUpdate} onAction={handleAction} />;
}
```
2. **Handle unknown components** gracefully:
```typescript
const renderer = a2uiRegistry.get(componentType);
if (!renderer) {
return <FallbackComponent />;
}
```
3. **Use TypeScript types** for type safety:
```typescript
import type { SurfaceUpdate, A2UIComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
```
4. **Clean up resources** when surfaces are removed:
```typescript
useEffect(() => {
return () => {
// Cleanup timers, subscriptions, etc.
};
}, [surfaceId]);
```
## License
Part of the CCW project.

View File

@@ -0,0 +1,296 @@
// ========================================
// A2UI Component Registry Unit Tests
// ========================================
// Tests for component registry operations
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { A2UIComponentRegistry, a2uiRegistry } from '../core/A2UIComponentRegistry';
import type { A2UIComponent, A2UIState, ActionHandler, BindingResolver } from '../core/A2UIComponentRegistry';
// Import component renderers to trigger auto-registration
import '../renderer/components';
// Mock component renderer
const mockRenderer: any = vi.fn(() => null);
const anotherMockRenderer: any = vi.fn(() => null);
describe('A2UIComponentRegistry', () => {
let registry: A2UIComponentRegistry;
beforeEach(() => {
// Create a fresh registry for each test
registry = new A2UIComponentRegistry();
});
describe('register()', () => {
it('should register a component renderer', () => {
registry.register('TestComponent', mockRenderer);
expect(registry.has('TestComponent')).toBe(true);
});
it('should allow overriding existing renderer', () => {
registry.register('TestComponent', mockRenderer);
registry.register('TestComponent', anotherMockRenderer);
const retrieved = registry.get('TestComponent');
expect(retrieved).toBe(anotherMockRenderer);
});
it('should register multiple component types', () => {
registry.register('Text', mockRenderer);
registry.register('Button', mockRenderer);
registry.register('Card', anotherMockRenderer);
expect(registry.has('Text')).toBe(true);
expect(registry.has('Button')).toBe(true);
expect(registry.has('Card')).toBe(true);
});
});
describe('unregister()', () => {
it('should remove a registered renderer', () => {
registry.register('TestComponent', mockRenderer);
expect(registry.has('TestComponent')).toBe(true);
registry.unregister('TestComponent');
expect(registry.has('TestComponent')).toBe(false);
});
it('should be idempotent for non-existent components', () => {
expect(() => registry.unregister('NonExistent')).not.toThrow();
expect(registry.has('NonExistent')).toBe(false);
});
});
describe('get()', () => {
it('should return registered renderer', () => {
registry.register('TestComponent', mockRenderer);
const retrieved = registry.get('TestComponent');
expect(retrieved).toBe(mockRenderer);
});
it('should return undefined for unregistered component', () => {
const retrieved = registry.get('NonExistent');
expect(retrieved).toBeUndefined();
});
it('should return correct renderer after multiple registrations', () => {
registry.register('First', mockRenderer);
registry.register('Second', anotherMockRenderer);
expect(registry.get('First')).toBe(mockRenderer);
expect(registry.get('Second')).toBe(anotherMockRenderer);
});
});
describe('has()', () => {
it('should return true for registered components', () => {
registry.register('TestComponent', mockRenderer);
expect(registry.has('TestComponent')).toBe(true);
});
it('should return false for unregistered components', () => {
expect(registry.has('NonExistent')).toBe(false);
});
it('should return false after unregistering', () => {
registry.register('TestComponent', mockRenderer);
expect(registry.has('TestComponent')).toBe(true);
registry.unregister('TestComponent');
expect(registry.has('TestComponent')).toBe(false);
});
});
describe('getRegisteredTypes()', () => {
it('should return empty array for new registry', () => {
const types = registry.getRegisteredTypes();
expect(types).toEqual([]);
});
it('should return all registered component types', () => {
registry.register('Text', mockRenderer);
registry.register('Button', mockRenderer);
registry.register('Card', anotherMockRenderer);
const types = registry.getRegisteredTypes();
expect(types).toHaveLength(3);
expect(types).toContain('Text');
expect(types).toContain('Button');
expect(types).toContain('Card');
});
it('should update after unregister', () => {
registry.register('Text', mockRenderer);
registry.register('Button', mockRenderer);
registry.register('Card', anotherMockRenderer);
registry.unregister('Button');
const types = registry.getRegisteredTypes();
expect(types).toHaveLength(2);
expect(types).not.toContain('Button');
});
});
describe('clear()', () => {
it('should remove all registered renderers', () => {
registry.register('Text', mockRenderer);
registry.register('Button', mockRenderer);
registry.register('Card', anotherMockRenderer);
registry.clear();
expect(registry.has('Text')).toBe(false);
expect(registry.has('Button')).toBe(false);
expect(registry.has('Card')).toBe(false);
expect(registry.size).toBe(0);
});
it('should be idempotent', () => {
registry.register('Test', mockRenderer);
registry.clear();
expect(registry.size).toBe(0);
registry.clear(); // Clear again
expect(registry.size).toBe(0);
});
});
describe('size', () => {
it('should return 0 for new registry', () => {
expect(registry.size).toBe(0);
});
it('should increment on registration', () => {
registry.register('Text', mockRenderer);
expect(registry.size).toBe(1);
registry.register('Button', mockRenderer);
expect(registry.size).toBe(2);
});
it('should not increment on duplicate registration', () => {
registry.register('Text', mockRenderer);
expect(registry.size).toBe(1);
registry.register('Text', anotherMockRenderer);
expect(registry.size).toBe(1);
});
it('should decrement on unregistration', () => {
registry.register('Text', mockRenderer);
registry.register('Button', mockRenderer);
expect(registry.size).toBe(2);
registry.unregister('Text');
expect(registry.size).toBe(1);
});
it('should reset on clear', () => {
registry.register('Text', mockRenderer);
registry.register('Button', mockRenderer);
expect(registry.size).toBe(2);
registry.clear();
expect(registry.size).toBe(0);
});
});
});
describe('Global a2uiRegistry', () => {
it('should be a singleton instance', () => {
expect(a2uiRegistry).toBeInstanceOf(A2UIComponentRegistry);
});
it('should have built-in components registered', () => {
// The global registry should have components registered by registry.ts
const types = a2uiRegistry.getRegisteredTypes();
// At minimum, these should be registered
expect(types.length).toBeGreaterThan(0);
expect(a2uiRegistry.has('Text')).toBe(true);
expect(a2uiRegistry.has('Button')).toBe(true);
});
it('should allow custom component registration', () => {
const customRenderer: any = vi.fn(() => null);
const testType = 'TestCustomComponent' as any;
// Register custom component
a2uiRegistry.register(testType, customRenderer);
expect(a2uiRegistry.has(testType)).toBe(true);
// Clean up
a2uiRegistry.unregister(testType);
expect(a2uiRegistry.has(testType)).toBe(false);
});
});
describe('Component Renderer Interface', () => {
it('should accept all required parameters', () => {
const mockComponent: A2UIComponent = {
Text: { text: { literalString: 'Test' } },
};
const mockState: A2UIState = { key: 'value' };
const mockOnAction: ActionHandler = vi.fn();
const mockResolveBinding: BindingResolver = vi.fn(() => 'resolved');
const renderer: any = (props: {
component: A2UIComponent;
state: A2UIState;
onAction: ActionHandler;
resolveBinding: BindingResolver;
}) => {
expect(props.component).toBeDefined();
expect(props.state).toBeDefined();
expect(props.onAction).toBeDefined();
expect(props.resolveBinding).toBeDefined();
return null;
};
renderer({
component: mockComponent,
state: mockState,
onAction: mockOnAction,
resolveBinding: mockResolveBinding,
});
});
it('should support async action handlers', async () => {
const asyncAction: ActionHandler = async (actionId, params) => {
await Promise.resolve();
return;
};
const mockComponent: A2UIComponent = {
Button: {
onClick: { actionId: 'click' },
content: { Text: { text: { literalString: 'Click' } } },
},
};
const mockState: A2UIState = {};
const mockResolveBinding: BindingResolver = vi.fn();
const renderer: any = async (props: {
component: A2UIComponent;
state: A2UIState;
onAction: ActionHandler;
resolveBinding: BindingResolver;
}) => {
// Should not throw with async handler
await props.onAction('test-action', {});
return null;
};
await renderer({
component: mockComponent,
state: mockState,
onAction: asyncAction,
resolveBinding: mockResolveBinding,
});
});
});

View File

@@ -0,0 +1,402 @@
// ========================================
// A2UI Parser Unit Tests
// ========================================
// Tests for A2UI protocol parsing and validation
import { describe, it, expect } from 'vitest';
import { A2UIParser, a2uiParser, A2UIParseError } from '../core/A2UIParser';
import type { SurfaceUpdate, A2UIComponent } from '../core/A2UITypes';
// Import component renderers to trigger auto-registration
import '../renderer/components';
describe('A2UIParser', () => {
describe('parse()', () => {
it('should parse valid surface update JSON', () => {
const validJson = JSON.stringify({
surfaceId: 'test-surface',
components: [
{
id: 'comp-1',
component: {
Text: {
text: { literalString: 'Hello, World!' },
usageHint: 'h1',
},
},
},
],
initialState: { key: 'value' },
});
const result = a2uiParser.parse(validJson);
expect(result).toEqual({
surfaceId: 'test-surface',
components: [
{
id: 'comp-1',
component: {
Text: {
text: { literalString: 'Hello, World!' },
usageHint: 'h1',
},
},
},
],
initialState: { key: 'value' },
});
});
it('should throw A2UIParseError on invalid JSON', () => {
const invalidJson = '{ invalid json }';
expect(() => a2uiParser.parse(invalidJson)).toThrow(A2UIParseError);
expect(() => a2uiParser.parse(invalidJson)).toThrow('Invalid JSON');
});
it('should throw A2UIParseError on validation failure', () => {
const invalidSchema = JSON.stringify({
surfaceId: 'test',
// Missing required components array
});
expect(() => a2uiParser.parse(invalidSchema)).toThrow(A2UIParseError);
expect(() => a2uiParser.parse(invalidSchema)).toThrow('A2UI validation failed');
});
it('should parse surface with all component types', () => {
const complexJson = JSON.stringify({
surfaceId: 'complex-surface',
components: [
{
id: 'text-1',
component: { Text: { text: { literalString: 'Text' } } },
},
{
id: 'button-1',
component: {
Button: {
onClick: { actionId: 'click' },
content: { Text: { text: { literalString: 'Click me' } } },
variant: 'primary',
},
},
},
{
id: 'dropdown-1',
component: {
Dropdown: {
options: [
{ label: { literalString: 'Option 1' }, value: 'opt1' },
{ label: { literalString: 'Option 2' }, value: 'opt2' },
],
onChange: { actionId: 'change' },
},
},
},
{
id: 'textfield-1',
component: {
TextField: {
value: { literalString: 'input' },
onChange: { actionId: 'input' },
},
},
},
{
id: 'textarea-1',
component: {
TextArea: {
onChange: { actionId: 'textarea' },
rows: 5,
},
},
},
{
id: 'checkbox-1',
component: {
Checkbox: {
checked: { literalBoolean: true },
onChange: { actionId: 'check' },
},
},
},
{
id: 'codeblock-1',
component: {
CodeBlock: {
code: { literalString: 'console.log("hello");' },
language: 'javascript',
},
},
},
{
id: 'progress-1',
component: {
Progress: {
value: { literalNumber: 50 },
max: 100,
},
},
},
{
id: 'card-1',
component: {
Card: {
title: { literalString: 'Card Title' },
content: [
{ Text: { text: { literalString: 'Card content' } } },
],
},
},
},
{
id: 'clioutput-1',
component: {
CLIOutput: {
output: { literalString: 'Command output' },
language: 'bash',
streaming: true,
},
},
},
{
id: 'datetime-1',
component: {
DateTimeInput: {
onChange: { actionId: 'datetime' },
includeTime: true,
},
},
},
],
});
const result = a2uiParser.parse(complexJson);
expect(result.components).toHaveLength(11);
});
});
describe('parseObject()', () => {
it('should validate and return surface update object', () => {
const validObject = {
surfaceId: 'test-surface',
components: [
{
id: 'comp-1',
component: {
Text: { text: { literalString: 'Test' } },
},
},
],
};
const result = a2uiParser.parseObject(validObject);
expect(result).toEqual(validObject);
});
it('should throw A2UIParseError on invalid object', () => {
const invalidObject = { surfaceId: 'test' }; // Missing components
expect(() => a2uiParser.parseObject(invalidObject)).toThrow(A2UIParseError);
});
});
describe('validate()', () => {
it('should return true for valid surface update', () => {
const validUpdate = {
surfaceId: 'test',
components: [
{
id: 'comp-1',
component: { Text: { text: { literalString: 'Test' } } },
},
],
};
expect(a2uiParser.validate(validUpdate)).toBe(true);
});
it('should return false for invalid surface update', () => {
const invalidUpdate = { surfaceId: 'test' };
expect(a2uiParser.validate(invalidUpdate)).toBe(false);
});
it('should work as type guard', () => {
const unknownValue: unknown = {
surfaceId: 'test',
components: [
{
id: 'comp-1',
component: { Text: { text: { literalString: 'Test' } } },
},
],
};
if (a2uiParser.validate(unknownValue)) {
// TypeScript should know this is SurfaceUpdate
expect(unknownValue.surfaceId).toBe('test');
expect(unknownValue.components).toBeDefined();
} else {
expect.fail('Should have validated successfully');
}
});
});
describe('safeParse()', () => {
it('should return success with data for valid JSON', () => {
const validJson = JSON.stringify({
surfaceId: 'test',
components: [{ id: '1', component: { Text: { text: { literalString: 'Test' } } } }],
});
const result = a2uiParser.safeParse(validJson);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.surfaceId).toBe('test');
}
});
it('should return error for invalid JSON', () => {
const invalidJson = '{ invalid }';
const result = a2uiParser.safeParse(invalidJson);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
});
describe('safeParseObject()', () => {
it('should return success with data for valid object', () => {
const validObject = {
surfaceId: 'test',
components: [{ id: '1', component: { Text: { text: { literalString: 'Test' } } } }],
};
const result = a2uiParser.safeParseObject(validObject);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.surfaceId).toBe('test');
}
});
it('should return error for invalid object', () => {
const invalidObject = { invalid: 'object' };
const result = a2uiParser.safeParseObject(invalidObject);
expect(result.success).toBe(false);
});
});
describe('validateComponent()', () => {
it('should return true for valid Text component', () => {
const validComponent = { Text: { text: { literalString: 'Hello' } } };
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
});
it('should return true for valid Button component', () => {
const validComponent = {
Button: {
onClick: { actionId: 'click' },
content: { Text: { text: { literalString: 'Click' } } },
},
};
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
});
it('should return true for valid CLIOutput component', () => {
const validComponent = {
CLIOutput: {
output: { literalString: 'Output' },
language: 'bash',
streaming: false,
},
};
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
});
it('should return true for valid DateTimeInput component', () => {
const validComponent = {
DateTimeInput: {
onChange: { actionId: 'change' },
includeTime: true,
},
};
expect(a2uiParser.validateComponent(validComponent)).toBe(true);
});
it('should return false for invalid component', () => {
const invalidComponent = { InvalidComponent: {} };
expect(a2uiParser.validateComponent(invalidComponent)).toBe(false);
});
it('should work as type guard', () => {
const unknownValue: unknown = { Text: { text: { literalString: 'Test' } } };
if (a2uiParser.validateComponent(unknownValue)) {
// TypeScript should know this is A2UIComponent
expect('Text' in unknownValue).toBe(true);
} else {
expect.fail('Should have validated successfully');
}
});
});
describe('A2UIParseError', () => {
it('should have correct name', () => {
const error = new A2UIParseError('Test error');
expect(error.name).toBe('A2UIParseError');
});
it('should store original error', () => {
const originalError = new Error('Original');
const parseError = new A2UIParseError('Parse failed', originalError);
expect(parseError.originalError).toBe(originalError);
});
it('should provide details for Zod errors', () => {
// Create a mock ZodError
const mockZodError = {
issues: [
{ path: ['components', 0, 'id'], message: 'Required' },
{ path: ['surfaceId'], message: 'Invalid format' },
],
};
const parseError = new A2UIParseError('Validation failed', mockZodError as any);
const details = parseError.getDetails();
expect(details).toContain('components.0.id: Required');
expect(details).toContain('surfaceId: Invalid format');
});
it('should provide message for Error original errors', () => {
const originalError = new Error('Something went wrong');
const parseError = new A2UIParseError('Parse failed', originalError);
const details = parseError.getDetails();
expect(details).toBe('Something went wrong');
});
it('should return basic message for unknown error types', () => {
const parseError = new A2UIParseError('Unknown error', 'string error');
const details = parseError.getDetails();
expect(details).toBe('Unknown error');
});
});
});

View File

@@ -0,0 +1,636 @@
// ========================================
// A2UI Component Renderer Unit Tests
// ========================================
// Tests for all A2UI component renderers
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { A2UIComponent } from '../core/A2UITypes';
import type { A2UIState, ActionHandler, BindingResolver } from '../core/A2UIComponentRegistry';
import type { TextComponent, ButtonComponent, DropdownComponent, CLIOutputComponent, DateTimeInputComponent } from '../core/A2UITypes';
// Import component renderers to trigger auto-registration
import '../renderer/components';
// Import component renderers
import { A2UIText } from '../renderer/components/A2UIText';
import { A2UIButton } from '../renderer/components/A2UIButton';
import { A2UIDropdown } from '../renderer/components/A2UIDropdown';
import { A2UITextField } from '../renderer/components/A2UITextField';
import { A2UITextArea } from '../renderer/components/A2UITextArea';
import { A2UICheckbox } from '../renderer/components/A2UICheckbox';
import { A2UIProgress } from '../renderer/components/A2UIProgress';
import { A2UICard } from '../renderer/components/A2UICard';
import { A2UICLIOutput } from '../renderer/components/A2UICLIOutput';
import { A2UIDateTimeInput } from '../renderer/components/A2UIDateTimeInput';
// Common test helpers
function createMockProps(component: A2UIComponent) {
const mockState: A2UIState = {};
const mockOnAction: ActionHandler = vi.fn();
const mockResolveBinding: BindingResolver = vi.fn((binding) => {
if (binding.path === 'test.value') return 'resolved-value';
return binding.path;
});
return {
component,
state: mockState,
onAction: mockOnAction,
resolveBinding: mockResolveBinding,
};
}
// Wrapper for testing renderer components
function RendererWrapper({ children }: { children: React.ReactNode }) {
return <div data-testid="renderer-wrapper">{children}</div>;
}
describe('A2UI Component Renderers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('A2UIText', () => {
it('should render text with literal string', () => {
const component: TextComponent = {
Text: { text: { literalString: 'Hello, World!' } },
};
const props = createMockProps(component);
const result = A2UIText(props);
expect(result).toBeTruthy();
});
it('should render different usage hints', () => {
const hints: Array<'h1' | 'h2' | 'p' | 'code' | 'span'> = ['h1', 'h2', 'p', 'code', 'span'];
hints.forEach((hint) => {
const component: TextComponent = {
Text: { text: { literalString: 'Test' }, usageHint: hint },
};
const props = createMockProps(component);
const result = A2UIText(props);
expect(result).toBeTruthy();
});
});
it('should resolve binding for text content', () => {
const component: TextComponent = {
Text: { text: { path: 'test.value' } },
};
const props = createMockProps(component);
const result = A2UIText(props);
expect(result).toBeTruthy();
expect(props.resolveBinding).toHaveBeenCalledWith({ path: 'test.value' });
});
});
describe('A2UIButton', () => {
it('should render button with text content', () => {
const component: ButtonComponent = {
Button: {
onClick: { actionId: 'click-action' },
content: { Text: { text: { literalString: 'Click Me' } } },
variant: 'primary',
},
};
const props = createMockProps(component);
render(<RendererWrapper>{A2UIButton(props)}</RendererWrapper>);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
it('should call onAction when clicked', () => {
const component: ButtonComponent = {
Button: {
onClick: { actionId: 'test-action', parameters: { key: 'value' } },
content: { Text: { text: { literalString: 'Test' } } },
},
};
const props = createMockProps(component);
const result = A2UIButton(props);
expect(result).toBeTruthy();
});
it('should render different variants', () => {
const variants: Array<'primary' | 'secondary' | 'destructive' | 'ghost' | 'outline'> = [
'primary',
'secondary',
'destructive',
'ghost',
'outline',
];
variants.forEach((variant) => {
const component: ButtonComponent = {
Button: {
onClick: { actionId: 'click' },
content: { Text: { text: { literalString: variant } } },
variant,
},
};
const props = createMockProps(component);
const result = A2UIButton(props);
expect(result).toBeTruthy();
});
});
it('should be disabled when specified', () => {
const component: ButtonComponent = {
Button: {
onClick: { actionId: 'click' },
content: { Text: { text: { literalString: 'Disabled' } } },
disabled: { literalBoolean: true },
},
};
const props = createMockProps(component);
const result = A2UIButton(props);
expect(result).toBeTruthy();
});
});
describe('A2UIDropdown', () => {
it('should render dropdown with options', () => {
const component: DropdownComponent = {
Dropdown: {
options: [
{ label: { literalString: 'Option 1' }, value: 'opt1' },
{ label: { literalString: 'Option 2' }, value: 'opt2' },
{ label: { literalString: 'Option 3' }, value: 'opt3' },
],
onChange: { actionId: 'change' },
placeholder: 'Select an option',
},
};
const props = createMockProps(component);
const result = A2UIDropdown(props);
expect(result).toBeTruthy();
});
it('should render with selected value', () => {
const component: DropdownComponent = {
Dropdown: {
options: [
{ label: { literalString: 'A' }, value: 'a' },
{ label: { literalString: 'B' }, value: 'b' },
],
selectedValue: { literalString: 'a' },
onChange: { actionId: 'change' },
},
};
const props = createMockProps(component);
const result = A2UIDropdown(props);
expect(result).toBeTruthy();
});
it('should call onChange with actionId when selection changes', () => {
const component: DropdownComponent = {
Dropdown: {
options: [{ label: { literalString: 'Test' }, value: 'test' }],
onChange: { actionId: 'select-action' },
},
};
const props = createMockProps(component);
const result = A2UIDropdown(props);
expect(result).toBeTruthy();
});
});
describe('A2UITextField', () => {
it('should render text input', () => {
const component = {
TextField: {
value: { literalString: 'initial value' },
onChange: { actionId: 'input' },
placeholder: 'Enter text',
type: 'text' as const,
},
};
const props = createMockProps(component);
const result = A2UITextField(props);
expect(result).toBeTruthy();
});
it('should render different input types', () => {
const types: Array<'text' | 'email' | 'password' | 'number' | 'url'> = [
'text',
'email',
'password',
'number',
'url',
];
types.forEach((type) => {
const component = {
TextField: { onChange: { actionId: 'input' }, type },
};
const props = createMockProps(component);
const result = A2UITextField(props);
expect(result).toBeTruthy();
});
});
it('should call onChange when value changes', () => {
const component = {
TextField: {
value: { literalString: 'test' },
onChange: { actionId: 'text-change' },
},
};
const props = createMockProps(component);
const result = A2UITextField(props);
expect(result).toBeTruthy();
});
});
describe('A2UITextArea', () => {
it('should render textarea', () => {
const component = {
TextArea: {
value: { literalString: 'Multi-line text' },
onChange: { actionId: 'textarea-change' },
rows: 5,
},
};
const props = createMockProps(component);
const result = A2UITextArea(props);
expect(result).toBeTruthy();
});
it('should render with custom rows', () => {
const component = {
TextArea: {
onChange: { actionId: 'change' },
rows: 10,
},
};
const props = createMockProps(component);
const result = A2UITextArea(props);
expect(result).toBeTruthy();
});
it('should render with placeholder', () => {
const component = {
TextArea: {
onChange: { actionId: 'change' },
placeholder: 'Enter description',
},
};
const props = createMockProps(component);
const result = A2UITextArea(props);
expect(result).toBeTruthy();
});
});
describe('A2UICheckbox', () => {
it('should render checkbox unchecked', () => {
const component = {
Checkbox: {
checked: { literalBoolean: false },
onChange: { actionId: 'check' },
label: { literalString: 'Accept terms' },
},
};
const props = createMockProps(component);
const result = A2UICheckbox(props);
expect(result).toBeTruthy();
});
it('should render checkbox checked', () => {
const component = {
Checkbox: {
checked: { literalBoolean: true },
onChange: { actionId: 'check' },
label: { literalString: 'Checked' },
},
};
const props = createMockProps(component);
const result = A2UICheckbox(props);
expect(result).toBeTruthy();
});
it('should call onChange when toggled', () => {
const component = {
Checkbox: {
checked: { literalBoolean: false },
onChange: { actionId: 'toggle' },
},
};
const props = createMockProps(component);
const result = A2UICheckbox(props);
expect(result).toBeTruthy();
});
});
describe('A2UIProgress', () => {
it('should render progress bar with value', () => {
const component = {
Progress: {
value: { literalNumber: 50 },
max: 100,
},
};
const props = createMockProps(component);
const result = A2UIProgress(props);
expect(result).toBeTruthy();
});
it('should render indeterminate progress', () => {
const component = {
Progress: {},
};
const props = createMockProps(component);
const result = A2UIProgress(props);
expect(result).toBeTruthy();
});
it('should handle max value', () => {
const component = {
Progress: {
value: { literalNumber: 75 },
max: 200,
},
};
const props = createMockProps(component);
const result = A2UIProgress(props);
expect(result).toBeTruthy();
});
});
describe('A2UICard', () => {
it('should render card with title and content', () => {
const component = {
Card: {
title: { literalString: 'Card Title' },
description: { literalString: 'Card description' },
content: [
{ Text: { text: { literalString: 'Content 1' } } },
{ Text: { text: { literalString: 'Content 2' } } },
],
},
};
const props = createMockProps(component);
render(<RendererWrapper>{A2UICard(props)}</RendererWrapper>);
expect(screen.getByText('Card Title')).toBeInTheDocument();
});
it('should render card without title', () => {
const component = {
Card: {
content: [{ Text: { text: { literalString: 'Content' } } }],
},
};
const props = createMockProps(component);
const result = A2UICard(props);
expect(result).toBeTruthy();
});
it('should render nested content', () => {
const component = {
Card: {
title: { literalString: 'Nested' },
content: [
{
Card: {
title: { literalString: 'Inner Card' },
content: [{ Text: { text: { literalString: 'Inner content' } } }],
},
},
],
},
};
const props = createMockProps(component);
const result = A2UICard(props);
expect(result).toBeTruthy();
});
});
describe('A2UICLIOutput', () => {
it('should render CLI output with syntax highlighting', () => {
const component: CLIOutputComponent = {
CLIOutput: {
output: { literalString: '$ echo "Hello"\nHello\n' },
language: 'bash',
streaming: false,
},
};
const props = createMockProps(component);
const result = A2UICLIOutput(props);
expect(result).toBeTruthy();
});
it('should render with streaming indicator', () => {
const component: CLIOutputComponent = {
CLIOutput: {
output: { literalString: 'Streaming output...' },
language: 'bash',
streaming: true,
},
};
const props = createMockProps(component);
render(<RendererWrapper>{A2UICLIOutput(props)}</RendererWrapper>);
// Should show streaming indicator
expect(screen.getByText(/Streaming/i)).toBeInTheDocument();
});
it('should render different languages', () => {
const languages = ['bash', 'javascript', 'python'];
languages.forEach((lang) => {
const component: CLIOutputComponent = {
CLIOutput: {
output: { literalString: `Code in ${lang}` },
language: lang,
},
};
const props = createMockProps(component);
const result = A2UICLIOutput(props);
expect(result).toBeTruthy();
});
});
it('should truncate output when maxLines is set', () => {
const component: CLIOutputComponent = {
CLIOutput: {
output: { literalString: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5' },
language: 'bash',
maxLines: 3,
},
};
const props = createMockProps(component);
const result = A2UICLIOutput(props);
expect(result).toBeTruthy();
});
it('should render empty output', () => {
const component: CLIOutputComponent = {
CLIOutput: {
output: { literalString: '' },
language: 'bash',
},
};
const props = createMockProps(component);
render(<RendererWrapper>{A2UICLIOutput(props)}</RendererWrapper>);
expect(screen.getByText(/No output/i)).toBeInTheDocument();
});
it('should highlight bash error patterns', () => {
const component: CLIOutputComponent = {
CLIOutput: {
output: { literalString: '$ command\nError: command failed' },
language: 'bash',
},
};
const props = createMockProps(component);
const result = A2UICLIOutput(props);
expect(result).toBeTruthy();
});
});
describe('A2UIDateTimeInput', () => {
it('should render datetime input', () => {
const component: DateTimeInputComponent = {
DateTimeInput: {
value: { literalString: '2024-01-15T10:30:00Z' },
onChange: { actionId: 'datetime-change' },
includeTime: true,
},
};
const props = createMockProps(component);
const result = A2UIDateTimeInput(props);
expect(result).toBeTruthy();
});
it('should render date-only input', () => {
const component: DateTimeInputComponent = {
DateTimeInput: {
onChange: { actionId: 'date-change' },
includeTime: false,
},
};
const props = createMockProps(component);
const result = A2UIDateTimeInput(props);
expect(result).toBeTruthy();
});
it('should call onChange when value changes', () => {
const component: DateTimeInputComponent = {
DateTimeInput: {
onChange: { actionId: 'datetime-action', parameters: { field: 'date' } },
includeTime: true,
},
};
const props = createMockProps(component);
const result = A2UIDateTimeInput(props);
expect(result).toBeTruthy();
});
it('should respect min and max date constraints', () => {
const component: DateTimeInputComponent = {
DateTimeInput: {
onChange: { actionId: 'change' },
minDate: { literalString: '2024-01-01T00:00:00Z' },
maxDate: { literalString: '2024-12-31T23:59:59Z' },
},
};
const props = createMockProps(component);
const result = A2UIDateTimeInput(props);
expect(result).toBeTruthy();
});
it('should render with placeholder', () => {
const component: DateTimeInputComponent = {
DateTimeInput: {
onChange: { actionId: 'change' },
placeholder: 'Select appointment time',
includeTime: true,
},
};
const props = createMockProps(component);
const result = A2UIDateTimeInput(props);
expect(result).toBeTruthy();
});
});
});
describe('A2UI Component Integration', () => {
it('should handle binding resolution across components', () => {
const mockResolveBinding: BindingResolver = vi.fn((binding) => {
if (binding.path === 'user.name') return 'Test User';
return undefined;
});
const textComponent: TextComponent = {
Text: { text: { path: 'user.name' } },
};
const props = {
component: textComponent,
state: {},
onAction: vi.fn(),
resolveBinding: mockResolveBinding,
};
const result = A2UIText(props);
expect(result).toBeTruthy();
expect(mockResolveBinding).toHaveBeenCalledWith({ path: 'user.name' });
});
it('should handle async action handlers', async () => {
const asyncOnAction: ActionHandler = async (actionId, params) => {
await new Promise((resolve) => setTimeout(resolve, 10));
};
const buttonComponent: ButtonComponent = {
Button: {
onClick: { actionId: 'async-action' },
content: { Text: { text: { literalString: 'Async' } } },
},
};
const props = {
component: buttonComponent,
state: {},
onAction: asyncOnAction,
resolveBinding: vi.fn(),
};
const result = A2UIButton(props);
expect(result).toBeTruthy();
});
});

View File

@@ -111,8 +111,8 @@ export class A2UIParser {
return SurfaceUpdateSchema.safeParse(data);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error)),
success: false as const,
error: error as z.ZodError,
};
}
}

View File

@@ -129,20 +129,31 @@ export const CardComponentSchema = z.object({
}),
});
// ========== Component Union ==========
/** CLIOutput component - for streaming CLI output with syntax highlighting */
export const CLIOutputComponentSchema = z.object({
CLIOutput: z.object({
output: TextContentSchema,
language: z.string().optional(),
streaming: z.boolean().optional(),
maxLines: z.number().optional(),
}),
});
/** DateTimeInput component - for date/time selection */
export const DateTimeInputComponentSchema = z.object({
DateTimeInput: z.object({
value: TextContentSchema.optional(),
onChange: ActionSchema,
placeholder: z.string().optional(),
minDate: TextContentSchema.optional(),
maxDate: TextContentSchema.optional(),
includeTime: z.boolean().optional(),
}),
});
// ========== Component Union ==========
/** All A2UI component types */
export const ComponentSchema: z.ZodType<
| z.infer<typeof TextComponentSchema>
| z.infer<typeof ButtonComponentSchema>
| z.infer<typeof DropdownComponentSchema>
| z.infer<typeof TextFieldComponentSchema>
| z.infer<typeof TextAreaComponentSchema>
| z.infer<typeof CheckboxComponentSchema>
| z.infer<typeof CodeBlockComponentSchema>
| z.infer<typeof ProgressComponentSchema>
| z.infer<typeof CardComponentSchema>
> = z.union([
export const ComponentSchema: z.ZodType<any> = z.union([
TextComponentSchema,
ButtonComponentSchema,
DropdownComponentSchema,
@@ -152,6 +163,8 @@ export const ComponentSchema: z.ZodType<
CodeBlockComponentSchema,
ProgressComponentSchema,
CardComponentSchema,
CLIOutputComponentSchema,
DateTimeInputComponentSchema,
]);
// ========== Surface Schemas ==========
@@ -187,6 +200,8 @@ export type CheckboxComponent = z.infer<typeof CheckboxComponentSchema>;
export type CodeBlockComponent = z.infer<typeof CodeBlockComponentSchema>;
export type ProgressComponent = z.infer<typeof ProgressComponentSchema>;
export type CardComponent = z.infer<typeof CardComponentSchema>;
export type CLIOutputComponent = z.infer<typeof CLIOutputComponentSchema>;
export type DateTimeInputComponent = z.infer<typeof DateTimeInputComponentSchema>;
export type A2UIComponent = z.infer<typeof ComponentSchema>;
export type SurfaceComponent = z.infer<typeof SurfaceComponentSchema>;
@@ -204,7 +219,9 @@ export type A2UIComponentType =
| 'Checkbox'
| 'CodeBlock'
| 'Progress'
| 'Card';
| 'Card'
| 'CLIOutput'
| 'DateTimeInput';
/** Get component type from discriminated union */
export function getComponentType(component: A2UIComponent): A2UIComponentType {

View File

@@ -0,0 +1,213 @@
// ========================================
// A2UI CLIOutput Component Renderer
// ========================================
// Displays CLI output with syntax highlighting and streaming indicator
import React, { useState, useRef, useEffect, useCallback } from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { CLIOutputComponent } from '../../core/A2UITypes';
/**
* Lightweight syntax highlighter for CLI output
* Uses simple regex-based highlighting for common patterns
*/
function highlightSyntax(output: string, language?: string): React.ReactNode {
const lines = output.split('\n');
// Define syntax patterns by language
const patterns: Record<string, RegExp[]> = {
bash: [
{ regex: /^(\$|>|\s)(\s*)/gm, className: 'text-muted-foreground' }, // Prompt
{ regex: /\b(error|fail|failed|failure)\b/gi, className: 'text-destructive font-semibold' },
{ regex: /\b(warn|warning)\b/gi, className: 'text-yellow-500 font-semibold' },
{ regex: /\b(success|done|completed|passed)\b/gi, className: 'text-green-500 font-semibold' },
{ regex: /\b(info|notice|note)\b/gi, className: 'text-blue-400' },
{ regex: /--?[\w-]+/g, className: 'text-purple-400' }, // Flags
{ regex: /'[^']*'|"[^"]*"/g, className: 'text-green-400' }, // Strings
],
javascript: [
{ regex: /\b(const|let|var|function|return|if|else|for|while|import|export|from)\b/g, className: 'text-purple-400' },
{ regex: /\b(true|false|null|undefined)\b/g, className: 'text-blue-400' },
{ regex: /\/\/.*$/gm, className: 'text-muted-foreground italic' }, // Comments
{ regex: /'[^']*'|"[^"]*"|`[^`]*`/g, className: 'text-green-400' }, // Strings
{ regex: /\b(console|document|window)\b/g, className: 'text-yellow-400' },
],
python: [
{ regex: /\b(def|class|if|else|elif|for|while|return|import|from|as|try|except|with)\b/g, className: 'text-purple-400' },
{ regex: /\b(True|False|None)\b/g, className: 'text-blue-400' },
{ regex: /#.*/g, className: 'text-muted-foreground italic' }, // Comments
{ regex: /'[^']*'|"[^"]*"/g, className: 'text-green-400' }, // Strings
{ regex: /\b(print|len|range|str|int|float|list|dict)\b/g, className: 'text-yellow-400' },
],
};
const defaultPatterns = patterns.bash;
const langPatterns = patterns[language || ''] || defaultPatterns;
// Apply highlighting to a single line
const highlightLine = (line: string): React.ReactNode => {
if (!line) return '\n';
// Split by regex matches and wrap in spans
let result: React.ReactNode = line;
let key = 0;
for (const pattern of langPatterns) {
if (typeof result === 'string') {
const parts = result.split(pattern.regex);
result = parts.map((part, i) => {
if (pattern.regex.test(part)) {
return (
<span key={`${key}-${i}`} className={pattern.className}>
{part}
</span>
);
}
return part;
});
key++;
}
}
return result;
};
return (
<>
{lines.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-words">
{highlightLine(line)}
</div>
))}
</>
);
}
/**
* Streaming indicator animation
*/
function StreamingIndicator() {
return (
<span className="inline-flex items-center gap-1 ml-2">
<span className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="w-2 h-2 bg-primary rounded-full animate-pulse delay-75" />
<span className="w-2 h-2 bg-primary rounded-full animate-pulse delay-150" />
</span>
);
}
/**
* A2UI CLIOutput Component Renderer
* Displays CLI output with optional syntax highlighting and streaming indicator
*/
export const A2UICLIOutput: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const cliOutputComp = component as CLIOutputComponent;
const { CLIOutput: config } = cliOutputComp;
// Resolve output content
const output = resolveTextContent(config.output, resolveBinding) || '';
const language = config.language || 'bash';
const streaming = config.streaming ?? false;
const maxLines = config.maxLines;
// Local state for scroll management
const [isPaused, setIsPaused] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const endRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when output changes
useEffect(() => {
if (!isPaused && isAtBottom && streaming) {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [output, isPaused, isAtBottom, streaming]);
// Handle scroll to detect if user is at bottom
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsAtBottom(atBottom);
}, []);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
setIsAtBottom(true);
}, []);
// Truncate output if maxLines is set
const displayOutput = maxLines
? output.split('\n').slice(-maxLines).join('\n')
: output;
return (
<div className="a2ui-cli-output relative group">
{/* Output container with scroll */}
<div
ref={containerRef}
onScroll={handleScroll}
className="bg-muted/50 rounded-md p-3 font-mono text-sm overflow-y-auto max-h-[400px] border"
>
{displayOutput ? (
<div className="min-h-[50px]">
{highlightSyntax(String(displayOutput), language)}
</div>
) : (
<div className="text-muted-foreground italic">No output</div>
)}
<div ref={endRef} />
</div>
{/* Controls overlay - visible on hover or when not at bottom */}
<div className={cn(
"absolute top-2 right-2 flex gap-1 transition-opacity",
isAtBottom ? "opacity-0 group-hover:opacity-100" : "opacity-100"
)}>
{/* Streaming indicator */}
{streaming && !isPaused && (
<div className="bg-background/90 backdrop-blur rounded px-2 py-1 text-xs flex items-center">
<span className="text-muted-foreground mr-1">Streaming</span>
<StreamingIndicator />
</div>
)}
{/* Pause/Resume button */}
{streaming && (
<button
onClick={() => setIsPaused(!isPaused)}
className="bg-background/90 backdrop-blur rounded px-2 py-1 text-xs hover:bg-background transition-colors border"
title={isPaused ? "Resume scrolling" : "Pause scrolling"}
>
{isPaused ? "Resume" : "Pause"}
</button>
)}
{/* Scroll to bottom button */}
{!isAtBottom && (
<button
onClick={scrollToBottom}
className="bg-background/90 backdrop-blur rounded px-2 py-1 text-xs hover:bg-background transition-colors border"
title="Scroll to bottom"
>
v
</button>
)}
</div>
{/* Language indicator */}
{language && (
<div className="text-xs text-muted-foreground mt-1">
Language: {language}
</div>
)}
</div>
);
};
// Utility function for className merging
function cn(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(' ');
}

View File

@@ -0,0 +1,109 @@
// ========================================
// A2UI DateTimeInput Component Renderer
// ========================================
// Date/time picker with ISO string format support
import React, { useState, useCallback, useEffect } from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
import type { DateTimeInputComponent } from '../../core/A2UITypes';
/**
* Convert ISO string to datetime-local input format (YYYY-MM-DDTHH:mm)
*/
function isoToDateTimeLocal(isoString: string): string {
if (!isoString) return '';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '';
// Format: YYYY-MM-DDTHH:mm
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* Convert datetime-local input format to ISO string
*/
function dateTimeLocalToIso(dateTimeLocal: string, includeTime: boolean): string {
if (!dateTimeLocal) return '';
const date = new Date(dateTimeLocal);
if (isNaN(date.getTime())) return '';
return date.toISOString();
}
/**
* A2UI DateTimeInput Component Renderer
* Uses native input[type="datetime-local"] or input[type="date"] based on includeTime
*/
export const A2UIDateTimeInput: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const dateTimeComp = component as DateTimeInputComponent;
const { DateTimeInput: config } = dateTimeComp;
// Resolve initial value
const getInitialValue = (): string => {
if (!config.value) return '';
const resolved = resolveTextContent(config.value, resolveBinding);
return resolved || '';
};
const [internalValue, setInternalValue] = useState(getInitialValue);
const includeTime = config.includeTime ?? true;
// Update internal value when binding changes
useEffect(() => {
setInternalValue(getInitialValue());
}, [config.value]);
// Resolve min/max date constraints
const minDate = config.minDate ? resolveTextContent(config.minDate, resolveBinding) : undefined;
const maxDate = config.maxDate ? resolveTextContent(config.maxDate, resolveBinding) : undefined;
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInternalValue(newValue);
// Convert to ISO string and trigger action
const isoValue = dateTimeLocalToIso(newValue, includeTime);
onAction(config.onChange.actionId, {
...config.onChange.parameters,
value: isoValue,
});
}, [onAction, config.onChange, includeTime]);
const inputType = includeTime ? 'datetime-local' : 'date';
const inputMin = minDate ? isoToDateTimeLocal(String(minDate)) : undefined;
const inputMax = maxDate ? isoToDateTimeLocal(String(maxDate)) : undefined;
const inputValue = internalValue ? isoToDateTimeLocal(String(internalValue)) : '';
return (
<div className="a2ui-datetime-input">
<input
type={inputType}
value={inputValue}
onChange={handleChange}
placeholder={config.placeholder || (includeTime ? 'Select date and time' : 'Select date')}
min={inputMin}
max={inputMax}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
"ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)}
/>
</div>
);
};
// Utility function for className merging
function cn(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(' ');
}

View File

@@ -1,6 +0,0 @@
// ========================================
// A2UI Component Renderers Index
// ========================================
// Exports all A2UI component renderers
export * from './registry';

View File

@@ -0,0 +1,46 @@
// ========================================
// A2UI Component Renderers Index
// ========================================
// Exports all A2UI component renderers
// Importing this file automatically registers all components
import { a2uiRegistry } from '../../core/A2UIComponentRegistry';
import type { A2UIComponentType } from '../../core/A2UITypes';
// Import all component renderers synchronously
import { A2UIText } from './A2UIText';
import { A2UIButton } from './A2UIButton';
import { A2UIDropdown } from './A2UIDropdown';
import { A2UITextField } from './A2UITextField';
import { A2UITextArea } from './A2UITextArea';
import { A2UICheckbox } from './A2UICheckbox';
import { A2UIProgress } from './A2UIProgress';
import { A2UICard } from './A2UICard';
import { A2UICLIOutput } from './A2UICLIOutput';
import { A2UIDateTimeInput } from './A2UIDateTimeInput';
// Synchronous auto-registration of all built-in components
// This runs immediately when the module is loaded
a2uiRegistry.register('Text' as A2UIComponentType, A2UIText);
a2uiRegistry.register('Button' as A2UIComponentType, A2UIButton);
a2uiRegistry.register('Dropdown' as A2UIComponentType, A2UIDropdown);
a2uiRegistry.register('TextField' as A2UIComponentType, A2UITextField);
a2uiRegistry.register('TextArea' as A2UIComponentType, A2UITextArea);
a2uiRegistry.register('Checkbox' as A2UIComponentType, A2UICheckbox);
a2uiRegistry.register('Progress' as A2UIComponentType, A2UIProgress);
a2uiRegistry.register('Card' as A2UIComponentType, A2UICard);
a2uiRegistry.register('CLIOutput' as A2UIComponentType, A2UICLIOutput);
a2uiRegistry.register('DateTimeInput' as A2UIComponentType, A2UIDateTimeInput);
// Export all components
export * from './A2UIText';
export * from './A2UIButton';
export * from './A2UIDropdown';
export * from './A2UITextField';
export * from './A2UITextArea';
export * from './A2UICheckbox';
export * from './A2UIProgress';
export * from './A2UICard';
export * from './A2UICLIOutput';
export * from './A2UIDateTimeInput';

View File

@@ -1,44 +0,0 @@
// ========================================
// A2UI Component Registry Initialization
// ========================================
// Registers all A2UI component renderers
import { a2uiRegistry } from '../../core/A2UIComponentRegistry';
import { A2UIText } from './A2UIText';
import { A2UIButton } from './A2UIButton';
import { A2UIDropdown } from './A2UIDropdown';
import { A2UITextField } from './A2UITextField';
import { A2UITextArea } from './A2UITextArea';
import { A2UICheckbox } from './A2UICheckbox';
import { A2UIProgress } from './A2UIProgress';
import { A2UICard } from './A2UICard';
/**
* Initialize and register all built-in A2UI component renderers
*/
export function registerBuiltInComponents(): void {
// Register all component types
a2uiRegistry.register('Text', A2UIText);
a2uiRegistry.register('Button', A2UIButton);
a2uiRegistry.register('Dropdown', A2UIDropdown);
a2uiRegistry.register('TextField', A2UITextField);
a2uiRegistry.register('TextArea', A2UITextArea);
a2uiRegistry.register('Checkbox', A2UICheckbox);
a2uiRegistry.register('Progress', A2UIProgress);
a2uiRegistry.register('Card', A2UICard);
}
/**
* Auto-initialize on import
* This ensures all components are registered when the renderer package is loaded
*/
registerBuiltInComponents();
export * from './A2UIText';
export * from './A2UIButton';
export * from './A2UIDropdown';
export * from './A2UITextField';
export * from './A2UITextArea';
export * from './A2UICheckbox';
export * from './A2UIProgress';
export * from './A2UICard';

View File

@@ -3,3 +3,6 @@
// ========================================
export * from './A2UIRenderer';
// Import component renderers to trigger auto-registration
import './components';

View File

@@ -0,0 +1,412 @@
// ========================================
// NotificationStore A2UI Methods Tests
// ========================================
// Tests for A2UI-related notification store functionality
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useNotificationStore } from '../notificationStore';
import type { SurfaceUpdate } from '../packages/a2ui-runtime/core/A2UITypes';
describe('NotificationStore A2UI Methods', () => {
beforeEach(() => {
// Reset store state before each test
useNotificationStore.setState({
toasts: [],
a2uiSurfaces: new Map(),
currentQuestion: null,
persistentNotifications: [],
});
vi.clearAllMocks();
});
afterEach(() => {
// Clean up any listeners
window.removeEventListener('a2ui-action', vi.fn());
});
describe('addA2UINotification()', () => {
it('should add A2UI notification to toasts array', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test-surface',
components: [
{
id: 'comp-1',
component: { Text: { text: { literalString: 'Hello' } } },
},
],
initialState: { key: 'value' },
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface, 'Test Surface');
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]).toMatchObject({
type: 'a2ui',
title: 'Test Surface',
a2uiSurface: surface,
a2uiState: { key: 'value' },
dismissible: true,
duration: 0, // Persistent by default
});
});
it('should store surface in a2uiSurfaces Map', () => {
const surface: SurfaceUpdate = {
surfaceId: 'surface-123',
components: [
{
id: 'comp-1',
component: { Button: { onClick: { actionId: 'click' }, content: { Text: { text: { literalString: 'Click' } } } } },
},
],
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface);
});
expect(result.current.a2uiSurfaces.has('surface-123')).toBe(true);
expect(result.current.a2uiSurfaces.get('surface-123')).toEqual(surface);
});
it('should respect maxToasts limit for A2UI notifications', () => {
const { result } = renderHook(() => useNotificationStore());
// Set max toasts to 3
act(() => {
result.current.maxToasts = 3;
});
// Add 4 A2UI notifications
for (let i = 0; i < 4; i++) {
act(() => {
result.current.addA2UINotification({
surfaceId: `surface-${i}`,
components: [{ id: `comp-${i}`, component: { Text: { text: { literalString: `Test ${i}` } } } }],
});
});
}
// 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');
});
it('should use default title when not provided', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface);
});
expect(result.current.toasts[0].title).toBe('A2UI Surface');
});
it('should return toast ID', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
};
const { result } = renderHook(() => useNotificationStore());
let toastId: string;
act(() => {
toastId = result.current.addA2UINotification(surface);
});
expect(toastId).toBeDefined();
expect(typeof toastId).toBe('string');
expect(result.current.toasts[0].id).toBe(toastId);
});
it('should include initialState in a2uiState', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
initialState: { counter: 0, user: 'Alice' },
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface);
});
expect(result.current.toasts[0].a2uiState).toEqual({ counter: 0, user: 'Alice' });
});
it('should default to empty a2uiState when initialState is not provided', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface);
});
expect(result.current.toasts[0].a2uiState).toEqual({});
});
});
describe('updateA2UIState()', () => {
it('should update a2uiState for matching toast', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test-surface',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
initialState: { count: 0 },
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface);
});
act(() => {
result.current.updateA2UIState('test-surface', { count: 5, newField: 'value' });
});
expect(result.current.toasts[0].a2uiState).toEqual({ count: 5, newField: 'value' });
});
it('should update surface initialState in a2uiSurfaces Map', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test-surface',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
initialState: { value: 'initial' },
};
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification(surface);
});
act(() => {
result.current.updateA2UIState('test-surface', { value: 'updated' });
});
const updatedSurface = result.current.a2uiSurfaces.get('test-surface');
expect(updatedSurface?.initialState).toEqual({ value: 'updated' });
});
it('should not affect other toasts with different surface IDs', () => {
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification({
surfaceId: 'surface-1',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'A' } } } }],
initialState: { value: 'A' },
});
result.current.addA2UINotification({
surfaceId: 'surface-2',
components: [{ id: 'c2', component: { Text: { text: { literalString: 'B' } } } }],
initialState: { value: 'B' },
});
});
act(() => {
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' });
});
it('should handle updates for non-existent surface gracefully', () => {
const { result } = renderHook(() => useNotificationStore());
expect(() => {
act(() => {
result.current.updateA2UIState('non-existent', { value: 'test' });
});
}).not.toThrow();
});
});
describe('sendA2UIAction()', () => {
it('should dispatch custom event with action details', () => {
const { result } = renderHook(() => useNotificationStore());
const mockListener = vi.fn();
window.addEventListener('a2ui-action', mockListener);
act(() => {
result.current.sendA2UIAction('test-action', 'surface-123', { key: 'value' });
});
expect(mockListener).toHaveBeenCalledTimes(1);
const event = mockListener.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual({
type: 'a2ui-action',
actionId: 'test-action',
surfaceId: 'surface-123',
parameters: { key: 'value' },
});
window.removeEventListener('a2ui-action', mockListener);
});
it('should use empty parameters object when not provided', () => {
const { result } = renderHook(() => useNotificationStore());
const mockListener = vi.fn();
window.addEventListener('a2ui-action', mockListener);
act(() => {
result.current.sendA2UIAction('action-1', 'surface-1');
});
const event = mockListener.mock.calls[0][0] as CustomEvent;
expect(event.detail.parameters).toEqual({});
window.removeEventListener('a2ui-action', mockListener);
});
it('should dispatch event on window object', () => {
const { result } = renderHook(() => useNotificationStore());
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
act(() => {
result.current.sendA2UIAction('test', 'surface-1', { data: 'test' });
});
expect(dispatchSpy).toHaveBeenCalled();
expect(dispatchSpy.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
expect((dispatchSpy.mock.calls[0][0] as CustomEvent).type).toBe('a2ui-action');
dispatchSpy.mockRestore();
});
});
describe('setCurrentQuestion()', () => {
it('should set current question state', () => {
const { result } = renderHook(() => useNotificationStore());
const mockQuestion = {
surfaceId: 'question-1',
title: 'Test Question',
questions: [
{ id: 'q1', question: 'What is your name?', type: 'text', required: true },
],
};
act(() => {
result.current.setCurrentQuestion(mockQuestion);
});
expect(result.current.currentQuestion).toEqual(mockQuestion);
});
it('should clear question when set to null', () => {
const { result } = renderHook(() => useNotificationStore());
const mockQuestion = {
surfaceId: 'question-1',
title: 'Test',
questions: [{ id: 'q1', question: 'Test?', type: 'text' }],
};
act(() => {
result.current.setCurrentQuestion(mockQuestion);
});
expect(result.current.currentQuestion).toEqual(mockQuestion);
act(() => {
result.current.setCurrentQuestion(null);
});
expect(result.current.currentQuestion).toBeNull();
});
});
describe('Integration with toast actions', () => {
it('should allow removing A2UI toast via removeToast', () => {
const surface: SurfaceUpdate = {
surfaceId: 'test',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
};
const { result } = renderHook(() => useNotificationStore());
let toastId: string;
act(() => {
toastId = result.current.addA2UINotification(surface);
});
expect(result.current.toasts).toHaveLength(1);
act(() => {
result.current.removeToast(toastId);
});
expect(result.current.toasts).toHaveLength(0);
});
it('should clear all A2UI toasts with clearAllToasts', () => {
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification({
surfaceId: 's1',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'A' } } } }],
});
result.current.addToast({ type: 'info', title: 'Regular toast' });
result.current.addA2UINotification({
surfaceId: 's2',
components: [{ id: 'c2', component: { Text: { text: { literalString: 'B' } } } }],
});
});
expect(result.current.toasts).toHaveLength(3);
act(() => {
result.current.clearAllToasts();
});
expect(result.current.toasts).toHaveLength(0);
});
});
describe('A2UI surfaces Map management', () => {
it('should maintain separate surfaces Map from toasts', () => {
const { result } = renderHook(() => useNotificationStore());
act(() => {
result.current.addA2UINotification({
surfaceId: 'surface-1',
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
});
});
expect(result.current.a2uiSurfaces.size).toBe(1);
expect(result.current.toasts).toHaveLength(1);
act(() => {
result.current.removeToast(result.current.toasts[0].id);
});
// Surface should remain in Map even after toast is removed
expect(result.current.a2uiSurfaces.size).toBe(1);
expect(result.current.toasts).toHaveLength(0);
});
});
});

View File

@@ -45,6 +45,7 @@ export {
selectWsLastMessage,
selectIsPanelVisible,
selectPersistentNotifications,
selectCurrentQuestion,
toast,
} from './notificationStore';
@@ -113,6 +114,9 @@ export type {
ToastType,
WebSocketStatus,
WebSocketMessage,
QuestionType,
Question,
AskQuestionPayload,
} from '../types/store';
// Execution Types

View File

@@ -74,6 +74,9 @@ const initialState: NotificationState = {
// A2UI surfaces
a2uiSurfaces: new Map<string, SurfaceUpdate>(),
// Current question dialog state
currentQuestion: null,
};
export const useNotificationStore = create<NotificationStore>()(
@@ -334,6 +337,12 @@ export const useNotificationStore = create<NotificationStore>()(
});
window.dispatchEvent(event);
},
// ========== Current Question Actions ==========
setCurrentQuestion: (question: any) => {
set({ currentQuestion: question }, false, 'setCurrentQuestion');
},
}),
{ name: 'NotificationStore' }
)
@@ -354,6 +363,7 @@ export const selectWsLastMessage = (state: NotificationStore) => state.wsLastMes
export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelVisible;
export const selectPersistentNotifications = (state: NotificationStore) =>
state.persistentNotifications;
export const selectCurrentQuestion = (state: NotificationStore) => state.currentQuestion;
// Helper to create toast shortcuts
export const toast = {

View File

@@ -325,6 +325,37 @@ export interface WebSocketMessage {
timestamp?: string;
}
// ========== Ask Question Types ==========
/** Question type for ask_question tool */
export type QuestionType = 'single' | 'multi' | 'text' | 'yes_no';
/** Single question definition */
export interface Question {
/** Question ID */
id: string;
/** Question text */
question: string;
/** Question type */
type: QuestionType;
/** Whether this question is required */
required: boolean;
/** Default value */
default?: string | string[];
/** Options for single/multi/yes_no questions */
options?: string[];
}
/** Ask question payload from MCP ask_question tool */
export interface AskQuestionPayload {
/** Surface ID for this question */
surfaceId: string;
/** List of questions to ask */
questions: Question[];
/** Title for the question dialog */
title?: string;
}
export interface NotificationState {
// Toast queue
toasts: Toast[];
@@ -343,6 +374,9 @@ export interface NotificationState {
// A2UI surfaces (Map of surfaceId to SurfaceUpdate)
a2uiSurfaces: Map<string, SurfaceUpdate>;
// Current question dialog state
currentQuestion: AskQuestionPayload | null;
}
export interface NotificationActions {
@@ -373,6 +407,9 @@ export interface NotificationActions {
addA2UINotification: (surface: SurfaceUpdate, title?: string) => string;
updateA2UIState: (surfaceId: string, state: Record<string, unknown>) => void;
sendA2UIAction: (actionId: string, surfaceId: string, parameters?: Record<string, unknown>) => void;
// Current question actions
setCurrentQuestion: (question: AskQuestionPayload | null) => void;
}
export type NotificationStore = NotificationState & NotificationActions;