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:
iliya 2026-03-15 13:37:53 +02:00
parent d4688825fd
commit cb6a41d899
13 changed files with 459 additions and 122 deletions

View file

@ -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"

View file

@ -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,

View file

@ -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) };
}
}

View file

@ -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();

View file

@ -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
// ===========================================================================

View file

@ -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',

View file

@ -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)),

View file

@ -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
&quot;Electron&quot; (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&apos;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 */}

View file

@ -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' },
];

View file

@ -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>
);
};

View file

@ -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}

View 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,
};
}

View file

@ -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