refactor(team): extract global task notifications
This commit is contained in:
parent
abd40efdaf
commit
e9a37e7325
3 changed files with 813 additions and 492 deletions
|
|
@ -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,
|
||||
|
|
|
|||
501
src/renderer/store/team/teamGlobalTaskNotifications.ts
Normal file
501
src/renderer/store/team/teamGlobalTaskNotifications.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
297
test/renderer/store/teamGlobalTaskNotifications.test.ts
Normal file
297
test/renderer/store/teamGlobalTaskNotifications.test.ts
Normal 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' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue