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:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type RefObject, type MutableRefObject } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { List, type ListImperativeAPI } from 'react-window';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
@@ -6,13 +6,14 @@ import { useConnectionStore } from '../store/connectionStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
import { useConfigStore } from '../store/configStore';
|
||||
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
|
||||
import { Button, EmptyState } from './ui';
|
||||
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
|
||||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||||
import { MessageSearch } from './MessageSearch';
|
||||
import { OfflineIndicator } from './OfflineIndicator';
|
||||
import {
|
||||
useVirtualizedMessages,
|
||||
type VirtualizedMessageItem,
|
||||
type VirtualizedMessageItem
|
||||
} from '../lib/message-virtualization';
|
||||
|
||||
// Default heights for virtualized messages
|
||||
@@ -30,7 +31,7 @@ const VIRTUALIZATION_THRESHOLD = 100;
|
||||
|
||||
export function ChatArea() {
|
||||
const {
|
||||
messages, currentAgent, isStreaming, currentModel,
|
||||
messages, currentAgent, isStreaming, isLoading, currentModel,
|
||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||
newConversation,
|
||||
} = useChatStore();
|
||||
@@ -105,7 +106,8 @@ export function ChatArea() {
|
||||
}, [messages, useVirtualization, scrollToBottom]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isStreaming || !connected) return;
|
||||
if (!input.trim() || isStreaming) return;
|
||||
// Allow sending in offline mode - message will be queued
|
||||
sendToGateway(input);
|
||||
setInput('');
|
||||
};
|
||||
@@ -134,6 +136,7 @@ export function ChatArea() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -151,6 +154,8 @@ export function ChatArea() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Offline indicator in header */}
|
||||
<OfflineIndicator compact />
|
||||
{messages.length > 0 && (
|
||||
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
||||
)}
|
||||
@@ -171,9 +176,23 @@ export function ChatArea() {
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6 bg-white dark:bg-gray-900">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-gray-900">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{messages.length === 0 && (
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && messages.length === 0 && (
|
||||
<motion.div
|
||||
key="loading-skeleton"
|
||||
variants={fadeInVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
<MessageListSkeleton count={3} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<motion.div
|
||||
key="empty-state"
|
||||
variants={fadeInVariants}
|
||||
@@ -189,8 +208,8 @@ export function ChatArea() {
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="w-8 h-8" />}
|
||||
title="欢迎使用 ZCLAW"
|
||||
description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}
|
||||
title="Welcome to ZCLAW"
|
||||
description={connected ? 'Send a message to start the conversation.' : 'Please connect to Gateway first in Settings.'}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
@@ -242,13 +261,11 @@ export function ChatArea() {
|
||||
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
!connected
|
||||
? '请先连接 Gateway'
|
||||
: isStreaming
|
||||
? 'Agent 正在回复...'
|
||||
: `发送给 ${currentAgent?.name || 'ZCLAW'}`
|
||||
isStreaming
|
||||
? 'Agent 正在回复...'
|
||||
: `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}`
|
||||
}
|
||||
disabled={isStreaming || !connected}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
|
||||
style={{ minHeight: '24px', maxHeight: '160px' }}
|
||||
@@ -289,8 +306,8 @@ export function ChatArea() {
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={isStreaming || !input.trim() || !connected}
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white"
|
||||
disabled={isStreaming || !input.trim()}
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 text-white" />
|
||||
@@ -549,14 +566,10 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
</div>
|
||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||
{isThinking ? (
|
||||
// 思考中指示器
|
||||
// Thinking indicator
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex gap-1">
|
||||
<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>
|
||||
<span className="text-sm">思考中...</span>
|
||||
<LoadingDots />
|
||||
<span className="text-sm">Thinking...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
|
||||
import { EmptyConversations, ConversationListSkeleton } from './ui';
|
||||
|
||||
export function ConversationList() {
|
||||
const {
|
||||
conversations, currentConversationId, messages, agents, currentAgent,
|
||||
newConversation, switchConversation, deleteConversation,
|
||||
isLoading,
|
||||
} = useChatStore();
|
||||
|
||||
const hasActiveChat = messages.length > 0;
|
||||
|
||||
// Show skeleton during initial load
|
||||
if (isLoading && conversations.length === 0 && !hasActiveChat) {
|
||||
return <ConversationListSkeleton count={4} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
@@ -86,11 +93,7 @@ export function ConversationList() {
|
||||
})}
|
||||
|
||||
{conversations.length === 0 && !hasActiveChat && (
|
||||
<div className="text-center py-8 text-xs text-gray-400">
|
||||
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p>暂无对话</p>
|
||||
<p className="mt-1">发送消息开始对话</p>
|
||||
</div>
|
||||
<EmptyConversations size="sm" className="h-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
378
desktop/src/components/OfflineIndicator.tsx
Normal file
378
desktop/src/components/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OfflineIndicator Component
|
||||
*
|
||||
* Displays offline mode status, pending message count, and reconnection info.
|
||||
* Shows a prominent banner when the app is offline with visual feedback.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
WifiOff,
|
||||
CloudOff,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Send,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useOfflineStore, type QueuedMessage } from '../store/offlineStore';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
/** Show compact version (minimal) */
|
||||
compact?: boolean;
|
||||
/** Show pending messages list */
|
||||
showQueue?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Callback when reconnect button is clicked */
|
||||
onReconnect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return '刚刚';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时前`;
|
||||
return `${Math.floor(seconds / 86400)}天前`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reconnect delay for display
|
||||
*/
|
||||
function formatReconnectDelay(delay: number): string {
|
||||
if (delay < 1000) return '立即';
|
||||
if (delay < 60000) return `${Math.ceil(delay / 1000)}秒`;
|
||||
return `${Math.ceil(delay / 60000)}分钟`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate message content for display
|
||||
*/
|
||||
function truncateContent(content: string, maxLength: number = 50): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Full offline indicator with banner, queue, and reconnect info
|
||||
*/
|
||||
export function OfflineIndicator({
|
||||
compact = false,
|
||||
showQueue = true,
|
||||
className = '',
|
||||
onReconnect,
|
||||
}: OfflineIndicatorProps) {
|
||||
const {
|
||||
isOffline,
|
||||
isReconnecting,
|
||||
reconnectAttempt,
|
||||
nextReconnectDelay,
|
||||
queuedMessages,
|
||||
cancelReconnect,
|
||||
} = useOfflineStore();
|
||||
|
||||
const connect = useConnectionStore((s) => s.connect);
|
||||
|
||||
const [showMessageQueue, setShowMessageQueue] = useState(false);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
|
||||
// Countdown timer for reconnection
|
||||
useEffect(() => {
|
||||
if (!isReconnecting || !nextReconnectDelay) {
|
||||
setCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const endTime = Date.now() + nextReconnectDelay;
|
||||
setCountdown(nextReconnectDelay);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const remaining = Math.max(0, endTime - Date.now());
|
||||
setCountdown(remaining);
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isReconnecting, nextReconnectDelay]);
|
||||
|
||||
// Handle manual reconnect
|
||||
const handleReconnect = async () => {
|
||||
onReconnect?.();
|
||||
try {
|
||||
await connect();
|
||||
} catch (err) {
|
||||
console.error('[OfflineIndicator] Manual reconnect failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
).length;
|
||||
|
||||
// Don't show if online and no pending messages
|
||||
if (!isOffline && pendingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compact version for headers/toolbars
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{isOffline ? (
|
||||
<>
|
||||
<CloudOff className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm text-orange-500 font-medium">
|
||||
离线模式
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{pendingCount} 条待发
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm text-green-500">已恢复连接</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
|
||||
发送中 {pendingCount} 条
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full banner version
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{/* Main Banner */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
||||
isOffline
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
}`}
|
||||
>
|
||||
{/* Status Icon */}
|
||||
<motion.div
|
||||
animate={isReconnecting ? { rotate: 360 } : {}}
|
||||
transition={
|
||||
isReconnecting
|
||||
? { duration: 1, repeat: Infinity, ease: 'linear' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{isOffline ? (
|
||||
<WifiOff className="w-5 h-5 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Status Text */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
isOffline ? 'text-orange-700 dark:text-orange-400' : 'text-green-700 dark:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{isOffline ? '后端服务不可用' : '连接已恢复'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isReconnecting ? (
|
||||
<>
|
||||
正在尝试重连 ({reconnectAttempt}次)
|
||||
{countdown !== null && (
|
||||
<span className="ml-2">
|
||||
{formatReconnectDelay(countdown)}后重试
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : isOffline ? (
|
||||
'消息将保存在本地,连接后自动发送'
|
||||
) : pendingCount > 0 ? (
|
||||
`正在发送 ${pendingCount} 条排队消息...`
|
||||
) : (
|
||||
'所有消息已同步'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isOffline && !isReconnecting && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重连
|
||||
</button>
|
||||
)}
|
||||
{isReconnecting && (
|
||||
<button
|
||||
onClick={cancelReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{showQueue && pendingCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowMessageQueue(!showMessageQueue)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{showMessageQueue ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
{pendingCount} 条待发
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Queue */}
|
||||
<AnimatePresence>
|
||||
{showMessageQueue && pendingCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
排队消息
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{queuedMessages
|
||||
.filter((m) => m.status === 'pending' || m.status === 'failed')
|
||||
.map((msg) => (
|
||||
<QueuedMessageItem key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual queued message item
|
||||
*/
|
||||
function QueuedMessageItem({ message }: { message: QueuedMessage }) {
|
||||
const { removeMessage } = useOfflineStore();
|
||||
|
||||
const statusConfig = {
|
||||
pending: { icon: Clock, color: 'text-gray-400', label: '等待中' },
|
||||
sending: { icon: Send, color: 'text-blue-500', label: '发送中' },
|
||||
failed: { icon: AlertCircle, color: 'text-red-500', label: '发送失败' },
|
||||
sent: { icon: CheckCircle, color: 'text-green-500', label: '已发送' },
|
||||
};
|
||||
|
||||
const config = statusConfig[message.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-2 border-b border-gray-100 dark:border-gray-800 last:border-b-0">
|
||||
<StatusIcon className={`w-4 h-4 mt-0.5 ${config.color}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{truncateContent(message.content)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatRelativeTime(message.timestamp)}
|
||||
</span>
|
||||
{message.status === 'failed' && message.lastError && (
|
||||
<span className="text-xs text-red-500">{message.lastError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => removeMessage(message.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="删除消息"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal connection status indicator for headers
|
||||
*/
|
||||
export function ConnectionStatusBadge({ className = '' }: { className?: string }) {
|
||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||
const queuedMessages = useOfflineStore((s) => s.queuedMessages);
|
||||
|
||||
const pendingCount = queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
).length;
|
||||
|
||||
const isConnected = connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 ${className}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isConnected
|
||||
? 'bg-green-400'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isConnected
|
||||
? 'text-green-500'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'text-orange-500'
|
||||
: 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? '在线' : connectionState === 'reconnecting' ? '重连中' : '离线'}
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfflineIndicator;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
106
desktop/src/components/ui/LoadingSpinner.tsx
Normal file
106
desktop/src/components/ui/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user