agent-ecosystem/src/renderer/store/slices/notificationSlice.ts
iliya 355fe237a6 feat: enhance notification management and team messaging functionality
- Refactored notification handling to utilize NotificationManager for team events, improving consistency and reducing duplicate notifications.
- Introduced deduplication keys for notifications to prevent storage of identical messages.
- Updated notifyNewInboxMessages and notifyNewSentMessages functions to streamline message processing and enhance user experience.
- Enhanced rate limit message handling with in-memory tracking to prevent re-notification of deleted messages.
- Improved UI components with new animations and consistent styling for better user engagement.
- Added support for team-specific notifications, including new event types and improved error handling in notifications.
2026-03-07 13:44:07 +02:00

277 lines
9.2 KiB
TypeScript

/**
* Notification slice - manages notifications state and actions.
*/
import { api } from '@renderer/api';
import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs';
import { createLogger } from '@shared/utils/logger';
import { getAllTabs } from '../utils/paneHelpers';
import type { AppState } from '../types';
import type { DetectedError } from '@renderer/types/data';
import type { StateCreator } from 'zustand';
const logger = createLogger('Store:notification');
const NOTIFICATIONS_FETCH_LIMIT = 200;
// =============================================================================
// Slice Interface
// =============================================================================
export interface NotificationSlice {
// State
notifications: DetectedError[];
unreadCount: number;
notificationsLoading: boolean;
notificationsError: string | null;
// Actions
fetchNotifications: () => Promise<void>;
markNotificationRead: (id: string) => Promise<void>;
markAllNotificationsRead: (triggerName?: string) => Promise<void>;
deleteNotification: (id: string) => Promise<void>;
clearNotifications: (triggerName?: string) => Promise<void>;
navigateToError: (error: DetectedError) => void;
openNotificationsTab: () => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createNotificationSlice: StateCreator<AppState, [], [], NotificationSlice> = (
set,
get
) => ({
// Initial state
notifications: [],
unreadCount: 0,
notificationsLoading: false,
notificationsError: null,
// Fetch all notifications from main process
fetchNotifications: async () => {
set({ notificationsLoading: true, notificationsError: null });
try {
// Fetch the full stored history (manager currently caps storage at 100).
const result = await api.notifications.get({
limit: NOTIFICATIONS_FETCH_LIMIT,
offset: 0,
});
const notifications = result.notifications || [];
const unreadCount =
typeof result.unreadCount === 'number' && Number.isFinite(result.unreadCount)
? Math.max(0, Math.floor(result.unreadCount))
: notifications.filter((n: { isRead: boolean }) => !n.isRead).length;
set({
notifications,
unreadCount,
notificationsLoading: false,
});
} catch (error) {
set({
notificationsError:
error instanceof Error ? error.message : 'Failed to fetch notifications',
notificationsLoading: false,
});
}
},
// Mark a single notification as read
markNotificationRead: async (id: string) => {
try {
const success = await api.notifications.markRead(id);
if (!success) {
await get().fetchNotifications();
return;
}
// Optimistically update local state
set((state) => {
const notifications = state.notifications.map((n) =>
n.id === id ? { ...n, isRead: true } : n
);
const unreadCount = notifications.filter((n) => !n.isRead).length;
return { notifications, unreadCount };
});
} catch (error) {
logger.error('Failed to mark notification as read:', error);
}
},
// Mark all notifications as read (optionally scoped to a trigger)
markAllNotificationsRead: async (triggerName?: string) => {
try {
if (triggerName !== undefined) {
// Scoped: mark only matching unread notifications
const { notifications } = get();
const matching = notifications.filter((n) => {
const label = n.triggerName ?? 'Other';
return label === triggerName && !n.isRead;
});
if (matching.length === 0) return;
const results = await Promise.all(matching.map((n) => api.notifications.markRead(n.id)));
if (results.some((r) => !r)) {
await get().fetchNotifications();
return;
}
const matchingIds = new Set(matching.map((n) => n.id));
set((state) => {
const updated = state.notifications.map((n) =>
matchingIds.has(n.id) ? { ...n, isRead: true } : n
);
return { notifications: updated, unreadCount: updated.filter((n) => !n.isRead).length };
});
} else {
// Unscoped: mark all
const success = await api.notifications.markAllRead();
if (!success) {
await get().fetchNotifications();
return;
}
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
unreadCount: 0,
}));
}
} catch (error) {
logger.error('Failed to mark all notifications as read:', error);
}
},
// Delete a single notification
deleteNotification: async (id: string) => {
try {
const success = await api.notifications.delete(id);
if (!success) {
await get().fetchNotifications();
return;
}
// Optimistically update local state
set((state) => {
const notifications = state.notifications.filter((n) => n.id !== id);
const unreadCount = notifications.filter((n) => !n.isRead).length;
return { notifications, unreadCount };
});
} catch (error) {
logger.error('Failed to delete notification:', error);
}
},
// Clear all notifications (optionally scoped to a trigger)
clearNotifications: async (triggerName?: string) => {
try {
if (triggerName !== undefined) {
// Scoped: delete only matching notifications
const { notifications } = get();
const matching = notifications.filter((n) => {
const label = n.triggerName ?? 'Other';
return label === triggerName;
});
if (matching.length === 0) return;
const results = await Promise.all(matching.map((n) => api.notifications.delete(n.id)));
if (results.some((r) => !r)) {
await get().fetchNotifications();
return;
}
const matchingIds = new Set(matching.map((n) => n.id));
set((state) => {
const remaining = state.notifications.filter((n) => !matchingIds.has(n.id));
return {
notifications: remaining,
unreadCount: remaining.filter((n) => !n.isRead).length,
};
});
} else {
// Unscoped: clear all
const success = await api.notifications.clear();
if (!success) {
await get().fetchNotifications();
return;
}
set({
notifications: [],
unreadCount: 0,
});
}
} catch (error) {
logger.error('Failed to clear notifications:', error);
}
},
// Navigate to error location in session (deep linking)
navigateToError: (error: DetectedError) => {
const state = get();
// Mark the notification as read
void state.markNotificationRead(error.id);
// Team notifications (inbox, clarification, status change, rate-limit): open team tab
if (error.sessionId.startsWith('team:')) {
const teamName = error.sessionId.slice('team:'.length);
state.openTeamTab(teamName, error.context.cwd);
return;
}
// Create the navigation request with a fresh nonce
const navRequest = createErrorNavigationRequest(
{
errorId: error.id,
errorTimestamp: error.timestamp,
toolUseId: error.toolUseId,
subagentId: error.subagentId,
lineNumber: error.lineNumber,
},
'notification',
error.triggerColor
);
// Check if session tab is already open across all panes
const allTabs = getAllTabs(state.paneLayout);
const existingTab = findTabBySessionAndProject(allTabs, error.sessionId, error.projectId);
if (existingTab) {
// Focus existing tab via setActiveTab for proper sidebar sync
state.setActiveTab(existingTab.id);
// Enqueue navigation request with fresh nonce
state.enqueueTabNavigation(existingTab.id, navRequest);
} else {
// Open new session tab via openTab (properly adds to focused pane)
state.openTab({
type: 'session',
label: 'Loading...',
projectId: error.projectId,
sessionId: error.sessionId,
});
// Enqueue navigation on the newly created tab, then trigger sidebar
// sync + session data fetch via setActiveTab
const newTabId = get().activeTabId;
if (newTabId) {
state.enqueueTabNavigation(newTabId, navRequest);
get().setActiveTab(newTabId);
}
}
},
// Open or focus the notifications tab (per-pane singleton)
openNotificationsTab: () => {
const state = get();
// Check if notifications tab exists in focused pane
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const notificationsTab = focusedPane?.tabs.find((t) => t.type === 'notifications');
if (notificationsTab) {
state.setActiveTab(notificationsTab.id);
// Re-sync in case updates happened while tab was inactive.
void state.fetchNotifications();
return;
}
// Create new notifications tab via openTab (which adds to focused pane)
state.openTab({
type: 'notifications',
label: 'Notifications',
});
},
});