diff --git a/src/main/index.ts b/src/main/index.ts index 7f14611e..9624b6ec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -255,16 +255,27 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise const summary = msg.summary || extracted.summary; const msgId = msg.timestamp ?? String(prevCount + i); + // Cross-team messages get their own event type and per-type toggle + const isCrossTeam = msg.source === 'cross_team'; + const eventType: 'lead_inbox' | 'user_inbox' | 'cross_team_message' = isCrossTeam + ? 'cross_team_message' + : isLeadInbox + ? 'lead_inbox' + : 'user_inbox'; + const effectiveSuppressToast = isCrossTeam + ? !config.notifications.enabled || !config.notifications.notifyOnCrossTeamMessage + : suppressToast; + void notificationManager .addTeamNotification({ - teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox', + teamEventType: eventType, teamName, teamDisplayName, from: fromLabel, summary, body: extracted.body, dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`, - suppressToast, + suppressToast: effectiveSuppressToast, }) .catch(() => undefined); } diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index f2a122d7..4cfc74a9 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -115,6 +115,8 @@ function validateNotificationsSection( 'notifyOnStatusChange', 'notifyOnTaskComments', 'notifyOnTaskCreated', + 'notifyOnAllTasksCompleted', + 'notifyOnCrossTeamMessage', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -185,6 +187,18 @@ function validateNotificationsSection( } result.notifyOnTaskCreated = value; break; + case 'notifyOnAllTasksCompleted': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnAllTasksCompleted = value; + break; + case 'notifyOnCrossTeamMessage': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnCrossTeamMessage = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 67994b31..43141da9 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -60,6 +60,8 @@ export interface DetectedError { | 'task_status_change' | 'task_comment' | 'task_created' + | 'all_tasks_completed' + | 'cross_team_message' | 'schedule_completed' | 'schedule_failed'; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index bce97aa8..2733948a 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -52,6 +52,10 @@ export interface NotificationConfig { notifyOnTaskComments: boolean; /** Whether to show native OS notifications when a new task is created */ notifyOnTaskCreated: boolean; + /** Whether to show native OS notifications when all tasks in a team are completed */ + notifyOnAllTasksCompleted: boolean; + /** Whether to show native OS notifications for cross-team messages */ + notifyOnCrossTeamMessage: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -267,6 +271,8 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnStatusChange: true, notifyOnTaskComments: true, notifyOnTaskCreated: true, + notifyOnAllTasksCompleted: true, + notifyOnCrossTeamMessage: true, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index eab6ffdf..63a01d75 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -22,6 +22,8 @@ export type TeamEventType = | 'task_status_change' | 'task_comment' | 'task_created' + | 'all_tasks_completed' + | 'cross_team_message' | 'schedule_completed' | 'schedule_failed'; @@ -65,6 +67,8 @@ const TEAM_NOTIFICATION_CONFIG: Record = task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' }, task_created: { triggerName: 'Task Created', triggerColor: 'green' }, + all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' }, + cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, }; diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 78473019..eb5ff447 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -48,6 +48,8 @@ export interface SafeConfig { notifyOnStatusChange: boolean; notifyOnTaskComments: boolean; notifyOnTaskCreated: boolean; + notifyOnAllTasksCompleted: boolean; + notifyOnCrossTeamMessage: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -183,6 +185,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true, notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true, notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true, + notifyOnAllTasksCompleted: + displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true, + notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ 'in_progress', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 431b451e..1ed4f7c4 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -301,6 +301,8 @@ export function useSettingsHandlers({ notifyOnStatusChange: true, notifyOnTaskComments: true, notifyOnTaskCreated: true, + notifyOnAllTasksCompleted: true, + notifyOnCrossTeamMessage: true, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 52b7e565..d4bdcaaf 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -24,6 +24,8 @@ import { MessageSquare, PartyPopper, CirclePlus, + CheckCircle2, + GitBranch, Send, Users, Volume2, @@ -37,7 +39,7 @@ import type { NotificationTrigger } from '@renderer/types/data'; import type { TeamReviewState, TeamTaskStatus } from '@shared/types'; /** Notification targets span workflow status plus the explicit review axis. */ -type NotifiableStatus = TeamTaskStatus | Extract; +type NotifiableStatus = TeamTaskStatus | Extract; // Snooze duration options const SNOOZE_OPTIONS = [ @@ -66,6 +68,8 @@ interface NotificationsSectionProps { | 'notifyOnStatusChange' | 'notifyOnTaskComments' | 'notifyOnTaskCreated' + | 'notifyOnAllTasksCompleted' + | 'notifyOnCrossTeamMessage' | 'statusChangeOnlySolo', value: boolean ) => void; @@ -306,6 +310,28 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + } + > + onNotificationToggle('notifyOnAllTasksCompleted', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + } + > + onNotificationToggle('notifyOnCrossTeamMessage', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + {/* Task Status Change Notifications — nested within team card */}
@@ -447,6 +473,7 @@ export const NotificationsSection = ({ const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [ { value: 'in_progress', label: 'Started' }, { value: 'completed', label: 'Completed' }, + { value: 'review', label: 'Review' }, { value: 'needsFix', label: 'Needs Fixes' }, { value: 'approved', label: 'Approved' }, { value: 'pending', label: 'Pending' }, diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 1658000c..552ba129 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -117,6 +117,7 @@ const notifiedClarificationTaskKeys = new Set(); const notifiedStatusChangeKeys = new Set(); const notifiedCommentKeys = new Set(); const notifiedCreatedTaskKeys = new Set(); +const notifiedAllCompletedTeams = new Set(); let isFirstFetchAllTasks = true; @@ -182,10 +183,11 @@ function detectStatusChangeNotifications( const taskKanbanColumn = getTaskKanbanColumn(task); const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask); const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved'; + const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review'; const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix'; const statusChanged = oldTask.status !== task.status; - if (!statusChanged && !becameApproved && !becameNeedsFix) continue; + if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue; if (onlySolo) { const team = teamByName[task.teamName]; @@ -193,18 +195,24 @@ function detectStatusChangeNotifications( } // Resolve the effective status for notification matching - const effectiveStatus = becameApproved ? 'approved' : becameNeedsFix ? 'needsFix' : task.status; + const effectiveStatus = becameApproved + ? 'approved' + : becameReview + ? 'review' + : becameNeedsFix + ? 'needsFix' + : task.status; if (!statuses.includes(effectiveStatus)) continue; const key = `${task.teamName}:${task.id}:${effectiveStatus}`; if (notifiedStatusChangeKeys.has(key)) continue; notifiedStatusChangeKeys.add(key); - const fromLabel = becameApproved ? 'Completed' : oldTask.status; + const fromLabel = becameApproved ? 'Completed' : becameReview ? 'Completed' : oldTask.status; fireStatusChangeNotification( task, fromLabel, - becameApproved ? 'approved' : becameNeedsFix ? 'needsFix' : undefined, + becameApproved ? 'approved' : becameReview ? 'review' : becameNeedsFix ? 'needsFix' : undefined, !statusChangeEnabled ); } @@ -221,6 +229,7 @@ function fireStatusChangeNotification( in_progress: 'In Progress', completed: 'Completed', deleted: 'Deleted', + review: 'Review', needsFix: 'Needs Fixes', approved: 'Approved', }; @@ -329,6 +338,64 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): .catch(() => undefined); } +function detectAllTasksCompletedNotification( + oldTasks: GlobalTask[], + newTasks: GlobalTask[], + notifyEnabled: boolean +): void { + // Group tasks by team + const teamTasks = new Map(); + for (const task of newTasks) { + const list = teamTasks.get(task.teamName) ?? []; + list.push(task); + teamTasks.set(task.teamName, list); + } + + for (const [teamName, tasks] of teamTasks) { + if (tasks.length === 0) continue; + const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted'); + if (!allCompleted) { + // Reset so we can notify again if tasks become all-completed later + notifiedAllCompletedTeams.delete(teamName); + continue; + } + if (notifiedAllCompletedTeams.has(teamName)) continue; + + // Check that at least one task was NOT completed before (real transition) + const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName); + const wasAlreadyAllCompleted = + oldTeamTasks.length > 0 && + oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted'); + if (wasAlreadyAllCompleted) { + notifiedAllCompletedTeams.add(teamName); + continue; + } + + notifiedAllCompletedTeams.add(teamName); + fireAllTasksCompletedNotification(tasks[0], tasks.length, !notifyEnabled); + } +} + +function fireAllTasksCompletedNotification( + sampleTask: GlobalTask, + taskCount: number, + suppressToast: boolean +): void { + void api.teams + ?.showMessageNotification({ + teamName: sampleTask.teamName, + teamDisplayName: sampleTask.teamDisplayName, + from: 'system', + to: 'user', + summary: `All ${taskCount} tasks completed`, + body: `All tasks in team "${sampleTask.teamDisplayName}" are done`, + teamEventType: 'all_tasks_completed', + dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`, + suppressToast, + }) + .catch(() => undefined); +} + function collectTaskChangeInvalidationState( teamName: string, prevTasks: TeamData['tasks'], @@ -888,6 +955,9 @@ export const createTeamSlice: StateCreator = (set, detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments); const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true; detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated); + const notifyOnAllCompleted = + get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true; + detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted); } else { // Initial load — seed the Sets to prevent false notifications on next update for (const task of tasks) { @@ -901,6 +971,9 @@ export const createTeamSlice: StateCreator = (set, if (getTaskKanbanColumn(task) === 'approved') { notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`); } + if (getTaskKanbanColumn(task) === 'review') { + notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`); + } // Seed comment keys to prevent false notifications for (const comment of task.comments ?? []) { notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`); @@ -908,6 +981,18 @@ export const createTeamSlice: StateCreator = (set, // Seed created task keys to prevent false notifications notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`); } + // Seed all-completed teams + const teamTasksMap = new Map(); + for (const task of tasks) { + const list = teamTasksMap.get(task.teamName) ?? []; + list.push(task); + teamTasksMap.set(task.teamName, list); + } + for (const [teamName, teamTasks] of teamTasksMap) { + if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) { + notifiedAllCompletedTeams.add(teamName); + } + } } set({ diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 95c6eb43..f5cc5e57 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -61,6 +61,8 @@ export interface DetectedError { | 'task_status_change' | 'task_comment' | 'task_created' + | 'all_tasks_completed' + | 'cross_team_message' | 'schedule_completed' | 'schedule_failed'; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ @@ -274,6 +276,10 @@ export interface AppConfig { notifyOnTaskComments: boolean; /** Whether to show native OS notifications when a new task is created */ notifyOnTaskCreated: boolean; + /** Whether to show native OS notifications when all tasks in a team are completed */ + notifyOnAllTasksCompleted: boolean; + /** Whether to show native OS notifications for cross-team messages */ + notifyOnCrossTeamMessage: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 76d7fbd2..a59c2251 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -713,7 +713,7 @@ export interface TeamMessageNotificationData { /** Optional sender color for visual context. */ color?: string; /** Team event sub-type for notification categorization. */ - teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment' | 'task_created'; + teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment' | 'task_created' | 'all_tasks_completed'; /** Stable key for storage deduplication. Required — no fallback to Date.now(). */ dedupeKey?: string; /** diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts index 2b394840..7765f604 100644 --- a/test/main/utils/teamNotificationBuilder.test.ts +++ b/test/main/utils/teamNotificationBuilder.test.ts @@ -94,6 +94,8 @@ describe('buildDetectedErrorFromTeam', () => { task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' }, task_created: { triggerName: 'Task Created', triggerColor: 'green' }, + all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' }, + cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, };