feat: production readiness improvements

## Error Handling
- Add GlobalErrorBoundary with error classification and recovery
- Add custom error types (SecurityError, ConnectionError, TimeoutError)
- Fix ErrorAlert component syntax errors

## Offline Mode
- Add offlineStore for offline state management
- Implement message queue with localStorage persistence
- Add exponential backoff reconnection (1s→60s)
- Add OfflineIndicator component with status display
- Queue messages when offline, auto-retry on reconnect

## Security Hardening
- Add AES-256-GCM encryption for chat history storage
- Add secure API key storage with OS keychain integration
- Add security audit logging system
- Add XSS prevention and input validation utilities
- Add rate limiting and token generation helpers

## CI/CD (Gitea Actions)
- Add .gitea/workflows/ci.yml for continuous integration
- Add .gitea/workflows/release.yml for release automation
- Support Windows Tauri build and release

## UI Components
- Add LoadingSpinner, LoadingOverlay, LoadingDots components
- Add MessageSkeleton, ConversationListSkeleton skeletons
- Add EmptyMessages, EmptyConversations empty states
- Integrate loading states in ChatArea and ConversationList

## E2E Tests
- Fix WebSocket mock for streaming response tests
- Fix approval endpoint route matching
- Add store state exposure for testing
- All 19 core-features tests now passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

View File

@@ -1,4 +1,5 @@
import { cn } from '../../lib/utils';
import { MessageSquare, Inbox, Search, FileX, Wifi, Bot } from 'lucide-react';
interface EmptyStateProps {
icon: React.ReactNode;
@@ -6,19 +7,60 @@ interface EmptyStateProps {
description: string;
action?: React.ReactNode;
className?: string;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
export function EmptyState({
icon,
title,
description,
action,
className,
size = 'md'
}: EmptyStateProps) {
const sizeClasses = {
sm: {
container: 'py-4',
iconWrapper: 'w-12 h-12',
icon: 'w-5 h-5',
title: 'text-sm',
description: 'text-xs',
},
md: {
container: 'p-6',
iconWrapper: 'w-16 h-16',
icon: 'w-8 h-8',
title: 'text-base',
description: 'text-sm',
},
lg: {
container: 'p-8',
iconWrapper: 'w-20 h-20',
icon: 'w-10 h-10',
title: 'text-lg',
description: 'text-base',
},
};
const sizes = sizeClasses[size];
return (
<div className={cn('h-full flex items-center justify-center p-6', className)}>
<div className={cn('h-full flex items-center justify-center', sizes.container, className)}>
<div className="text-center max-w-sm">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400">
<div
className={cn(
'rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400',
sizes.iconWrapper,
'bg-gray-100 dark:bg-gray-800'
)}
>
{icon}
</div>
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
<h3 className={cn('font-semibold text-gray-700 dark:text-gray-300 mb-2', sizes.title)}>
{title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
<p className={cn('text-gray-500 dark:text-gray-400 mb-4', sizes.description)}>
{description}
</p>
{action}
@@ -26,3 +68,134 @@ export function EmptyState({ icon, title, description, action, className }: Empt
</div>
);
}
// === Pre-built Empty State Variants ===
interface PrebuiltEmptyStateProps {
action?: React.ReactNode;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
/**
* Empty state for no messages in chat.
*/
export function EmptyMessages({ action, className, size }: PrebuiltEmptyStateProps) {
return (
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title="No messages yet"
description="Start the conversation by sending a message below."
action={action}
className={className}
size={size}
/>
);
}
/**
* Empty state for no conversations.
*/
export function EmptyConversations({ action, className, size }: PrebuiltEmptyStateProps) {
return (
<EmptyState
icon={<Inbox className="w-8 h-8" />}
title="No conversations"
description="Your conversation history will appear here."
action={action}
className={className}
size={size}
/>
);
}
/**
* Empty state for search with no results.
*/
export function EmptySearchResults({ query, action, className, size }: PrebuiltEmptyStateProps & { query?: string }) {
return (
<EmptyState
icon={<Search className="w-8 h-8" />}
title="No results found"
description={query ? `No messages matching "${query}"` : 'Try adjusting your search terms.'}
action={action}
className={className}
size={size}
/>
);
}
/**
* Empty state for no files or attachments.
*/
export function EmptyFiles({ action, className, size }: PrebuiltEmptyStateProps) {
return (
<EmptyState
icon={<FileX className="w-8 h-8" />}
title="No files"
description="No files or attachments here yet."
action={action}
className={className}
size={size}
/>
);
}
/**
* Empty state for offline/disconnected state.
*/
export function EmptyOffline({ action, className, size }: PrebuiltEmptyStateProps) {
return (
<EmptyState
icon={<Wifi className="w-8 h-8 text-orange-400" />}
title="Offline"
description="Please check your connection and try again."
action={action}
className={className}
size={size}
/>
);
}
/**
* Empty state for no agents/clones available.
*/
export function EmptyAgents({ action, className, size }: PrebuiltEmptyStateProps) {
return (
<EmptyState
icon={<Bot className="w-8 h-8" />}
title="No agents"
description="Create an agent to get started with personalized conversations."
action={action}
className={className}
size={size}
/>
);
}
/**
* Empty state for welcome screen.
*/
export function WelcomeEmptyState({
title = "Welcome to ZCLAW",
description = "Send a message to start the conversation.",
connected = true,
action,
className,
size,
}: PrebuiltEmptyStateProps & {
title?: string;
description?: string;
connected?: boolean;
}) {
return (
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title={title}
description={connected ? description : 'Please connect to Gateway first.'}
action={action}
className={className}
size={size}
/>
);
}

View File

@@ -109,15 +109,15 @@ const CATEGORY_CONFIG: Record<ErrorCategory, {
/**
* Get icon component for error category
*/
export function getIconByCategory(category: ErrorCategory) typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].icon : AlertCircle;
export function getIconByCategory(category: ErrorCategory): typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
return CATEGORY_CONFIG[category]?.icon ?? AlertCircle;
}
/**
* Get color class for error category
*/
export function getColorByCategory(category: ErrorCategory) string {
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].color : 'text-gray-500';
export function getColorByCategory(category: ErrorCategory): string {
return CATEGORY_CONFIG[category]?.color ?? 'text-gray-500';
}
/**
@@ -140,11 +140,11 @@ export function ErrorAlert({
});
// Normalize error input
const appError = typeof error === 'string'
? classifyError(new Error(error))
: error instanceof Error
? classifyError(error)
: error;
const appError = typeof errorProp === 'string'
? classifyError(new Error(errorProp))
: errorProp instanceof Error
? classifyError(errorProp)
: errorProp;
const {
category,

View File

@@ -1,66 +1,210 @@
import { Component, ReactNode, ErrorInfo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, RefreshCcw, Bug, Home } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Component, ReactNode, ErrorInfo as ReactErrorInfo } from 'react';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCcw, Bug, Home, WifiOff } from 'lucide-react';
import { Button } from './Button';
import { reportError } from '../../lib/error-handling';
import { classifyError, AppError } from '../../lib/error-types';
// === Types ===
/** Extended error info with additional metadata */
interface ExtendedErrorInfo extends ReactErrorInfo {
errorName?: string;
errorMessage?: string;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
onError?: (error: Error, errorInfo: ReactErrorInfo) => void;
onReset?: () => void;
/** Whether to show connection status indicator */
showConnectionStatus?: boolean;
/** Custom error title */
errorTitle?: string;
/** Custom error message */
errorMessage?: string;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
errorInfo: ExtendedErrorInfo | null;
appError: AppError | null;
showDetails: boolean;
}
// === Global Error Types ===
type GlobalErrorType = 'unhandled-rejection' | 'error' | 'websocket' | 'network';
interface GlobalErrorEvent {
type: GlobalErrorType;
error: unknown;
timestamp: Date;
}
// === Global Error Handler Registry ===
const globalErrorListeners = new Set<(event: GlobalErrorEvent) => void>();
export function addGlobalErrorListener(listener: (event: GlobalErrorEvent) => void): () => void {
globalErrorListeners.add(listener);
return () => globalErrorListeners.delete(listener);
}
function notifyGlobalErrorListeners(event: GlobalErrorEvent): void {
globalErrorListeners.forEach(listener => {
try {
listener(event);
} catch (e) {
console.error('[GlobalErrorHandler] Listener error:', e);
}
});
}
// === Setup Global Error Handlers ===
let globalHandlersSetup = false;
export function setupGlobalErrorHandlers(): () => void {
if (globalHandlersSetup) {
return () => {};
}
globalHandlersSetup = true;
// Handle unhandled promise rejections
const handleRejection = (event: PromiseRejectionEvent) => {
console.error('[GlobalErrorHandler] Unhandled rejection:', event.reason);
notifyGlobalErrorListeners({
type: 'unhandled-rejection',
error: event.reason,
timestamp: new Date(),
});
// Prevent default browser error logging (we handle it ourselves)
event.preventDefault();
};
// Handle uncaught errors
const handleError = (event: ErrorEvent) => {
console.error('[GlobalErrorHandler] Uncaught error:', event.error);
notifyGlobalErrorListeners({
type: 'error',
error: event.error,
timestamp: new Date(),
});
// Let the error boundary handle it if possible
};
// Handle WebSocket errors globally
const handleWebSocketError = (event: Event) => {
if (event.target instanceof WebSocket) {
console.error('[GlobalErrorHandler] WebSocket error:', event);
notifyGlobalErrorListeners({
type: 'websocket',
error: new Error('WebSocket connection error'),
timestamp: new Date(),
});
}
};
window.addEventListener('unhandledrejection', handleRejection);
window.addEventListener('error', handleError);
window.addEventListener('error', handleWebSocketError, true); // Capture phase for WebSocket
return () => {
window.removeEventListener('unhandledrejection', handleRejection);
window.removeEventListener('error', handleError);
window.removeEventListener('error', handleWebSocketError, true);
globalHandlersSetup = false;
};
}
/**
* ErrorBoundary Component
* GlobalErrorBoundary Component
*
* Catches React rendering errors and displays a friendly error screen
* with recovery options and error reporting.
* Root-level error boundary that catches all React errors and global errors.
* Displays a user-friendly error screen with recovery options.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
export class GlobalErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
private cleanupGlobalHandlers: (() => void) | null = null;
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
appError: null,
showDetails: false,
};
}
static getDerivedStateFromError(error: Error): ErrorInfo {
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
const appError = classifyError(error);
return {
componentStack: error.stack || 'No stack trace available',
hasError: true,
error,
appError,
};
}
componentDidMount() {
// Setup global error handlers
this.cleanupGlobalHandlers = setupGlobalErrorHandlers();
// Listen for global errors and update state
const unsubscribe = addGlobalErrorListener((event) => {
if (!this.state.hasError) {
const appError = classifyError(event.error);
this.setState({
hasError: true,
error: event.error instanceof Error ? event.error : new Error(String(event.error)),
appError,
errorInfo: null,
});
}
});
// Store cleanup function
this.cleanupGlobalHandlers = () => {
unsubscribe();
};
}
componentWillUnmount() {
this.cleanupGlobalHandlers?.();
}
componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
const { onError } = this.props;
// Classify the error
const appError = classifyError(error);
// Update state with extended error info
const extendedErrorInfo: ExtendedErrorInfo = {
componentStack: errorInfo.componentStack,
errorName: error.name || 'Unknown Error',
errorMessage: error.message || 'An unexpected error occurred',
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const { onError } = this.props;
this.setState({
errorInfo: extendedErrorInfo,
appError,
});
// Call optional error handler
if (onError) {
onError(error, errorInfo);
}
// Update state to show error UI
this.setState({
hasError: true,
error,
errorInfo: {
componentStack: errorInfo.componentStack,
errorName: errorInfo.errorName || error.name || 'Unknown Error',
errorMessage: errorInfo.errorMessage || error.message || 'An unexpected error occurred',
},
// Report to error tracking
reportError(error, {
componentStack: errorInfo.componentStack ?? undefined,
errorName: error.name,
errorMessage: error.message,
});
}
handleReset = () => {
@@ -71,6 +215,8 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
hasError: false,
error: null,
errorInfo: null,
appError: null,
showDetails: false,
});
// Call optional reset handler
@@ -79,25 +225,34 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
};
handleReport = () => {
const { error, errorInfo } = this.state;
if (error && errorInfo) {
reportError(error, {
componentStack: errorInfo.componentStack,
errorName: errorInfo.errorName,
errorMessage: errorInfo.errorMessage,
});
}
handleReload = () => {
window.location.reload();
};
handleGoHome = () => {
// Navigate to home/main view
window.location.href = '/';
};
handleReport = () => {
const { error, errorInfo } = this.state;
if (error) {
reportError(error, {
componentStack: errorInfo?.componentStack ?? undefined,
errorName: errorInfo?.errorName || error.name,
errorMessage: errorInfo?.errorMessage || error.message,
});
// Show confirmation
alert('Error reported. Thank you for your feedback.');
}
};
toggleDetails = () => {
this.setState(prev => ({ showDetails: !prev.showDetails }));
};
render() {
const { children, fallback } = this.props;
const { hasError, error, errorInfo } = this.state;
const { children, fallback, errorTitle, errorMessage } = this.props;
const { hasError, error, errorInfo, appError, showDetails } = this.state;
if (hasError && error) {
// Use custom fallback if provided
@@ -105,47 +260,129 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return fallback;
}
// Default error UI
// Get error display info
const title = errorTitle || appError?.title || 'Something went wrong';
const message = errorMessage || appError?.message || error.message || 'An unexpected error occurred';
const category = appError?.category || 'system';
const isNetworkError = category === 'network';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
transition={{ duration: 0.2 }}
className="max-w-lg w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
>
{/* Error Icon */}
<div className="flex items-center justify-center w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full mx-4">
<AlertTriangle className="w-8 h-8 text-red-500" />
{/* Error Header */}
<div className={`p-6 ${isNetworkError ? 'bg-orange-50 dark:bg-orange-900/20' : 'bg-red-50 dark:bg-red-900/20'}`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-full ${isNetworkError ? 'bg-orange-100 dark:bg-orange-900/40' : 'bg-red-100 dark:bg-red-900/40'}`}>
{isNetworkError ? (
<WifiOff className="w-8 h-8 text-orange-500" />
) : (
<AlertTriangle className="w-8 h-8 text-red-500" />
)}
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{message}
</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-6 text-center">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Something went wrong
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{errorInfo?.errorMessage || error.message || 'An unexpected error occurred'}
</p>
{/* Error Details */}
<div className="p-6">
{/* Category Badge */}
{appError && (
<div className="flex items-center gap-2 mb-4">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
category === 'network' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' :
category === 'auth' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
category === 'server' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}>
{category.charAt(0).toUpperCase() + category.slice(1)} Error
</span>
{appError.recoverable && (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Recoverable
</span>
)}
</div>
)}
{/* Error Details */}
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg text-left">
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{errorInfo?.errorName || 'Unknown Error'}
</p>
</div>
{/* Recovery Steps */}
{appError?.recoverySteps && appError.recoverySteps.length > 0 && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Suggested Actions:
</h3>
<ul className="space-y-2">
{appError.recoverySteps.slice(0, 3).map((step, index) => (
<li key={index} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-gray-400 mt-0.5">{index + 1}.</span>
<span>{step.description}</span>
</li>
))}
</ul>
</div>
)}
{/* Technical Details Toggle */}
<button
onClick={this.toggleDetails}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 flex items-center gap-1 mb-4"
>
<span>{showDetails ? 'Hide' : 'Show'} technical details</span>
<motion.span
animate={{ rotate: showDetails ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
</motion.span>
</button>
{/* Technical Details */}
{showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden mb-4"
>
<pre className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-words max-h-48">
{errorInfo?.errorName || error.name}: {errorInfo?.errorMessage || error.message}
{errorInfo?.componentStack && `\n\nComponent Stack:${errorInfo.componentStack}`}
</pre>
</motion.div>
)}
{/* Actions */}
<div className="flex flex-col gap-2 mt-6">
<Button
variant="primary"
size="sm"
onClick={this.handleReset}
className="w-full"
>
<RefreshC className="w-4 h-4 mr-2" />
Try Again
</Button>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={this.handleReset}
className="flex-1"
>
<RefreshCcw className="w-4 h-4 mr-2" />
Try Again
</Button>
<Button
variant="secondary"
size="sm"
onClick={this.handleReload}
className="flex-1"
>
Reload Page
</Button>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
@@ -156,7 +393,6 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
<Bug className="w-4 h-4 mr-2" />
Report Issue
</Button>
<Button
variant="ghost"
size="sm"
@@ -168,12 +404,123 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
</Button>
</div>
</div>
</motion.div>
</div>
);
}
return children;
</div>
</motion.div>
</div>
);
}
return children;
}
}
/**
* ErrorBoundary Component
*
* A simpler error boundary for wrapping individual components or sections.
* Use GlobalErrorBoundary for the root level.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
appError: null,
showDetails: false,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
const appError = classifyError(error);
return {
hasError: true,
error,
appError,
};
}
componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
const { onError } = this.props;
// Update state with extended error info
const extendedErrorInfo: ExtendedErrorInfo = {
componentStack: errorInfo.componentStack,
errorName: error.name || 'Unknown Error',
errorMessage: error.message || 'An unexpected error occurred',
};
this.setState({
errorInfo: extendedErrorInfo,
});
// Call optional error handler
if (onError) {
onError(error, errorInfo);
}
// Report error
reportError(error, {
componentStack: errorInfo.componentStack ?? undefined,
errorName: error.name,
errorMessage: error.message,
});
}
handleReset = () => {
const { onReset } = this.props;
this.setState({
hasError: false,
error: null,
errorInfo: null,
appError: null,
showDetails: false,
});
if (onReset) {
onReset();
}
};
render() {
const { children, fallback } = this.props;
const { hasError, error, appError } = this.state;
if (hasError && error) {
if (fallback) {
return fallback;
}
// Compact error UI for nested boundaries
return (
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
{appError?.title || 'Error'}
</h3>
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{appError?.message || error.message}
</p>
<Button
variant="ghost"
size="sm"
onClick={this.handleReset}
className="mt-2 text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
>
<RefreshCcw className="w-3 h-3 mr-1" />
Retry
</Button>
</div>
</div>
</div>
);
}
return children;
}
}
// === Re-export for convenience ===
export { GlobalErrorBoundary as RootErrorBoundary };

View File

@@ -0,0 +1,106 @@
import { cn } from '../../lib/utils';
import { Loader2 } from 'lucide-react';
interface LoadingSpinnerProps {
/** Size of the spinner */
size?: 'sm' | 'md' | 'lg';
/** Optional text to display below the spinner */
text?: string;
/** Additional class names */
className?: string;
}
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
/**
* Small inline loading spinner for buttons and inline contexts.
*/
export function LoadingSpinner({ size = 'md', text, className }: LoadingSpinnerProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
<Loader2 className={cn('animate-spin text-gray-400 dark:text-gray-500', sizeClasses[size])} />
{text && <span className="text-sm text-gray-500 dark:text-gray-400">{text}</span>}
</div>
);
}
interface LoadingOverlayProps {
/** Whether the overlay is visible */
visible: boolean;
/** Optional text to display */
text?: string;
/** Additional class names */
className?: string;
}
/**
* Full-screen loading overlay for blocking interactions during loading.
*/
export function LoadingOverlay({ visible, text = 'Loading...', className }: LoadingOverlayProps) {
if (!visible) return null;
return (
<div
className={cn(
'absolute inset-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm',
'flex items-center justify-center z-50',
className
)}
>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="text-sm text-gray-600 dark:text-gray-300">{text}</span>
</div>
</div>
);
}
interface LoadingDotsProps {
/** Additional class names */
className?: string;
}
/**
* Animated dots for "thinking" states.
*/
export function LoadingDots({ className }: LoadingDotsProps) {
return (
<div className={cn('flex items-center gap-1', className)}>
<span
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
);
}
interface InlineLoadingProps {
/** Loading text */
text?: string;
/** Additional class names */
className?: string;
}
/**
* Compact inline loading indicator with text.
*/
export function InlineLoading({ text = 'Loading...', className }: InlineLoadingProps) {
return (
<div className={cn('flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400', className)}>
<LoadingDots />
<span className="text-sm">{text}</span>
</div>
);
}

View File

@@ -40,3 +40,142 @@ export function ListSkeleton({ count = 3 }: { count?: number }) {
</div>
);
}
/**
* Skeleton for a single chat message bubble.
* Supports both user and assistant message styles.
*/
export function MessageSkeleton({ isUser = false }: { isUser?: boolean }) {
return (
<div className={cn('flex gap-4', isUser && 'justify-end')}>
<div
className={cn(
'w-8 h-8 rounded-lg flex-shrink-0',
isUser ? 'bg-gray-200 dark:bg-gray-600 order-last' : 'bg-gray-300 dark:bg-gray-600'
)}
>
<Skeleton className="w-full h-full rounded-lg" />
</div>
<div className={cn('flex-1', isUser && 'max-w-2xl')}>
<div
className={cn(
'p-4 rounded-2xl',
isUser
? 'bg-orange-100 dark:bg-orange-900/30'
: 'bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700'
)}
>
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</div>
);
}
/**
* Skeleton for a list of chat messages.
* Alternates between user and assistant skeletons.
*/
export function MessageListSkeleton({ count = 4 }: { count?: number }) {
return (
<div className="space-y-6 p-6">
{Array.from({ length: count }).map((_, i) => (
<MessageSkeleton key={i} isUser={i % 2 === 0} />
))}
</div>
);
}
/**
* Skeleton for a conversation item in the sidebar.
*/
export function ConversationItemSkeleton() {
return (
<div className="flex items-center gap-3 px-3 py-3 border-b border-gray-50 dark:border-gray-800">
<Skeleton className="w-7 h-7 rounded-lg flex-shrink-0" />
<div className="flex-1 min-w-0">
<Skeleton className="h-3 w-24 mb-1.5" />
<Skeleton className="h-2 w-32" />
</div>
</div>
);
}
/**
* Skeleton for the conversation list sidebar.
*/
export function ConversationListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="flex flex-col h-full">
{/* Header skeleton */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<Skeleton className="h-3 w-16" />
<Skeleton className="w-4 h-4 rounded" />
</div>
{/* List items */}
<div className="flex-1 overflow-hidden">
{Array.from({ length: count }).map((_, i) => (
<ConversationItemSkeleton key={i} />
))}
</div>
</div>
);
}
/**
* Skeleton for the chat header.
*/
export function ChatHeaderSkeleton() {
return (
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-3 w-20" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</div>
);
}
/**
* Skeleton for the chat input area.
*/
export function ChatInputSkeleton() {
return (
<div className="border-t border-gray-100 dark:border-gray-800 p-4 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto">
<div className="flex items-end gap-2 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-2">
<Skeleton className="w-5 h-5 rounded" />
<div className="flex-1 py-1">
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="w-16 h-6 rounded" />
<Skeleton className="w-8 h-8 rounded-full" />
</div>
<div className="text-center mt-2">
<Skeleton className="h-3 w-40 mx-auto" />
</div>
</div>
</div>
);
}
/**
* Full chat area skeleton including header, messages, and input.
*/
export function ChatAreaSkeleton({ messageCount = 4 }: { messageCount?: number }) {
return (
<div className="flex flex-col h-full">
<ChatHeaderSkeleton />
<div className="flex-1 overflow-hidden">
<MessageListSkeleton count={messageCount} />
</div>
<ChatInputSkeleton />
</div>
);
}

View File

@@ -8,8 +8,38 @@ export type { InputProps } from './Input';
export { Badge } from './Badge';
export { Skeleton, CardSkeleton, ListSkeleton } from './Skeleton';
// Skeleton components
export {
Skeleton,
CardSkeleton,
ListSkeleton,
MessageSkeleton,
MessageListSkeleton,
ConversationItemSkeleton,
ConversationListSkeleton,
ChatHeaderSkeleton,
ChatInputSkeleton,
ChatAreaSkeleton,
} from './Skeleton';
export { EmptyState } from './EmptyState';
// Empty state components
export {
EmptyState,
EmptyMessages,
EmptyConversations,
EmptySearchResults,
EmptyFiles,
EmptyOffline,
EmptyAgents,
WelcomeEmptyState,
} from './EmptyState';
// Loading components
export {
LoadingSpinner,
LoadingOverlay,
LoadingDots,
InlineLoading,
} from './LoadingSpinner';
export { ToastProvider, useToast } from './Toast';