557 lines
20 KiB
TypeScript
557 lines
20 KiB
TypeScript
/**
|
|
* NotificationsSection - Notification settings including triggers and ignored repositories.
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import {
|
|
RepositoryDropdown,
|
|
SelectedRepositoryItem,
|
|
} from '@renderer/components/common/RepositoryDropdown';
|
|
import {
|
|
AlertTriangle,
|
|
ArrowRightLeft,
|
|
Bell,
|
|
BellRing,
|
|
CheckCircle2,
|
|
CirclePlus,
|
|
Clock,
|
|
ExternalLink,
|
|
EyeOff,
|
|
GitBranch,
|
|
HelpCircle,
|
|
Inbox,
|
|
Info,
|
|
Mail,
|
|
MessageSquare,
|
|
PartyPopper,
|
|
Rocket,
|
|
Send,
|
|
ShieldQuestion,
|
|
Users,
|
|
Volume2,
|
|
} from 'lucide-react';
|
|
|
|
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
|
import { NotificationTriggerSettings } from '../NotificationTriggerSettings';
|
|
|
|
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
|
|
import type { NotificationTrigger } from '@renderer/types/data';
|
|
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
|
|
|
/** Notification targets span workflow status plus the explicit review axis. */
|
|
type NotifiableStatus =
|
|
| TeamTaskStatus
|
|
| Extract<TeamReviewState, 'review' | 'needsFix' | 'approved'>;
|
|
|
|
// Snooze duration options
|
|
const SNOOZE_OPTIONS = [
|
|
{ value: 15, label: '15 minutes' },
|
|
{ value: 30, label: '30 minutes' },
|
|
{ value: 60, label: '1 hour' },
|
|
{ value: 120, label: '2 hours' },
|
|
{ value: 240, label: '4 hours' },
|
|
{ value: -1, label: 'Until tomorrow' },
|
|
] as const;
|
|
|
|
interface NotificationsSectionProps {
|
|
readonly safeConfig: SafeConfig;
|
|
readonly saving: boolean;
|
|
readonly isSnoozed: boolean;
|
|
readonly ignoredRepositoryItems: RepositoryDropdownItem[];
|
|
readonly excludedRepositoryIds: string[];
|
|
readonly onNotificationToggle: (
|
|
key:
|
|
| 'enabled'
|
|
| 'soundEnabled'
|
|
| 'includeSubagentErrors'
|
|
| 'notifyOnLeadInbox'
|
|
| 'notifyOnUserInbox'
|
|
| 'notifyOnClarifications'
|
|
| 'notifyOnStatusChange'
|
|
| 'notifyOnTaskComments'
|
|
| 'notifyOnTaskCreated'
|
|
| 'notifyOnAllTasksCompleted'
|
|
| 'notifyOnCrossTeamMessage'
|
|
| 'notifyOnTeamLaunched'
|
|
| 'notifyOnToolApproval'
|
|
| 'autoResumeOnRateLimit'
|
|
| 'statusChangeOnlySolo',
|
|
value: boolean
|
|
) => void;
|
|
readonly onStatusChangeStatusesUpdate: (statuses: string[]) => void;
|
|
readonly onSnooze: (minutes: number) => Promise<void>;
|
|
readonly onClearSnooze: () => Promise<void>;
|
|
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
|
readonly onRemoveIgnoredRepository: (repositoryId: string) => Promise<void>;
|
|
readonly onAddTrigger: (trigger: Omit<NotificationTrigger, 'isBuiltin'>) => Promise<void>;
|
|
readonly onUpdateTrigger: (
|
|
triggerId: string,
|
|
updates: Partial<NotificationTrigger>
|
|
) => Promise<void>;
|
|
readonly onRemoveTrigger: (triggerId: string) => Promise<void>;
|
|
}
|
|
|
|
export const NotificationsSection = ({
|
|
safeConfig,
|
|
saving,
|
|
isSnoozed,
|
|
ignoredRepositoryItems,
|
|
excludedRepositoryIds,
|
|
onNotificationToggle,
|
|
onSnooze,
|
|
onClearSnooze,
|
|
onAddIgnoredRepository,
|
|
onRemoveIgnoredRepository,
|
|
onAddTrigger,
|
|
onUpdateTrigger,
|
|
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 (err) {
|
|
console.error('[notifications] testNotification failed:', err);
|
|
setTestStatus('error');
|
|
const message = err instanceof Error ? err.message : 'Failed to send test notification';
|
|
setTestError(message);
|
|
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}
|
|
|
|
{/* Notification Settings */}
|
|
<SettingsSectionHeader title="Notification Settings" icon={<Bell className="size-3.5" />} />
|
|
<SettingRow
|
|
label="Enable System Notifications"
|
|
description="Show system notifications for errors and events"
|
|
icon={<BellRing className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.enabled}
|
|
onChange={(v) => onNotificationToggle('enabled', v)}
|
|
disabled={saving}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Play sound"
|
|
description="Play a sound when notifications appear"
|
|
icon={<Volume2 className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.soundEnabled}
|
|
onChange={(v) => onNotificationToggle('soundEnabled', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Include subagent errors"
|
|
description="Detect and notify about errors in subagent sessions"
|
|
icon={<AlertTriangle className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.includeSubagentErrors}
|
|
onChange={(v) => onNotificationToggle('includeSubagentErrors', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Test notification"
|
|
description="Send a test notification to verify delivery"
|
|
icon={<Send className="size-4" />}
|
|
>
|
|
<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"
|
|
description={
|
|
isSnoozed
|
|
? `Snoozed until ${new Date(safeConfig.notifications.snoozedUntil!).toLocaleTimeString()}`
|
|
: 'Temporarily pause notifications'
|
|
}
|
|
icon={<Clock className="size-4" />}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isSnoozed ? (
|
|
<button
|
|
onClick={onClearSnooze}
|
|
disabled={saving}
|
|
className={`rounded-md bg-red-500/10 px-3 py-1.5 text-sm font-medium text-red-400 transition-all duration-150 hover:bg-red-500/20 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
|
|
>
|
|
Clear Snooze
|
|
</button>
|
|
) : (
|
|
<SettingsSelect
|
|
value={0}
|
|
options={[{ value: 0, label: 'Select duration...' }, ...SNOOZE_OPTIONS]}
|
|
onChange={(v) => v !== 0 && onSnooze(v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
dropUp
|
|
/>
|
|
)}
|
|
</div>
|
|
</SettingRow>
|
|
|
|
{/* 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>
|
|
<SettingRow
|
|
label="Task created notifications"
|
|
description="Show native OS notifications when a new task is created"
|
|
icon={<CirclePlus className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.notifyOnTaskCreated}
|
|
onChange={(v) => onNotificationToggle('notifyOnTaskCreated', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="All tasks completed"
|
|
description="Notify when every task in a team reaches completed status"
|
|
icon={<CheckCircle2 className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.notifyOnAllTasksCompleted}
|
|
onChange={(v) => onNotificationToggle('notifyOnAllTasksCompleted', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Cross-team message notifications"
|
|
description="Notify when a message arrives from another team"
|
|
icon={<GitBranch className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.notifyOnCrossTeamMessage}
|
|
onChange={(v) => onNotificationToggle('notifyOnCrossTeamMessage', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Team launched notifications"
|
|
description="Notify when a team finishes launching and is ready"
|
|
icon={<Rocket className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.notifyOnTeamLaunched}
|
|
onChange={(v) => onNotificationToggle('notifyOnTeamLaunched', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Tool approval notifications"
|
|
description="Notify when a tool needs your approval (Allow/Deny) while the app is not focused"
|
|
icon={<ShieldQuestion className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.notifyOnToolApproval}
|
|
onChange={(v) => onNotificationToggle('notifyOnToolApproval', v)}
|
|
disabled={saving || !safeConfig.notifications.enabled}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label="Auto-resume after rate limit"
|
|
description="When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets"
|
|
icon={<Clock className="size-4" />}
|
|
>
|
|
<SettingsToggle
|
|
enabled={safeConfig.notifications.autoResumeOnRateLimit}
|
|
onChange={(v) => onNotificationToggle('autoResumeOnRateLimit', 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}
|
|
/>
|
|
</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 flex-col gap-1.5">
|
|
<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>
|
|
<StatusCheckboxGroup
|
|
selected={safeConfig.notifications.statusChangeStatuses}
|
|
onChange={onStatusChangeStatusesUpdate}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Triggers */}
|
|
<NotificationTriggerSettings
|
|
triggers={safeConfig.notifications.triggers || []}
|
|
saving={saving}
|
|
onUpdateTrigger={onUpdateTrigger}
|
|
onAddTrigger={onAddTrigger}
|
|
onRemoveTrigger={onRemoveTrigger}
|
|
/>
|
|
|
|
<SettingsSectionHeader title="Ignored Repositories" icon={<EyeOff className="size-3.5" />} />
|
|
<p className="mb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
Notifications from these repositories will be ignored
|
|
</p>
|
|
{ignoredRepositoryItems.length > 0 ? (
|
|
<div className="mb-3">
|
|
{ignoredRepositoryItems.map((item) => (
|
|
<SelectedRepositoryItem
|
|
key={item.id}
|
|
item={item}
|
|
onRemove={() => onRemoveIgnoredRepository(item.id)}
|
|
disabled={saving}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="mb-3 rounded-md border border-dashed py-3 text-center"
|
|
style={{ borderColor: 'var(--color-border)' }}
|
|
>
|
|
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
No repositories ignored
|
|
</p>
|
|
</div>
|
|
)}
|
|
<RepositoryDropdown
|
|
onSelect={onAddIgnoredRepository}
|
|
excludeIds={excludedRepositoryIds}
|
|
placeholder="Select repository to ignore..."
|
|
disabled={saving}
|
|
dropUp
|
|
/>
|
|
|
|
{/* Task Completion Notifications */}
|
|
<SettingsSectionHeader
|
|
title="Task Completion Notifications"
|
|
icon={<PartyPopper className="size-3.5" />}
|
|
/>
|
|
<div
|
|
className="mb-4 rounded-lg border p-4"
|
|
style={{
|
|
borderColor: 'var(--color-border)',
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
}}
|
|
>
|
|
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
|
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
|
badges. Works on macOS, Linux, and Windows.
|
|
</p>
|
|
<button
|
|
onClick={() =>
|
|
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
|
}
|
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
|
style={{
|
|
backgroundColor: 'var(--color-border-emphasis)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
Install claude-notifications-go plugin
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
|
|
{ value: 'in_progress', label: 'Started' },
|
|
{ value: 'completed', label: 'Completed' },
|
|
{ value: 'review', label: 'Review' },
|
|
{ value: 'needsFix', label: 'Needs Fixes' },
|
|
{ value: 'approved', label: 'Approved' },
|
|
{ value: 'pending', label: 'Pending' },
|
|
{ value: 'deleted', label: 'Deleted' },
|
|
];
|
|
|
|
const StatusCheckboxGroup = ({
|
|
selected,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
selected: string[];
|
|
onChange: (statuses: string[]) => void;
|
|
disabled: boolean;
|
|
}) => (
|
|
<div className="flex flex-wrap gap-2">
|
|
{STATUS_OPTIONS.map((opt) => {
|
|
const checked = selected.includes(opt.value);
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
const next = checked
|
|
? selected.filter((s) => s !== opt.value)
|
|
: [...selected, opt.value];
|
|
onChange(next);
|
|
}}
|
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
checked
|
|
? 'bg-indigo-500/20 text-indigo-400'
|
|
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
|
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|