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.
This commit is contained in:
parent
d4688825fd
commit
cb6a41d899
13 changed files with 459 additions and 122 deletions
|
|
@ -204,7 +204,7 @@
|
||||||
"vitest": "^3.1.4"
|
"vitest": "^3.1.4"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.claudecode.context",
|
"appId": "com.agent-teams.app",
|
||||||
"productName": "Claude Agent Teams UI",
|
"productName": "Claude Agent Teams UI",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ exports.default = async function notarizing(context) {
|
||||||
|
|
||||||
return await notarize({
|
return await notarize({
|
||||||
tool: 'notarytool',
|
tool: 'notarytool',
|
||||||
appBundleId: 'com.claudecode.context',
|
appBundleId: 'com.agent-teams.app',
|
||||||
appPath: `${appOutDir}/${appName}.app`,
|
appPath: `${appOutDir}/${appName}.app`,
|
||||||
appleId: process.env.APPLE_ID,
|
appleId: process.env.APPLE_ID,
|
||||||
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
|
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
* - notifications:delete: Delete a single notification
|
* - notifications:delete: Delete a single notification
|
||||||
* - notifications:clear: Clear all notifications
|
* - notifications:clear: Clear all notifications
|
||||||
* - notifications:getUnreadCount: Get unread count for badge
|
* - notifications:getUnreadCount: Get unread count for badge
|
||||||
|
* - notifications:testNotification: Send a test notification to verify delivery
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
|
|
@ -36,6 +37,7 @@ export function registerNotificationHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle('notifications:delete', handleDelete);
|
ipcMain.handle('notifications:delete', handleDelete);
|
||||||
ipcMain.handle('notifications:clear', handleClear);
|
ipcMain.handle('notifications:clear', handleClear);
|
||||||
ipcMain.handle('notifications:getUnreadCount', handleGetUnreadCount);
|
ipcMain.handle('notifications:getUnreadCount', handleGetUnreadCount);
|
||||||
|
ipcMain.handle('notifications:testNotification', handleTestNotification);
|
||||||
|
|
||||||
logger.info('Notification handlers registered');
|
logger.info('Notification handlers registered');
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +53,7 @@ export function removeNotificationHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler('notifications:delete');
|
ipcMain.removeHandler('notifications:delete');
|
||||||
ipcMain.removeHandler('notifications:clear');
|
ipcMain.removeHandler('notifications:clear');
|
||||||
ipcMain.removeHandler('notifications:getUnreadCount');
|
ipcMain.removeHandler('notifications:getUnreadCount');
|
||||||
|
ipcMain.removeHandler('notifications:testNotification');
|
||||||
|
|
||||||
logger.info('Notification handlers removed');
|
logger.info('Notification handlers removed');
|
||||||
}
|
}
|
||||||
|
|
@ -184,3 +187,17 @@ async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise<number>
|
||||||
return 0;
|
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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,12 @@ const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
|
|
||||||
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
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<Notification>();
|
||||||
const MAX_ATTACHMENTS = 5;
|
const MAX_ATTACHMENTS = 5;
|
||||||
const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total
|
const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total
|
||||||
|
|
||||||
|
|
@ -2274,6 +2280,12 @@ export function showTeamNativeNotification(opts: {
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(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', () => {
|
notification.on('click', () => {
|
||||||
const windows = BrowserWindow.getAllWindows();
|
const windows = BrowserWindow.getAllWindows();
|
||||||
const mainWin = windows[0];
|
const mainWin = windows[0];
|
||||||
|
|
@ -2281,7 +2293,9 @@ export function showTeamNativeNotification(opts: {
|
||||||
mainWin.show();
|
mainWin.show();
|
||||||
mainWin.focus();
|
mainWin.focus();
|
||||||
}
|
}
|
||||||
|
cleanup();
|
||||||
});
|
});
|
||||||
|
notification.on('close', cleanup);
|
||||||
|
|
||||||
notification.on('show', () => {
|
notification.on('show', () => {
|
||||||
logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`);
|
logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`);
|
||||||
|
|
@ -2289,6 +2303,7 @@ export function showTeamNativeNotification(opts: {
|
||||||
|
|
||||||
notification.on('failed', (_, error) => {
|
notification.on('failed', (_, error) => {
|
||||||
logger.warn(`[native-notification] failed: ${error}`);
|
logger.warn(`[native-notification] failed: ${error}`);
|
||||||
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
notification.show();
|
notification.show();
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,13 @@ export class NotificationManager extends EventEmitter {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
private throttleMap = new Map<string, number>();
|
private throttleMap = new Map<string, number>();
|
||||||
private isInitialized: boolean = false;
|
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<Notification>();
|
||||||
/** Promise that resolves when async initialization is complete.
|
/** Promise that resolves when async initialization is complete.
|
||||||
* Used by addError() to wait for notifications to be loaded from disk
|
* Used by addError() to wait for notifications to be loaded from disk
|
||||||
* before writing, preventing a race where save overwrites unloaded data. */
|
* before writing, preventing a race where save overwrites unloaded data. */
|
||||||
|
|
@ -383,8 +390,24 @@ export class NotificationManager extends EventEmitter {
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(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', () => {
|
notification.on('click', () => {
|
||||||
this.handleNativeNotificationClick(stored);
|
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();
|
notification.show();
|
||||||
|
|
@ -412,8 +435,24 @@ export class NotificationManager extends EventEmitter {
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(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', () => {
|
notification.on('click', () => {
|
||||||
this.handleNativeNotificationClick(stored);
|
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();
|
notification.show();
|
||||||
|
|
@ -446,6 +485,51 @@ export class NotificationManager extends EventEmitter {
|
||||||
return true;
|
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
|
// IPC Event Emission
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,11 @@ const electronAPI: ElectronAPI = {
|
||||||
delete: (id: string) => ipcRenderer.invoke('notifications:delete', id),
|
delete: (id: string) => ipcRenderer.invoke('notifications:delete', id),
|
||||||
clear: () => ipcRenderer.invoke('notifications:clear'),
|
clear: () => ipcRenderer.invoke('notifications:clear'),
|
||||||
getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'),
|
getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'),
|
||||||
|
testNotification: () =>
|
||||||
|
ipcRenderer.invoke('notifications:testNotification') as Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>,
|
||||||
onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => {
|
onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'notification:new',
|
'notification:new',
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`),
|
delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`),
|
||||||
clear: () => this.del('/api/notifications'),
|
clear: () => this.del('/api/notifications'),
|
||||||
getUnreadCount: () => this.get('/api/notifications/unread-count'),
|
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
|
// IPC signature: (event: unknown, error: unknown) => void
|
||||||
onNew: (callback) =>
|
onNew: (callback) =>
|
||||||
this.addEventListener('notification:new', (data: unknown) => callback(null, data)),
|
this.addEventListener('notification:new', (data: unknown) => callback(null, data)),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* NotificationsSection - Notification settings including triggers and ignored repositories.
|
* NotificationsSection - Notification settings including triggers and ignored repositories.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
RepositoryDropdown,
|
RepositoryDropdown,
|
||||||
|
|
@ -17,9 +19,12 @@ import {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
PartyPopper,
|
PartyPopper,
|
||||||
|
Send,
|
||||||
|
Users,
|
||||||
Volume2,
|
Volume2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -91,8 +96,55 @@ export const NotificationsSection = ({
|
||||||
onRemoveTrigger,
|
onRemoveTrigger,
|
||||||
onStatusChangeStatusesUpdate,
|
onStatusChangeStatusesUpdate,
|
||||||
}: NotificationsSectionProps): React.JSX.Element => {
|
}: NotificationsSectionProps): React.JSX.Element => {
|
||||||
|
const [testStatus, setTestStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
|
||||||
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleTestNotification = async (): Promise<void> => {
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Dev-mode warning */}
|
||||||
|
{isDev ? (
|
||||||
|
<div
|
||||||
|
className="mb-4 flex items-start gap-2.5 rounded-lg border p-3"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(234, 179, 8, 0.2)',
|
||||||
|
backgroundColor: 'rgba(234, 179, 8, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0 text-yellow-500" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-yellow-500">Dev Mode</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Notifications may not work in development mode. macOS identifies the app as
|
||||||
|
"Electron" (bundle ID <code className="text-xs">com.github.Electron</code>)
|
||||||
|
instead of the production app name. Check System Settings → Notifications → Electron
|
||||||
|
to verify permissions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Task Completion Notifications */}
|
{/* Task Completion Notifications */}
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title="Task Completion Notifications"
|
title="Task Completion Notifications"
|
||||||
|
|
@ -160,48 +212,32 @@ export const NotificationsSection = ({
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Lead inbox notifications"
|
label="Test notification"
|
||||||
description="Notify when teammates send messages to the team lead"
|
description="Send a test notification to verify delivery"
|
||||||
icon={<Inbox className="size-4" />}
|
icon={<Send className="size-4" />}
|
||||||
>
|
>
|
||||||
<SettingsToggle
|
<div className="flex items-center gap-2">
|
||||||
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
{testStatus === 'success' ? (
|
||||||
onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)}
|
<span className="text-xs text-green-400">Sent!</span>
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
) : testStatus === 'error' ? (
|
||||||
/>
|
<span className="max-w-48 truncate text-xs text-red-400">{testError}</span>
|
||||||
</SettingRow>
|
) : null}
|
||||||
<SettingRow
|
<button
|
||||||
label="User inbox notifications"
|
onClick={handleTestNotification}
|
||||||
description="Notify when teammates send messages to you"
|
disabled={saving || !safeConfig.notifications.enabled || testStatus === 'sending'}
|
||||||
icon={<Mail className="size-4" />}
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125 ${
|
||||||
>
|
saving || !safeConfig.notifications.enabled || testStatus === 'sending'
|
||||||
<SettingsToggle
|
? 'cursor-not-allowed opacity-50'
|
||||||
enabled={safeConfig.notifications.notifyOnUserInbox}
|
: ''
|
||||||
onChange={(v) => onNotificationToggle('notifyOnUserInbox', v)}
|
}`}
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
style={{
|
||||||
/>
|
backgroundColor: 'var(--color-border-emphasis)',
|
||||||
</SettingRow>
|
color: 'var(--color-text)',
|
||||||
<SettingRow
|
}}
|
||||||
label="Task clarification notifications"
|
>
|
||||||
description="Show native OS notifications when a task needs your input"
|
{testStatus === 'sending' ? 'Sending...' : 'Send Test'}
|
||||||
icon={<HelpCircle className="size-4" />}
|
</button>
|
||||||
>
|
</div>
|
||||||
<SettingsToggle
|
|
||||||
enabled={safeConfig.notifications.notifyOnClarifications}
|
|
||||||
onChange={(v) => onNotificationToggle('notifyOnClarifications', v)}
|
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
label="Task comment notifications"
|
|
||||||
description="Show native OS notifications when agents comment on tasks"
|
|
||||||
icon={<MessageSquare className="size-4" />}
|
|
||||||
>
|
|
||||||
<SettingsToggle
|
|
||||||
enabled={safeConfig.notifications.notifyOnTaskComments}
|
|
||||||
onChange={(v) => onNotificationToggle('notifyOnTaskComments', v)}
|
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Snooze notifications"
|
label="Snooze notifications"
|
||||||
|
|
@ -233,77 +269,121 @@ export const NotificationsSection = ({
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
{/* Task Status Change Notifications — grouped section */}
|
{/* Team Notifications — grouped card */}
|
||||||
<div className="border-b py-3" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
<SettingsSectionHeader title="Team Notifications" icon={<Users className="size-3.5" />} />
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
<div className="flex items-start gap-2.5">
|
className="mb-4 rounded-lg border p-4"
|
||||||
<div className="mt-0.5 shrink-0" style={{ color: 'var(--color-text-muted)' }}>
|
style={{
|
||||||
<ArrowRightLeft className="size-4" />
|
borderColor: 'var(--color-border)',
|
||||||
</div>
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
<div>
|
}}
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
>
|
||||||
Task status change notifications
|
<SettingRow
|
||||||
</div>
|
label="Lead inbox notifications"
|
||||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
description="Notify when teammates send messages to the team lead"
|
||||||
Show native OS notifications when a task's status changes
|
icon={<Inbox className="size-4" />}
|
||||||
</div>
|
>
|
||||||
</div>
|
<SettingsToggle
|
||||||
</div>
|
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
||||||
<div className="shrink-0">
|
onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="User inbox notifications"
|
||||||
|
description="Notify when teammates send messages to you"
|
||||||
|
icon={<Mail className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnUserInbox}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnUserInbox', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Task clarification notifications"
|
||||||
|
description="Show native OS notifications when a task needs your input"
|
||||||
|
icon={<HelpCircle className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnClarifications}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnClarifications', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Task comment notifications"
|
||||||
|
description="Show native OS notifications when agents comment on tasks"
|
||||||
|
icon={<MessageSquare className="size-4" />}
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.notifyOnTaskComments}
|
||||||
|
onChange={(v) => onNotificationToggle('notifyOnTaskComments', v)}
|
||||||
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
|
<div className="last:*:border-b-0">
|
||||||
|
<SettingRow
|
||||||
|
label="Task status change notifications"
|
||||||
|
description="Show native OS notifications when a task's status changes"
|
||||||
|
icon={<ArrowRightLeft className="size-4" />}
|
||||||
|
>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
enabled={safeConfig.notifications.notifyOnStatusChange}
|
enabled={safeConfig.notifications.notifyOnStatusChange}
|
||||||
onChange={(v) => onNotificationToggle('notifyOnStatusChange', v)}
|
onChange={(v) => onNotificationToggle('notifyOnStatusChange', v)}
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingRow>
|
||||||
|
{safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-3 border-b pb-3"
|
||||||
|
style={{ borderColor: 'var(--color-border-subtle)', paddingLeft: 30 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Only in Solo mode
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Notify only when the team has no teammates
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<SettingsToggle
|
||||||
|
enabled={safeConfig.notifications.statusChangeOnlySolo}
|
||||||
|
onChange={(v) => onNotificationToggle('statusChangeOnlySolo', v)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Notify on these statuses
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Which target statuses trigger a notification
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<StatusCheckboxGroup
|
||||||
|
selected={safeConfig.notifications.statusChangeStatuses}
|
||||||
|
onChange={onStatusChangeStatusesUpdate}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? (
|
|
||||||
<div
|
|
||||||
className="mt-3 flex flex-col gap-3 border-t pt-3"
|
|
||||||
style={{ borderColor: 'var(--color-border-subtle)', paddingLeft: 15 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm font-medium"
|
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
|
||||||
>
|
|
||||||
Only in Solo mode
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
||||||
Notify only when the team has no teammates
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<SettingsToggle
|
|
||||||
enabled={safeConfig.notifications.statusChangeOnlySolo}
|
|
||||||
onChange={(v) => onNotificationToggle('statusChangeOnlySolo', v)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm font-medium"
|
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
|
||||||
>
|
|
||||||
Notify on these statuses
|
|
||||||
</div>
|
|
||||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
||||||
Which target statuses trigger a notification
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<StatusCheckboxGroup
|
|
||||||
selected={safeConfig.notifications.statusChangeStatuses}
|
|
||||||
onChange={onStatusChangeStatusesUpdate}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Triggers */}
|
{/* Custom Triggers */}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
areStringMapsEqual,
|
areStringMapsEqual,
|
||||||
} from '@renderer/utils/messageRenderEquality';
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import {
|
import {
|
||||||
CROSS_TEAM_SENT_SOURCE,
|
CROSS_TEAM_SENT_SOURCE,
|
||||||
|
|
@ -257,7 +256,7 @@ const NoiseRow = ({
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
|
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+approved/, label: 'Task approved' },
|
||||||
{ pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' },
|
{ pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="my-2 flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-1.5">
|
||||||
|
{/* Pulsing dot */}
|
||||||
|
<span className="relative inline-flex size-2.5 shrink-0">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60" />
|
||||||
|
<span className="relative inline-flex size-full rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">Awaiting reply from</span>
|
||||||
|
|
||||||
|
{result.awaitingFrom.map((name, i) => (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
{i > 0 && <span className="text-[10px] text-[var(--color-text-muted)]">or</span>}
|
||||||
|
<MemberBadge name={name} color={colorMap.get(name)} size="xs" />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">{since}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -54,7 +54,6 @@ import {
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
|
||||||
FileDiff,
|
FileDiff,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
|
@ -74,6 +73,7 @@ import {
|
||||||
|
|
||||||
import { WorkflowTimeline } from './StatusHistoryTimeline';
|
import { WorkflowTimeline } from './StatusHistoryTimeline';
|
||||||
import { TaskAttachments } from './TaskAttachments';
|
import { TaskAttachments } from './TaskAttachments';
|
||||||
|
import { TaskCommentAwaitingReply } from './TaskCommentAwaitingReply';
|
||||||
import { TaskCommentInput } from './TaskCommentInput';
|
import { TaskCommentInput } from './TaskCommentInput';
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
|
|
@ -509,7 +509,8 @@ 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>
|
||||||
{currentTask.reviewState === 'approved' && currentTask.reviewer ? (
|
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
|
||||||
|
currentTask.reviewer ? (
|
||||||
(() => {
|
(() => {
|
||||||
const reviewerColor = colorMap.get(currentTask.reviewer);
|
const reviewerColor = colorMap.get(currentTask.reviewer);
|
||||||
const colors = getTeamColorSet(reviewerColor ?? '');
|
const colors = getTeamColorSet(reviewerColor ?? '');
|
||||||
|
|
@ -613,16 +614,6 @@ export const TaskDetailDialog = ({
|
||||||
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
|
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTask.reviewer ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Eye size={12} className="text-[var(--color-text-muted)]" />
|
|
||||||
<MemberBadge
|
|
||||||
name={currentTask.reviewer}
|
|
||||||
color={colorMap.get(currentTask.reviewer)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{currentTask.createdBy ? (
|
{currentTask.createdBy ? (
|
||||||
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
||||||
<PenLine size={12} />
|
<PenLine size={12} />
|
||||||
|
|
@ -1163,6 +1154,12 @@ export const TaskDetailDialog = ({
|
||||||
onClearReply={clearReply}
|
onClearReply={clearReply}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<TaskCommentAwaitingReply
|
||||||
|
comments={currentTask.comments}
|
||||||
|
taskOwner={currentTask.owner}
|
||||||
|
taskCreatedBy={currentTask.createdBy}
|
||||||
|
members={members}
|
||||||
|
/>
|
||||||
<TaskCommentsSection
|
<TaskCommentsSection
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={currentTask.id}
|
taskId={currentTask.id}
|
||||||
|
|
|
||||||
78
src/renderer/utils/taskCommentPendingReply.ts
Normal file
78
src/renderer/utils/taskCommentPendingReply.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { TaskComment } from '@shared/types';
|
||||||
|
|
||||||
|
export interface AwaitingReplyResult {
|
||||||
|
/** Whether the user is awaiting a reply from task responders. */
|
||||||
|
isAwaiting: boolean;
|
||||||
|
/** Names of responders who haven't replied yet. */
|
||||||
|
awaitingFrom: string[];
|
||||||
|
/** Timestamp (ms) of the last user comment that triggered the awaiting state. */
|
||||||
|
userCommentAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_AWAITING: AwaitingReplyResult = {
|
||||||
|
isAwaiting: false,
|
||||||
|
awaitingFrom: [],
|
||||||
|
userCommentAtMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the human user is awaiting a reply on task comments.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* 1. Find the latest comment authored by "user".
|
||||||
|
* 2. Collect the set of expected responders (task owner + task creator), deduplicated.
|
||||||
|
* 3. For each responder, check if they posted a comment AFTER the user's latest comment.
|
||||||
|
* Any comment type counts as a response (regular, review_approved, review_request).
|
||||||
|
* 4. If at least one responder has NOT replied → isAwaiting = true.
|
||||||
|
*
|
||||||
|
* Edge cases:
|
||||||
|
* - No user comments → not awaiting.
|
||||||
|
* - owner/createdBy are undefined or empty → not awaiting (no one to wait for).
|
||||||
|
* - owner === createdBy → single responder.
|
||||||
|
* - User posted multiple comments in a row → still awaiting (based on latest user comment).
|
||||||
|
*/
|
||||||
|
export function computeAwaitingReply(
|
||||||
|
comments: TaskComment[] | undefined,
|
||||||
|
taskOwner: string | undefined,
|
||||||
|
taskCreatedBy: string | undefined
|
||||||
|
): AwaitingReplyResult {
|
||||||
|
if (!comments || comments.length === 0) return NO_AWAITING;
|
||||||
|
|
||||||
|
// Build responder set (deduplicated, non-empty, non-"user")
|
||||||
|
const responders = new Set<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -155,6 +155,7 @@ export interface NotificationsAPI {
|
||||||
delete: (id: string) => Promise<boolean>;
|
delete: (id: string) => Promise<boolean>;
|
||||||
clear: () => Promise<boolean>;
|
clear: () => Promise<boolean>;
|
||||||
getUnreadCount: () => Promise<number>;
|
getUnreadCount: () => Promise<number>;
|
||||||
|
testNotification: () => Promise<{ success: boolean; error?: string }>;
|
||||||
onNew: (callback: (event: unknown, error: unknown) => void) => () => void;
|
onNew: (callback: (event: unknown, error: unknown) => void) => () => void;
|
||||||
onUpdated: (
|
onUpdated: (
|
||||||
callback: (event: unknown, payload: { total: number; unreadCount: number }) => void
|
callback: (event: unknown, payload: { total: number; unreadCount: number }) => void
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue