/** * 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; markNotificationRead: (id: string) => Promise; markAllNotificationsRead: (triggerName?: string) => Promise; deleteNotification: (id: string) => Promise; clearNotifications: (triggerName?: string) => Promise; navigateToError: (error: DetectedError) => void; openNotificationsTab: () => void; } // ============================================================================= // Slice Creator // ============================================================================= export const createNotificationSlice: StateCreator = ( 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', }); }, });