feat: add task created notification type
New notification fires when agents create tasks, with toggle in settings. Follows existing pattern: detect → fire → seed on initial load.
This commit is contained in:
parent
fadd4e04c5
commit
48033ef701
11 changed files with 72 additions and 1 deletions
|
|
@ -114,6 +114,7 @@ function validateNotificationsSection(
|
||||||
'snoozeMinutes',
|
'snoozeMinutes',
|
||||||
'notifyOnStatusChange',
|
'notifyOnStatusChange',
|
||||||
'notifyOnTaskComments',
|
'notifyOnTaskComments',
|
||||||
|
'notifyOnTaskCreated',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -178,6 +179,12 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnTaskComments = value;
|
result.notifyOnTaskComments = value;
|
||||||
break;
|
break;
|
||||||
|
case 'notifyOnTaskCreated':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.notifyOnTaskCreated = 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` };
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export interface DetectedError {
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
|
| 'task_created'
|
||||||
| '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. */
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ export interface NotificationConfig {
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
/** Whether to show native OS notifications when a new comment is added to a task */
|
/** Whether to show native OS notifications when a new comment is added to a task */
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
|
/** Whether to show native OS notifications when a new task is created */
|
||||||
|
notifyOnTaskCreated: 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']) */
|
||||||
|
|
@ -264,6 +266,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
notifyOnTaskComments: true,
|
notifyOnTaskComments: true,
|
||||||
|
notifyOnTaskCreated: true,
|
||||||
statusChangeOnlySolo: false,
|
statusChangeOnlySolo: false,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export type TeamEventType =
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
|
| 'task_created'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
||||||
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' },
|
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||||
|
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
||||||
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' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export interface SafeConfig {
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
|
notifyOnTaskCreated: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -181,6 +182,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
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,
|
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
||||||
|
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,7 @@ export function useSettingsHandlers({
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
notifyOnTaskComments: true,
|
notifyOnTaskComments: true,
|
||||||
|
notifyOnTaskCreated: true,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
PartyPopper,
|
PartyPopper,
|
||||||
|
CirclePlus,
|
||||||
Send,
|
Send,
|
||||||
Users,
|
Users,
|
||||||
Volume2,
|
Volume2,
|
||||||
|
|
@ -64,6 +65,7 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnClarifications'
|
| 'notifyOnClarifications'
|
||||||
| 'notifyOnStatusChange'
|
| 'notifyOnStatusChange'
|
||||||
| 'notifyOnTaskComments'
|
| 'notifyOnTaskComments'
|
||||||
|
| 'notifyOnTaskCreated'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -293,6 +295,17 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Task created notifications"
|
||||||
|
description="Show native OS notifications when a new task is created"
|
||||||
|
icon={<CirclePlus className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnTaskCreated}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnTaskCreated', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{/* Task Status Change Notifications — nested within team card */}
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
<div className="last:*:border-b-0">
|
<div className="last:*:border-b-0">
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ import type { StateCreator } from 'zustand';
|
||||||
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>();
|
const notifiedCommentKeys = new Set<string>();
|
||||||
|
const notifiedCreatedTaskKeys = new Set<string>();
|
||||||
|
|
||||||
let isFirstFetchAllTasks = true;
|
let isFirstFetchAllTasks = true;
|
||||||
|
|
||||||
|
|
@ -295,6 +296,39 @@ function fireTaskCommentNotification(
|
||||||
.catch(() => undefined);
|
.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: task.description || task.subject,
|
||||||
|
teamEventType: 'task_created',
|
||||||
|
dedupeKey: `created:${task.teamName}:${task.id}`,
|
||||||
|
suppressToast,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function collectTaskChangeInvalidationState(
|
function collectTaskChangeInvalidationState(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
prevTasks: TeamData['tasks'],
|
prevTasks: TeamData['tasks'],
|
||||||
|
|
@ -852,6 +886,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||||
const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true;
|
||||||
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
|
||||||
|
const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
|
||||||
|
detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
|
||||||
} 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) {
|
||||||
|
|
@ -869,6 +905,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
for (const comment of task.comments ?? []) {
|
for (const comment of task.comments ?? []) {
|
||||||
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
|
||||||
}
|
}
|
||||||
|
// Seed created task keys to prevent false notifications
|
||||||
|
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export interface DetectedError {
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
|
| 'task_created'
|
||||||
| '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. */
|
||||||
|
|
@ -271,6 +272,8 @@ export interface AppConfig {
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
/** Whether to show native OS notifications when a new comment is added to a task */
|
/** Whether to show native OS notifications when a new comment is added to a task */
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
|
/** Whether to show native OS notifications when a new task is created */
|
||||||
|
notifyOnTaskCreated: 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']) */
|
||||||
|
|
|
||||||
|
|
@ -713,7 +713,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' | 'task_comment';
|
teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment' | 'task_created';
|
||||||
/** 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;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ describe('buildDetectedErrorFromTeam', () => {
|
||||||
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' },
|
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||||
|
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
||||||
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