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"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.claudecode.context",
|
||||
"appId": "com.agent-teams.app",
|
||||
"productName": "Claude Agent Teams UI",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<number>
|
|||
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 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_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();
|
||||
|
|
|
|||
|
|
@ -102,6 +102,13 @@ export class NotificationManager extends EventEmitter {
|
|||
private mainWindow: BrowserWindow | null = null;
|
||||
private throttleMap = new Map<string, number>();
|
||||
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.
|
||||
* 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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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<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 (
|
||||
<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 */}
|
||||
<SettingsSectionHeader
|
||||
title="Task Completion Notifications"
|
||||
|
|
@ -160,48 +212,32 @@ export const NotificationsSection = ({
|
|||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Lead inbox notifications"
|
||||
description="Notify when teammates send messages to the team lead"
|
||||
icon={<Inbox className="size-4" />}
|
||||
label="Test notification"
|
||||
description="Send a test notification to verify delivery"
|
||||
icon={<Send className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
||||
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}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{testStatus === 'success' ? (
|
||||
<span className="text-xs text-green-400">Sent!</span>
|
||||
) : testStatus === 'error' ? (
|
||||
<span className="max-w-48 truncate text-xs text-red-400">{testError}</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleTestNotification}
|
||||
disabled={saving || !safeConfig.notifications.enabled || testStatus === 'sending'}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125 ${
|
||||
saving || !safeConfig.notifications.enabled || testStatus === 'sending'
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{testStatus === 'sending' ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Snooze notifications"
|
||||
|
|
@ -233,77 +269,121 @@ export const NotificationsSection = ({
|
|||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* Task Status Change Notifications — grouped section */}
|
||||
<div className="border-b py-3" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 shrink-0" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<ArrowRightLeft className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Task status change notifications
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Show native OS notifications when a task's status changes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{/* Team Notifications — grouped card */}
|
||||
<SettingsSectionHeader title="Team Notifications" icon={<Users className="size-3.5" />} />
|
||||
<div
|
||||
className="mb-4 rounded-lg border p-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
}}
|
||||
>
|
||||
<SettingRow
|
||||
label="Lead inbox notifications"
|
||||
description="Notify when teammates send messages to the team lead"
|
||||
icon={<Inbox className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
||||
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
|
||||
enabled={safeConfig.notifications.notifyOnStatusChange}
|
||||
onChange={(v) => onNotificationToggle('notifyOnStatusChange', v)}
|
||||
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>
|
||||
{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>
|
||||
|
||||
{/* Custom Triggers */}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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 = ({
|
|||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
{formatTaskDisplayLabel(currentTask)}
|
||||
</Badge>
|
||||
{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 = ({
|
|||
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
|
||||
)}
|
||||
</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 ? (
|
||||
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
||||
<PenLine size={12} />
|
||||
|
|
@ -1163,6 +1154,12 @@ export const TaskDetailDialog = ({
|
|||
onClearReply={clearReply}
|
||||
/>
|
||||
</div>
|
||||
<TaskCommentAwaitingReply
|
||||
comments={currentTask.comments}
|
||||
taskOwner={currentTask.owner}
|
||||
taskCreatedBy={currentTask.createdBy}
|
||||
members={members}
|
||||
/>
|
||||
<TaskCommentsSection
|
||||
teamName={teamName}
|
||||
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>;
|
||||
clear: () => Promise<boolean>;
|
||||
getUnreadCount: () => Promise<number>;
|
||||
testNotification: () => Promise<{ success: boolean; error?: string }>;
|
||||
onNew: (callback: (event: unknown, error: unknown) => void) => () => void;
|
||||
onUpdated: (
|
||||
callback: (event: unknown, payload: { total: number; unreadCount: number }) => void
|
||||
|
|
|
|||
Loading…
Reference in a new issue