feat: add tests and implementation for issue discovery and queue pages

- Implemented `DiscoveryPage` with session management and findings display.
- Added tests for `DiscoveryPage` to ensure proper rendering and functionality.
- Created `QueuePage` for managing issue execution queues with stats and actions.
- Added tests for `QueuePage` to verify UI elements and translations.
- Introduced `useIssues` hooks for fetching and managing issue data.
- Added loading skeletons and error handling for better user experience.
- Created `vite-env.d.ts` for TypeScript support in Vite environment.
This commit is contained in:
catlog22
2026-01-31 21:20:10 +08:00
parent 6d225948d1
commit 1bd082a725
79 changed files with 5870 additions and 449 deletions

View File

@@ -16,13 +16,22 @@ import {
CheckCircle,
AlertTriangle,
XCircle,
File,
Download,
Loader2,
RotateCcw,
Code,
Image as ImageIcon,
Database,
Mail,
MailOpen,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer/A2UIRenderer';
import { useNotificationStore, selectPersistentNotifications } from '@/stores';
import type { Toast } from '@/types/store';
import type { Toast, NotificationAttachment, NotificationAction, ActionStateType, NotificationSource } from '@/types/store';
// ========== Helper Functions ==========
@@ -83,23 +92,67 @@ function getNotificationIcon(type: Toast['type']) {
}
}
function getSourceColor(source: NotificationSource): string {
switch (source) {
case 'system':
return 'bg-blue-500/10 text-blue-600 border-blue-200 dark:border-blue-800';
case 'websocket':
return 'bg-purple-500/10 text-purple-600 border-purple-200 dark:border-purple-800';
case 'cli':
return 'bg-green-500/10 text-green-600 border-green-200 dark:border-green-800';
case 'workflow':
return 'bg-orange-500/10 text-orange-600 border-orange-200 dark:border-orange-800';
case 'user':
return 'bg-cyan-500/10 text-cyan-600 border-cyan-200 dark:border-cyan-800';
case 'external':
return 'bg-pink-500/10 text-pink-600 border-pink-200 dark:border-pink-800';
default:
return 'bg-gray-500/10 text-gray-600 border-gray-200 dark:border-gray-800';
}
}
function getTypeBorder(type: Toast['type']): string {
switch (type) {
case 'success':
return 'border-l-green-500';
case 'warning':
return 'border-l-yellow-500';
case 'error':
return 'border-l-red-500';
case 'info':
default:
return 'border-l-blue-500';
}
}
// ========== Sub-Components ==========
interface PanelHeaderProps {
notificationCount: number;
hasNotifications: boolean;
hasUnread: boolean;
onClose: () => void;
onMarkAllRead: () => void;
onClearAll: () => void;
}
function PanelHeader({ notificationCount, onClose }: PanelHeaderProps) {
function PanelHeader({
notificationCount,
hasNotifications,
hasUnread,
onClose,
onMarkAllRead,
onClearAll,
}: PanelHeaderProps) {
const { formatMessage } = useIntl();
return (
<div className="flex items-start justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex-1 min-w-0 mr-4">
<div className="flex-1 min-w-0 mr-2">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'notificationPanel.title' }) || 'Notifications'}
{formatMessage({ id: 'notifications.title' }) || 'Notifications'}
</h2>
{notificationCount > 0 && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
@@ -108,46 +161,297 @@ function PanelHeader({ notificationCount, onClose }: PanelHeaderProps) {
)}
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
<div className="flex items-center gap-0.5 shrink-0">
{/* Mark All Read button */}
{hasNotifications && (
<Button
variant="ghost"
size="icon"
onClick={onMarkAllRead}
disabled={!hasUnread}
className="h-8 w-8"
aria-label={formatMessage({ id: 'notifications.markAllRead' }) || 'Mark all as read'}
title={formatMessage({ id: 'notifications.markAllRead' }) || 'Mark all as read'}
>
<Check className="h-4 w-4" />
</Button>
)}
{/* Clear All button */}
{hasNotifications && (
<Button
variant="ghost"
size="icon"
onClick={onClearAll}
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
aria-label={formatMessage({ id: 'notifications.clearAll' }) || 'Clear all notifications'}
title={formatMessage({ id: 'notifications.clearAll' }) || 'Clear all notifications'}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
{/* Close button */}
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
aria-label={formatMessage({ id: 'notifications.close' }) || 'Close notifications'}
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
);
}
interface PanelActionsProps {
hasNotifications: boolean;
hasUnread: boolean;
onMarkAllRead: () => void;
onClearAll: () => void;
// ========== Helper Components for Attachments and Actions ==========
interface NotificationAttachmentItemProps {
attachment: NotificationAttachment;
}
function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }: PanelActionsProps) {
function NotificationAttachmentItem({ attachment }: NotificationAttachmentItemProps) {
const { formatMessage } = useIntl();
if (!hasNotifications) return null;
// Format file size
function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// Render different attachment types
switch (attachment.type) {
case 'image':
return (
<div className="mt-2 rounded-md overflow-hidden border border-border">
{attachment.url ? (
<img
src={attachment.url}
alt={attachment.filename || formatMessage({ id: 'notifications.attachments.image' }) || 'Image'}
className="max-w-full max-h-48 object-contain bg-muted"
loading="lazy"
/>
) : attachment.content ? (
<img
src={attachment.content}
alt={attachment.filename || formatMessage({ id: 'notifications.attachments.image' }) || 'Image'}
className="max-w-full max-h-48 object-contain bg-muted"
loading="lazy"
/>
) : null}
{attachment.filename && (
<div className="px-2 py-1 text-xs text-muted-foreground bg-muted/50 truncate">
{attachment.filename}
</div>
)}
</div>
);
case 'code':
return (
<div className="mt-2 rounded-md border border-border overflow-hidden">
<div className="flex items-center justify-between px-2 py-1 bg-muted/50 border-b border-border">
<div className="flex items-center gap-1.5">
<Code className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-mono">
{attachment.filename || formatMessage({ id: 'notifications.attachments.code' }) || 'Code'}
</span>
</div>
{attachment.mimeType && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{attachment.mimeType.replace('text/', '').replace('application/', '')}
</span>
)}
</div>
{attachment.content && (
<pre className="p-2 text-xs bg-background overflow-x-auto max-h-48 overflow-y-auto">
<code className="font-mono">{attachment.content}</code>
</pre>
)}
</div>
);
case 'file':
return (
<div className="mt-2 flex items-center gap-2 p-2 rounded-md border border-border bg-muted/30">
<File className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-foreground truncate">
{attachment.filename || formatMessage({ id: 'notifications.attachments.file' }) || 'File'}
</div>
{attachment.size && (
<div className="text-[10px] text-muted-foreground">
{formatFileSize(attachment.size)}
</div>
)}
</div>
{attachment.url && (
<Button
variant="ghost"
size="sm"
asChild
className="h-7 px-2 text-xs"
>
<a href={attachment.url} download={attachment.filename}>
<Download className="h-3 w-3 mr-1" />
{formatMessage({ id: 'notifications.attachments.download' }) || 'Download'}
</a>
</Button>
)}
</div>
);
case 'data':
return (
<div className="mt-2 rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-1.5 px-2 py-1 bg-muted/50 border-b border-border">
<Database className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'notifications.attachments.data' }) || 'Data'}
</span>
</div>
{attachment.content && (
<pre className="p-2 text-xs bg-muted/20 overflow-x-auto max-h-48 overflow-y-auto">
<code className="font-mono text-muted-foreground">
{JSON.stringify(JSON.parse(attachment.content), null, 2)}
</code>
</pre>
)}
</div>
);
default:
return null;
}
}
interface NotificationActionsProps {
actions: NotificationAction[];
}
function NotificationActions({ actions }: NotificationActionsProps) {
const { formatMessage } = useIntl();
const [actionStates, setActionStates] = useState<Record<string, ActionStateType>>({});
const [retryCounts, setRetryCounts] = useState<Record<string, number>>({});
const handleActionClick = useCallback(
async (action: NotificationAction, index: number) => {
const actionKey = `${index}-${action.label}`;
// Skip if already loading
if (actionStates[actionKey] === 'loading') {
return;
}
// Handle confirmation if present
if (action.confirm) {
const confirmed = window.confirm(
action.confirm.message || action.label
);
if (!confirmed) {
return;
}
}
// Set loading state
setActionStates((prev) => ({ ...prev, [actionKey]: 'loading' }));
try {
// Call the action handler
await action.onClick();
// Set success state
setActionStates((prev) => ({ ...prev, [actionKey]: 'success' }));
// Reset after 2 seconds
setTimeout(() => {
setActionStates((prev) => ({ ...prev, [actionKey]: 'idle' }));
}, 2000);
} catch (error) {
// Set error state
setActionStates((prev) => ({ ...prev, [actionKey]: 'error' }));
// Increment retry count
setRetryCounts((prev) => ({
...prev,
[actionKey]: (prev[actionKey] || 0) + 1,
}));
// Log error
console.error('[NotificationActions] Action failed:', error);
}
},
[actionStates]
);
const getActionButtonContent = (action: NotificationAction, index: number) => {
const actionKey = `${index}-${action.label}`;
const state = actionStates[actionKey];
const retryCount = retryCounts[actionKey] || 0;
switch (state) {
case 'loading':
return (
<>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{formatMessage({ id: 'notifications.actions.loading' }) || 'Loading...'}
</>
);
case 'success':
return (
<>
<Check className="h-3 w-3 mr-1 text-green-500" />
{formatMessage({ id: 'notifications.actions.success' }) || 'Done'}
</>
);
case 'error':
return (
<>
<RotateCcw className="h-3 w-3 mr-1" />
{formatMessage({ id: 'notifications.actions.retry' }) || 'Retry'}
{retryCount > 0 && (
<span className="ml-1 text-[10px] text-muted-foreground">
({retryCount})
</span>
)}
</>
);
default:
return action.label;
}
};
if (actions.length === 0) return null;
return (
<div className="flex items-center justify-between px-4 py-2 bg-secondary/30 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={onMarkAllRead}
disabled={!hasUnread}
className="h-7 text-xs"
>
<Check className="h-3 w-3 mr-1" />
{formatMessage({ id: 'notificationPanel.markAllRead' }) || 'Mark Read'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-7 text-xs text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
{formatMessage({ id: 'notificationPanel.clearAll' }) || 'Clear All'}
</Button>
<div className="flex flex-wrap gap-2 mt-2">
{actions.map((action, index) => {
const actionKey = `${index}-${action.label}`;
const state = actionStates[actionKey];
return (
<Button
key={actionKey}
variant={action.primary ? 'default' : 'outline'}
size="sm"
onClick={() => handleActionClick(action, index)}
disabled={
action.disabled ||
action.loading ||
state === 'loading'
}
className={cn(
'h-7 text-xs',
state === 'error' && 'text-destructive border-destructive hover:bg-destructive/10',
state === 'success' && 'text-green-600 border-green-600 hover:bg-green-50'
)}
>
{getActionButtonContent(action, index)}
</Button>
);
})}
</div>
);
}
@@ -155,22 +459,31 @@ function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }
interface NotificationItemProps {
notification: Toast;
onDelete: (id: string) => void;
onToggleRead?: (id: string) => void;
}
function NotificationItem({ notification, onDelete }: NotificationItemProps) {
function NotificationItem({ notification, onDelete, onToggleRead }: NotificationItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hasDetails = notification.message && notification.message.length > 100;
const { formatMessage } = useIntl();
const isRead = notification.read ?? false;
const hasActions = notification.actions && notification.actions.length > 0;
const hasLegacyAction = notification.action && !hasActions;
const hasAttachments = notification.attachments && notification.attachments.length > 0;
// Check if this is an A2UI notification
const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface;
// Format absolute timestamp
const absoluteTime = new Date(notification.timestamp).toLocaleString();
return (
<div
className={cn(
'p-3 border-b border-border hover:bg-muted/50 transition-colors',
// Read opacity will be handled in T5 when read field is added
'opacity-100'
'border-l-4',
getTypeBorder(notification.type),
isRead && 'opacity-70'
)}
>
<div className="flex gap-3">
@@ -179,14 +492,59 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header row: title + actions */}
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium text-foreground truncate">
{notification.title}
</h4>
<div className="flex-1 min-w-0">
{/* Title with source badge */}
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-medium text-foreground truncate">
{notification.title}
</h4>
{/* Source badge */}
{notification.source && (
<Badge
variant="outline"
className={cn(
'h-5 px-1.5 text-[10px] font-medium border shrink-0',
getSourceColor(notification.source)
)}
>
{notification.source}
</Badge>
)}
</div>
{/* Timestamp row: absolute + relative */}
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground">
{absoluteTime}
</span>
<span className="text-[10px] text-muted-foreground/70">
({formatTimeAgo(notification.timestamp, formatMessage)})
</span>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatTimeAgo(notification.timestamp, formatMessage)}
</span>
{/* Read/unread toggle */}
{onToggleRead && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 hover:bg-muted"
onClick={() => onToggleRead(notification.id)}
aria-label={isRead
? formatMessage({ id: 'notifications.markAsUnread' }) || 'Mark as unread'
: formatMessage({ id: 'notifications.markAsRead' }) || 'Mark as read'}
title={isRead
? formatMessage({ id: 'notifications.markAsUnread' }) || 'Mark as unread'
: formatMessage({ id: 'notifications.markAsRead' }) || 'Mark as read'}
>
{isRead ? <MailOpen className="h-3 w-3" /> : <Mail className="h-3 w-3" />}
</Button>
)}
{/* Delete button */}
<Button
variant="ghost"
size="icon"
@@ -207,7 +565,7 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
<>
{/* Regular message content */}
{notification.message && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
<p className="text-xs text-muted-foreground mt-1.5 line-clamp-2">
{isExpanded || !hasDetails
? notification.message
: notification.message.slice(0, 100) + '...'}
@@ -223,19 +581,36 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showLess' }) || 'Show less'}
{formatMessage({ id: 'notifications.showLess' }) || 'Show less'}
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showMore' }) || 'Show more'}
{formatMessage({ id: 'notifications.showMore' }) || 'Show more'}
</>
)}
</button>
)}
{/* Action button */}
{notification.action && (
{/* Attachments */}
{hasAttachments && notification.attachments && (
<div className="mt-2">
{notification.attachments.map((attachment, index) => (
<NotificationAttachmentItem
key={`${attachment.type}-${index}`}
attachment={attachment}
/>
))}
</div>
)}
{/* Action buttons (new actions array) */}
{hasActions && notification.actions && (
<NotificationActions actions={notification.actions} />
)}
{/* Legacy single action button */}
{hasLegacyAction && notification.action && (
<Button
variant="outline"
size="sm"
@@ -256,9 +631,10 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
interface NotificationListProps {
notifications: Toast[];
onDelete: (id: string) => void;
onToggleRead?: (id: string) => void;
}
function NotificationList({ notifications, onDelete }: NotificationListProps) {
function NotificationList({ notifications, onDelete, onToggleRead }: NotificationListProps) {
if (notifications.length === 0) return null;
return (
@@ -268,6 +644,7 @@ function NotificationList({ notifications, onDelete }: NotificationListProps) {
key={notification.id}
notification={notification}
onDelete={onDelete}
onToggleRead={onToggleRead}
/>
))}
</div>
@@ -287,11 +664,11 @@ function EmptyState({ message }: EmptyStateProps) {
<Bell className="h-16 w-16 mx-auto mb-4 opacity-30" />
<p className="text-sm">
{message ||
formatMessage({ id: 'notificationPanel.empty' }) ||
formatMessage({ id: 'notifications.empty' }) ||
'No notifications'}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'notificationPanel.emptyHint' }) ||
{formatMessage({ id: 'notifications.emptyHint' }) ||
'Notifications will appear here'}
</p>
</div>
@@ -317,8 +694,11 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
const clearPersistentNotifications = useNotificationStore(
(state) => state.clearPersistentNotifications
);
const toggleNotificationRead = useNotificationStore(
(state) => state.toggleNotificationRead
);
// Check if markAllAsRead exists (will be added in T5)
// Check if markAllAsRead exists
const store = useNotificationStore.getState();
const markAllAsRead = 'markAllAsRead' in store ? (store.markAllAsRead as () => void) : undefined;
@@ -362,6 +742,14 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
clearPersistentNotifications();
}, [clearPersistentNotifications]);
// Toggle read handler
const handleToggleRead = useCallback(
(id: string) => {
toggleNotificationRead(id);
},
[toggleNotificationRead]
);
// ESC key to close
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
@@ -373,9 +761,8 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
// Check for unread notifications (will be enhanced in T5 with read field)
// For now, all notifications are considered "unread" for UI purposes
const hasUnread = sortedNotifications.length > 0;
// Check for unread notifications based on read field
const hasUnread = sortedNotifications.some((n) => !n.read);
if (!isOpen) {
return null;
@@ -403,13 +790,12 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
aria-modal="true"
aria-labelledby="notification-panel-title"
>
{/* Header */}
<PanelHeader notificationCount={sortedNotifications.length} onClose={onClose} />
{/* Action Bar */}
<PanelActions
{/* Header with integrated actions */}
<PanelHeader
notificationCount={sortedNotifications.length}
hasNotifications={sortedNotifications.length > 0}
hasUnread={hasUnread}
onClose={onClose}
onMarkAllRead={handleMarkAllRead}
onClearAll={handleClearAll}
/>
@@ -419,6 +805,7 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
<NotificationList
notifications={sortedNotifications}
onDelete={handleDelete}
onToggleRead={handleToggleRead}
/>
) : (
<EmptyState />