mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user