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.
This commit is contained in:
parent
a175566b83
commit
2ca66d8632
14 changed files with 163 additions and 31 deletions
|
|
@ -113,6 +113,7 @@ function validateNotificationsSection(
|
||||||
'snoozedUntil',
|
'snoozedUntil',
|
||||||
'snoozeMinutes',
|
'snoozeMinutes',
|
||||||
'notifyOnStatusChange',
|
'notifyOnStatusChange',
|
||||||
|
'notifyOnTaskComments',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -171,6 +172,12 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnStatusChange = value;
|
result.notifyOnStatusChange = value;
|
||||||
break;
|
break;
|
||||||
|
case 'notifyOnTaskComments':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.notifyOnTaskComments = value;
|
||||||
|
break;
|
||||||
case 'statusChangeOnlySolo':
|
case 'statusChangeOnlySolo':
|
||||||
if (typeof value !== 'boolean') {
|
if (typeof value !== 'boolean') {
|
||||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export interface DetectedError {
|
||||||
| 'user_inbox'
|
| 'user_inbox'
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
|
| 'task_comment'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export interface NotificationConfig {
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
/** Whether to show native OS notifications when a task status changes */
|
/** Whether to show native OS notifications when a task status changes */
|
||||||
notifyOnStatusChange: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
@ -261,6 +263,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnUserInbox: true,
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
|
notifyOnTaskComments: true,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export type TeamEventType =
|
||||||
| 'user_inbox'
|
| 'user_inbox'
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
|
| 'task_comment'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
||||||
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
||||||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||||
|
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export interface SafeConfig {
|
||||||
notifyOnUserInbox: boolean;
|
notifyOnUserInbox: boolean;
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
|
notifyOnTaskComments: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -179,6 +180,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
||||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||||
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||||
|
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@ export function useSettingsHandlers({
|
||||||
notifyOnUserInbox: true,
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
|
notifyOnTaskComments: true,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnUserInbox'
|
| 'notifyOnUserInbox'
|
||||||
| 'notifyOnClarifications'
|
| 'notifyOnClarifications'
|
||||||
| 'notifyOnStatusChange'
|
| 'notifyOnStatusChange'
|
||||||
|
| 'notifyOnTaskComments'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -165,6 +166,16 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Task comment notifications"
|
||||||
|
description="Show native OS notifications when agents comment on tasks"
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnTaskComments}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnTaskComments', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Snooze notifications"
|
label="Snooze notifications"
|
||||||
description={
|
description={
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
|
|
@ -127,25 +127,17 @@ export const AddMemberDialog = ({
|
||||||
|
|
||||||
// Re-initialize drafts when the dialog opens with fresh suggested name
|
// Re-initialize drafts when the dialog opens with fresh suggested name
|
||||||
// (existingNames may have changed since last close)
|
// (existingNames may have changed since last close)
|
||||||
const handleAfterOpen = useMemo(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) return;
|
||||||
return () => {
|
setMembers((prev) => {
|
||||||
setMembers((prev) => {
|
const allEmpty = prev.every((m) => !m.name.trim());
|
||||||
// Only reset if previous state looks like a leftover from last session
|
if (prev.length === 0 || allEmpty) {
|
||||||
const allEmpty = prev.every((m) => !m.name.trim());
|
return buildInitialDrafts(existingNames);
|
||||||
if (prev.length === 0 || allEmpty) {
|
}
|
||||||
return buildInitialDrafts(existingNames);
|
return prev;
|
||||||
}
|
});
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [open, existingNames]);
|
}, [open, existingNames]);
|
||||||
|
|
||||||
// Trigger on mount/open
|
|
||||||
useMemo(() => handleAfterOpen?.(), [handleAfterOpen]);
|
|
||||||
|
|
||||||
const memberCount = members.filter((m) => m.name.trim() && !validateName(m.name)).length;
|
const memberCount = members.filter((m) => m.name.trim() && !validateName(m.name)).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import {
|
||||||
ImageLightbox,
|
ImageLightbox,
|
||||||
LightboxLockProvider,
|
LightboxLockProvider,
|
||||||
} from '@renderer/components/team/attachments/ImageLightbox';
|
} from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
|
import {
|
||||||
|
getTeamColorSet,
|
||||||
|
getThemedBadge,
|
||||||
|
getThemedBorder,
|
||||||
|
getThemedText,
|
||||||
|
} from '@renderer/constants/teamColors';
|
||||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
|
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import {
|
import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
|
|
@ -33,6 +40,8 @@ import {
|
||||||
REVIEW_STATE_DISPLAY,
|
REVIEW_STATE_DISPLAY,
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
|
agentAvatarUrl,
|
||||||
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
|
@ -113,6 +122,7 @@ export const TaskDetailDialog = ({
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
const { isLight } = useTheme();
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||||
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
|
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
|
||||||
|
|
@ -499,18 +509,46 @@ export const TaskDetailDialog = ({
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||||
{formatTaskDisplayLabel(currentTask)}
|
{formatTaskDisplayLabel(currentTask)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
{currentTask.reviewState === 'approved' && currentTask.reviewer ? (
|
{currentTask.reviewState === 'approved' && currentTask.reviewer ? (
|
||||||
<MemberBadge
|
(() => {
|
||||||
name={currentTask.reviewer}
|
const reviewerColor = colorMap.get(currentTask.reviewer);
|
||||||
color={colorMap.get(currentTask.reviewer)}
|
const colors = getTeamColorSet(reviewerColor ?? '');
|
||||||
size="sm"
|
const reviewerBadgeStyle = {
|
||||||
/>
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
) : null}
|
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 (
|
||||||
|
<span className="inline-flex items-stretch">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-l-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-r-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={reviewerBadgeStyle}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(currentTask.reviewer, 18)}
|
||||||
|
alt=""
|
||||||
|
className="size-4 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{displayMemberName(currentTask.reviewer)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{currentTask.reviewState === 'needsFix' ? (
|
{currentTask.reviewState === 'needsFix' ? (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ function filterChunksByWorkIntervals(
|
||||||
|
|
||||||
if (parsed.length === 0) return chunks;
|
if (parsed.length === 0) return chunks;
|
||||||
|
|
||||||
return chunks.filter((chunk) => {
|
const filtered = chunks.filter((chunk) => {
|
||||||
const cs = chunk.startTime.getTime();
|
const cs = chunk.startTime.getTime();
|
||||||
const ce = chunk.endTime.getTime();
|
const ce = chunk.endTime.getTime();
|
||||||
if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true;
|
if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true;
|
||||||
|
|
@ -65,6 +65,11 @@ function filterChunksByWorkIntervals(
|
||||||
return cs <= end && ce >= i.startMs;
|
return cs <= end && ce >= i.startMs;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// DEBUG
|
||||||
|
console.log(
|
||||||
|
`[filterChunks] intervals=${parsed.length} chunks=${chunks.length}→${filtered.length}`
|
||||||
|
);
|
||||||
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemberLogsTabProps {
|
interface MemberLogsTabProps {
|
||||||
|
|
@ -106,6 +111,12 @@ export const MemberLogsTab = ({
|
||||||
showLeadPreview = false,
|
showLeadPreview = false,
|
||||||
onPreviewOnlineChange,
|
onPreviewOnlineChange,
|
||||||
}: MemberLogsTabProps): React.JSX.Element => {
|
}: 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 MIN_REFRESH_VISIBLE_MS = 250;
|
||||||
const intervalsKey = useMemo(
|
const intervalsKey = useMemo(
|
||||||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ import type { StateCreator } from 'zustand';
|
||||||
// handles clarification-specific logic (e.g., marking tasks as needing user input).
|
// handles clarification-specific logic (e.g., marking tasks as needing user input).
|
||||||
const notifiedClarificationTaskKeys = new Set<string>();
|
const notifiedClarificationTaskKeys = new Set<string>();
|
||||||
const notifiedStatusChangeKeys = new Set<string>();
|
const notifiedStatusChangeKeys = new Set<string>();
|
||||||
|
const notifiedCommentKeys = new Set<string>();
|
||||||
|
|
||||||
let isFirstFetchAllTasks = true;
|
let isFirstFetchAllTasks = true;
|
||||||
|
|
||||||
|
|
@ -241,6 +242,59 @@ function fireStatusChangeNotification(
|
||||||
.catch(() => undefined);
|
.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(
|
function collectTaskChangeInvalidationState(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
prevTasks: TeamData['tasks'],
|
prevTasks: TeamData['tasks'],
|
||||||
|
|
@ -800,6 +854,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||||
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||||
|
const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
||||||
|
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
||||||
} else {
|
} else {
|
||||||
// Initial load — seed the Sets to prevent false notifications on next update
|
// Initial load — seed the Sets to prevent false notifications on next update
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
|
|
@ -813,6 +869,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (getTaskKanbanColumn(task) === 'approved') {
|
if (getTaskKanbanColumn(task) === 'approved') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}: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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export interface DetectedError {
|
||||||
| 'user_inbox'
|
| 'user_inbox'
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
|
| 'task_comment'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
/** 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;
|
notifyOnClarifications: boolean;
|
||||||
/** Whether to show native OS notifications when a task status changes */
|
/** Whether to show native OS notifications when a task status changes */
|
||||||
notifyOnStatusChange: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
|
||||||
|
|
@ -681,7 +681,7 @@ export interface TeamMessageNotificationData {
|
||||||
/** Optional sender color for visual context. */
|
/** Optional sender color for visual context. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Team event sub-type for notification categorization. */
|
/** 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(). */
|
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ describe('buildDetectedErrorFromTeam', () => {
|
||||||
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
||||||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||||
|
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue