docs(claude): restructure documentation management and add feedback system

- Restructure §8 from "文档沉淀规则" to "文档管理规则" with 4 subsections
  - Add docs/ structure with features/ and knowledge-base/ directories
  - Add feature documentation template with 7 sections (概述/设计初衷/技术设计/预期作用/实际效果/演化路线/头脑风暴)
  - Add feature update trigger matrix (新增/修改/完成/问题/反馈)
  - Add documentation quality checklist
- Add §16
This commit is contained in:
iven
2026-03-16 13:54:03 +08:00
parent 8e630882c7
commit adfd7024df
44 changed files with 10491 additions and 248 deletions

View File

@@ -0,0 +1,40 @@
import { MessageCircle } from 'lucide-react';
import { motion } from 'framer-motion';
import { useFeedbackStore } from './feedbackStore';
import { Button } from '../ui';
interface FeedbackButtonProps {
onClick: () => void;
showCount?: boolean;
}
export function FeedbackButton({ onClick, showCount = true }: FeedbackButtonProps) {
const feedbackItems = useFeedbackStore((state) => state.feedbackItems);
const pendingCount = feedbackItems.filter((f) => f.status === 'pending' || f.status === 'submitted').length;
return (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="relative flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
<MessageCircle className="w-4 h-4" />
<span className="text-sm">Feedback</span>
{showCount && pendingCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1 -right-1 w-4 h-4 bg-orange-500 text-white text-[10px] rounded-full flex items-center justify-center"
>
{pendingCount > 9 ? '9+' : pendingCount}
</motion.span>
)}
</Button>
</motion.div>
);
}

View File

@@ -0,0 +1,193 @@
import { format } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, CheckCircle, AlertCircle, Hourglass, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { useFeedbackStore, type FeedbackSubmission, type FeedbackStatus } from './feedbackStore';
import { Button, Badge } from '../ui';
const statusConfig: Record<FeedbackStatus, { label: string; color: string; icon: React.ReactNode }> = {
pending: { label: 'Pending', color: 'text-gray-500', icon: <Clock className="w-4 h-4" /> },
submitted: { label: 'Submitted', color: 'text-blue-500', icon: <CheckCircle className="w-4 h-4" /> },
acknowledged: { label: 'Acknowledged', color: 'text-purple-500', icon: <CheckCircle className="w-4 h-4" /> },
in_progress: { label: 'In Progress', color: 'text-yellow-500', icon: <Hourglass className="w-4 h-4" /> },
resolved: { label: 'Resolved', color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" /> },
};
const typeLabels: Record<string, string> = {
bug: 'Bug Report',
feature: 'Feature Request';
general: 'General Feedback',
};
const priorityLabels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
};
interface FeedbackHistoryProps {
onViewDetails?: (feedback: FeedbackSubmission) => void;
}
export function FeedbackHistory({ onViewDetails }: FeedbackHistoryProps) {
const { feedbackItems, deleteFeedback, updateFeedbackStatus } = useFeedbackStore();
const [expandedId, setExpandedId] = useState<string | null>(null);
const formatDate = (timestamp: number) => {
return format(new Date(timestamp), 'yyyy-MM-dd HH:mm');
};
const handleDelete = (id: string) => {
if (confirm('Are you sure you want to delete this feedback?')) {
deleteFeedback(id);
}
};
const handleStatusChange = (id: string, newStatus: FeedbackStatus) => {
updateFeedbackStatus(id, newStatus);
};
if (feedbackItems.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No feedback submissions yet.</p>
<p className="text-sm mt-1">Click the feedback button to submit your first feedback.</p>
</div>
);
}
return (
<div className="space-y-3">
{feedbackItems.map((feedback) => {
const isExpanded = expandedId === feedback.id;
const statusInfo = statusConfig[feedback.status];
return (
<motion.div
key={feedback.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => setExpandedId(isExpanded ? null : feedback.id)}
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{feedback.type === 'bug' && <span className="text-red-500"><AlertCircle className="w-4 h-4" /></span>}
{feedback.type === 'feature' && <span className="text-yellow-500"><ChevronUp className="w-4 h-4" /></span>}
{feedback.type === 'general' && <span className="text-blue-500"><CheckCircle className="w-4 h-4" /></span>}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{feedback.title}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{typeLabels[feedback.type]} - {formatDate(feedback.createdAt)}
</p>
</div>
<Badge variant={feedback.priority === 'high' ? 'error' : feedback.priority === 'medium' ? 'warning' : 'default'}>
{priorityLabels[feedback.priority]}
</Badge>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setExpandedId(isExpanded ? null : feedback.id);
}}
className="text-gray-400 hover:text-gray-600 p-1"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* Expandable Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-4 pb-3 border-t border-gray-100 dark:border-gray-700"
>
<div className="space-y-3">
{/* Description */}
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</h5>
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{feedback.description}
</p>
</div>
{/* Attachments */}
{feedback.attachments.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Attachments ({feedback.attachments.length})
</h5>
<div className="flex flex-wrap gap-2 mt-1">
{feedback.attachments.map((att, idx) => (
<span
key={idx}
className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded"
>
{att.name}
</span>
))}
</div>
</div>
)}
{/* Metadata */}
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">System Info</h5>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>App Version: {feedback.metadata.appVersion}</p>
<p>OS: {feedback.metadata.os}</p>
<p>Submitted: {format(feedback.createdAt)}</p>
</div>
</div>
{/* Status and Actions */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1 text-xs ${statusInfo.color}`}>
{statusInfo.icon}
{statusInfo.label}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={feedback.status}
onChange={(e) => handleStatusChange(feedback.id, e.target.value as FeedbackStatus)}
className="text-xs border border-gray-200 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-800"
>
<option value="pending">Pending</option>
<option value="submitted">Submitted</option>
<option value="acknowledged">Acknowledged</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(feedback.id)}
className="text-red-500 hover:text-red-600"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, Bug, Lightbulb, MessageSquare, AlertCircle, Upload, Trash2 } from 'lucide-react';
import { useFeedbackStore, type FeedbackType, type FeedbackPriority } from './feedbackStore';
import { Button } from '../ui';
import { useToast } from '../ui/Toast';
interface FeedbackModalProps {
onClose: () => void;
}
const typeOptions: { value: FeedbackType; label: string; icon: React.ReactNode }[] = [
{ value: 'bug', label: 'Bug Report', icon: <Bug className="w-4 h-4" /> },
{ value: 'feature', label: 'Feature Request', icon: <Lightbulb className="w-4 h-4" /> },
{ value: 'general', label: 'General Feedback', icon: <MessageSquare className="w-4 h-4" /> },
];
const priorityOptions: { value: FeedbackPriority; label: string; color: string }[] = [
{ value: 'low', label: 'Low', color: 'text-gray-500' },
{ value: 'medium', label: 'Medium', color: 'text-yellow-600' },
{ value: 'high', label: 'High', color: 'text-red-500' },
];
export function FeedbackModal({ onClose }: FeedbackModalProps) {
const { submitFeedback, isLoading, error } = useFeedbackStore();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [type, setType] = useState<FeedbackType>('bug');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<FeedbackPriority>('medium');
const [attachments, setAttachments] = useState<File[]>([]);
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) {
toast('Please fill in title and description', 'warning');
return;
}
// Convert files to base64 for storage
const processedAttachments = await Promise.all(
attachments.map(async (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
name: file.name,
type: file.type,
size: file.size,
data: reader.result as string,
});
};
reader.readAsDataURL(file);
});
})
);
try {
const result = await submitFeedback({
type,
title: title.trim(),
description: description.trim(),
priority,
attachments: processedAttachments,
metadata: {
appVersion: '0.0.0',
os: navigator.platform,
timestamp: Date.now(),
},
});
if (result) {
toast('Feedback submitted successfully!', 'success');
// Reset form
setTitle('');
setDescription('');
setAttachments([]);
setType('bug');
setPriority('medium');
onClose();
}
} catch (err) {
toast('Failed to submit feedback. Please try again.', 'error');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
// Limit to 5 attachments
const newFiles = [...attachments, ...files].slice(0, 5);
setAttachments(newFiles);
};
const removeAttachment = (index: number) => {
setAttachments(attachments.filter((_, i) => i !== index));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="feedback-title"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="feedback-title" className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Submit Feedback
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 space-y-4">
{/* Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Feedback Type
</label>
<div className="flex gap-2">
{typeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setType(opt.value)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all ${
type === opt.value
? 'border-orange-400 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label htmlFor="feedback-title-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title
</label>
<input
id="feedback-title-input"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Brief summary of your feedback"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 dark:bg-gray-700 dark:text-gray-100"
maxLength={100}
/>
</div>
{/* Description */}
<div>
<label htmlFor="feedback-desc-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
id="feedback-desc-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Please describe your feedback in detail. For bugs, include steps to reproduce."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 dark:bg-gray-700 dark:text-gray-100 resize-none"
rows={4}
maxLength={2000}
/>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority
</label>
<div className="flex gap-2">
{priorityOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setPriority(opt.value)}
className={`flex-1 px-3 py-2 rounded-lg border text-sm transition-all ${
priority === opt.value
? 'border-orange-400 bg-orange-50 dark:bg-orange-900/20 font-medium'
: 'border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${opt.color}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Attachments */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Attachments (optional, max 5)
</label>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Upload className="w-4 h-4" />
Add Screenshots
</button>
{attachments.length > 0 && (
<div className="mt-2 space-y-1">
{attachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between px-2 py-1 bg-gray-50 dark:bg-gray-700 rounded text-xs"
>
<span className="truncate text-gray-600 dark:text-gray-300">
{file.name} ({formatFileSize(file.size)})
</span>
<button
onClick={() => removeAttachment(index)}
className="text-gray-400 hover:text-red-500"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
{/* Error Display */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 px-3 py-2 rounded-lg">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => { handleSubmit().catch(() => {}); }}
loading={isLoading}
disabled={!title.trim() || !description.trim()}
>
<Send className="w-4 h-4 mr-2" />
Submit
</Button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,143 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Types
export type FeedbackType = 'bug' | 'feature' | 'general';
export type FeedbackPriority = 'low' | 'medium' | 'high';
export type FeedbackStatus = 'pending' | 'submitted' | 'acknowledged' | 'in_progress' | 'resolved';
export interface FeedbackAttachment {
name: string;
type: string;
size: number;
data: string; // base64 encoded
}
export interface FeedbackSubmission {
id: string;
type: FeedbackType;
title: string;
description: string;
priority: FeedbackPriority;
status: FeedbackStatus;
attachments: FeedbackAttachment[];
metadata: {
appVersion: string;
os: string;
timestamp: number;
userAgent?: string;
};
createdAt: number;
updatedAt: number;
}
interface FeedbackState {
feedbackItems: FeedbackSubmission[];
isModalOpen: boolean;
isLoading: boolean;
error: string | null;
}
interface FeedbackActions {
openModal: () => void;
closeModal: () => void;
submitFeedback: (feedback: Omit<FeedbackSubmission, 'id' | 'createdAt' | 'updatedAt' | 'status'>) => Promise<void>;
updateFeedbackStatus: (id: string, status: FeedbackStatus) => void;
deleteFeedback: (id: string) => void;
clearError: () => void;
}
export type FeedbackStore = FeedbackState & FeedbackActions;
const STORAGE_KEY = 'zclaw-feedback-history';
const MAX_FEEDBACK_ITEMS = 100;
// Helper to get app metadata
function getAppMetadata() {
return {
appVersion: '0.0.0',
os: typeof navigator !== 'undefined' ? navigator.platform : 'unknown',
timestamp: Date.now(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
};
}
// Generate unique ID
function generateFeedbackId(): string {
return `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export const useFeedbackStore = create<FeedbackStore>()(
persist(
(set, get) => ({
feedbackItems: [],
isModalOpen: false,
isLoading: false,
error: null,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
submitFeedback: async (feedback) => {
const { feedbackItems } = get();
set({ isLoading: true, error: null });
try {
const newFeedback: FeedbackSubmission = {
...feedback,
id: generateFeedbackId(),
createdAt: Date.now(),
updatedAt: Date.now(),
status: 'submitted',
metadata: {
...feedback.metadata,
...getAppMetadata(),
},
};
// Simulate async submission
await new Promise(resolve => setTimeout(resolve, 300));
// Keep only MAX_FEEDBACK_ITEMS
const updatedItems = [newFeedback, ...feedbackItems].slice(0, MAX_FEEDBACK_ITEMS);
set({
feedbackItems: updatedItems,
isLoading: false,
isModalOpen: false,
});
return newFeedback;
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to submit feedback',
});
throw err;
}
},
updateFeedbackStatus: (id, status) => {
const { feedbackItems } = get();
const updatedItems = feedbackItems.map(item =>
item.id === id
? { ...item, status, updatedAt: Date.now() }
: item
);
set({ feedbackItems: updatedItems });
},
deleteFeedback: (id) => {
const { feedbackItems } = get();
set({
feedbackItems: feedbackItems.filter(item => item.id !== id),
});
},
clearError: () => set({ error: null }),
}),
{
name: STORAGE_KEY,
}
)
);

View File

@@ -0,0 +1,11 @@
export { FeedbackButton } from './FeedbackButton';
export { FeedbackModal } from './FeedbackModal';
export { FeedbackHistory } from './FeedbackHistory';
export {
useFeedbackStore,
type FeedbackSubmission,
type FeedbackType,
type FeedbackPriority,
type FeedbackStatus,
type FeedbackAttachment,
} from './feedbackStore';

View File

@@ -0,0 +1,464 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Search, X, ChevronUp, ChevronDown, Clock, User, Bot, Filter } from 'lucide-react';
import { Button } from './ui';
import { useChatStore, Message } from '../store/chatStore';
export interface SearchFilters {
sender: 'all' | 'user' | 'assistant';
timeRange: 'all' | 'today' | 'week' | 'month';
}
export interface SearchResult {
message: Message;
matchIndices: Array<{ start: number; end: number }>;
}
interface MessageSearchProps {
onNavigateToMessage: (messageId: string) => void;
}
const SEARCH_HISTORY_KEY = 'zclaw-search-history';
const MAX_HISTORY_ITEMS = 10;
export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
const { messages } = useChatStore();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<SearchFilters>({
sender: 'all',
timeRange: 'all',
});
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const [showFilters, setShowFilters] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
// Load search history from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
if (saved) {
setSearchHistory(JSON.parse(saved));
}
} catch {
// Ignore parse errors
}
}, []);
// Save search query to history
const saveToHistory = useCallback((searchQuery: string) => {
if (!searchQuery.trim()) return;
setSearchHistory((prev) => {
const filtered = prev.filter((item) => item !== searchQuery);
const updated = [searchQuery, ...filtered].slice(0, MAX_HISTORY_ITEMS);
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
} catch {
// Ignore storage errors
}
return updated;
});
}, []);
// Filter messages by time range
const filterByTimeRange = useCallback((message: Message, timeRange: SearchFilters['timeRange']): boolean => {
if (timeRange === 'all') return true;
const messageTime = new Date(message.timestamp).getTime();
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
switch (timeRange) {
case 'today':
return messageTime >= now - day;
case 'week':
return messageTime >= now - 7 * day;
case 'month':
return messageTime >= now - 30 * day;
default:
return true;
}
}, []);
// Filter messages by sender
const filterBySender = useCallback((message: Message, sender: SearchFilters['sender']): boolean => {
if (sender === 'all') return true;
if (sender === 'user') return message.role === 'user';
if (sender === 'assistant') return message.role === 'assistant' || message.role === 'tool';
return true;
}, []);
// Search messages and find matches
const searchResults = useMemo((): SearchResult[] => {
if (!query.trim()) return [];
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
if (searchTerms.length === 0) return [];
const results: SearchResult[] = [];
for (const message of messages) {
// Apply filters
if (!filterBySender(message, filters.sender)) continue;
if (!filterByTimeRange(message, filters.timeRange)) continue;
const content = message.content.toLowerCase();
const matchIndices: Array<{ start: number; end: number }> = [];
// Find all matches
for (const term of searchTerms) {
let startIndex = 0;
while (true) {
const index = content.indexOf(term, startIndex);
if (index === -1) break;
matchIndices.push({ start: index, end: index + term.length });
startIndex = index + 1;
}
}
if (matchIndices.length > 0) {
// Sort and merge overlapping matches
matchIndices.sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
for (const match of matchIndices) {
if (merged.length === 0 || merged[merged.length - 1].end < match.start) {
merged.push(match);
} else {
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end);
}
}
results.push({ message, matchIndices: merged });
}
}
return results;
}, [query, messages, filters, filterBySender, filterByTimeRange]);
// Navigate to previous match
const handlePrevious = useCallback(() => {
if (searchResults.length === 0) return;
setCurrentMatchIndex((prev) =>
prev > 0 ? prev - 1 : searchResults.length - 1
);
const result = searchResults[currentMatchIndex > 0 ? currentMatchIndex - 1 : searchResults.length - 1];
onNavigateToMessage(result.message.id);
}, [searchResults, currentMatchIndex, onNavigateToMessage]);
// Navigate to next match
const handleNext = useCallback(() => {
if (searchResults.length === 0) return;
setCurrentMatchIndex((prev) =>
prev < searchResults.length - 1 ? prev + 1 : 0
);
const result = searchResults[currentMatchIndex < searchResults.length - 1 ? currentMatchIndex + 1 : 0];
onNavigateToMessage(result.message.id);
}, [searchResults, currentMatchIndex, onNavigateToMessage]);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+F or Cmd+F to open search
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
setIsOpen((prev) => !prev);
setTimeout(() => inputRef.current?.focus(), 100);
}
// Escape to close search
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
setQuery('');
}
// Enter to navigate to next match
if (e.key === 'Enter' && isOpen && searchResults.length > 0) {
if (e.shiftKey) {
handlePrevious();
} else {
handleNext();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, searchResults.length, handlePrevious, handleNext]);
// Reset current match index when results change
useEffect(() => {
setCurrentMatchIndex(0);
}, [searchResults.length]);
// Handle search submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
saveToHistory(query.trim());
if (searchResults.length > 0) {
onNavigateToMessage(searchResults[0].message.id);
}
}
};
// Clear search
const handleClear = () => {
setQuery('');
inputRef.current?.focus();
};
// Toggle search panel
const toggleSearch = () => {
setIsOpen((prev) => !prev);
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 100);
}
};
return (
<>
{/* Search toggle button */}
<Button
variant="ghost"
size="sm"
onClick={toggleSearch}
className={`flex items-center gap-1.5 ${isOpen ? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20' : 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
title="Search messages (Ctrl+F)"
aria-label="Search messages"
aria-expanded={isOpen}
>
<Search className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Search</span>
</Button>
{/* Search panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 overflow-hidden"
>
<div className="px-4 py-3">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
{/* Search input */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
className="w-full pl-9 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400 focus:border-transparent"
aria-label="Search query"
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Filter toggle */}
<Button
type="button"
variant={showFilters ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setShowFilters((prev) => !prev)}
className="flex items-center gap-1"
aria-label="Toggle filters"
aria-expanded={showFilters}
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span>
</Button>
{/* Navigation buttons */}
{searchResults.length > 0 && (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">
{currentMatchIndex + 1} / {searchResults.length}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handlePrevious}
className="p-1.5"
aria-label="Previous match"
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNext}
className="p-1.5"
aria-label="Next match"
>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
)}
</form>
{/* Filters panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
>
<div className="flex flex-wrap gap-4">
{/* Sender filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<User className="w-3.5 h-3.5" />
Sender:
</label>
<select
value={filters.sender}
onChange={(e) => setFilters((prev) => ({ ...prev, sender: e.target.value as SearchFilters['sender'] }))}
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
>
<option value="all">All</option>
<option value="user">User</option>
<option value="assistant">Assistant</option>
</select>
</div>
{/* Time range filter */}
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
Time:
</label>
<select
value={filters.timeRange}
onChange={(e) => setFilters((prev) => ({ ...prev, timeRange: e.target.value as SearchFilters['timeRange'] }))}
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
>
<option value="all">All time</option>
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
</select>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Search history */}
{!query && searchHistory.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">Recent searches:</div>
<div className="flex flex-wrap gap-1">
{searchHistory.slice(0, 5).map((item, index) => (
<button
key={index}
type="button"
onClick={() => setQuery(item)}
className="text-xs px-2 py-1 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
{item}
</button>
))}
</div>
</div>
)}
{/* No results message */}
{query && searchResults.length === 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center py-2">
No messages found matching "{query}"
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
// Utility function to highlight search matches in text
export function highlightSearchMatches(
text: string,
query: string,
highlightClassName: string = 'bg-yellow-200 dark:bg-yellow-700/50 rounded px-0.5'
): React.ReactNode[] {
if (!query.trim()) return [text];
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
if (searchTerms.length === 0) return [text];
const lowerText = text.toLowerCase();
const matches: Array<{ start: number; end: number }> = [];
// Find all matches
for (const term of searchTerms) {
let startIndex = 0;
while (true) {
const index = lowerText.indexOf(term, startIndex);
if (index === -1) break;
matches.push({ start: index, end: index + term.length });
startIndex = index + 1;
}
}
if (matches.length === 0) return [text];
// Sort and merge overlapping matches
matches.sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
for (const match of matches) {
if (merged.length === 0 || merged[merged.length - 1].end < match.start) {
merged.push({ ...match });
} else {
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, match.end);
}
}
// Build highlighted result
const result: React.ReactNode[] = [];
let lastIndex = 0;
for (let i = 0; i < merged.length; i++) {
const match = merged[i];
// Text before match
if (match.start > lastIndex) {
result.push(text.slice(lastIndex, match.start));
}
// Highlighted match
result.push(
<mark key={i} className={highlightClassName}>
{text.slice(match.start, match.end)}
</mark>
);
lastIndex = match.end;
}
// Remaining text
if (lastIndex < text.length) {
result.push(text.slice(lastIndex));
}
return result;
}

View File

@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain, MessageCircle
} from 'lucide-react';
import { MemoryPanel } from './MemoryPanel';
import { FeedbackModal, FeedbackHistory } from './Feedback';
import { cardHover, defaultTransition } from '../lib/animations';
import { Button, Badge, EmptyState } from './ui';
@@ -17,7 +18,8 @@ export function RightPanel() {
connect, loadClones, loadUsageStats, loadPluginStatus, workspaceInfo, quickConfig, updateClone,
} = useGatewayStore();
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory'>('status');
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'feedback'>('status');
const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false);
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
@@ -152,6 +154,18 @@ export function RightPanel() {
>
<Brain className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'feedback' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('feedback')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Feedback"
aria-label="Feedback"
aria-selected={activeTab === 'feedback'}
role="tab"
>
<MessageCircle className="w-4 h-4" />
</Button>
</div>
</div>
@@ -382,6 +396,29 @@ export function RightPanel() {
)}
</motion.div>
</div>
) : activeTab === 'feedback' ? (
<div className="space-y-4">
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<MessageCircle className="w-4 h-4" />
User Feedback
</h3>
<Button
variant="primary"
size="sm"
onClick={() => setIsFeedbackModalOpen(true)}
>
New Feedback
</Button>
</div>
<FeedbackHistory />
</motion.div>
</div>
) : (
<>
{/* Gateway 连接状态 */}
@@ -592,6 +629,13 @@ export function RightPanel() {
</>
)}
</div>
{/* Feedback Modal */}
<AnimatePresence>
{isFeedbackModalOpen && (
<FeedbackModal onClose={() => setIsFeedbackModalOpen(false)} />
)}
</AnimatePresence>
</aside>
);
}

View File

@@ -1,15 +1,16 @@
import { useState } from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Users, Bot, GitBranch, MessageSquare } from 'lucide-react';
import { Settings, Users, Bot, GitBranch, MessageSquare, Layers } from 'lucide-react';
import { CloneManager } from './CloneManager';
import { HandList } from './HandList';
import { TaskList } from './TaskList';
import { TeamList } from './TeamList';
import { SwarmDashboard } from './SwarmDashboard';
import { useGatewayStore } from '../store/gatewayStore';
import { Button } from './ui';
import { containerVariants, defaultTransition } from '../lib/animations';
export type MainViewType = 'chat' | 'hands' | 'workflow' | 'team';
export type MainViewType = 'chat' | 'hands' | 'workflow' | 'team' | 'swarm';
interface SidebarProps {
onOpenSettings?: () => void;
@@ -20,13 +21,14 @@ interface SidebarProps {
onSelectTeam?: (teamId: string) => void;
}
type Tab = 'clones' | 'hands' | 'workflow' | 'team';
type Tab = 'clones' | 'hands' | 'workflow' | 'team' | 'swarm';
const TABS: { key: Tab; label: string; icon: React.ComponentType<{ className?: string }>; mainView?: MainViewType }[] = [
{ key: 'clones', label: '分身', icon: Bot },
{ key: 'hands', label: 'Hands', icon: MessageSquare, mainView: 'hands' },
{ key: 'workflow', label: '工作流', icon: GitBranch, mainView: 'workflow' },
{ key: 'team', label: '团队', icon: Users, mainView: 'team' },
{ key: 'swarm', label: '协作', icon: Layers, mainView: 'swarm' },
];
export function Sidebar({
@@ -55,6 +57,12 @@ export function Sidebar({
onMainViewChange?.('hands');
};
const handleSelectTeam = (teamId: string) => {
onSelectTeam?.(teamId);
setActiveTab('team');
onMainViewChange?.('team');
};
return (
<aside className="w-64 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
{/* 顶部标签 - 使用图标 */}
@@ -102,9 +110,10 @@ export function Sidebar({
{activeTab === 'team' && (
<TeamList
selectedTeamId={selectedTeamId}
onSelectTeam={onSelectTeam}
onSelectTeam={handleSelectTeam}
/>
)}
{activeTab === 'swarm' && <SwarmDashboard />}
</motion.div>
</AnimatePresence>
</div>

View File

@@ -12,7 +12,6 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Users,
Play,
Pause,
CheckCircle,
XCircle,
Clock,
@@ -211,7 +210,7 @@ function TaskCard({
onSelect: () => void;
}) {
const [expandedSubtasks, setExpandedSubtasks] = useState<Set<string>>(new Set());
const { agents } = useAgentStore();
const { clones } = useAgentStore();
const toggleSubtask = useCallback((subtaskId: string) => {
setExpandedSubtasks((prev) => {
@@ -234,7 +233,7 @@ function TaskCard({
}, [task.createdAt, task.completedAt]);
const getAgentName = (agentId: string) => {
const agent = agents.find((a) => a.id === agentId);
const agent = clones.find((a) => a.id === agentId);
return agent?.name || agentId;
};

View File

@@ -0,0 +1,345 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertTriangle,
Wifi,
Shield,
Clock,
Settings,
AlertCircle,
ChevronDown,
ChevronUp,
Copy,
CheckCircle,
ExternalLink,
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
import {
AppError,
ErrorCategory,
classifyError,
formatErrorForClipboard,
getErrorIcon as getIconByCategory,
getErrorColor as getColorByCategory,
} from '../../lib/error-types';
import { reportError } from '../../lib/error-handling';
// === Props ===
export interface ErrorAlertProps {
error: AppError | string | Error | null;
onDismiss?: () => void;
onRetry?: () => void;
showTechnicalDetails?: boolean;
className?: string;
compact?: boolean;
}
interface ErrorAlertState {
showDetails: boolean;
copied: boolean;
}
// === Category Configuration ===
const CATEGORY_CONFIG: Record<ErrorCategory, {
icon: typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle;
color: string;
bgColor: string;
label: string;
}> = {
network: {
icon: Wifi,
color: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
label: 'Network',
},
auth: {
icon: Shield,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'Authentication',
},
permission: {
icon: Shield,
color: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
label: 'Permission',
},
validation: {
icon: AlertCircle,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
label: 'Validation',
},
timeout: {
icon: Clock,
color: 'text-amber-500',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
label: 'Timeout',
},
server: {
icon: AlertTriangle,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'Server',
},
client: {
icon: AlertCircle,
color: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
label: 'Client',
},
config: {
icon: Settings,
color: 'text-gray-500',
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
label: 'Configuration',
},
system: {
icon: AlertTriangle,
color: 'text-red-600',
bgColor: 'bg-red-50 dark:bg-red-900/20',
label: 'System',
},
};
/**
* 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;
}
/**
* Get color class for error category
*/
export function getColorByCategory(category: ErrorCategory) string {
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].color : 'text-gray-500';
}
/**
* ErrorAlert Component
*
* Displays detailed error information with recovery suggestions,
* technical details, and action buttons.
*/
export function ErrorAlert({
error: errorProp,
onDismiss,
onRetry,
showTechnicalDetails = true,
className,
compact = false,
}: ErrorAlertProps) {
const [state, setState] = useState<ErrorAlertState>({
showDetails: false,
copied: false,
});
// Normalize error input
const appError = typeof error === 'string'
? classifyError(new Error(error))
: error instanceof Error
? classifyError(error)
: error;
const {
category,
title,
message,
technicalDetails,
recoverable,
recoverySteps,
timestamp,
} = appError;
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.system!;
const IconComponent = config.icon;
const handleCopyDetails = useCallback(async () => {
const text = formatErrorForClipboard(appError);
try {
await navigator.clipboard.writeText(text);
setState({ copied: true });
setTimeout(() => setState({ copied: false }), 2000);
} catch (err) {
console.error('Failed to copy error details:', err);
}
}, [appError]);
const handleReport = useCallback(() => {
reportError(appError.originalError || appError, {
errorId: appError.id,
category: appError.category,
title: appError.title,
message: appError.message,
timestamp: appError.timestamp.toISOString(),
});
}, [appError]);
const toggleDetails = useCallback(() => {
setState((prev) => ({ showDetails: !prev.showDetails }));
}, []);
const handleRetry = useCallback(() => {
onRetry?.();
}, [onRetry]);
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
'rounded-lg border overflow-hidden',
config.bgColor,
'border-gray-200 dark:border-gray-700',
className
)}
>
{/* Header */}
<div className="flex items-start gap-3 p-3 bg-white/50 dark:bg-gray-800/50">
<div className={cn('p-2 rounded-lg', config.bgColor)}>
<IconComponent className={cn('w-5 h-5', config.color)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn('text-xs font-medium', config.color)}>
{config.label}
</span>
<span className="text-xs text-gray-400">
{timestamp.toLocaleTimeString()}
</span>
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mt-1">
{title}
</h4>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
aria-label="Dismiss"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Body */}
<div className="px-3 pb-2">
<p className={cn(
'text-gray-700 dark:text-gray-300',
compact ? 'text-sm line-clamp-2' : 'text-sm'
)}>
{message}
</p>
{/* Recovery Steps */}
{recoverySteps.length > 0 && !compact && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Recovery Suggestions
</p>
<ul className="space-y-1">
{recoverySteps.slice(0, 3).map((step, index) => (
<li key={index} className="text-xs text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-gray-400">-</span>
{step.description}
{step.action && step.label && (
<button
onClick={step.action}
className="text-blue-500 hover:text-blue-600 ml-1"
>
{step.label}
</button>
)}
</li>
))}
</ul>
</div>
)}
{/* Technical Details Toggle */}
{showTechnicalDetails && technicalDetails && !compact && (
<div className="mt-2">
<button
onClick={toggleDetails}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
{state.showDetails ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
Technical Details
</button>
<AnimatePresence>
{state.showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-all">
{technicalDetails}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between gap-2 p-3 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white/30 dark:bg-gray-800/30">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCopyDetails}
className="text-xs"
>
{state.copied ? (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReport}
className="text-xs"
>
<ExternalLink className="w-3 h-3 mr-1" />
Report
</Button>
</div>
{recoverable && onRetry && (
<Button
variant="primary"
size="sm"
onClick={handleRetry}
className="text-xs"
>
Try Again
</Button>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,179 @@
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 { Button } from './Button';
import { reportError } from '../../lib/error-handling';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
/**
* ErrorBoundary Component
*
* Catches React rendering errors and displays a friendly error screen
* with recovery options and error reporting.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): ErrorInfo {
return {
componentStack: error.stack || 'No stack trace available',
errorName: error.name || 'Unknown Error',
errorMessage: error.message || 'An unexpected error occurred',
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const { onError } = this.props;
// 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',
},
});
}
handleReset = () => {
const { onReset } = this.props;
// Reset error state
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
// Call optional reset handler
if (onReset) {
onReset();
}
};
handleReport = () => {
const { error, errorInfo } = this.state;
if (error && errorInfo) {
reportError(error, {
componentStack: errorInfo.componentStack,
errorName: errorInfo.errorName,
errorMessage: errorInfo.errorMessage,
});
}
};
handleGoHome = () => {
// Navigate to home/main view
window.location.href = '/';
};
render() {
const { children, fallback } = this.props;
const { hasError, error, errorInfo } = this.state;
if (hasError && error) {
// Use custom fallback if provided
if (fallback) {
return fallback;
}
// Default error UI
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"
>
{/* 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" />
</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="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>
{/* 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 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>
</motion.div>
</div>
);
}
return children;
}
}
}