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:
iliya 2026-03-15 12:52:59 +02:00
parent a175566b83
commit 2ca66d8632
14 changed files with 163 additions and 31 deletions

View file

@ -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` };

View file

@ -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. */

View file

@ -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,

View file

@ -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<TeamEventType, TeamNotificationConfig> =
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' },
};

View file

@ -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',

View file

@ -299,6 +299,7 @@ export function useSettingsHandlers({
notifyOnUserInbox: true,
notifyOnClarifications: true,
notifyOnStatusChange: true,
notifyOnTaskComments: true,
statusChangeOnlySolo: true,
statusChangeStatuses: ['in_progress', 'completed'],
triggers: defaultTriggers,

View file

@ -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}
/>
</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
label="Snooze notifications"
description={

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildMembersFromDrafts,
@ -127,25 +127,17 @@ export const AddMemberDialog = ({
// Re-initialize drafts when the dialog opens with fresh suggested name
// (existingNames may have changed since last close)
const handleAfterOpen = useMemo(() => {
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 (

View file

@ -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 = ({
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
{formatTaskDisplayLabel(currentTask)}
</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 ? (
<MemberBadge
name={currentTask.reviewer}
color={colorMap.get(currentTask.reviewer)}
size="sm"
/>
) : 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 (
<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' ? (
<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}`}

View file

@ -56,7 +56,7 @@ function filterChunksByWorkIntervals(
if (parsed.length === 0) return chunks;
return chunks.filter((chunk) => {
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) : ''),

View file

@ -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<string>();
const notifiedStatusChangeKeys = new Set<string>();
const notifiedCommentKeys = new Set<string>();
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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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}`);
}
}
}

View file

@ -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']) */

View file

@ -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;
/**

View file

@ -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' },
};