## 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>
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
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: 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: 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;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* GlobalErrorBoundary Component
|
|
*
|
|
* Root-level error boundary that catches all React errors and global errors.
|
|
* Displays a user-friendly error screen with recovery options.
|
|
*/
|
|
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): Partial<ErrorBoundaryState> {
|
|
const appError = classifyError(error);
|
|
return {
|
|
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',
|
|
};
|
|
|
|
this.setState({
|
|
errorInfo: extendedErrorInfo,
|
|
appError,
|
|
});
|
|
|
|
// Call optional error handler
|
|
if (onError) {
|
|
onError(error, errorInfo);
|
|
}
|
|
|
|
// Report to error tracking
|
|
reportError(error, {
|
|
componentStack: errorInfo.componentStack ?? undefined,
|
|
errorName: error.name,
|
|
errorMessage: error.message,
|
|
});
|
|
}
|
|
|
|
handleReset = () => {
|
|
const { onReset } = this.props;
|
|
|
|
// Reset error state
|
|
this.setState({
|
|
hasError: false,
|
|
error: null,
|
|
errorInfo: null,
|
|
appError: null,
|
|
showDetails: false,
|
|
});
|
|
|
|
// Call optional reset handler
|
|
if (onReset) {
|
|
onReset();
|
|
}
|
|
};
|
|
|
|
handleReload = () => {
|
|
window.location.reload();
|
|
};
|
|
|
|
handleGoHome = () => {
|
|
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, errorTitle, errorMessage } = this.props;
|
|
const { hasError, error, errorInfo, appError, showDetails } = this.state;
|
|
|
|
if (hasError && error) {
|
|
// Use custom fallback if provided
|
|
if (fallback) {
|
|
return fallback;
|
|
}
|
|
|
|
// 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 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="max-w-lg w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
|
|
>
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* 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">
|
|
<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"
|
|
size="sm"
|
|
onClick={this.handleReport}
|
|
className="flex-1"
|
|
>
|
|
<Bug className="w-4 h-4 mr-2" />
|
|
Report Issue
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={this.handleGoHome}
|
|
className="flex-1"
|
|
>
|
|
<Home className="w-4 h-4 mr-2" />
|
|
Go Home
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</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 };
|