From cb6a41d8993d9adaea668a9ab48e5ebdeb713d88 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 15 Mar 2026 13:37:53 +0200 Subject: [PATCH] feat: add test notification feature and update app identifiers - Introduced a new IPC call for sending test notifications, allowing users to verify notification delivery. - Updated app identifiers in package.json and notarization script to reflect the new application name. - Enhanced notification handling in the NotificationManager to prevent garbage collection of active notifications. - Updated UI components to include a test notification button in the settings, improving user experience. --- package.json | 2 +- scripts/notarize.cjs | 2 +- src/main/ipc/notifications.ts | 17 + src/main/ipc/teams.ts | 15 + .../infrastructure/NotificationManager.ts | 84 +++++ src/preload/index.ts | 5 + src/renderer/api/httpClient.ts | 4 + .../sections/NotificationsSection.tsx | 292 +++++++++++------- .../components/team/activity/ActivityItem.tsx | 3 +- .../team/dialogs/TaskCommentAwaitingReply.tsx | 57 ++++ .../team/dialogs/TaskDetailDialog.tsx | 21 +- src/renderer/utils/taskCommentPendingReply.ts | 78 +++++ src/shared/types/api.ts | 1 + 13 files changed, 459 insertions(+), 122 deletions(-) create mode 100644 src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx create mode 100644 src/renderer/utils/taskCommentPendingReply.ts diff --git a/package.json b/package.json index 6c27c152..8470c20e 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "vitest": "^3.1.4" }, "build": { - "appId": "com.claudecode.context", + "appId": "com.agent-teams.app", "productName": "Claude Agent Teams UI", "directories": { "output": "release" diff --git a/scripts/notarize.cjs b/scripts/notarize.cjs index 7a7af681..40ad28cd 100644 --- a/scripts/notarize.cjs +++ b/scripts/notarize.cjs @@ -13,7 +13,7 @@ exports.default = async function notarizing(context) { return await notarize({ tool: 'notarytool', - appBundleId: 'com.claudecode.context', + appBundleId: 'com.agent-teams.app', appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLE_ID, appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 7f1afe8e..3dc08fda 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -8,6 +8,7 @@ * - notifications:delete: Delete a single notification * - notifications:clear: Clear all notifications * - notifications:getUnreadCount: Get unread count for badge + * - notifications:testNotification: Send a test notification to verify delivery */ import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -36,6 +37,7 @@ export function registerNotificationHandlers(ipcMain: IpcMain): void { ipcMain.handle('notifications:delete', handleDelete); ipcMain.handle('notifications:clear', handleClear); ipcMain.handle('notifications:getUnreadCount', handleGetUnreadCount); + ipcMain.handle('notifications:testNotification', handleTestNotification); logger.info('Notification handlers registered'); } @@ -51,6 +53,7 @@ export function removeNotificationHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('notifications:delete'); ipcMain.removeHandler('notifications:clear'); ipcMain.removeHandler('notifications:getUnreadCount'); + ipcMain.removeHandler('notifications:testNotification'); logger.info('Notification handlers removed'); } @@ -184,3 +187,17 @@ async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise return 0; } } + +/** + * Handler for 'notifications:testNotification' IPC call. + * Sends a test notification to verify that native OS notifications are delivered. + */ +function handleTestNotification(_event: IpcMainInvokeEvent): { success: boolean; error?: string } { + try { + const manager = NotificationManager.getInstance(); + return manager.sendTestNotification(); + } catch (error) { + logger.error('Error in notifications:testNotification:', error); + return { success: false, error: getErrorMessage(error) }; + } +} diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ab113ac9..a6e77440 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -205,6 +205,12 @@ const taskAttachmentStore = new TeamTaskAttachmentStore(); const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file + +/** + * Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification. + * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html + */ +const activeTeamNotifications = new Set(); const MAX_ATTACHMENTS = 5; const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total @@ -2274,6 +2280,12 @@ export function showTeamNativeNotification(opts: { ...(iconPath ? { icon: iconPath } : {}), }); + // Hold a strong reference to prevent GC from collecting the notification + activeTeamNotifications.add(notification); + const cleanup = (): void => { + activeTeamNotifications.delete(notification); + }; + notification.on('click', () => { const windows = BrowserWindow.getAllWindows(); const mainWin = windows[0]; @@ -2281,7 +2293,9 @@ export function showTeamNativeNotification(opts: { mainWin.show(); mainWin.focus(); } + cleanup(); }); + notification.on('close', cleanup); notification.on('show', () => { logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`); @@ -2289,6 +2303,7 @@ export function showTeamNativeNotification(opts: { notification.on('failed', (_, error) => { logger.warn(`[native-notification] failed: ${error}`); + cleanup(); }); notification.show(); diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 22381588..daf33196 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -102,6 +102,13 @@ export class NotificationManager extends EventEmitter { private mainWindow: BrowserWindow | null = null; private throttleMap = new Map(); private isInitialized: boolean = false; + /** + * Prevents GC from collecting Notification objects before they are dismissed. + * On macOS, if the reference is lost, the notification may silently fail + * and click handlers stop working after ~1-2 minutes. + * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html + */ + private activeNotifications = new Set(); /** Promise that resolves when async initialization is complete. * Used by addError() to wait for notifications to be loaded from disk * before writing, preventing a race where save overwrites unloaded data. */ @@ -383,8 +390,24 @@ export class NotificationManager extends EventEmitter { ...(iconPath ? { icon: iconPath } : {}), }); + // Hold a strong reference to prevent GC from collecting the notification + this.activeNotifications.add(notification); + const cleanup = (): void => { + this.activeNotifications.delete(notification); + }; + notification.on('click', () => { this.handleNativeNotificationClick(stored); + cleanup(); + }); + notification.on('close', cleanup); + + notification.on('show', () => { + logger.debug(`[notification] shown: "Claude Code Error" — ${stored.context.projectName}`); + }); + notification.on('failed', (_, error) => { + logger.warn(`[notification] failed: ${error}`); + cleanup(); }); notification.show(); @@ -412,8 +435,24 @@ export class NotificationManager extends EventEmitter { ...(iconPath ? { icon: iconPath } : {}), }); + // Hold a strong reference to prevent GC from collecting the notification + this.activeNotifications.add(notification); + const cleanup = (): void => { + this.activeNotifications.delete(notification); + }; + notification.on('click', () => { this.handleNativeNotificationClick(stored); + cleanup(); + }); + notification.on('close', cleanup); + + notification.on('show', () => { + logger.debug(`[notification] shown: "${payload.teamDisplayName}" — ${payload.summary ?? ''}`); + }); + notification.on('failed', (_, error) => { + logger.warn(`[notification] failed: ${error}`); + cleanup(); }); notification.show(); @@ -446,6 +485,51 @@ export class NotificationManager extends EventEmitter { return true; } + // =========================================================================== + // Test Notification + // =========================================================================== + + /** + * Sends a test notification to verify that native notifications work. + * Returns a result object indicating success or failure reason. + */ + sendTestNotification(): { success: boolean; error?: string } { + if (!this.isNativeNotificationSupported()) { + return { success: false, error: 'Native notifications are not supported on this platform' }; + } + + const isMac = process.platform === 'darwin'; + const iconPath = isMac ? undefined : getAppIconPath(); + const notification = new Notification({ + title: 'Test Notification', + ...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}), + body: isMac + ? 'Notifications are working correctly!' + : 'Claude Agent Teams UI\nNotifications are working correctly!', + ...(iconPath ? { icon: iconPath } : {}), + }); + + // Hold a strong reference to prevent GC + this.activeNotifications.add(notification); + const cleanup = (): void => { + this.activeNotifications.delete(notification); + }; + + notification.on('click', cleanup); + notification.on('close', cleanup); + + notification.on('show', () => { + logger.debug('[notification] test notification shown successfully'); + }); + notification.on('failed', (_, error) => { + logger.warn(`[notification] test notification failed: ${error}`); + cleanup(); + }); + + notification.show(); + return { success: true }; + } + // =========================================================================== // IPC Event Emission // =========================================================================== diff --git a/src/preload/index.ts b/src/preload/index.ts index 2c52ae01..79fef7a3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -481,6 +481,11 @@ const electronAPI: ElectronAPI = { delete: (id: string) => ipcRenderer.invoke('notifications:delete', id), clear: () => ipcRenderer.invoke('notifications:clear'), getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'), + testNotification: () => + ipcRenderer.invoke('notifications:testNotification') as Promise<{ + success: boolean; + error?: string; + }>, onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => { ipcRenderer.on( 'notification:new', diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 07975dfb..6c4ed967 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -381,6 +381,10 @@ export class HttpAPIClient implements ElectronAPI { delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`), clear: () => this.del('/api/notifications'), getUnreadCount: () => this.get('/api/notifications/unread-count'), + testNotification: async () => ({ + success: false, + error: 'Test notifications require Electron (not available in browser mode)', + }), // IPC signature: (event: unknown, error: unknown) => void onNew: (callback) => this.addEventListener('notification:new', (data: unknown) => callback(null, data)), diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 898af92a..4594b8bd 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -2,6 +2,8 @@ * NotificationsSection - Notification settings including triggers and ignored repositories. */ +import { useState } from 'react'; + import { api } from '@renderer/api'; import { RepositoryDropdown, @@ -17,9 +19,12 @@ import { EyeOff, HelpCircle, Inbox, + Info, Mail, MessageSquare, PartyPopper, + Send, + Users, Volume2, } from 'lucide-react'; @@ -91,8 +96,55 @@ export const NotificationsSection = ({ onRemoveTrigger, onStatusChangeStatusesUpdate, }: NotificationsSectionProps): React.JSX.Element => { + const [testStatus, setTestStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle'); + const [testError, setTestError] = useState(null); + + const handleTestNotification = async (): Promise => { + setTestStatus('sending'); + setTestError(null); + try { + const result = await api.notifications.testNotification(); + if (result.success) { + setTestStatus('success'); + setTimeout(() => setTestStatus('idle'), 3000); + } else { + setTestStatus('error'); + setTestError(result.error ?? 'Unknown error'); + setTimeout(() => setTestStatus('idle'), 5000); + } + } catch { + setTestStatus('error'); + setTestError('Failed to send test notification'); + setTimeout(() => setTestStatus('idle'), 5000); + } + }; + + const isDev = import.meta.env.DEV; + return (
+ {/* Dev-mode warning */} + {isDev ? ( +
+ +
+
Dev Mode
+
+ Notifications may not work in development mode. macOS identifies the app as + "Electron" (bundle ID com.github.Electron) + instead of the production app name. Check System Settings → Notifications → Electron + to verify permissions. +
+
+
+ ) : null} + {/* Task Completion Notifications */} } + label="Test notification" + description="Send a test notification to verify delivery" + icon={} > - onNotificationToggle('notifyOnLeadInbox', v)} - disabled={saving || !safeConfig.notifications.enabled} - /> - - } - > - onNotificationToggle('notifyOnUserInbox', v)} - disabled={saving || !safeConfig.notifications.enabled} - /> - - } - > - onNotificationToggle('notifyOnClarifications', v)} - disabled={saving || !safeConfig.notifications.enabled} - /> - - } - > - onNotificationToggle('notifyOnTaskComments', v)} - disabled={saving || !safeConfig.notifications.enabled} - /> +
+ {testStatus === 'success' ? ( + Sent! + ) : testStatus === 'error' ? ( + {testError} + ) : null} + +
- {/* Task Status Change Notifications — grouped section */} -
-
-
-
- -
-
-
- Task status change notifications -
-
- Show native OS notifications when a task's status changes -
-
-
-
+ {/* Team Notifications — grouped card */} + } /> +
+ } + > + onNotificationToggle('notifyOnLeadInbox', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + } + > + onNotificationToggle('notifyOnUserInbox', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + } + > + onNotificationToggle('notifyOnClarifications', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + } + > + onNotificationToggle('notifyOnTaskComments', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + + {/* Task Status Change Notifications — nested within team card */} +
+ } + > onNotificationToggle('notifyOnStatusChange', v)} disabled={saving || !safeConfig.notifications.enabled} /> -
+ + {safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? ( +
+
+
+
+ Only in Solo mode +
+
+ Notify only when the team has no teammates +
+
+
+ onNotificationToggle('statusChangeOnlySolo', v)} + disabled={saving} + /> +
+
+
+
+
+ Notify on these statuses +
+
+ Which target statuses trigger a notification +
+
+
+ +
+
+
+ ) : null}
- {safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? ( -
-
-
-
- Only in Solo mode -
-
- Notify only when the team has no teammates -
-
-
- onNotificationToggle('statusChangeOnlySolo', v)} - disabled={saving} - /> -
-
-
-
-
- Notify on these statuses -
-
- Which target statuses trigger a notification -
-
-
- -
-
-
- ) : null}
{/* Custom Triggers */} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 5425648e..9c3be877 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -30,7 +30,6 @@ import { areStringMapsEqual, } from '@renderer/utils/messageRenderEquality'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; -import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_SENT_SOURCE, @@ -257,7 +256,7 @@ const NoiseRow = ({ // --------------------------------------------------------------------------- const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ - { pattern: /^New task assigned to you:/, label: 'Task assignment' }, + { pattern: /^New task assigned to you:/, label: 'Task' }, { pattern: /^Task #[A-Za-z0-9-]+\s+approved/, label: 'Task approved' }, { pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' }, ]; diff --git a/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx b/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx new file mode 100644 index 00000000..516a1cc9 --- /dev/null +++ b/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; + +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { computeAwaitingReply } from '@renderer/utils/taskCommentPendingReply'; +import { formatDistanceToNowStrict } from 'date-fns'; + +import type { ResolvedTeamMember, TaskComment } from '@shared/types'; + +interface TaskCommentAwaitingReplyProps { + comments: TaskComment[] | undefined; + taskOwner: string | undefined; + taskCreatedBy: string | undefined; + members: ResolvedTeamMember[]; +} + +/** + * Compact indicator shown between the comment input and the comment list + * when the human user is awaiting a reply from the task owner or creator. + */ +export const TaskCommentAwaitingReply = ({ + comments, + taskOwner, + taskCreatedBy, + members, +}: TaskCommentAwaitingReplyProps): React.JSX.Element | null => { + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const result = useMemo( + () => computeAwaitingReply(comments, taskOwner, taskCreatedBy), + [comments, taskOwner, taskCreatedBy] + ); + + if (!result.isAwaiting) return null; + + const since = formatDistanceToNowStrict(result.userCommentAtMs, { addSuffix: true }); + + return ( +
+ {/* Pulsing dot */} + + + + + + Awaiting reply from + + {result.awaitingFrom.map((name, i) => ( + + {i > 0 && or} + + + ))} + + {since} +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 81916392..50a0bfae 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -54,7 +54,6 @@ import { ArrowRightFromLine, Check, Clock, - Eye, FileDiff, GitCompareArrows, HelpCircle, @@ -74,6 +73,7 @@ import { import { WorkflowTimeline } from './StatusHistoryTimeline'; import { TaskAttachments } from './TaskAttachments'; +import { TaskCommentAwaitingReply } from './TaskCommentAwaitingReply'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; @@ -509,7 +509,8 @@ export const TaskDetailDialog = ({ {formatTaskDisplayLabel(currentTask)} - {currentTask.reviewState === 'approved' && currentTask.reviewer ? ( + {(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') && + currentTask.reviewer ? ( (() => { const reviewerColor = colorMap.get(currentTask.reviewer); const colors = getTeamColorSet(reviewerColor ?? ''); @@ -613,16 +614,6 @@ export const TaskDetailDialog = ({ Unassigned )}
- {currentTask.reviewer ? ( -
- - -
- ) : null} {currentTask.createdBy ? (
@@ -1163,6 +1154,12 @@ export const TaskDetailDialog = ({ onClearReply={clearReply} />
+ (); + if (taskOwner && taskOwner !== 'user') responders.add(taskOwner); + if (taskCreatedBy && taskCreatedBy !== 'user') responders.add(taskCreatedBy); + if (responders.size === 0) return NO_AWAITING; + + // Find the latest "user" comment by createdAt + let latestUserCommentMs = 0; + for (const comment of comments) { + if (comment.author !== 'user') continue; + const ts = Date.parse(comment.createdAt); + if (Number.isFinite(ts) && ts > latestUserCommentMs) { + latestUserCommentMs = ts; + } + } + if (latestUserCommentMs === 0) return NO_AWAITING; + + // Check which responders have NOT replied after the user's comment + const awaitingFrom: string[] = []; + for (const responder of responders) { + const hasReplied = comments.some((c) => { + if (c.author !== responder) return false; + const ts = Date.parse(c.createdAt); + return Number.isFinite(ts) && ts > latestUserCommentMs; + }); + if (!hasReplied) { + awaitingFrom.push(responder); + } + } + + if (awaitingFrom.length === 0) return NO_AWAITING; + + return { + isAwaiting: true, + awaitingFrom, + userCommentAtMs: latestUserCommentMs, + }; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 6ed3f744..dbc0e18a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -155,6 +155,7 @@ export interface NotificationsAPI { delete: (id: string) => Promise; clear: () => Promise; getUnreadCount: () => Promise; + testNotification: () => Promise<{ success: boolean; error?: string }>; onNew: (callback: (event: unknown, error: unknown) => void) => () => void; onUpdated: ( callback: (event: unknown, payload: { total: number; unreadCount: number }) => void