- 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.
277 lines
9.2 KiB
TypeScript
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',
|
|
});
|
|
},
|
|
});
|