agent-ecosystem/src/renderer/components/notifications/NotificationsView.tsx
matt 0cd75a4f1a feat: enhance notification handling with scoped actions
- Updated `markAllNotificationsRead` and `clearNotifications` functions to support optional filtering by trigger name, allowing for more granular control over notification management.
- Adjusted the `NotificationsView` component to reflect the new scoped functionality, including updates to button labels and unread count displays based on active filters.
- Added tests to verify the behavior of scoped actions for marking notifications as read and clearing notifications.
2026-02-20 12:46:40 +09:00

371 lines
13 KiB
TypeScript

/**
* NotificationsView - Linear Inbox-style notifications page.
* Single list showing all notifications with unread indicator.
* Includes a filter chip bar to filter by trigger name.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { useVirtualizer } from '@tanstack/react-virtual';
import { CheckCheck, Inbox, Loader2, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { NotificationRow } from './NotificationRow';
import type { DetectedError } from '@renderer/types/data';
// Virtual list constants
const ROW_HEIGHT = 56;
const OVERSCAN = 5;
/** Label used for notifications without a triggerName */
const OTHER_LABEL = 'Other';
interface FilterChip {
label: string;
count: number;
colorHex: string;
}
export const NotificationsView = (): React.JSX.Element => {
const {
notifications,
unreadCount,
fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
deleteNotification,
clearNotifications,
navigateToError,
} = useStore(
useShallow((s) => ({
notifications: s.notifications,
unreadCount: s.unreadCount,
fetchNotifications: s.fetchNotifications,
markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead,
deleteNotification: s.deleteNotification,
clearNotifications: s.clearNotifications,
navigateToError: s.navigateToError,
}))
);
const parentRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [activeFilter, setActiveFilter] = useState<string | null>(null);
// Fetch notifications on mount
useEffect(() => {
const loadNotifications = async (): Promise<void> => {
setIsLoading(true);
try {
await fetchNotifications();
} finally {
setIsLoading(false);
}
};
void loadNotifications();
}, [fetchNotifications]);
// Sort notifications by timestamp (most recent first)
const sortedNotifications = useMemo(() => {
return [...notifications].sort((a, b) => b.timestamp - a.timestamp);
}, [notifications]);
// Derive filter chips from notifications
const filterChips = useMemo((): FilterChip[] => {
const counts = new Map<string, { count: number; colorHex: string }>();
for (const n of sortedNotifications) {
const label = n.triggerName ?? OTHER_LABEL;
const existing = counts.get(label);
if (existing) {
existing.count++;
} else {
counts.set(label, {
count: 1,
colorHex: getTriggerColorDef(n.triggerColor).hex,
});
}
}
// Sort by frequency descending
return Array.from(counts.entries())
.sort((a, b) => b[1].count - a[1].count)
.map(([label, { count, colorHex }]) => ({ label, count, colorHex }));
}, [sortedNotifications]);
// Reset filter when all notifications are cleared
useEffect(() => {
if (notifications.length === 0) {
setActiveFilter(null);
}
}, [notifications.length]);
// Apply filter
const filteredNotifications = useMemo(() => {
if (activeFilter === null) return sortedNotifications;
return sortedNotifications.filter((n) => {
const label = n.triggerName ?? OTHER_LABEL;
return label === activeFilter;
});
}, [sortedNotifications, activeFilter]);
// Estimate item size
const estimateSize = useCallback(() => ROW_HEIGHT, []);
// Set up virtualizer
const rowVirtualizer = useVirtualizer({
count: filteredNotifications.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: OVERSCAN,
});
// Scroll to top when filter changes
useEffect(() => {
rowVirtualizer.scrollToIndex(0);
}, [activeFilter, rowVirtualizer]);
// Derive filtered unread count for scoped button visibility
const filteredUnreadCount = useMemo(() => {
if (activeFilter === null) return unreadCount;
return filteredNotifications.filter((n) => !n.isRead).length;
}, [activeFilter, filteredNotifications, unreadCount]);
// Handle mark all read (scoped to active filter)
const handleMarkAllRead = async (): Promise<void> => {
await markAllNotificationsRead(activeFilter ?? undefined);
};
// Handle clear all with confirmation (scoped to active filter)
const handleClearAll = async (): Promise<void> => {
if (showClearConfirm) {
await clearNotifications(activeFilter ?? undefined);
setShowClearConfirm(false);
} else {
setShowClearConfirm(true);
// Auto-hide confirmation after 3 seconds
setTimeout(() => setShowClearConfirm(false), 3000);
}
};
// Handle archive (mark as read)
const handleArchive = async (id: string): Promise<void> => {
await markNotificationRead(id);
};
// Handle delete
const handleDelete = async (id: string): Promise<void> => {
await deleteNotification(id);
};
// Handle row click - navigate to error
const handleRowClick = (error: DetectedError): void => {
// Mark as read when navigating
if (!error.isRead) {
void markNotificationRead(error.id);
}
navigateToError(error);
};
// Handle filter chip click
const handleFilterClick = (label: string): void => {
setActiveFilter((prev) => (prev === label ? null : label));
};
// Loading state
if (isLoading) {
return (
<div
className="flex flex-1 flex-col overflow-hidden"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex items-center justify-center py-16">
<Loader2
className="mr-2 size-5 animate-spin"
style={{ color: 'var(--color-text-muted)' }}
/>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Loading notifications...
</span>
</div>
</div>
);
}
return (
<div
className="flex flex-1 flex-col overflow-hidden"
style={{ backgroundColor: 'var(--color-surface)' }}
>
{/* Header */}
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border-subtle)' }}>
<div className="flex items-center justify-between px-4 py-3">
{/* Title */}
<div className="flex items-center gap-2">
<Inbox className="size-4" style={{ color: 'var(--color-text-secondary)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Notifications
</span>
{notifications.length > 0 && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{activeFilter !== null
? filteredUnreadCount > 0
? `${filteredUnreadCount} unread in filter`
: `${filteredNotifications.length} in filter`
: unreadCount > 0
? `${unreadCount} unread`
: `${notifications.length} total`}
</span>
)}
</div>
{/* Action Buttons */}
{notifications.length > 0 && (
<div className="flex items-center gap-1">
{/* Mark all/filtered read */}
{filteredUnreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors hover:opacity-80"
style={{ color: 'var(--color-text-muted)' }}
title={activeFilter !== null ? 'Mark filtered as read' : 'Mark all as read'}
>
<CheckCheck className="size-4" />
<span className="hidden sm:inline">
{activeFilter !== null ? 'Mark filtered read' : 'Mark all read'}
</span>
</button>
)}
{/* Clear all/filtered */}
<button
onClick={handleClearAll}
className={`flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
showClearConfirm
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'hover:opacity-80'
}`}
style={showClearConfirm ? undefined : { color: 'var(--color-text-muted)' }}
title={
activeFilter !== null ? 'Clear filtered notifications' : 'Clear all notifications'
}
>
<Trash2 className="size-4" />
<span className="hidden sm:inline">
{showClearConfirm
? 'Click to confirm'
: activeFilter !== null
? 'Clear filtered'
: 'Clear all'}
</span>
</button>
</div>
)}
</div>
</div>
{/* Filter Chip Bar */}
{filterChips.length > 1 && (
<div
className="scrollbar-none shrink-0 overflow-x-auto border-b"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="flex items-center gap-1.5 px-4 py-2">
{/* All chip */}
<button
onClick={() => setActiveFilter(null)}
className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs transition-colors"
style={{
backgroundColor: activeFilter === null ? 'var(--color-surface-raised)' : undefined,
color: activeFilter === null ? 'var(--color-text)' : 'var(--color-text-muted)',
border:
activeFilter === null
? '1px solid var(--color-border-emphasis)'
: '1px solid var(--color-border)',
}}
>
All
<span className="opacity-60">({sortedNotifications.length})</span>
</button>
{/* Trigger chips */}
{filterChips.map((chip) => (
<button
key={chip.label}
onClick={() => handleFilterClick(chip.label)}
className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs transition-colors"
style={{
backgroundColor:
activeFilter === chip.label ? 'var(--color-surface-raised)' : undefined,
color:
activeFilter === chip.label ? 'var(--color-text)' : 'var(--color-text-muted)',
border:
activeFilter === chip.label
? '1px solid var(--color-border-emphasis)'
: '1px solid var(--color-border)',
}}
>
<span className="size-2 rounded-full" style={{ backgroundColor: chip.colorHex }} />
{chip.label}
<span className="opacity-60">({chip.count})</span>
</button>
))}
</div>
</div>
)}
{/* Notifications List */}
<div ref={parentRef} className="flex-1 overflow-y-auto">
{filteredNotifications.length === 0 ? (
<div
className="flex flex-col items-center justify-center py-16"
style={{ color: 'var(--color-text-muted)' }}
>
<Inbox className="mb-3 size-10 opacity-30" />
<p className="mb-1 text-sm font-medium">
{activeFilter !== null ? 'No matching notifications' : 'No notifications'}
</p>
<p className="text-xs opacity-70">
{activeFilter !== null ? 'Try a different filter' : "You're all caught up!"}
</p>
</div>
) : (
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const notification = filteredNotifications[virtualRow.index];
if (!notification) return null;
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<NotificationRow
error={notification}
onRowClick={() => handleRowClick(notification)}
onArchive={() => handleArchive(notification.id)}
onDelete={() => handleDelete(notification.id)}
/>
</div>
);
})}
</div>
)}
</div>
</div>
);
};