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',
|
||||
'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` };
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ export function useSettingsHandlers({
|
|||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
notifyOnStatusChange: true,
|
||||
notifyOnTaskComments: true,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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) : ''),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']) */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue