From 2ca66d8632e528ee4c6a04f09bae2908b81aed0d Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 15 Mar 2026 12:52:59 +0200 Subject: [PATCH] feat: add task comment notification feature - Introduced a new notification setting for task comments, allowing users to receive OS notifications when comments are added to tasks. - Updated relevant interfaces and configuration files to include the new `notifyOnTaskComments` option. - Enhanced notification handling logic to detect and notify users of new task comments, excluding comments made by the user themselves. - Updated UI components to support the new notification setting, ensuring a seamless user experience. --- src/main/ipc/configValidation.ts | 7 +++ .../services/error/ErrorMessageBuilder.ts | 1 + .../services/infrastructure/ConfigManager.ts | 3 + src/main/utils/teamNotificationBuilder.ts | 2 + .../settings/hooks/useSettingsConfig.ts | 2 + .../settings/hooks/useSettingsHandlers.ts | 1 + .../sections/NotificationsSection.tsx | 11 ++++ .../team/dialogs/AddMemberDialog.tsx | 28 ++++----- .../team/dialogs/TaskDetailDialog.tsx | 60 +++++++++++++++---- .../components/team/members/MemberLogsTab.tsx | 13 +++- src/renderer/store/slices/teamSlice.ts | 60 +++++++++++++++++++ src/shared/types/notifications.ts | 3 + src/shared/types/team.ts | 2 +- .../utils/teamNotificationBuilder.test.ts | 1 + 14 files changed, 163 insertions(+), 31 deletions(-) diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index e4f64c8f..5d08014d 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -113,6 +113,7 @@ function validateNotificationsSection( 'snoozedUntil', 'snoozeMinutes', 'notifyOnStatusChange', + 'notifyOnTaskComments', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -171,6 +172,12 @@ function validateNotificationsSection( } result.notifyOnStatusChange = value; break; + case 'notifyOnTaskComments': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnTaskComments = 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 28d888c3..88b0e3ed 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -58,6 +58,7 @@ export interface DetectedError { | 'user_inbox' | 'task_clarification' | 'task_status_change' + | 'task_comment' | '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 8030a0e1..4600013d 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -48,6 +48,8 @@ export interface NotificationConfig { notifyOnClarifications: boolean; /** Whether to show native OS notifications when a task status changes */ notifyOnStatusChange: boolean; + /** Whether to show native OS notifications when a new comment is added to a task */ + notifyOnTaskComments: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -261,6 +263,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnUserInbox: true, notifyOnClarifications: true, notifyOnStatusChange: true, + notifyOnTaskComments: true, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index e731944d..6f49c8d7 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -20,6 +20,7 @@ export type TeamEventType = | 'user_inbox' | 'task_clarification' | 'task_status_change' + | 'task_comment' | 'schedule_completed' | 'schedule_failed'; @@ -61,6 +62,7 @@ const TEAM_NOTIFICATION_CONFIG: Record = user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' }, task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' }, task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, + task_comment: { triggerName: 'Task Comment', 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 c60682c2..6917c455 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -46,6 +46,7 @@ export interface SafeConfig { notifyOnUserInbox: boolean; notifyOnClarifications: boolean; notifyOnStatusChange: boolean; + notifyOnTaskComments: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -179,6 +180,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true, notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true, notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true, + notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? 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 d2c4f3a7..5e469136 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -299,6 +299,7 @@ export function useSettingsHandlers({ notifyOnUserInbox: true, notifyOnClarifications: true, notifyOnStatusChange: true, + notifyOnTaskComments: 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 330fd53b..b75c4986 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -44,6 +44,7 @@ interface NotificationsSectionProps { | 'notifyOnUserInbox' | 'notifyOnClarifications' | 'notifyOnStatusChange' + | 'notifyOnTaskComments' | 'statusChangeOnlySolo', value: boolean ) => void; @@ -165,6 +166,16 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + + onNotificationToggle('notifyOnTaskComments', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + { - if (open) { - return () => { - setMembers((prev) => { - // Only reset if previous state looks like a leftover from last session - const allEmpty = prev.every((m) => !m.name.trim()); - if (prev.length === 0 || allEmpty) { - return buildInitialDrafts(existingNames); - } - return prev; - }); - }; - } - return undefined; + useEffect(() => { + if (!open) return; + setMembers((prev) => { + const allEmpty = prev.every((m) => !m.name.trim()); + if (prev.length === 0 || allEmpty) { + return buildInitialDrafts(existingNames); + } + return prev; + }); }, [open, existingNames]); - // Trigger on mount/open - useMemo(() => handleAfterOpen?.(), [handleAfterOpen]); - const memberCount = members.filter((m) => m.name.trim() && !validateName(m.name)).length; return ( diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 9516feb8..81916392 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -6,6 +6,12 @@ import { ImageLightbox, LightboxLockProvider, } from '@renderer/components/team/attachments/ImageLightbox'; +import { + getTeamColorSet, + getThemedBadge, + getThemedBorder, + getThemedText, +} from '@renderer/constants/teamColors'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -26,6 +32,7 @@ import { TiptapEditor } from '@renderer/components/ui/tiptap'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getLastReadTimestamp } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; +import { useTheme } from '@renderer/hooks/useTheme'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { buildMemberColorMap, @@ -33,6 +40,8 @@ import { REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, TASK_STATUS_STYLES, + agentAvatarUrl, + displayMemberName, } from '@renderer/utils/memberHelpers'; import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -113,6 +122,7 @@ export const TaskDetailDialog = ({ headerExtra, }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const { isLight } = useTheme(); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges); @@ -499,18 +509,46 @@ export const TaskDetailDialog = ({ {formatTaskDisplayLabel(currentTask)} - - {statusLabel} - {currentTask.reviewState === 'approved' && currentTask.reviewer ? ( - - ) : null} + (() => { + const reviewerColor = colorMap.get(currentTask.reviewer); + const colors = getTeamColorSet(reviewerColor ?? ''); + const reviewerBadgeStyle = { + backgroundColor: getThemedBadge(colors, isLight), + color: getThemedText(colors, isLight), + borderTop: `1px solid ${getThemedBorder(colors, isLight)}40`, + borderRight: `1px solid ${getThemedBorder(colors, isLight)}40`, + borderBottom: `1px solid ${getThemedBorder(colors, isLight)}40`, + }; + return ( + + + {statusLabel} + + + + {displayMemberName(currentTask.reviewer)} + + + ); + })() + ) : ( + + {statusLabel} + + )} {currentTask.reviewState === 'needsFix' ? ( { + const filtered = chunks.filter((chunk) => { const cs = chunk.startTime.getTime(); const ce = chunk.endTime.getTime(); if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true; @@ -65,6 +65,11 @@ function filterChunksByWorkIntervals( return cs <= end && ce >= i.startMs; }); }); + // DEBUG + console.log( + `[filterChunks] intervals=${parsed.length} chunks=${chunks.length}→${filtered.length}` + ); + return filtered; } interface MemberLogsTabProps { @@ -106,6 +111,12 @@ export const MemberLogsTab = ({ showLeadPreview = false, onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { + // DEBUG: verify workIntervals reach this component + if (taskId && taskWorkIntervals) { + console.log( + `[MemberLogsTab] taskId=${taskId} workIntervals=${JSON.stringify(taskWorkIntervals)}` + ); + } const MIN_REFRESH_VISIBLE_MS = 250; const intervalsKey = useMemo( () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index dad82e01..0e2e2d44 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -115,6 +115,7 @@ import type { StateCreator } from 'zustand'; // handles clarification-specific logic (e.g., marking tasks as needing user input). const notifiedClarificationTaskKeys = new Set(); const notifiedStatusChangeKeys = new Set(); +const notifiedCommentKeys = new Set(); let isFirstFetchAllTasks = true; @@ -241,6 +242,59 @@ function fireStatusChangeNotification( .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; + // Skip review-related comment types (already covered by status change notifications) + if (comment.type === 'review_request' || comment.type === 'review_approved') continue; + + const key = `${task.teamName}:${task.id}:${comment.id}`; + if (notifiedCommentKeys.has(key)) continue; + notifiedCommentKeys.add(key); + + fireTaskCommentNotification(task, comment, !notifyEnabled); + } + } +} + +function fireTaskCommentNotification( + task: GlobalTask, + comment: { author: string; text: string; id: string }, + suppressToast: boolean +): void { + const preview = comment.text.length > 100 ? comment.text.slice(0, 100) + '...' : comment.text; + + 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}`, + suppressToast, + }) + .catch(() => undefined); +} + function collectTaskChangeInvalidationState( teamName: string, prevTasks: TeamData['tasks'], @@ -800,6 +854,8 @@ export const createTeamSlice: StateCreator = (set, get().appConfig?.notifications?.notifyOnClarifications ?? true; detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications); detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName); + const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true; + detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments); } else { // Initial load — seed the Sets to prevent false notifications on next update for (const task of tasks) { @@ -813,6 +869,10 @@ export const createTeamSlice: StateCreator = (set, if (getTaskKanbanColumn(task) === 'approved') { notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`); } + // Seed comment keys to prevent false notifications + for (const comment of task.comments ?? []) { + notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`); + } } } diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 876733c5..ab86408f 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -59,6 +59,7 @@ export interface DetectedError { | 'user_inbox' | 'task_clarification' | 'task_status_change' + | 'task_comment' | 'schedule_completed' | 'schedule_failed'; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ @@ -268,6 +269,8 @@ export interface AppConfig { notifyOnClarifications: boolean; /** Whether to show native OS notifications when a task status changes */ notifyOnStatusChange: boolean; + /** Whether to show native OS notifications when a new comment is added to a task */ + notifyOnTaskComments: 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 8942f9f2..0ce517c8 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -681,7 +681,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'; + teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment'; /** 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 f228664a..1636af2a 100644 --- a/test/main/utils/teamNotificationBuilder.test.ts +++ b/test/main/utils/teamNotificationBuilder.test.ts @@ -92,6 +92,7 @@ describe('buildDetectedErrorFromTeam', () => { user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' }, task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' }, task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, + task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, };