diff --git a/ccw/docs/a2ui-integration.md b/ccw/docs/a2ui-integration.md new file mode 100644 index 00000000..f3a7ead9 --- /dev/null +++ b/ccw/docs/a2ui-integration.md @@ -0,0 +1,617 @@ +# A2UI Integration Guide for CCW Developers + +This guide explains how to integrate and extend the A2UI (AI-to-UI) system in the CCW application. + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [WebSocket Integration](#websocket-integration) +3. [Notification System](#notification-system) +4. [Creating Custom Components](#creating-custom-components) +5. [Backend Tool Integration](#backend-tool-integration) +6. [Testing](#testing) +7. [Troubleshooting](#troubleshooting) + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CCW Frontend │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────┐ ┌─────────────────┐ │ +│ │ WebSocket │─────▶│ A2UI Parser │ │ +│ │ Handler │ │ (validation) │ │ +│ └────────────────┘ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────┐ ┌─────────────────┐ │ +│ │ Notification │◀─────│ A2UI Component │ │ +│ │ Store │ │ Registry │ │ +│ └────────────────┘ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────┐ ┌─────────────────┐ │ +│ │ UI Components │◀─────│ A2UI Renderer │ │ +│ │ (Dialogs, │ │ (React) │ │ +│ │ Panels) │ └─────────────────┘ │ +│ └────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ WebSocket + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CCW Backend │ +├─────────────────────────────────────────────────────────────┤ +│ ┌────────────────┐ ┌─────────────────┐ │ +│ │ MCP Tools │─────▶│ A2UI Types │ │ +│ │ (ask_question, │ │ (Zod schemas) │ │ +│ │ etc.) │ └─────────────────┘ │ +│ └────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## WebSocket Integration + +### Message Flow + +The A2UI system uses WebSocket messages for bidirectional communication: + +#### Frontend to Backend (Actions) + +```typescript +// Send action via WebSocket +const sendA2UIAction = ( + actionId: string, + surfaceId: string, + parameters: Record +) => { + const event = new CustomEvent('a2ui-action', { + detail: { + type: 'a2ui-action', + actionId, + surfaceId, + parameters, + }, + }); + window.dispatchEvent(event); + + // WebSocket handler picks up this event +}; +``` + +#### Backend to Frontend (Surface Updates) + +```typescript +// WebSocket message handler processes incoming messages +interface A2UISurfaceMessage { + type: 'a2ui-surface'; + surfaceId: string; + title?: string; + surface: SurfaceUpdate; +} + +// In useWebSocket hook +useEffect(() => { + ws.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'a2ui-surface') { + // Add to notification store + addA2UINotification(message.surface, message.title); + } + }); +}, [ws]); +``` + +## Notification System + +### A2UI Notifications + +A2UI surfaces appear as notifications in the notification panel: + +```typescript +// Add A2UI notification +import { useNotificationStore } from '@/stores/notificationStore'; + +const { addA2UINotification } = useNotificationStore(); + +addA2UINotification(surfaceUpdate, 'Notification Title'); +``` + +### State Management + +A2UI state is managed through the notification store: + +```typescript +// Update A2UI state +updateA2UIState(surfaceId, { + counter: 5, + userInput: 'value', +}); +``` + +### Ask Question Dialog + +The `ask_question` tool uses a dedicated dialog: + +```typescript +// Set current question (triggered by WebSocket) +setCurrentQuestion({ + surfaceId: 'question-123', + title: 'Confirmation Required', + questions: [ + { + id: 'q1', + type: 'confirm', + question: 'Do you want to continue?', + required: true, + }, + ], +}); + +// Dialog renders automatically +// {currentQuestion && } +``` + +## Creating Custom Components + +### Step 1: Create Component Renderer + +Create file: `src/packages/a2ui-runtime/renderer/components/A2UICustom.tsx` + +```typescript +import React, { useState, useCallback } from 'react'; +import type { ComponentRenderer } from '../../core/A2UIComponentRegistry'; +import { resolveTextContent } from '../A2UIRenderer'; + +// Define component config interface (for TypeScript) +interface CustomComponentConfig { + title: { literalString: string } | { path: string }; + dataSource: { literalString: string } | { path: string }; + onRefresh: { actionId: string; parameters?: Record }; +} + +export const A2UICustom: ComponentRenderer = ({ + component, + state, + onAction, + resolveBinding +}) => { + const customComp = component as { Custom: CustomComponentConfig }; + const { Custom: config } = customComp; + + // Resolve title (literal or binding) + const title = resolveTextContent(config.title, resolveBinding); + + // Resolve data source + const dataSource = resolveTextContent(config.dataSource, resolveBinding); + + // Local state + const [isLoading, setIsLoading] = useState(false); + + // Handle refresh action + const handleRefresh = useCallback(() => { + setIsLoading(true); + onAction(config.onRefresh.actionId, { + ...config.onRefresh.parameters, + dataSource, + }); + // Reset loading after action (implementation dependent) + setTimeout(() => setIsLoading(false), 1000); + }, [onAction, config.onRefresh, dataSource]); + + return ( +
+

{title}

+
+ Data source: {dataSource} +
+ +
+ ); +}; +``` + +### Step 2: Register Component + +Update: `src/packages/a2ui-runtime/renderer/components/registry.ts` + +```typescript +import { A2UICustom } from './A2UICustom'; + +export function registerBuiltInComponents(): void { + // ... existing registrations + + // Register custom component + a2uiRegistry.register('Custom', A2UICustom); +} +``` + +### Step 3: Export Component + +Update: `src/packages/a2ui-runtime/renderer/components/index.ts` + +```typescript +export * from './A2UICustom'; +``` + +### Step 4: Add Type Definition (Optional) + +Update: `src/packages/a2ui-runtime/core/A2UITypes.ts` + +```typescript +// Add schema +export const CustomComponentSchema = z.object({ + Custom: z.object({ + title: TextContentSchema, + dataSource: TextContentSchema, + onRefresh: ActionSchema, + description: z.string().optional(), + }), +}); + +// Add to ComponentSchema union +export const ComponentSchema: z.ZodType< + | z.infer + | z.infer + // ... other components + | z.infer +> = z.union([ + // ... existing schemas + CustomComponentSchema, +]); + +// Add to TypeScript type +export type CustomComponent = z.infer; +export type A2UIComponent = z.infer; + +// Add to discriminated union +export type A2UIComponentType = + | 'Text' | 'Button' | // ... existing types + | 'Custom'; +``` + +## Backend Tool Integration + +### Creating an A2UI Tool + +Create file: `src/tools/my-a2ui-tool.ts` + +```typescript +import { z } from 'zod'; +import type { ToolSchema, ToolResult } from '../types/tool.js'; +import type { SurfaceUpdate } from '../core/a2ui/A2UITypes.js'; + +// Generate A2UI surface for your tool +function generateSurface(params: MyToolParams): { + surfaceUpdate: SurfaceUpdate; +} { + const components: unknown[] = [ + { + id: 'title', + component: { + Text: { + text: { literalString: params.title || 'My Tool' }, + usageHint: 'h3', + }, + }, + }, + // Add more components... + ]; + + return { + surfaceUpdate: { + surfaceId: `my-tool-${Date.now()}`, + components, + initialState: { + toolName: 'my-tool', + timestamp: Date.now(), + }, + }, + }; +} + +// Execute tool +export async function execute(params: MyToolParams): Promise { + try { + // Generate surface + const { surfaceUpdate } = generateSurface(params); + + // TODO: Send surface via WebSocket to frontend + // For now, return surface in result + return { + success: true, + result: { + surfaceId: surfaceUpdate.surfaceId, + surface: surfaceUpdate, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// Tool schema +export const schema: ToolSchema = { + name: 'my_a2ui_tool', + description: 'Description of what your tool does', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Title for the A2UI surface' }, + // Add more properties... + }, + required: ['title'], + }, +}; +``` + +### Handling Actions from Frontend + +Create handler in backend: + +```typescript +// In WebSocket handler or tool manager +function handleA2UIAction( + actionId: string, + surfaceId: string, + parameters: Record +): void { + // Find pending question or surface + const pending = pendingSurfaces.get(surfaceId); + + if (!pending) { + console.warn(`No surface found for ID: ${surfaceId}`); + return; + } + + // Process action based on actionId + switch (actionId) { + case 'confirm': + // Handle confirmation + pending.resolve({ + success: true, + surfaceId, + cancelled: false, + answers: [parameters], + timestamp: new Date().toISOString(), + }); + break; + + case 'cancel': + // Handle cancellation + pending.resolve({ + success: false, + surfaceId, + cancelled: true, + answers: [], + timestamp: new Date().toISOString(), + error: 'User cancelled', + }); + break; + + default: + // Handle custom actions + console.log(`Action ${actionId} with params:`, parameters); + } + + // Clean up + pendingSurfaces.delete(surfaceId); +} +``` + +## Testing + +### Unit Tests + +Test component renderers: + +```typescript +// src/packages/a2ui-runtime/__tests__/components/A2UICustom.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { A2UICustom } from '../A2UICustom'; +import type { A2UIComponent } from '@/packages/a2ui-runtime/core/A2UITypes'; + +describe('A2UICustom', () => { + it('should render with title', () => { + const component: A2UIComponent = { + Custom: { + title: { literalString: 'Test Title' }, + dataSource: { literalString: 'test-source' }, + onRefresh: { actionId: 'refresh' }, + }, + }; + + const props = { + component, + state: {}, + onAction: vi.fn(), + resolveBinding: vi.fn(), + }; + + render(); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); +}); +``` + +### E2E Tests + +Test A2UI flow in browser: + +```typescript +// tests/e2e/my-a2ui-tool.spec.ts +import { test, expect } from '@playwright/test'; + +test('My A2UI Tool E2E', async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }); + + // Simulate WebSocket message + await page.evaluate(() => { + const event = new CustomEvent('ws-message', { + detail: { + type: 'a2ui-surface', + surfaceId: 'test-custom', + title: 'Custom Tool', + surface: { + surfaceId: 'test-custom', + components: [ + { + id: 'custom', + component: { + Custom: { + title: { literalString: 'Test Custom' }, + dataSource: { literalString: 'data' }, + onRefresh: { actionId: 'refresh' }, + }, + }, + }, + ], + initialState: {}, + }, + }, + }); + window.dispatchEvent(event); + }); + + // Verify rendering + await expect(page.getByText('Test Custom')).toBeVisible(); + await expect(page.getByText('Data source: data')).toBeVisible(); +}); +``` + +## Troubleshooting + +### Common Issues + +#### 1. Component Not Rendering + +**Symptoms**: A2UI surface received but component not visible + +**Solutions**: +- Verify component is registered in registry +- Check console for warnings about unknown component types +- Ensure component returns valid JSX + +```typescript +// Debug: Check if registered +console.log(a2uiRegistry.getRegisteredTypes()); +// Should include your component type +``` + +#### 2. State Not Updating + +**Symptoms**: State changes not reflected in UI + +**Solutions**: +- Verify binding path matches state key +- Check `resolveBinding` implementation +- Ensure `updateA2UIState` is called with correct surfaceId + +```typescript +// Debug: Log state changes +const handleAction = (actionId: string, params: any) => { + console.log('Action:', actionId, 'Params:', params); + onAction(actionId, params); +}; +``` + +#### 3. Actions Not Being Sent + +**Symptoms**: Clicking buttons doesn't trigger backend action + +**Solutions**: +- Check WebSocket connection status +- Verify action event listener is attached +- Ensure actionId matches backend handler + +```typescript +// Debug: Monitor a2ui-action events +window.addEventListener('a2ui-action', (e) => { + console.log('A2UI Action:', (e as CustomEvent).detail); +}); +``` + +#### 4. Parse Errors + +**Symptoms**: Surface updates fail validation + +**Solutions**: +- Validate JSON structure against A2UI spec +- Check Zod error details +- Use `safeParse` for debugging + +```typescript +// Debug: Safe parse with details +const result = a2uiParser.safeParse(jsonString); +if (!result.success) { + console.error('Parse error:', result.error); + // Get detailed Zod errors +} +``` + +### Development Tools + +#### React DevTools + +Inspect A2UI component hierarchy: + +```typescript +// Add data attributes for debugging +
+ {/* component content */} +
+``` + +#### Logging + +Enable verbose logging in development: + +```typescript +// In development mode +if (process.env.NODE_ENV === 'development') { + console.log('[A2UI] Rendering component:', componentType); + console.log('[A2UI] Surface:', surface); + console.log('[A2UI] State:', state); +} +``` + +### Performance Considerations + +1. **Lazy Load Components**: For complex custom components, use React.lazy + +2. **Debounce Actions**: Prevent rapid action firing + +```typescript +import { debounce } from 'lodash-es'; + +const debouncedAction = debounce(onAction, 300); +``` + +3. **Memoize Renders**: Use React.memo for expensive components + +```typescript +export const A2UICustom = React.memo(({ /* ... */ }) => { + // component implementation +}); +``` + +## Additional Resources + +- [A2UI Runtime README](./a2ui-runtime/README.md) +- [A2UI Protocol Usage](./a2ui-integration.md#protocol-usage) +- [Component Examples](./a2ui-integration.md#examples) + +For questions or issues, please refer to the main CCW documentation. diff --git a/ccw/docs/a2ui-protocol-guide.md b/ccw/docs/a2ui-protocol-guide.md new file mode 100644 index 00000000..559f0320 --- /dev/null +++ b/ccw/docs/a2ui-protocol-guide.md @@ -0,0 +1,775 @@ +# A2UI Protocol Usage and Troubleshooting Guide + +This guide provides comprehensive information about the A2UI protocol for AI agent developers using CCW. + +## Table of Contents + +1. [Protocol Overview](#protocol-overview) +2. [Surface Update Structure](#surface-update-structure) +3. [Component Reference](#component-reference) +4. [Action Handling](#action-handling) +5. [State Management](#state-management) +6. [Code Examples](#code-examples) +7. [Troubleshooting](#troubleshooting) +8. [Best Practices](#best-practices) + +## Protocol Overview + +A2UI (AI-to-UI) is a protocol that enables AI agents to generate dynamic user interfaces through structured JSON messages. The protocol defines: + +- **Surface Updates**: Complete UI descriptions that can be rendered +- **Components**: Reusable UI building blocks +- **Actions**: User interaction handlers +- **State**: Data binding and state management + +### Message Flow + +``` +AI Agent ──(generates)──▶ A2UI Surface Update ──(WebSocket)──▶ Frontend + │ + ▼ + A2UI Parser + │ + ▼ + Component Registry + │ + ▼ + Rendered UI + │ + ▼ + User Interaction + │ + ▼ + Action Event ──(WebSocket)──▶ Backend + │ + ▼ + AI Agent +``` + +## Surface Update Structure + +### Basic Structure + +```json +{ + "surfaceId": "unique-surface-identifier", + "components": [ + { + "id": "component-1", + "component": { + "ComponentType": { + // Component-specific properties + } + } + } + ], + "initialState": { + "key": "value", + "nested": { + "data": true + } + } +} +``` + +### Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `surfaceId` | string | Yes | Unique identifier for this surface | +| `components` | SurfaceComponent[] | Yes | Array of component definitions | +| `initialState` | Record | No | Initial state for bindings | + +## Component Reference + +### Content Types + +#### Literal String + +Direct text value: + +```json +{ + "literalString": "Hello, World!" +} +``` + +#### Binding + +Reference to state value: + +```json +{ + "path": "user.name" +} +``` + +### Standard Components + +#### Text + +Display text with semantic hints. + +```json +{ + "Text": { + "text": { "literalString": "Hello" }, + "usageHint": "h1" + } +} +``` + +| Property | Type | Required | Values | +|----------|------|----------|--------| +| `text` | Content | Yes | Literal or binding | +| `usageHint` | string | No | h1, h2, h3, h4, h5, h6, p, span, code, small | + +#### Button + +Clickable button with nested content. + +```json +{ + "Button": { + "onClick": { "actionId": "submit", "parameters": { "formId": "login" } }, + "content": { + "Text": { "text": { "literalString": "Submit" } } + }, + "variant": "primary", + "disabled": { "literalBoolean": false } + } +} +``` + +| Property | Type | Required | Values | +|----------|------|----------|--------| +| `onClick` | Action | Yes | Action definition | +| `content` | Component | Yes | Nested component (usually Text) | +| `variant` | string | No | primary, secondary, destructive, ghost, outline | +| `disabled` | BooleanContent | No | Literal or binding | + +#### Dropdown + +Select dropdown with options. + +```json +{ + "Dropdown": { + "options": [ + { "label": { "literalString": "Option 1" }, "value": "opt1" }, + { "label": { "literalString": "Option 2" }, "value": "opt2" } + ], + "selectedValue": { "literalString": "opt1" }, + "onChange": { "actionId": "select-change" }, + "placeholder": "Select an option" + } +} +``` + +#### TextField + +Single-line text input. + +```json +{ + "TextField": { + "value": { "literalString": "Initial value" }, + "onChange": { "actionId": "input-change", "parameters": { "field": "username" } }, + "placeholder": "Enter username", + "type": "text" + } +} +``` + +| Property | Type | Required | Values | +|----------|------|----------|--------| +| `type` | string | No | text, email, password, number, url | + +#### TextArea + +Multi-line text input. + +```json +{ + "TextArea": { + "onChange": { "actionId": "textarea-change" }, + "placeholder": "Enter description", + "rows": 5 + } +} +``` + +#### Checkbox + +Boolean checkbox with label. + +```json +{ + "Checkbox": { + "checked": { "literalBoolean": true }, + "onChange": { "actionId": "checkbox-change" }, + "label": { "literalString": "Accept terms" } + } +} +``` + +#### Progress + +Progress bar indicator. + +```json +{ + "Progress": { + "value": { "literalNumber": 75 }, + "max": 100 + } +} +``` + +#### Card + +Container with title and nested content. + +```json +{ + "Card": { + "title": { "literalString": "Card Title" }, + "description": { "literalString": "Card description" }, + "content": [ + { + "id": "text-1", + "component": { + "Text": { "text": { "literalString": "Card content" } } + } + } + ] + } +} +``` + +### Custom Components + +#### CLIOutput + +Terminal-style output with syntax highlighting. + +```json +{ + "CLIOutput": { + "output": { "literalString": "$ npm install\nInstalling...\nDone!" }, + "language": "bash", + "streaming": false, + "maxLines": 100 + } +} +``` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `output` | Content | Yes | Text to display | +| `language` | string | No | bash, javascript, python, etc. | +| `streaming` | boolean | No | Show streaming indicator | +| `maxLines` | number | No | Limit output lines | + +#### DateTimeInput + +Date and time picker. + +```json +{ + "DateTimeInput": { + "value": { "literalString": "2024-01-15T10:30:00Z" }, + "onChange": { "actionId": "datetime-change" }, + "placeholder": "Select date and time", + "includeTime": true, + "minDate": { "literalString": "2024-01-01T00:00:00Z" }, + "maxDate": { "literalString": "2024-12-31T23:59:59Z" } + } +} +``` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `includeTime` | boolean | No | Include time (default: true) | +| `minDate` | Content | No | Minimum selectable date | +| `maxDate` | Content | No | Maximum selectable date | + +## Action Handling + +### Action Structure + +```json +{ + "actionId": "unique-action-id", + "parameters": { + "key1": "value1", + "key2": 42 + } +} +``` + +### Action Flow + +1. User interacts with component (click, type, select) +2. Component triggers `onAction` callback +3. Action sent via WebSocket to backend +4. Backend processes action and responds + +### Action Response + +Backend can respond with: + +- **State Update**: Update component state +- **New Surface**: Replace or add components +- **Close Surface**: Dismiss notification/dialog + +## State Management + +### State Binding + +Components can bind to state values: + +```json +{ + "TextField": { + "value": { "path": "form.username" }, + "onChange": { "actionId": "update-field" } + } +} +``` + +### State Update + +Backend sends state updates: + +```json +{ + "type": "a2ui-state-update", + "surfaceId": "form-surface", + "updates": { + "form": { + "username": "newvalue", + "email": "updated@example.com" + } + } +} +``` + +## Code Examples + +### Example 1: Simple Form + +```json +{ + "surfaceId": "login-form", + "components": [ + { + "id": "title", + "component": { + "Text": { + "text": { "literalString": "Login" }, + "usageHint": "h2" + } + } + }, + { + "id": "username", + "component": { + "TextField": { + "onChange": { "actionId": "field-change", "parameters": { "field": "username" } }, + "placeholder": "Username", + "type": "text" + } + } + }, + { + "id": "password", + "component": { + "TextField": { + "onChange": { "actionId": "field-change", "parameters": { "field": "password" } }, + "placeholder": "Password", + "type": "password" + } + } + }, + { + "id": "submit", + "component": { + "Button": { + "onClick": { "actionId": "login" }, + "content": { + "Text": { "text": { "literalString": "Login" } } + }, + "variant": "primary" + } + } + } + ], + "initialState": { + "username": "", + "password": "" + } +} +``` + +### Example 2: Data Display with Actions + +```json +{ + "surfaceId": "user-list", + "components": [ + { + "id": "title", + "component": { + "Text": { + "text": { "literalString": "Users" }, + "usageHint": "h3" + } + } + }, + { + "id": "user-card", + "component": { + "Card": { + "title": { "path": "users.0.name" }, + "description": { "path": "users.0.email" }, + "content": [ + { + "id": "edit-btn", + "component": { + "Button": { + "onClick": { "actionId": "edit-user", "parameters": { "userId": "1" } }, + "content": { "Text": { "text": { "literalString": "Edit" } } }, + "variant": "secondary" + } + } + } + ] + } + } + } + ], + "initialState": { + "users": [ + { "id": 1, "name": "Alice", "email": "alice@example.com" }, + { "id": 2, "name": "Bob", "email": "bob@example.com" } + ] + } +} +``` + +### Example 3: CLI Output with Progress + +```json +{ + "surfaceId": "build-progress", + "components": [ + { + "id": "title", + "component": { + "Text": { + "text": { "literalString": "Building Project" }, + "usageHint": "h3" + } + } + }, + { + "id": "progress", + "component": { + "Progress": { + "value": { "path": "build.progress" }, + "max": 100 + } + } + }, + { + "id": "output", + "component": { + "CLIOutput": { + "output": { "path": "build.output" }, + "language": "bash", + "streaming": { "path": "build.running" } + } + } + } + ], + "initialState": { + "build": { + "progress": 45, + "output": "$ npm run build\nBuilding module 1/3...", + "running": true + } + } +} +``` + +## Troubleshooting + +### Common Errors + +#### Parse Errors + +**Error**: `A2UI validation failed` + +**Causes**: +- Invalid JSON structure +- Missing required fields +- Invalid component type +- Wrong data type for property + +**Solution**: +```typescript +// Use safeParse to get detailed errors +const result = a2uiParser.safeParse(jsonString); +if (!result.success) { + console.error('Validation errors:', result.error.errors); +} +``` + +#### Unknown Component Type + +**Error**: `Unknown component type: XYZ` + +**Causes**: +- Component not registered in registry +- Typo in component type name +- Custom component not exported + +**Solution**: +```typescript +// Check registered components +console.log(a2uiRegistry.getRegisteredTypes()); +// Should include: ['Text', 'Button', 'XYZ', ...] + +// Register custom component +a2uiRegistry.register('XYZ', XYZRenderer); +``` + +#### State Binding Failures + +**Error**: State not updating or showing undefined + +**Causes**: +- Wrong binding path +- State not initialized +- Case-sensitive path mismatch + +**Solution**: +```typescript +// Ensure state exists in initialState +initialState: { + user: { + name: "Alice" + } +} + +// Use correct binding path +{ "path": "user.name" } // ✓ +{ "path": "User.name" } // ✗ (case mismatch) +``` + +### Debugging Tips + +#### Enable Verbose Logging + +```typescript +// In development +if (process.env.NODE_ENV === 'development') { + (window as any).A2UI_DEBUG = true; +} +``` + +#### Inspect Component Props + +```typescript +// Add logging in renderer +export const A2UICustom: ComponentRenderer = (props) => { + console.log('[A2UICustom] Props:', props); + // ... rest of implementation +}; +``` + +#### Validate Before Sending + +```typescript +// Backend: Validate before sending +const result = a2uiParser.safeParseObject(surfaceUpdate); +if (!result.success) { + console.error('Invalid surface:', result.error); + return; +} +// Safe to send +ws.send(JSON.stringify(surfaceUpdate)); +``` + +### Performance Issues + +#### Too Many Re-renders + +**Symptoms**: UI lagging, high CPU usage + +**Solutions**: +- Memoize expensive components +- Debounce rapid actions +- Limit component count per surface + +```typescript +import { memo } from 'react'; + +export const ExpensiveComponent = memo(({ + component, state, onAction, resolveBinding +}) => { + // Component implementation +}); +``` + +#### Large Output in CLIOutput + +**Symptoms**: Page freeze with large CLI output + +**Solutions**: +```json +{ + "CLIOutput": { + "output": { "path": "output" }, + "maxLines": 1000 + } +} +``` + +## Best Practices + +### 1. Component IDs + +Use unique, descriptive IDs: + +```json +{ + "id": "user-form-username", + "component": { ... } +} +``` + +### 2. Surface IDs + +Include timestamp for uniqueness: + +```json +{ + "surfaceId": "form-1704067200000", + "components": [...] +} +``` + +### 3. State Structure + +Organize state logically: + +```json +{ + "initialState": { + "form": { + "username": "", + "email": "" + }, + "ui": { + "loading": false, + "error": null + } + } +} +``` + +### 4. Error Handling + +Always include error states: + +```json +{ + "components": [ + { + "id": "error-display", + "component": { + "Text": { + "text": { "path": "ui.error" }, + "usageHint": "p" + } + } + } + ] +} +``` + +### 5. Progressive Enhancement + +Start simple, add complexity: + +```json +// Simple: Just text +{ "Text": { "text": { "literalString": "Status: OK" } } } + +// Enhanced: Add status indicator +{ + "Card": { + "title": { "literalString": "Status" }, + "content": [ + { + "Text": { "text": { "literalString": "OK" } } + }, + { + "Progress": { "value": { "literalNumber": 100 } } + } + ] + } +} +``` + +### 6. Accessibility + +- Use semantic usageHint values (h1-h6, p) +- Provide labels for inputs +- Include descriptions for complex interactions + +## Quick Reference + +### Action IDs + +Use descriptive, action-oriented IDs: + +- `submit-form`, `cancel-action`, `delete-item` +- `refresh-data`, `load-more`, `sort-by-date` + +### Binding Paths + +Use dot notation for nested paths: + +- `user.profile.name` +- `items.0.title` +- `form.settings.theme` + +### Component Variants + +| Variant | Use Case | +|---------|----------| +| primary | Main action, important | +| secondary | Alternative action | +| destructive | Dangerous actions (delete) | +| ghost | Subtle, unobtrusive | +| outline | Bordered, less emphasis | + +### Language Values for CLIOutput + +| Language | Use For | +|----------|---------| +| bash | Shell commands, terminal output | +| javascript | JS/TS code, console logs | +| python | Python code, error traces | +| text | Plain text, no highlighting | + +## Support + +For issues, questions, or contributions: + +- Check existing [Issues](../../issues) +- Review [Integration Guide](./a2ui-integration.md) +- See [Component Examples](../../src/packages/a2ui-runtime/renderer/components/) diff --git a/ccw/frontend/package-lock.json b/ccw/frontend/package-lock.json index a14b676e..2bcd6d95 100644 --- a/ccw/frontend/package-lock.json +++ b/ccw/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.11.4", "@hello-pangea/dnd": "^18.0.1", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-select": "^2.1.0", @@ -1404,6 +1405,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/ccw/frontend/package.json b/ccw/frontend/package.json index 9318467e..7ccc0ae7 100644 --- a/ccw/frontend/package.json +++ b/ccw/frontend/package.json @@ -19,6 +19,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.11.4", "@hello-pangea/dnd": "^18.0.1", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-select": "^2.1.0", diff --git a/ccw/frontend/src/components/a2ui/AskQuestionDialog.tsx b/ccw/frontend/src/components/a2ui/AskQuestionDialog.tsx new file mode 100644 index 00000000..113dc52c --- /dev/null +++ b/ccw/frontend/src/components/a2ui/AskQuestionDialog.tsx @@ -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(() => { + 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>({}); + 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 ( +
+ + setValue(v)} + className="space-y-2" + > + {question.options?.map((option) => ( +
+ + +
+ ))} +
+ {error && ( +

+ + {error} +

+ )} +
+ ); + + case 'multi': + return ( +
+ +
+ {question.options?.map((option) => { + const checked = Array.isArray(value) && value.includes(option); + return ( +
+ { + const currentArray = Array.isArray(value) ? value : []; + if (checked) { + setValue([...currentArray, option]); + } else { + setValue(currentArray.filter((v) => v !== option)); + } + }} + /> + +
+ ); + })} +
+ {error && ( +

+ + {error} +

+ )} +
+ ); + + case 'text': + return ( +
+ +