refactor(team): extract global task notifications

This commit is contained in:
777genius 2026-05-22 18:04:42 +03:00
parent abd40efdaf
commit e9a37e7325
3 changed files with 813 additions and 492 deletions

View file

@ -12,17 +12,10 @@ import {
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { areTeamAgentRuntimeSnapshotsEqual } from '../team/teamAgentRuntimeSnapshotEquality';
import {
@ -45,6 +38,11 @@ import {
mapSendMessageError,
shouldInvalidateCachedTeamDataForError,
} from '../team/teamErrorPolicies';
import {
consumeFirstGlobalTasksFetchFlag,
processGlobalTaskNotifications,
resetGlobalTaskNotificationTrackerForTests,
} from '../team/teamGlobalTaskNotifications';
import {
areTeamGraphSlotAssignmentsEqual,
DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS,
@ -141,7 +139,6 @@ import type {
} from '../team/teamMessagesCache';
import type { AppState } from '../types';
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { AppConfig } from '@renderer/types/data';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import type {
@ -297,6 +294,7 @@ export function __resetTeamSliceModuleStateForTests(): void {
clearAllMemberSpawnUiEqualLastWarns();
clearResolvedMemberSelectorCaches();
clearTeamMessageSelectorCaches();
resetGlobalTaskNotificationTrackerForTests();
}
function clearTeamScopedSelectorCaches(teamName: string): void {
@ -712,433 +710,6 @@ async function pollProvisioningStatus(
}
}
// --- Clarification notification tracking ---
// Native OS notifications for new inbox messages are handled in main process
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
// handles clarification-specific logic (e.g., marking tasks as needing user input).
const notifiedClarificationTaskKeys = new Set<string>();
const notifiedStatusChangeKeys = new Set<string>();
const notifiedCommentKeys = new Set<string>();
const notifiedCreatedTaskKeys = new Set<string>();
const notifiedAllCompletedTeams = new Set<string>();
const notifiedBlockedTaskKeys = new Set<string>();
let isFirstFetchAllTasks = true;
function detectClarificationNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (task.needsClarification === 'user') {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
notifiedClarificationTaskKeys.add(key);
// Always store in-app; suppress OS toast when per-type toggle is off
fireClarificationNotification(task, !notifyEnabled);
}
} else {
notifiedClarificationTaskKeys.delete(key);
}
}
}
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
// Delegate to main process for native OS notification (cross-platform, no permission needed)
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
const rawBody =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
const body = stripAgentBlocks(rawBody).trim();
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: latestComment?.author || 'team-lead',
to: 'user',
summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`,
body,
teamEventType: 'task_clarification',
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: latestComment?.id,
focus: 'comments',
},
suppressToast,
})
.catch(() => undefined);
}
function detectStatusChangeNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
config: AppConfig | null,
teamByName: Record<string, TeamSummary>
): void {
const statusChangeEnabled =
!!config?.notifications?.notifyOnStatusChange && !!config.notifications.enabled;
const statuses = config?.notifications?.statusChangeStatuses ?? ['in_progress', 'completed'];
if (statuses.length === 0) return;
const onlySolo = config?.notifications?.statusChangeOnlySolo ?? true;
for (const task of newTasks) {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (!oldTask) continue;
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
const becameNeedsFix =
isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask);
const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
if (onlySolo) {
const team = teamByName[task.teamName];
if (team && team.memberCount > 0) continue;
}
// Resolve the effective status for notification matching
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' : becameReview ? 'Completed' : oldTask.status;
fireStatusChangeNotification(
task,
fromLabel,
becameApproved
? 'approved'
: becameReview
? 'review'
: becameNeedsFix
? 'needsFix'
: undefined,
!statusChangeEnabled
);
}
}
function fireStatusChangeNotification(
task: GlobalTask,
fromStatus: string,
overrideToStatus?: string,
suppressToast?: boolean
): void {
const statusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
deleted: 'Deleted',
review: 'Review',
needsFix: 'Needs Fixes',
approved: 'Approved',
};
const from = statusLabels[fromStatus] ?? fromStatus;
const toStatus = overrideToStatus ?? task.status;
const to = statusLabels[toStatus] ?? toStatus;
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Task ${formatTaskDisplayLabel(task)}: ${from}${to}`,
body: task.subject,
teamEventType: 'task_status_change',
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'status',
},
suppressToast,
})
.catch(() => undefined);
}
function detectTaskCommentNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskMap = new Map(oldTasks.map((t) => [`${t.teamName}:${t.id}`, t]));
for (const task of newTasks) {
const mapKey = `${task.teamName}:${task.id}`;
const oldTask = oldTaskMap.get(mapKey);
const oldCommentCount = oldTask?.comments?.length ?? 0;
const newCommentCount = task.comments?.length ?? 0;
if (newCommentCount <= oldCommentCount) continue;
const newComments = (task.comments ?? []).slice(oldCommentCount);
for (const comment of newComments) {
// Don't notify about user's own comments
if (comment.author === 'user') continue;
const key = `${task.teamName}:${task.id}:${comment.id}`;
if (notifiedCommentKeys.has(key)) continue;
notifiedCommentKeys.add(key);
if (comment.type === 'review_request') {
fireTaskReviewRequestedNotification(task, comment, !notifyEnabled);
continue;
}
if (comment.type === 'review_approved') continue;
fireTaskCommentNotification(task, comment, !notifyEnabled);
}
}
}
function fireTaskCommentNotification(
task: GlobalTask,
comment: Pick<TaskComment, 'author' | 'text' | 'id'>,
suppressToast: boolean
): void {
// Double-check: never notify about user's own comments
if (comment.author === 'user') return;
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: comment.author,
to: 'user',
summary: `Comment on ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: preview,
teamEventType: 'task_comment',
dedupeKey: `comment:${task.teamName}:${task.id}:${comment.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: comment.id,
focus: 'comments',
},
suppressToast,
})
.catch(() => undefined);
}
function fireTaskReviewRequestedNotification(
task: GlobalTask,
comment: Pick<TaskComment, 'author' | 'text' | 'id'>,
suppressToast: boolean
): void {
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: comment.author,
to: 'user',
summary: `Review requested ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: preview || task.subject,
teamEventType: 'task_review_requested',
dedupeKey: `review-request:${task.teamName}:${task.id}:${comment.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: comment.id,
focus: 'review',
},
suppressToast,
})
.catch(() => undefined);
}
function detectBlockedTaskNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskMap = new Map(oldTasks.map((task) => [`${task.teamName}:${task.id}`, task]));
for (const task of newTasks) {
const oldTask = oldTaskMap.get(`${task.teamName}:${task.id}`);
const oldBlockedBy = new Set(oldTask?.blockedBy?.filter(Boolean) ?? []);
const newBlockedBy = Array.from(new Set(task.blockedBy?.filter(Boolean) ?? []));
const taskKeyPrefix = `${task.teamName}:${task.id}:`;
const key = `${taskKeyPrefix}${[...newBlockedBy].sort().join(',')}`;
const addedBlockedBy = newBlockedBy.filter((id) => !oldBlockedBy.has(id));
for (const existingKey of Array.from(notifiedBlockedTaskKeys)) {
if (existingKey.startsWith(taskKeyPrefix) && existingKey !== key) {
notifiedBlockedTaskKeys.delete(existingKey);
}
}
if (newBlockedBy.length > 0 && addedBlockedBy.length > 0) {
if (notifiedBlockedTaskKeys.has(key)) continue;
notifiedBlockedTaskKeys.add(key);
fireTaskBlockedNotification(task, newBlockedBy, !notifyEnabled);
} else if (newBlockedBy.length === 0) {
for (const existingKey of Array.from(notifiedBlockedTaskKeys)) {
if (existingKey.startsWith(taskKeyPrefix)) {
notifiedBlockedTaskKeys.delete(existingKey);
}
}
}
}
}
function fireTaskBlockedNotification(
task: GlobalTask,
blockedBy: readonly string[],
suppressToast: boolean
): void {
const blockerRefs = blockedBy.map((id) => formatTaskDisplayLabel({ id })).join(', ');
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Blocked ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: blockerRefs ? `Blocked by ${blockerRefs}` : task.subject,
teamEventType: 'task_blocked',
dedupeKey: `blocked:${task.teamName}:${task.id}:${blockedBy.join(',')}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'detail',
},
suppressToast,
})
.catch(() => undefined);
}
function detectTaskCreatedNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskKeys = new Set(oldTasks.map((t) => `${t.teamName}:${t.id}`));
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (oldTaskKeys.has(key)) continue;
if (notifiedCreatedTaskKeys.has(key)) continue;
notifiedCreatedTaskKeys.add(key);
fireTaskCreatedNotification(task, !notifyEnabled);
}
}
function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): void {
void api.teams
?.showMessageNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: stripAgentBlocks(task.description || task.subject).trim(),
teamEventType: 'task_created',
dedupeKey: `created:${task.teamName}:${task.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'detail',
},
suppressToast,
})
.catch(() => undefined);
}
function detectAllTasksCompletedNotification(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
// Group tasks by team
const teamTasks = new Map<string, GlobalTask[]>();
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(isTeamTaskFinalForCompletionNotification);
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(isTeamTaskFinalForCompletionNotification);
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()}`,
target: {
kind: 'team',
teamName: sampleTask.teamName,
section: 'tasks',
},
suppressToast,
})
.catch(() => undefined);
}
function collectTaskChangeInvalidationState(
teamName: string,
prevTasks: TeamViewSnapshot['tasks'],
@ -1912,69 +1483,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ globalTasksLoading: true, globalTasksError: null });
}
const oldTasks = get().globalTasks;
const wasFirst = isFirstFetchAllTasks;
isFirstFetchAllTasks = false;
const wasFirst = consumeFirstGlobalTasksFetchFlag();
try {
const tasks = await withTimeout(
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
TEAM_FETCH_TIMEOUT_MS,
'fetchAllTasks'
);
if (!wasFirst) {
const notifyOnClarifications =
get().appConfig?.notifications?.notifyOnClarifications ?? true;
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
detectBlockedTaskNotifications(oldTasks, tasks, notifyOnClarifications);
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
const notifyOnTaskComments =
get().appConfig?.notifications?.notifyOnTaskComments ?? true;
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) {
if (task.needsClarification === 'user') {
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
}
if ((task.blockedBy?.length ?? 0) > 0) {
notifiedBlockedTaskKeys.add(
`${task.teamName}:${task.id}:${(task.blockedBy ?? []).join(',')}`
);
}
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (isTeamTaskNeedsFixActionable(task)) {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
}
if (getTeamTaskWorkflowColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
if (getTeamTaskWorkflowColumn(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}`);
}
// Seed created task keys to prevent false notifications
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
}
// Seed all-completed teams
const teamTasksMap = new Map<string, GlobalTask[]>();
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(isTeamTaskFinalForCompletionNotification)) {
notifiedAllCompletedTeams.add(teamName);
}
}
}
const notificationState = get();
processGlobalTaskNotifications({
oldTasks,
newTasks: tasks,
appConfig: notificationState.appConfig,
teamByName: notificationState.teamByName,
isInitialFetch: wasFirst,
});
set({
globalTasks: tasks,

View file

@ -0,0 +1,501 @@
import { api } from '@renderer/api';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import type { AppConfig } from '@renderer/types/data';
import type { GlobalTask, TaskComment, TeamMessageNotificationData, TeamSummary } from '@shared/types';
const notifiedClarificationTaskKeys = new Set<string>();
const notifiedStatusChangeKeys = new Set<string>();
const notifiedCommentKeys = new Set<string>();
const notifiedCreatedTaskKeys = new Set<string>();
const notifiedAllCompletedTeams = new Set<string>();
const notifiedBlockedTaskKeys = new Set<string>();
let isFirstFetchAllTasks = true;
export interface ProcessGlobalTaskNotificationsParams {
oldTasks: GlobalTask[];
newTasks: GlobalTask[];
appConfig: AppConfig | null;
teamByName: Record<string, TeamSummary>;
isInitialFetch: boolean;
}
export function resetGlobalTaskNotificationTrackerForTests(): void {
notifiedClarificationTaskKeys.clear();
notifiedStatusChangeKeys.clear();
notifiedCommentKeys.clear();
notifiedCreatedTaskKeys.clear();
notifiedAllCompletedTeams.clear();
notifiedBlockedTaskKeys.clear();
isFirstFetchAllTasks = true;
}
export function consumeFirstGlobalTasksFetchFlag(): boolean {
const wasFirst = isFirstFetchAllTasks;
isFirstFetchAllTasks = false;
return wasFirst;
}
export function processGlobalTaskNotifications(
params: ProcessGlobalTaskNotificationsParams
): void {
const { oldTasks, newTasks, appConfig, teamByName, isInitialFetch } = params;
if (isInitialFetch) {
seedGlobalTaskNotificationState(newTasks);
return;
}
const notifyOnClarifications = appConfig?.notifications?.notifyOnClarifications ?? true;
detectClarificationNotifications(oldTasks, newTasks, notifyOnClarifications);
detectBlockedTaskNotifications(oldTasks, newTasks, notifyOnClarifications);
detectStatusChangeNotifications(oldTasks, newTasks, appConfig, teamByName);
const notifyOnTaskComments = appConfig?.notifications?.notifyOnTaskComments ?? true;
detectTaskCommentNotifications(oldTasks, newTasks, notifyOnTaskComments);
const notifyOnTaskCreated = appConfig?.notifications?.notifyOnTaskCreated ?? true;
detectTaskCreatedNotifications(oldTasks, newTasks, notifyOnTaskCreated);
const notifyOnAllCompleted = appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
detectAllTasksCompletedNotification(oldTasks, newTasks, notifyOnAllCompleted);
}
function seedGlobalTaskNotificationState(tasks: readonly GlobalTask[]): void {
for (const task of tasks) {
if (task.needsClarification === 'user') {
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
}
if ((task.blockedBy?.length ?? 0) > 0) {
notifiedBlockedTaskKeys.add(`${task.teamName}:${task.id}:${(task.blockedBy ?? []).join(',')}`);
}
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (isTeamTaskNeedsFixActionable(task)) {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
}
if (getTeamTaskWorkflowColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
if (getTeamTaskWorkflowColumn(task) === 'review') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
}
for (const comment of task.comments ?? []) {
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
}
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
}
const teamTasksMap = new Map<string, GlobalTask[]>();
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(isTeamTaskFinalForCompletionNotification)) {
notifiedAllCompletedTeams.add(teamName);
}
}
}
function showTeamNotification(data: TeamMessageNotificationData): void {
void api.teams?.showMessageNotification(data).catch(() => undefined);
}
function detectClarificationNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (task.needsClarification === 'user') {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
notifiedClarificationTaskKeys.add(key);
showClarificationNotification(task, !notifyEnabled);
}
} else {
notifiedClarificationTaskKeys.delete(key);
}
}
}
function showClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
const rawBody =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
const body = stripAgentBlocks(rawBody).trim();
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: latestComment?.author || 'team-lead',
to: 'user',
summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`,
body,
teamEventType: 'task_clarification',
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: latestComment?.id,
focus: 'comments',
},
suppressToast,
});
}
function detectStatusChangeNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
config: AppConfig | null,
teamByName: Record<string, TeamSummary>
): void {
const statusChangeEnabled =
!!config?.notifications?.notifyOnStatusChange && !!config.notifications.enabled;
const statuses = config?.notifications?.statusChangeStatuses ?? ['in_progress', 'completed'];
if (statuses.length === 0) return;
const onlySolo = config?.notifications?.statusChangeOnlySolo ?? true;
for (const task of newTasks) {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (!oldTask) continue;
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
const becameNeedsFix =
isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask);
const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
if (onlySolo) {
const team = teamByName[task.teamName];
if (team && team.memberCount > 0) continue;
}
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' : becameReview ? 'Completed' : oldTask.status;
showStatusChangeNotification(
task,
fromLabel,
becameApproved
? 'approved'
: becameReview
? 'review'
: becameNeedsFix
? 'needsFix'
: undefined,
!statusChangeEnabled
);
}
}
function showStatusChangeNotification(
task: GlobalTask,
fromStatus: string,
overrideToStatus?: string,
suppressToast?: boolean
): void {
const statusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
deleted: 'Deleted',
review: 'Review',
needsFix: 'Needs Fixes',
approved: 'Approved',
};
const from = statusLabels[fromStatus] ?? fromStatus;
const toStatus = overrideToStatus ?? task.status;
const to = statusLabels[toStatus] ?? toStatus;
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Task ${formatTaskDisplayLabel(task)}: ${from}${to}`,
body: task.subject,
teamEventType: 'task_status_change',
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'status',
},
suppressToast,
});
}
function detectTaskCommentNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskMap = new Map(oldTasks.map((t) => [`${t.teamName}:${t.id}`, t]));
for (const task of newTasks) {
const mapKey = `${task.teamName}:${task.id}`;
const oldTask = oldTaskMap.get(mapKey);
const oldCommentCount = oldTask?.comments?.length ?? 0;
const newCommentCount = task.comments?.length ?? 0;
if (newCommentCount <= oldCommentCount) continue;
const newComments = (task.comments ?? []).slice(oldCommentCount);
for (const comment of newComments) {
if (comment.author === 'user') continue;
const key = `${task.teamName}:${task.id}:${comment.id}`;
if (notifiedCommentKeys.has(key)) continue;
notifiedCommentKeys.add(key);
if (comment.type === 'review_request') {
showTaskReviewRequestedNotification(task, comment, !notifyEnabled);
continue;
}
if (comment.type === 'review_approved') continue;
showTaskCommentNotification(task, comment, !notifyEnabled);
}
}
}
function showTaskCommentNotification(
task: GlobalTask,
comment: Pick<TaskComment, 'author' | 'text' | 'id'>,
suppressToast: boolean
): void {
if (comment.author === 'user') return;
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: comment.author,
to: 'user',
summary: `Comment on ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: preview,
teamEventType: 'task_comment',
dedupeKey: `comment:${task.teamName}:${task.id}:${comment.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: comment.id,
focus: 'comments',
},
suppressToast,
});
}
function showTaskReviewRequestedNotification(
task: GlobalTask,
comment: Pick<TaskComment, 'author' | 'text' | 'id'>,
suppressToast: boolean
): void {
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: comment.author,
to: 'user',
summary: `Review requested ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: preview || task.subject,
teamEventType: 'task_review_requested',
dedupeKey: `review-request:${task.teamName}:${task.id}:${comment.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: comment.id,
focus: 'review',
},
suppressToast,
});
}
function detectBlockedTaskNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskMap = new Map(oldTasks.map((task) => [`${task.teamName}:${task.id}`, task]));
for (const task of newTasks) {
const oldTask = oldTaskMap.get(`${task.teamName}:${task.id}`);
const oldBlockedBy = new Set(oldTask?.blockedBy?.filter(Boolean) ?? []);
const newBlockedBy = Array.from(new Set(task.blockedBy?.filter(Boolean) ?? []));
const taskKeyPrefix = `${task.teamName}:${task.id}:`;
const key = `${taskKeyPrefix}${[...newBlockedBy].sort().join(',')}`;
const addedBlockedBy = newBlockedBy.filter((id) => !oldBlockedBy.has(id));
for (const existingKey of Array.from(notifiedBlockedTaskKeys)) {
if (existingKey.startsWith(taskKeyPrefix) && existingKey !== key) {
notifiedBlockedTaskKeys.delete(existingKey);
}
}
if (newBlockedBy.length > 0 && addedBlockedBy.length > 0) {
if (notifiedBlockedTaskKeys.has(key)) continue;
notifiedBlockedTaskKeys.add(key);
showTaskBlockedNotification(task, newBlockedBy, !notifyEnabled);
} else if (newBlockedBy.length === 0) {
for (const existingKey of Array.from(notifiedBlockedTaskKeys)) {
if (existingKey.startsWith(taskKeyPrefix)) {
notifiedBlockedTaskKeys.delete(existingKey);
}
}
}
}
}
function showTaskBlockedNotification(
task: GlobalTask,
blockedBy: readonly string[],
suppressToast: boolean
): void {
const blockerRefs = blockedBy.map((id) => formatTaskDisplayLabel({ id })).join(', ');
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Blocked ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: blockerRefs ? `Blocked by ${blockerRefs}` : task.subject,
teamEventType: 'task_blocked',
dedupeKey: `blocked:${task.teamName}:${task.id}:${blockedBy.join(',')}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'detail',
},
suppressToast,
});
}
function detectTaskCreatedNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskKeys = new Set(oldTasks.map((t) => `${t.teamName}:${t.id}`));
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (oldTaskKeys.has(key)) continue;
if (notifiedCreatedTaskKeys.has(key)) continue;
notifiedCreatedTaskKeys.add(key);
showTaskCreatedNotification(task, !notifyEnabled);
}
}
function showTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): void {
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: stripAgentBlocks(task.description || task.subject).trim(),
teamEventType: 'task_created',
dedupeKey: `created:${task.teamName}:${task.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'detail',
},
suppressToast,
});
}
function detectAllTasksCompletedNotification(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const teamTasks = new Map<string, GlobalTask[]>();
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(isTeamTaskFinalForCompletionNotification);
if (!allCompleted) {
notifiedAllCompletedTeams.delete(teamName);
continue;
}
if (notifiedAllCompletedTeams.has(teamName)) continue;
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
const wasAlreadyAllCompleted =
oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
if (wasAlreadyAllCompleted) {
notifiedAllCompletedTeams.add(teamName);
continue;
}
notifiedAllCompletedTeams.add(teamName);
showAllTasksCompletedNotification(tasks[0], tasks.length, !notifyEnabled);
}
}
function showAllTasksCompletedNotification(
sampleTask: GlobalTask,
taskCount: number,
suppressToast: boolean
): void {
showTeamNotification({
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()}`,
target: {
kind: 'team',
teamName: sampleTask.teamName,
section: 'tasks',
},
suppressToast,
});
}

View file

@ -0,0 +1,297 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
consumeFirstGlobalTasksFetchFlag,
processGlobalTaskNotifications,
resetGlobalTaskNotificationTrackerForTests,
} from '../../../src/renderer/store/team/teamGlobalTaskNotifications';
import type { AppConfig } from '../../../src/renderer/types/data';
import type {
GlobalTask,
TaskComment,
TeamMessageNotificationData,
TeamSummary,
} from '../../../src/shared/types';
const hoisted = vi.hoisted(() => ({
showMessageNotification: vi.fn(async (_data: unknown) => undefined),
}));
vi.mock('@renderer/api', () => ({
api: {
teams: {
showMessageNotification: hoisted.showMessageNotification,
},
},
}));
function createTask(overrides: Partial<GlobalTask> = {}): GlobalTask {
return {
id: 'task-1',
teamName: 'team-a',
teamDisplayName: 'Team A',
subject: 'Ship refactor',
description: 'Refactor safely',
status: 'pending',
owner: 'alice',
comments: [],
blockedBy: [],
updatedAt: '2026-05-22T10:00:00.000Z',
...overrides,
} as GlobalTask;
}
function createComment(overrides: Partial<TaskComment> = {}): TaskComment {
return {
id: 'c1',
author: 'bob',
text: 'Looks good',
type: 'comment',
createdAt: '2026-05-22T10:00:00.000Z',
...overrides,
} as TaskComment;
}
function createConfig(
notifications: Partial<NonNullable<AppConfig['notifications']>> = {}
): AppConfig {
return {
notifications: {
enabled: true,
notifyOnClarifications: true,
notifyOnStatusChange: true,
notifyOnTaskComments: true,
notifyOnTaskCreated: true,
notifyOnAllTasksCompleted: true,
statusChangeStatuses: ['in_progress', 'completed'],
statusChangeOnlySolo: true,
...notifications,
},
} as AppConfig;
}
function teamSummary(memberCount = 0): TeamSummary {
return {
teamName: 'team-a',
displayName: 'Team A',
memberCount,
} as TeamSummary;
}
function sentNotifications(): TeamMessageNotificationData[] {
return hoisted.showMessageNotification.mock.calls.map(
([payload]) => payload as TeamMessageNotificationData
);
}
describe('teamGlobalTaskNotifications', () => {
afterEach(() => {
hoisted.showMessageNotification.mockClear();
resetGlobalTaskNotificationTrackerForTests();
});
it('tracks the first global tasks fetch as a resettable module flag', () => {
expect(consumeFirstGlobalTasksFetchFlag()).toBe(true);
expect(consumeFirstGlobalTasksFetchFlag()).toBe(false);
resetGlobalTaskNotificationTrackerForTests();
expect(consumeFirstGlobalTasksFetchFlag()).toBe(true);
});
it('seeds initial tasks without sending notifications', () => {
processGlobalTaskNotifications({
oldTasks: [],
newTasks: [
createTask({
needsClarification: 'user',
blockedBy: ['task-2'],
comments: [createComment({ text: 'Needs review' })],
status: 'completed',
}),
],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: true,
});
expect(hoisted.showMessageNotification).not.toHaveBeenCalled();
});
it('emits clarification notifications and respects the per-type toast toggle', () => {
processGlobalTaskNotifications({
oldTasks: [createTask()],
newTasks: [
createTask({
needsClarification: 'user',
comments: [createComment({ text: 'Please clarify' })],
}),
],
appConfig: createConfig({ notifyOnClarifications: false }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_clarification',
from: 'bob',
body: 'Please clarify',
suppressToast: true,
target: {
kind: 'task',
teamName: 'team-a',
taskId: 'task-1',
commentId: 'c1',
focus: 'comments',
},
},
{
teamEventType: 'task_comment',
from: 'bob',
body: 'Please clarify',
suppressToast: false,
target: {
kind: 'task',
teamName: 'team-a',
taskId: 'task-1',
commentId: 'c1',
focus: 'comments',
},
},
]);
});
it('emits status changes only for solo teams when solo filtering is enabled', () => {
const oldTask = createTask({ status: 'pending' });
const newTask = createTask({ status: 'in_progress' });
processGlobalTaskNotifications({
oldTasks: [oldTask],
newTasks: [newTask],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary(2) },
isInitialFetch: false,
});
expect(hoisted.showMessageNotification).not.toHaveBeenCalled();
processGlobalTaskNotifications({
oldTasks: [oldTask],
newTasks: [newTask],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary(0) },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_status_change',
from: 'alice',
body: 'Ship refactor',
suppressToast: false,
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', focus: 'status' },
},
]);
});
it('emits only actionable teammate comments and review requests', () => {
processGlobalTaskNotifications({
oldTasks: [createTask({ comments: [] })],
newTasks: [
createTask({
comments: [
createComment({ id: 'c1', author: 'bob', text: 'Looks blocked' }),
createComment({ id: 'c2', author: 'user', text: 'My own note' }),
createComment({
id: 'c3',
author: 'reviewer',
text: 'Review this',
type: 'review_request',
}),
],
}),
],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_comment',
from: 'bob',
body: 'Looks blocked',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', commentId: 'c1' },
},
{
teamEventType: 'task_review_requested',
from: 'reviewer',
body: 'Review this',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', commentId: 'c3' },
},
]);
});
it('emits blocked and created task notifications on non-initial updates', () => {
const existingTask = createTask();
const newTask = createTask({ id: 'task-3', subject: 'New work' });
processGlobalTaskNotifications({
oldTasks: [existingTask],
newTasks: [createTask({ blockedBy: ['task-2'] }), newTask],
appConfig: createConfig({ statusChangeStatuses: [] }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_blocked',
body: 'Blocked by #task-2',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', focus: 'detail' },
},
{
teamEventType: 'task_created',
body: 'Refactor safely',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-3', focus: 'detail' },
},
]);
});
it('emits all-completed once when a team transitions into final tasks', () => {
const oldTasks = [
createTask({ id: 'task-1', status: 'completed' }),
createTask({ id: 'task-2', status: 'in_progress' }),
];
const newTasks = [
createTask({ id: 'task-1', status: 'completed' }),
createTask({ id: 'task-2', status: 'completed' }),
];
processGlobalTaskNotifications({
oldTasks,
newTasks,
appConfig: createConfig({ statusChangeStatuses: [] }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
processGlobalTaskNotifications({
oldTasks,
newTasks,
appConfig: createConfig({ statusChangeStatuses: [] }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'all_tasks_completed',
from: 'system',
to: 'user',
summary: 'All 2 tasks completed',
target: { kind: 'team', teamName: 'team-a', section: 'tasks' },
},
]);
});
});