feat: Enhance A2UI with RadioGroup and Markdown support

- Added support for RadioGroup component in A2UI, allowing single selection from multiple options.
- Implemented Markdown parsing in A2UIPopupCard for better content rendering.
- Updated A2UIPopupCard to handle different question types and improved layout for multi-select and single-select questions.
- Introduced new utility functions for handling disabled items during installation.
- Enhanced installation process to restore previously disabled skills and commands.
- Updated notification store and related tests to accommodate new features.
- Adjusted Vite configuration for better development experience.
This commit is contained in:
catlog22
2026-02-04 13:45:47 +08:00
parent 1a05551d00
commit 341331325c
15 changed files with 743 additions and 178 deletions

View File

@@ -3,19 +3,22 @@
// ========================================
// Centered popup dialog for A2UI surfaces with minimalist design
// Used for displayMode: 'popup' surfaces (e.g., ask_question)
// Supports markdown content parsing
import { useCallback, useEffect } from 'react';
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer';
import { useNotificationStore } from '@/stores';
import type { SurfaceUpdate } from '@/packages/a2ui-runtime/core/A2UITypes';
import type { SurfaceUpdate, SurfaceComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -27,46 +30,146 @@ interface A2UIPopupCardProps {
onClose: () => void;
}
type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'unknown';
// ========== Helpers ==========
/** Get text content from A2UI Text component */
function getTextContent(component: SurfaceComponent | undefined): string {
if (!component?.component) return '';
const comp = component.component as any;
if (!comp?.Text?.text) return '';
const text = comp.Text.text;
if ('literalString' in text) return text.literalString;
return '';
}
/** Detect question type from surface */
function detectQuestionType(surface: SurfaceUpdate): QuestionType {
const state = surface.initialState as Record<string, unknown> | undefined;
if (state?.questionType) {
return state.questionType as QuestionType;
}
// Fallback: detect from components
const hasCheckbox = surface.components.some((c) => 'Checkbox' in (c.component as any));
const hasRadioGroup = surface.components.some((c) => 'RadioGroup' in (c.component as any));
const hasDropdown = surface.components.some((c) => 'Dropdown' in (c.component as any));
const hasTextField = surface.components.some((c) => 'TextField' in (c.component as any));
const hasConfirmCancel = surface.components.some(
(c) => c.id === 'confirm-btn' || c.id === 'cancel-btn'
);
if (hasCheckbox) return 'multi-select';
if (hasRadioGroup) return 'select';
if (hasDropdown) return 'select';
if (hasTextField) return 'input';
if (hasConfirmCancel) return 'confirm';
return 'unknown';
}
/** Check if component is an action button */
function isActionButton(component: SurfaceComponent): boolean {
const comp = component.component as any;
return 'Button' in comp;
}
// ========== Markdown Component ==========
interface MarkdownContentProps {
content: string;
className?: string;
}
function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={cn('prose prose-sm dark:prose-invert max-w-none', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Customize rendered elements
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2">{children}</ol>,
li: ({ children }) => <li className="mb-1">{children}</li>,
code: ({ children, className }) => {
const isInline = !className;
return isInline ? (
<code className="px-1 py-0.5 bg-muted rounded text-sm">{children}</code>
) : (
<code className={cn('block p-2 bg-muted rounded text-sm overflow-x-auto', className)}>
{children}
</code>
);
},
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}
// ========== Component ==========
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
const { formatMessage } = useIntl();
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
// Extract title and description from surface components if available
// Detect question type
const questionType = useMemo(() => detectQuestionType(surface), [surface]);
// Extract title, message, and description from surface components
const titleComponent = surface.components.find(
(c) => c.id === 'title' && 'Text' in c.component
);
const descriptionComponent = surface.components.find(
(c) => c.id === 'description' && 'Text' in c.component
(c) => c.id === 'title' && 'Text' in (c.component as any)
);
const messageComponent = surface.components.find(
(c) => c.id === 'message' && 'Text' in c.component
(c) => c.id === 'message' && 'Text' in (c.component as any)
);
const descriptionComponent = surface.components.find(
(c) => c.id === 'description' && 'Text' in (c.component as any)
);
// Get text content from component
const getTextContent = (component: any): string => {
if (!component?.component?.Text?.text) return '';
const text = component.component.Text.text;
if ('literalString' in text) return text.literalString;
return '';
};
const title =
getTextContent(titleComponent) ||
formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
const message = getTextContent(messageComponent);
const description = getTextContent(descriptionComponent);
const title = getTextContent(titleComponent) ||
formatMessage({ id: 'askQuestion.defaultTitle' }) ||
'Question';
const description = getTextContent(descriptionComponent) || getTextContent(messageComponent);
// Separate body components (interactive elements) from action buttons
const { bodyComponents, actionButtons } = useMemo(() => {
const body: SurfaceComponent[] = [];
const actions: SurfaceComponent[] = [];
// Filter out title/description components for body rendering
const bodyComponents = surface.components.filter(
(c) => c.id !== 'title' && c.id !== 'description' && c.id !== 'message'
for (const comp of surface.components) {
// Skip title, message, description
if (['title', 'message', 'description'].includes(comp.id)) continue;
// Separate action buttons (confirm, cancel, submit)
if (isActionButton(comp) && ['confirm-btn', 'cancel-btn', 'submit-btn'].includes(comp.id)) {
actions.push(comp);
} else {
body.push(comp);
}
}
return { bodyComponents: body, actionButtons: actions };
}, [surface.components]);
// Create surfaces for body and actions
const bodySurface: SurfaceUpdate = useMemo(
() => ({ ...surface, components: bodyComponents }),
[surface, bodyComponents]
);
// Create a surface subset for body rendering
const bodySurface: SurfaceUpdate = {
...surface,
components: bodyComponents,
};
const actionsSurface: SurfaceUpdate = useMemo(
() => ({ ...surface, components: actionButtons }),
[surface, actionButtons]
);
// Handle A2UI actions
const handleAction = useCallback(
@@ -75,7 +178,6 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
sendA2UIAction(actionId, surface.surfaceId, params);
// Check if this action should close the dialog
// (confirm, cancel, submit, answer actions typically resolve the question)
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
if (resolvingActions.includes(actionId)) {
onClose();
@@ -88,7 +190,6 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
// Send cancel action when closing via ESC or overlay
sendA2UIAction('cancel', surface.surfaceId, {
questionId: (surface.initialState as any)?.questionId,
});
@@ -98,33 +199,93 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
[sendA2UIAction, surface.surfaceId, onClose]
);
// Determine dialog width based on question type
const dialogWidth = useMemo(() => {
switch (questionType) {
case 'multi-select':
return 'sm:max-w-[480px]';
case 'input':
return 'sm:max-w-[500px]';
default:
return 'sm:max-w-[420px]';
}
}, [questionType]);
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
// Minimalist style: no heavy borders, light shadow, rounded corners
'sm:max-w-[420px] max-h-[80vh] overflow-y-auto',
'bg-card p-6 rounded-xl shadow-md border-0',
// Base styles
dialogWidth,
'max-h-[80vh] overflow-y-auto',
'bg-card p-6 rounded-xl shadow-lg border border-border/50',
// Animation classes
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
)}
onInteractOutside={(e) => {
// Prevent closing when clicking outside
e.preventDefault();
}}
onEscapeKeyDown={(e) => {
// Prevent closing with ESC key
e.preventDefault();
}}
>
<DialogHeader className="space-y-1.5 pb-4">
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
{/* Header */}
<DialogHeader className="space-y-2 pb-4">
<DialogTitle className="text-lg font-semibold leading-tight">{title}</DialogTitle>
{message && (
<div className="text-base text-foreground">
<MarkdownContent content={message} />
</div>
)}
{description && (
<DialogDescription className="text-sm text-muted-foreground">
{description}
</DialogDescription>
<div className="text-sm text-muted-foreground">
<MarkdownContent content={description} className="prose-muted" />
</div>
)}
</DialogHeader>
{/* A2UI Surface Body */}
<div className="space-y-4 py-2">
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
</div>
{/* Body - Interactive elements */}
{bodyComponents.length > 0 && (
<div className={cn(
'py-3',
// Add specific styling for multi-select (checkbox list)
questionType === 'multi-select' && 'space-y-2 max-h-[300px] overflow-y-auto px-1'
)}>
{questionType === 'multi-select' ? (
// Render each checkbox individually for better control
bodyComponents.map((comp) => (
<div key={comp.id} className="py-1">
<A2UIRenderer
surface={{ ...bodySurface, components: [comp] }}
onAction={handleAction}
/>
</div>
))
) : (
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
)}
</div>
)}
{/* Footer - Action buttons */}
{actionButtons.length > 0 && (
<DialogFooter className="pt-4">
<div className="flex flex-row justify-end gap-3">
{actionButtons.map((comp) => (
<A2UIRenderer
key={comp.id}
surface={{ ...actionsSurface, components: [comp] }}
onAction={handleAction}
/>
))}
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);

View File

@@ -104,6 +104,19 @@ export const CheckboxComponentSchema = z.object({
}),
});
/** RadioGroup component - single selection from multiple options */
export const RadioGroupComponentSchema = z.object({
RadioGroup: z.object({
options: z.array(z.object({
label: TextContentSchema,
value: z.string(),
description: TextContentSchema.optional(),
})),
selectedValue: TextContentSchema.optional(),
onChange: ActionSchema,
}),
});
/** Code block component */
export const CodeBlockComponentSchema = z.object({
CodeBlock: z.object({
@@ -160,6 +173,7 @@ export const ComponentSchema: z.ZodType<any> = z.union([
TextFieldComponentSchema,
TextAreaComponentSchema,
CheckboxComponentSchema,
RadioGroupComponentSchema,
CodeBlockComponentSchema,
ProgressComponentSchema,
CardComponentSchema,
@@ -202,6 +216,7 @@ export type DropdownComponent = z.infer<typeof DropdownComponentSchema>;
export type TextFieldComponent = z.infer<typeof TextFieldComponentSchema>;
export type TextAreaComponent = z.infer<typeof TextAreaComponentSchema>;
export type CheckboxComponent = z.infer<typeof CheckboxComponentSchema>;
export type RadioGroupComponent = z.infer<typeof RadioGroupComponentSchema>;
export type CodeBlockComponent = z.infer<typeof CodeBlockComponentSchema>;
export type ProgressComponent = z.infer<typeof ProgressComponentSchema>;
export type CardComponent = z.infer<typeof CardComponentSchema>;
@@ -223,6 +238,7 @@ export type A2UIComponentType =
| 'TextField'
| 'TextArea'
| 'Checkbox'
| 'RadioGroup'
| 'CodeBlock'
| 'Progress'
| 'Card'

View File

@@ -0,0 +1,83 @@
// ========================================
// A2UI RadioGroup Component Renderer
// ========================================
// Maps A2UI RadioGroup component to shadcn/ui RadioGroup
// Used for single-select questions with visible options
import React, { useState, useCallback } from 'react';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Label } from '@/components/ui/Label';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
import type { RadioGroupComponent } from '../../core/A2UITypes';
interface A2UIRadioGroupProps {
component: RadioGroupComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI RadioGroup Component Renderer
* Single selection from visible options with onChange handler
*/
export const A2UIRadioGroup: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const radioGroupComp = component as RadioGroupComponent;
const { RadioGroup: radioConfig } = radioGroupComp;
// Resolve initial selected value from binding
const getInitialValue = (): string | undefined => {
if (!radioConfig.selectedValue) return undefined;
const resolved = resolveLiteralOrBinding(radioConfig.selectedValue, resolveBinding);
return resolved ? String(resolved) : undefined;
};
// Local state for controlled radio group
const [selectedValue, setSelectedValue] = useState<string | undefined>(getInitialValue());
// Handle change with action dispatch
const handleChange = useCallback((newValue: string) => {
setSelectedValue(newValue);
// Trigger action with new selected value
onAction(radioConfig.onChange.actionId, {
value: newValue,
...(radioConfig.onChange.parameters || {}),
});
}, [radioConfig.onChange, onAction]);
return (
<RadioGroup value={selectedValue} onValueChange={handleChange} className="space-y-2">
{radioConfig.options.map((option, idx) => {
const labelText = resolveTextContent(option.label, resolveBinding);
const descriptionText = option.description
? resolveTextContent(option.description, resolveBinding)
: undefined;
return (
<div key={option.value || idx} className="flex items-start space-x-3 py-1">
<RadioGroupItem
value={option.value}
id={`radio-${option.value}`}
className="mt-0.5"
/>
<div className="flex flex-col">
<Label
htmlFor={`radio-${option.value}`}
className="text-sm font-medium leading-none cursor-pointer"
>
{labelText}
</Label>
{descriptionText && (
<span className="text-xs text-muted-foreground mt-1">
{descriptionText}
</span>
)}
</div>
</div>
);
})}
</RadioGroup>
);
};

View File

@@ -14,6 +14,7 @@ import { A2UIDropdown } from './A2UIDropdown';
import { A2UITextField } from './A2UITextField';
import { A2UITextArea } from './A2UITextArea';
import { A2UICheckbox } from './A2UICheckbox';
import { A2UIRadioGroup } from './A2UIRadioGroup';
import { A2UIProgress } from './A2UIProgress';
import { A2UICard } from './A2UICard';
import { A2UICLIOutput } from './A2UICLIOutput';
@@ -27,6 +28,7 @@ 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('RadioGroup' as A2UIComponentType, A2UIRadioGroup);
a2uiRegistry.register('Progress' as A2UIComponentType, A2UIProgress);
a2uiRegistry.register('Card' as A2UIComponentType, A2UICard);
a2uiRegistry.register('CLIOutput' as A2UIComponentType, A2UICLIOutput);
@@ -39,6 +41,7 @@ export * from './A2UIDropdown';
export * from './A2UITextField';
export * from './A2UITextArea';
export * from './A2UICheckbox';
export * from './A2UIRadioGroup';
export * from './A2UIProgress';
export * from './A2UICard';
export * from './A2UICLIOutput';

View File

@@ -6,7 +6,7 @@
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';
import type { SurfaceUpdate } from '../../packages/a2ui-runtime/core/A2UITypes';
describe('NotificationStore A2UI Methods', () => {
beforeEach(() => {
@@ -128,7 +128,7 @@ describe('NotificationStore A2UI Methods', () => {
const { result } = renderHook(() => useNotificationStore());
let notificationId: string;
let notificationId: string = '';
act(() => {
notificationId = result.current.addA2UINotification(surface);
});
@@ -312,7 +312,7 @@ describe('NotificationStore A2UI Methods', () => {
surfaceId: 'question-1',
title: 'Test Question',
questions: [
{ id: 'q1', question: 'What is your name?', type: 'text', required: true },
{ id: 'q1', question: 'What is your name?', type: 'text' as const, required: true },
],
};
@@ -329,7 +329,7 @@ describe('NotificationStore A2UI Methods', () => {
const mockQuestion = {
surfaceId: 'question-1',
title: 'Test',
questions: [{ id: 'q1', question: 'Test?', type: 'text' }],
questions: [{ id: 'q1', question: 'Test?', type: 'text' as const, required: true }],
};
act(() => {
@@ -353,7 +353,7 @@ describe('NotificationStore A2UI Methods', () => {
const { result } = renderHook(() => useNotificationStore());
let notificationId: string;
let notificationId: string = '';
act(() => {
notificationId = result.current.addA2UINotification(surface);
});

View File

@@ -349,7 +349,7 @@ export const useNotificationStore = create<NotificationStore>()(
}
},
retryAction: async (actionKey: string, notificationId: string) => {
retryAction: async (actionKey: string) => {
const state = get();
const actionState = state.actionStates.get(actionKey);