diff --git a/src/electron/main.ts b/src/electron/main.ts index 014732b..6ef1acb 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -29,6 +29,14 @@ app.commandLine.appendSwitch('--disable-gpu-sandbox'); app.commandLine.appendSwitch('--disable-software-rasterizer'); import { NetworkProxyManager } from '@/electron/main/network/network-proxy-manager'; +import { MacOsNotifier } from '@/electron/main/notifications/macos-notifier'; +import { NotificationManager } from '@/electron/main/notifications/notification-manager'; +import { TelegramNotifier } from '@/electron/main/notifications/telegram-notifier'; +import { + DEFAULT_NOTIFICATION_SETTINGS, + NotificationTrigger, + type NotificationSettingsUpdate, +} from '@/electron/main/notifications/types'; import type { DesktopRuntimeController } from '@/electron/main/runtime/desktop-runtime-controller'; import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; import { @@ -81,6 +89,7 @@ if (started) { let mainWindow: BrowserWindow | null = null; let networkProxyManager: NetworkProxyManager | null = null; +let notificationManager: NotificationManager | null = null; let runtimeController: DesktopRuntimeController | null = null; let nudgeScheduled = false; let nudgeIntervalHandle: ReturnType | null = null; @@ -284,6 +293,7 @@ const quitCoordinator = createQuitCoordinator({ console.error('Failed to shutdown the Dune runtime cleanly before quit.', error); }, shutdownRuntime: async () => { + notificationManager?.shutdown(); if (nudgeIntervalHandle) { clearInterval(nudgeIntervalHandle); nudgeIntervalHandle = null; @@ -448,6 +458,14 @@ void app.whenReady().then(async () => { settings: new JsonFileStorage(userDataDir, 'settings'), workflow: new JsonFileStorage(userDataDir, 'workflow'), }; + notificationManager = new NotificationManager({ + getAgents: () => runtimeController?.getSnapshot().agents ?? [], + macosNotifier: new MacOsNotifier(() => mainWindow), + settingsStore: stores.settings, + telegramNotifier: new TelegramNotifier(() => runtimeController?.getTelegramBridge() ?? null), + }); + await notificationManager.initialize(); + notificationManager.start(); async function compactWorkflowActivity(snapshot: StoredWorkflowSnapshot): Promise { const activeItemIds = new Set(snapshot.items.map((item) => item.id)); @@ -719,6 +737,7 @@ void app.whenReady().then(async () => { const previous = await stores.workflow.get('snapshot'); await reconcileAssignments(previous, value); + runtimeController?.handleWorkflowSnapshotChange(previous, value); if (isWorkflowSnapshotLike(value)) { await compactWorkflowActivity(value); } @@ -791,6 +810,14 @@ void app.whenReady().then(async () => { agentStore: stores.agents, bundledAgentDir: path.join(app.getAppPath(), 'agent'), ...(agentLiteHomeDir ? { homeDir: agentLiteHomeDir } : {}), + onAgentError: ({ agentId, agentName, error }) => { + void notificationManager?.notify(NotificationTrigger.agent_error, { + agentId, + body: `${agentName}: ${error}`, + throttleId: agentId, + title: 'Agent error', + }); + }, onAgentIdle: (_agentId) => { void nudgeIdleMainAgents(requireRuntimeController, workflowStore); }, @@ -799,6 +826,24 @@ void app.whenReady().then(async () => { window.webContents.send(ipcChannels.itemActivityUpdated, payload); } }, + onItemStatusChange: ({ itemId, nextStatus, title }) => { + void notificationManager?.notify( + nextStatus === 'review' + ? NotificationTrigger.item_review + : NotificationTrigger.item_acceptance, + { + body: + nextStatus === 'review' + ? `${title} moved into review.` + : `${title} moved into acceptance.`, + itemId, + title: + nextStatus === 'review' + ? 'Item moved to review' + : 'Item moved to acceptance', + }, + ); + }, resolveProjectName: async (projectId) => { const snapshot = await stores.workflow.get<{ projects?: Array<{ id: string; name: string; rootPath?: string | null }>; @@ -886,6 +931,36 @@ void app.whenReady().then(async () => { await ensureRuntime(); await requireRuntimeController().reloadExternalChannels(); }); + ipcMain.handle(ipcChannels.getNotificationSettings, async () => { + return notificationManager?.getSettings() ?? { + triggers: { ...DEFAULT_NOTIFICATION_SETTINGS.triggers }, + channels: { ...DEFAULT_NOTIFICATION_SETTINGS.channels }, + doNotDisturb: { ...DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb }, + telegramNotifyChatId: DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + }; + }); + ipcMain.handle(ipcChannels.updateNotificationSettings, async ( + _event, + update: NotificationSettingsUpdate, + ) => { + if (!notificationManager) { + return { + triggers: { ...DEFAULT_NOTIFICATION_SETTINGS.triggers }, + channels: { ...DEFAULT_NOTIFICATION_SETTINGS.channels }, + doNotDisturb: { ...DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb }, + telegramNotifyChatId: DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + }; + } + + return notificationManager.updateSettings(update); + }); + ipcMain.handle( + ipcChannels.getNotificationHistory, + async () => notificationManager?.getHistory() ?? [], + ); + ipcMain.handle(ipcChannels.clearNotificationHistory, async () => { + notificationManager?.clearHistory(); + }); ipcMain.handle(ipcChannels.copyText, (_event, text: string) => { clipboard.writeText(text); }); diff --git a/src/electron/main/notifications/macos-notifier.ts b/src/electron/main/notifications/macos-notifier.ts new file mode 100644 index 0000000..c7a58d9 --- /dev/null +++ b/src/electron/main/notifications/macos-notifier.ts @@ -0,0 +1,54 @@ +// macOS notification delivery. + +import { + BrowserWindow, + Notification, +} from 'electron'; + +/** Delivery payload shape. */ +export interface MacOsNotificationPayload { + body: string; + title: string; +} + +/** macOS system notification wrapper. */ +export class MacOsNotifier { + private readonly getWindow: () => BrowserWindow | null; + + constructor(getWindow: () => BrowserWindow | null) { + this.getWindow = getWindow; + } + + /** Sends a notification when supported. */ + async send(payload: MacOsNotificationPayload) { + if (process.platform !== 'darwin' || !Notification.isSupported()) { + return false; + } + + const notification = new Notification({ + body: payload.body, + title: payload.title, + }); + + notification.on('click', () => { + const window = this.getWindow() ?? BrowserWindow.getAllWindows()[0] ?? null; + + if (!window) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + window.focus(); + }); + + notification.show(); + return true; + } +} diff --git a/src/electron/main/notifications/notification-history.ts b/src/electron/main/notifications/notification-history.ts new file mode 100644 index 0000000..f11bc4d --- /dev/null +++ b/src/electron/main/notifications/notification-history.ts @@ -0,0 +1,28 @@ +// In-memory notification history. + +import type { NotificationRecord } from './types'; +import { NOTIFICATION_HISTORY_LIMIT } from './types'; + +/** Rolling in-memory notification log. */ +export class NotificationHistory { + private readonly records: NotificationRecord[] = []; + + /** Adds a notification record. */ + add(record: NotificationRecord) { + this.records.unshift({ ...record }); + + if (this.records.length > NOTIFICATION_HISTORY_LIMIT) { + this.records.length = NOTIFICATION_HISTORY_LIMIT; + } + } + + /** Returns history, newest first. */ + getAll() { + return this.records.map((record) => ({ ...record })); + } + + /** Clears all history. */ + clear() { + this.records.length = 0; + } +} diff --git a/src/electron/main/notifications/notification-manager.test.ts b/src/electron/main/notifications/notification-manager.test.ts new file mode 100644 index 0000000..2d195bc --- /dev/null +++ b/src/electron/main/notifications/notification-manager.test.ts @@ -0,0 +1,200 @@ +// Notification manager tests. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AppStorage } from '@/electron/main/storage'; +import type { Agent } from '@/renderer/features/agents/types'; + +import { NotificationManager } from './notification-manager'; +import { + AGENT_IDLE_POLL_INTERVAL_MS, + AGENT_IDLE_THRESHOLD_MS, + DEFAULT_NOTIFICATION_SETTINGS, + NotificationTrigger, +} from './types'; + +function createMemoryStore(initialValue: unknown = null): AppStorage { + let value = initialValue; + + return { + delete: async () => { + value = null; + }, + get: async () => value as never, + keys: async () => (value === null ? [] : ['notifications']), + set: async (_key, nextValue) => { + value = nextValue; + }, + }; +} + +function createAgent(overrides: Partial = {}): Agent { + return { + activityEvents: [], + channel: { + canCompose: true, + id: 'dune-chat', + kind: 'built-in', + label: 'Dune Chat', + status: 'connected', + }, + codingEngineEvents: [], + contextCards: [], + definition: { + archetype: 'custom', + responsibilities: [], + }, + id: 'agent-1', + lastActiveAt: 0, + messages: [], + name: 'Scout', + note: '', + preview: 'Scout preview', + projectId: null, + status: 'ready', + telegram: null, + transcript: { + archivedMessageCount: 0, + hasOlderMessages: false, + rollingSummary: null, + totalMessageCount: 0, + }, + updatedAt: 0, + workspace: '/tmp/scout', + ...overrides, + }; +} + +describe('NotificationManager', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns default settings when nothing is persisted yet', async () => { + const manager = new NotificationManager({ + getAgents: () => [], + macosNotifier: { send: vi.fn(async () => false) } as never, + settingsStore: createMemoryStore(), + telegramNotifier: { send: vi.fn(async () => false) } as never, + }); + + await expect(manager.getSettings()).resolves.toEqual(DEFAULT_NOTIFICATION_SETTINGS); + }); + + it('suppresses delivery during a midnight-crossing do-not-disturb window', async () => { + let currentTime = new Date(2026, 3, 19, 1, 0, 0).getTime(); + const macosSend = vi.fn(async () => true); + const manager = new NotificationManager({ + getAgents: () => [], + macosNotifier: { send: macosSend } as never, + now: () => currentTime, + settingsStore: createMemoryStore({ + ...DEFAULT_NOTIFICATION_SETTINGS, + doNotDisturb: { + enabled: true, + endHour: 8, + startHour: 23, + }, + }), + telegramNotifier: { send: vi.fn(async () => false) } as never, + }); + + await expect(manager.notify(NotificationTrigger.item_review, { + body: 'Ready for review.', + itemId: 'item-1', + title: 'Item moved to review', + })).resolves.toBe(false); + + expect(macosSend).not.toHaveBeenCalled(); + + currentTime = new Date(2026, 3, 19, 12, 0, 0).getTime(); + + await expect(manager.notify(NotificationTrigger.item_review, { + body: 'Ready for review.', + itemId: 'item-1', + title: 'Item moved to review', + })).resolves.toBe(true); + + expect(macosSend).toHaveBeenCalledTimes(1); + }); + + it('throttles notifications by trigger and item id while recording delivered history', async () => { + let currentTime = new Date(2026, 3, 19, 10, 0, 0).getTime(); + const macosSend = vi.fn(async () => true); + const manager = new NotificationManager({ + getAgents: () => [], + macosNotifier: { send: macosSend } as never, + now: () => currentTime, + settingsStore: createMemoryStore(DEFAULT_NOTIFICATION_SETTINGS), + telegramNotifier: { send: vi.fn(async () => false) } as never, + }); + + await expect(manager.notify(NotificationTrigger.item_review, { + body: 'Alpha moved to review.', + itemId: 'item-alpha', + title: 'Alpha ready for review', + })).resolves.toBe(true); + + currentTime += 60_000; + + await expect(manager.notify(NotificationTrigger.item_review, { + body: 'Alpha moved to review again.', + itemId: 'item-alpha', + title: 'Alpha ready for review', + })).resolves.toBe(false); + + await expect(manager.notify(NotificationTrigger.item_review, { + body: 'Beta moved to review.', + itemId: 'item-beta', + title: 'Beta ready for review', + })).resolves.toBe(true); + + expect(macosSend).toHaveBeenCalledTimes(2); + expect(manager.getHistory()).toHaveLength(2); + expect(manager.getHistory().map((record) => record.itemId)).toEqual([ + 'item-beta', + 'item-alpha', + ]); + }); + + it('polls for idle agents when the trigger is enabled', async () => { + vi.useFakeTimers(); + + let currentTime = new Date(2026, 3, 19, 12, 0, 0).getTime(); + const macosSend = vi.fn(async () => true); + const agents = [ + createAgent({ + id: 'agent-idle', + lastActiveAt: currentTime - AGENT_IDLE_THRESHOLD_MS - 1, + name: 'Idle Scout', + }), + ]; + const manager = new NotificationManager({ + getAgents: () => agents, + macosNotifier: { send: macosSend } as never, + now: () => currentTime, + settingsStore: createMemoryStore({ + ...DEFAULT_NOTIFICATION_SETTINGS, + triggers: { + ...DEFAULT_NOTIFICATION_SETTINGS.triggers, + [NotificationTrigger.agent_idle]: true, + }, + }), + telegramNotifier: { send: vi.fn(async () => false) } as never, + }); + + await manager.initialize(); + manager.start(); + + currentTime += AGENT_IDLE_POLL_INTERVAL_MS; + await vi.advanceTimersByTimeAsync(AGENT_IDLE_POLL_INTERVAL_MS); + + expect(macosSend).toHaveBeenCalledTimes(1); + expect(macosSend).toHaveBeenCalledWith({ + body: 'Idle Scout has been idle for more than 30 minutes.', + title: 'Agent idle', + }); + + manager.shutdown(); + }); +}); diff --git a/src/electron/main/notifications/notification-manager.ts b/src/electron/main/notifications/notification-manager.ts new file mode 100644 index 0000000..26e8ef6 --- /dev/null +++ b/src/electron/main/notifications/notification-manager.ts @@ -0,0 +1,263 @@ +// Main-process notification orchestration. + +import { createId } from '@/shared/id'; +import type { Agent } from '@/renderer/features/agents/types'; + +import type { AppStorage } from '@/electron/main/storage'; + +import { NotificationHistory } from './notification-history'; +import type { MacOsNotifier } from './macos-notifier'; +import type { TelegramNotifier } from './telegram-notifier'; +import { + AGENT_IDLE_POLL_INTERVAL_MS, + AGENT_IDLE_THRESHOLD_MS, + DEFAULT_NOTIFICATION_SETTINGS, + mergeNotificationSettings, + normalizeNotificationSettings, + NOTIFICATION_SETTINGS_KEY, + NOTIFICATION_THROTTLE_MS, + NotificationChannel, + type NotificationRecord, + type NotificationSettings, + type NotificationSettingsUpdate, + NotificationTrigger, +} from './types'; + +/** Notify options. */ +export interface NotifyOptions { + agentId?: string; + body: string; + itemId?: string; + throttleId?: string; + title: string; +} + +/** Notification manager dependencies. */ +export interface NotificationManagerOptions { + getAgents: () => Agent[]; + history?: NotificationHistory; + macosNotifier: MacOsNotifier; + now?: () => number; + settingsStore: AppStorage; + telegramNotifier: TelegramNotifier; +} + +/** Coordinates notification delivery, throttling, and settings persistence. */ +export class NotificationManager { + private readonly getAgents: () => Agent[]; + + private readonly history: NotificationHistory; + + private readonly macosNotifier: MacOsNotifier; + + private readonly now: () => number; + + private readonly settingsStore: AppStorage; + + private readonly telegramNotifier: TelegramNotifier; + + private readonly throttleMap = new Map(); + + private settings: NotificationSettings = { + triggers: { ...DEFAULT_NOTIFICATION_SETTINGS.triggers }, + channels: { ...DEFAULT_NOTIFICATION_SETTINGS.channels }, + doNotDisturb: { ...DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb }, + telegramNotifyChatId: DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + }; + + private initializationPromise: Promise | null = null; + + private idlePollHandle: ReturnType | null = null; + + constructor(options: NotificationManagerOptions) { + this.getAgents = options.getAgents; + this.history = options.history ?? new NotificationHistory(); + this.macosNotifier = options.macosNotifier; + this.now = options.now ?? Date.now; + this.settingsStore = options.settingsStore; + this.telegramNotifier = options.telegramNotifier; + } + + /** Loads persisted settings. */ + async initialize() { + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = (async () => { + const value = await this.settingsStore.get(NOTIFICATION_SETTINGS_KEY); + this.settings = normalizeNotificationSettings(value); + })(); + + return this.initializationPromise; + } + + /** Starts idle polling. */ + start() { + if (this.idlePollHandle) { + return; + } + + this.idlePollHandle = setInterval(() => { + void this.checkIdleAgents(); + }, AGENT_IDLE_POLL_INTERVAL_MS); + } + + /** Stops idle polling. */ + shutdown() { + if (!this.idlePollHandle) { + return; + } + + clearInterval(this.idlePollHandle); + this.idlePollHandle = null; + } + + /** Returns current settings. */ + async getSettings() { + await this.initialize(); + return normalizeNotificationSettings(this.settings); + } + + /** Returns notification history. */ + getHistory() { + return this.history.getAll(); + } + + /** Clears notification history. */ + clearHistory() { + this.history.clear(); + } + + /** Merges and persists settings. */ + async updateSettings(partial: NotificationSettingsUpdate) { + await this.initialize(); + this.settings = mergeNotificationSettings(this.settings, partial); + await this.settingsStore.set(NOTIFICATION_SETTINGS_KEY, this.settings); + return this.getSettings(); + } + + /** Returns whether the current time is inside do-not-disturb hours. */ + isInDoNotDisturb() { + const { doNotDisturb } = this.settings; + + if (!doNotDisturb.enabled) { + return false; + } + + const hour = new Date(this.now()).getHours(); + + if (doNotDisturb.startHour === doNotDisturb.endHour) { + return true; + } + + if (doNotDisturb.startHour < doNotDisturb.endHour) { + return hour >= doNotDisturb.startHour && hour < doNotDisturb.endHour; + } + + return hour >= doNotDisturb.startHour || hour < doNotDisturb.endHour; + } + + /** Returns whether a notification key is currently throttled. */ + isThrottled(key: string) { + const lastSentAt = this.throttleMap.get(key) ?? 0; + return this.now() - lastSentAt < NOTIFICATION_THROTTLE_MS; + } + + /** Sends notifications through enabled channels. */ + async notify(trigger: NotificationTrigger, options: NotifyOptions) { + await this.initialize(); + + if (!this.settings.triggers[trigger] || this.isInDoNotDisturb()) { + return false; + } + + const throttleKey = this.buildThrottleKey(trigger, options); + + if (throttleKey && this.isThrottled(throttleKey)) { + return false; + } + + const timestamp = this.now(); + const deliveries = await Promise.all([ + this.settings.channels[NotificationChannel.macos] + ? this.macosNotifier.send({ + body: options.body, + title: options.title, + }) + : Promise.resolve(false), + this.settings.channels[NotificationChannel.telegram] + ? this.telegramNotifier.send({ + body: options.body, + chatId: this.settings.telegramNotifyChatId, + title: options.title, + }) + : Promise.resolve(false), + ]); + + const [macosDelivered, telegramDelivered] = deliveries; + + if (macosDelivered) { + this.history.add(this.createRecord(NotificationChannel.macos, trigger, options, timestamp)); + } + + if (telegramDelivered) { + this.history.add(this.createRecord(NotificationChannel.telegram, trigger, options, timestamp)); + } + + if ((macosDelivered || telegramDelivered) && throttleKey) { + this.throttleMap.set(throttleKey, timestamp); + } + + return macosDelivered || telegramDelivered; + } + + private buildThrottleKey(trigger: NotificationTrigger, options: NotifyOptions) { + const throttleId = options.throttleId ?? options.itemId ?? options.agentId ?? null; + return throttleId ? `${trigger}:${throttleId}` : null; + } + + private async checkIdleAgents() { + await this.initialize(); + + if (!this.settings.triggers[NotificationTrigger.agent_idle]) { + return; + } + + const threshold = this.now() - AGENT_IDLE_THRESHOLD_MS; + + for (const agent of this.getAgents()) { + if ( + agent.status !== 'ready' + || typeof agent.lastActiveAt !== 'number' + || agent.lastActiveAt >= threshold + ) { + continue; + } + + await this.notify(NotificationTrigger.agent_idle, { + agentId: agent.id, + body: `${agent.name} has been idle for more than 30 minutes.`, + throttleId: agent.id, + title: 'Agent idle', + }); + } + } + + private createRecord( + channel: NotificationChannel, + trigger: NotificationTrigger, + options: NotifyOptions, + timestamp: number, + ): NotificationRecord { + return { + body: options.body, + channel, + id: createId('notification'), + ...(options.itemId ? { itemId: options.itemId } : {}), + timestamp, + title: options.title, + trigger, + }; + } +} diff --git a/src/electron/main/notifications/telegram-notifier.ts b/src/electron/main/notifications/telegram-notifier.ts new file mode 100644 index 0000000..424ffcb --- /dev/null +++ b/src/electron/main/notifications/telegram-notifier.ts @@ -0,0 +1,34 @@ +// Telegram notification delivery. + +import type { TelegramBridge } from '@/electron/main/runtime/telegram-bridge'; + +/** Delivery payload shape. */ +export interface TelegramNotificationPayload { + body: string; + chatId: string; + title: string; +} + +/** Telegram notification wrapper. */ +export class TelegramNotifier { + private readonly getBridge: () => TelegramBridge | null; + + constructor(getBridge: () => TelegramBridge | null) { + this.getBridge = getBridge; + } + + /** Sends a Telegram message when the bridge is configured. */ + async send(payload: TelegramNotificationPayload) { + const chatId = payload.chatId.trim(); + const bridge = this.getBridge(); + + if (!bridge || !chatId) { + return false; + } + + return bridge.sendNotificationMessage( + chatId, + `${payload.title}\n${payload.body}`.trim(), + ); + } +} diff --git a/src/electron/main/notifications/types.ts b/src/electron/main/notifications/types.ts new file mode 100644 index 0000000..c7b281d --- /dev/null +++ b/src/electron/main/notifications/types.ts @@ -0,0 +1,167 @@ +// Notification system types and normalization helpers. + +import { isPlainObject } from '@/shared/is-record'; + +export enum NotificationTrigger { + item_review = 'item_review', + item_acceptance = 'item_acceptance', + agent_error = 'agent_error', + budget_warning = 'budget_warning', + agent_idle = 'agent_idle', +} + +export enum NotificationChannel { + macos = 'macos', + telegram = 'telegram', +} + +export interface NotificationSettings { + triggers: Record; + channels: Record; + doNotDisturb: { + enabled: boolean; + startHour: number; + endHour: number; + }; + telegramNotifyChatId: string; +} + +export interface NotificationSettingsUpdate { + triggers?: Partial>; + channels?: Partial>; + doNotDisturb?: Partial; + telegramNotifyChatId?: string; +} + +export interface NotificationRecord { + id: string; + timestamp: number; + trigger: NotificationTrigger; + channel: NotificationChannel; + title: string; + body: string; + itemId?: string; +} + +export const NOTIFICATION_SETTINGS_KEY = 'notifications'; +export const NOTIFICATION_HISTORY_LIMIT = 50; +export const NOTIFICATION_THROTTLE_MS = 5 * 60_000; +export const AGENT_IDLE_THRESHOLD_MS = 30 * 60_000; +export const AGENT_IDLE_POLL_INTERVAL_MS = 5 * 60_000; + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { + triggers: { + [NotificationTrigger.item_review]: true, + [NotificationTrigger.item_acceptance]: true, + [NotificationTrigger.agent_error]: true, + [NotificationTrigger.budget_warning]: true, + [NotificationTrigger.agent_idle]: false, + }, + channels: { + [NotificationChannel.macos]: true, + [NotificationChannel.telegram]: false, + }, + doNotDisturb: { + enabled: false, + startHour: 23, + endHour: 8, + }, + telegramNotifyChatId: '', +}; + +function clampHour(value: unknown, fallback: number) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(0, Math.min(23, Math.trunc(value))); +} + +function normalizeBooleanRecord( + value: unknown, + defaults: Record, +): Record { + if (!isPlainObject(value)) { + return { ...defaults }; + } + + const next = {} as Record; + + for (const key of Object.keys(defaults) as K[]) { + next[key] = typeof value[key] === 'boolean' ? value[key] : defaults[key]; + } + + return next; +} + +export function normalizeNotificationSettings(value: unknown): NotificationSettings { + if (!isPlainObject(value)) { + return { + triggers: { ...DEFAULT_NOTIFICATION_SETTINGS.triggers }, + channels: { ...DEFAULT_NOTIFICATION_SETTINGS.channels }, + doNotDisturb: { ...DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb }, + telegramNotifyChatId: DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + }; + } + + const doNotDisturb = isPlainObject(value.doNotDisturb) ? value.doNotDisturb : {}; + + return { + triggers: normalizeBooleanRecord(value.triggers, DEFAULT_NOTIFICATION_SETTINGS.triggers), + channels: normalizeBooleanRecord(value.channels, DEFAULT_NOTIFICATION_SETTINGS.channels), + doNotDisturb: { + enabled: + typeof doNotDisturb.enabled === 'boolean' + ? doNotDisturb.enabled + : DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb.enabled, + startHour: clampHour( + doNotDisturb.startHour, + DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb.startHour, + ), + endHour: clampHour( + doNotDisturb.endHour, + DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb.endHour, + ), + }, + telegramNotifyChatId: + typeof value.telegramNotifyChatId === 'string' + ? value.telegramNotifyChatId.trim() + : DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + }; +} + +export function mergeNotificationSettings( + current: NotificationSettings, + partial: NotificationSettingsUpdate, +) { + return normalizeNotificationSettings({ + ...current, + ...(partial.triggers + ? { + triggers: { + ...current.triggers, + ...partial.triggers, + }, + } + : {}), + ...(partial.channels + ? { + channels: { + ...current.channels, + ...partial.channels, + }, + } + : {}), + ...(partial.doNotDisturb + ? { + doNotDisturb: { + ...current.doNotDisturb, + ...partial.doNotDisturb, + }, + } + : {}), + ...(partial.telegramNotifyChatId !== undefined + ? { telegramNotifyChatId: partial.telegramNotifyChatId } + : {}), + }); +} diff --git a/src/electron/main/runtime/agent-runtime.test.ts b/src/electron/main/runtime/agent-runtime.test.ts index 9afabbb..9d242f4 100644 --- a/src/electron/main/runtime/agent-runtime.test.ts +++ b/src/electron/main/runtime/agent-runtime.test.ts @@ -101,7 +101,21 @@ interface MockAgent { addChannel: ReturnType; channelDrivers: Map; getGroup: ReturnType; - getTask: ReturnType; + getTask: (taskId: string) => { + contextMode: 'group' | 'isolated'; + createdAt: string; + groupFolder: string; + id: string; + jid: string; + lastResult: string | null; + lastRun: string | null; + nextRun: string | null; + prompt: string; + runs: unknown[]; + scheduleType: 'once'; + scheduleValue: string; + status: 'active' | 'completed'; + } | undefined; name: string; off: ReturnType; on: ReturnType; diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index 6cfb7dd..75150b5 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -425,6 +425,7 @@ export interface AgentRuntimeOptions { homeDir?: string; loadAgentLiteModule?: () => Promise; now?: () => number; + onAgentError?: (payload: { agentId: string; agentName: string; error: string }) => void; onAgentIdle?: (agentId: string) => void; onItemActivityChanged?: (payload: { itemId: string; isWorking: boolean }) => void; resolveProjectName?: (projectId: string) => Promise; @@ -489,6 +490,8 @@ export class AgentRuntime implements AgentRuntimeContract { private readonly onAgentIdle: AgentRuntimeOptions['onAgentIdle']; + private readonly onAgentError: AgentRuntimeOptions['onAgentError']; + private readonly onItemActivityChanged: AgentRuntimeOptions['onItemActivityChanged']; /** Per-item ephemeral run state driven by AgentLite task.run.* events. */ @@ -530,6 +533,7 @@ export class AgentRuntime implements AgentRuntimeContract { this.agentStore = options.agentStore; this.actionServices = options.actionServices; this.homeDir = options.homeDir ?? os.homedir(); + this.onAgentError = options.onAgentError; this.onAgentIdle = options.onAgentIdle; this.onItemActivityChanged = options.onItemActivityChanged; this.runtimeRoot = resolveAgentLiteRuntimeRoot(options.homeDir); @@ -723,6 +727,11 @@ export class AgentRuntime implements AgentRuntimeContract { await this.telegram.refreshRuntimeState({ forceReconnect: true }); } + /** Returns the runtime Telegram bridge. */ + getTelegramBridge() { + return this.telegram; + } + /** Resets agent. */ reset() { this.messageStream.clear(); @@ -1977,6 +1986,7 @@ export class AgentRuntime implements AgentRuntimeContract { this.scheduleFinalizeAssistantMessage(agentId); this.persistState(); this.emit(); + this.touchAgentActivity(agentId, now); } /** Tracks running scheduled tasks per agent and updates agent.status accordingly. */ @@ -2008,6 +2018,10 @@ export class AgentRuntime implements AgentRuntimeContract { ), }; this.emit(); + + if (running) { + this.touchAgentActivity(agentId); + } } private pushActivityEvent(agentId: string, event: AgentActivityEvent) { @@ -2030,6 +2044,38 @@ export class AgentRuntime implements AgentRuntimeContract { }), }; this.emit(); + this.touchAgentActivity(agentId, event.timestamp); + } + + private touchAgentActivity(agentId: string, timestamp: number = this.now()) { + this.snapshot = { + ...this.snapshot, + agents: this.snapshot.agents.map((agent) => + agent.id === agentId && agent.status !== 'draft' + ? { + ...agent, + lastActiveAt: timestamp, + updatedAt: Math.max(agent.updatedAt, timestamp), + } + : agent, + ), + }; + this.persistState(); + this.emit(); + } + + private reportAgentError(agentId: string, error: string) { + const agent = this.snapshot.agents.find((item) => item.id === agentId) ?? null; + + if (!agent) { + return; + } + + this.onAgentError?.({ + agentId, + agentName: agent.name, + error, + }); } private scheduleFinalizeAssistantMessage(agentId: string) { @@ -2502,6 +2548,7 @@ export class AgentRuntime implements AgentRuntimeContract { alAgent.on('task.run.failed', (event) => { this.markTaskRunning(agentId, event.taskId, false); void this.updateItemActivityForTask(event.taskId, false); + this.reportAgentError(agentId, event.error || `Scheduled task ${event.taskId} failed.`); }); alAgent.on('task.run.skipped', (event) => { this.markTaskRunning(agentId, event.taskId, false); diff --git a/src/electron/main/runtime/agent-runtime/records.ts b/src/electron/main/runtime/agent-runtime/records.ts index cadf424..1800716 100644 --- a/src/electron/main/runtime/agent-runtime/records.ts +++ b/src/electron/main/runtime/agent-runtime/records.ts @@ -191,6 +191,7 @@ export function createDraftAgent( contextCards: [], definition: cloneAgentDefinition(definition), id: agentId, + lastActiveAt: now, messages: [] satisfies AgentMessage[], name, note: copy.note, @@ -268,6 +269,10 @@ export function normalizePersistedAgentRecord( : [], contextCards: record.agent.contextCards.map((card) => ({ ...card })), definition, + lastActiveAt: + typeof record.agent.lastActiveAt === 'number' + ? record.agent.lastActiveAt + : record.agent.updatedAt, messages: normalizedLiveMessages, projectId: typeof record.agent.projectId === 'string' ? record.agent.projectId : null, status: record.agent.status === 'live' ? 'ready' : record.agent.status, diff --git a/src/electron/main/runtime/desktop-runtime-controller.test.ts b/src/electron/main/runtime/desktop-runtime-controller.test.ts index 6505299..dc63d6f 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.test.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.test.ts @@ -114,4 +114,39 @@ describe('DesktopRuntimeController', () => { expect(cancelItemAssignment).toHaveBeenCalledWith('agent-1', 'task-123'); expect(taskId).toBe('task-123'); }); + + it('emits item status changes only for review and acceptance transitions', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dune-controller-home-')); + tempDirs.push(homeDir); + const onItemStatusChange = vi.fn(); + const controller = new DesktopRuntimeController({ + agentStore: { get: async () => null, set: async () => {} }, + homeDir, + onItemStatusChange, + }); + + controller.handleWorkflowSnapshotChange( + { + items: [ + { id: 'item-1', status: 'active', title: 'Alpha' }, + { id: 'item-2', status: 'review', title: 'Beta' }, + ], + }, + { + items: [ + { id: 'item-1', status: 'review', title: 'Alpha' }, + { id: 'item-2', status: 'done', title: 'Beta' }, + { id: 'item-3', status: 'acceptance', title: 'Gamma' }, + ], + }, + ); + + expect(onItemStatusChange).toHaveBeenCalledTimes(1); + expect(onItemStatusChange).toHaveBeenCalledWith({ + itemId: 'item-1', + nextStatus: 'review', + previousStatus: 'active', + title: 'Alpha', + }); + }); }); diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..e3a3aa2 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -1,10 +1,12 @@ // Desktop runtime controller and backend fallback wiring. +import { isPlainObject } from '@/shared/is-record'; import type { AgentRuntimeContract, AgentServiceListener, AgentServiceSnapshot, } from '@/shared/agents/agent-runtime'; +import type { WorkflowItemStatus } from '@/renderer/features/workflow/types'; import { createMockAgentRuntime } from '@/renderer/features/agents/services/mock-agent-service'; import type { AgentDefinition, @@ -18,9 +20,11 @@ import { resolveAgentLiteRuntimeRoot, type AgentRuntimeOptions, } from './agent-runtime'; +import type { TelegramBridge } from './telegram-bridge'; /** Active runtime shape. */ type ActiveRuntime = AgentRuntimeContract & { + getTelegramBridge?: () => TelegramBridge; reloadExternalChannels?: () => Promise; shutdown?: () => Promise; }; @@ -34,6 +38,12 @@ type RealRuntime = ActiveRuntime & { export interface DesktopRuntimeControllerOptions extends AgentRuntimeOptions { createRealRuntime?: (options: DesktopRuntimeControllerOptions) => RealRuntime; + onItemStatusChange?: (payload: { + itemId: string; + nextStatus: Extract; + previousStatus: WorkflowItemStatus; + title: string; + }) => void; } /** Coordinates desktop runtime. */ @@ -144,6 +154,11 @@ export class DesktopRuntimeController { await this.activeRuntime.reloadExternalChannels?.(); } + /** Returns the live Telegram bridge when available. */ + getTelegramBridge() { + return this.activeRuntime.getTelegramBridge?.() ?? null; + } + /** Runs an isolated multi-target research pass and reduces the results. */ async runIsolatedResearch(agentId: string, input: RunIsolatedResearchInput) { return this.activeRuntime.service.runIsolatedResearch(agentId, input); @@ -201,6 +216,43 @@ export class DesktopRuntimeController { } } + /** Emits item status changes for workflow transitions the host cares about. */ + handleWorkflowSnapshotChange(previous: unknown, next: unknown) { + const onItemStatusChange = this.runtimeOptions.onItemStatusChange; + + if (!onItemStatusChange || !isWorkflowSnapshotItems(previous) || !isWorkflowSnapshotItems(next)) { + return; + } + + const previousStatuses = new Map(); + + for (const item of previous.items) { + previousStatuses.set(item.id, { + status: item.status, + title: item.title, + }); + } + + for (const item of next.items) { + if (item.status !== 'review' && item.status !== 'acceptance') { + continue; + } + + const previousItem = previousStatuses.get(item.id); + + if (!previousItem || previousItem.status === item.status) { + continue; + } + + onItemStatusChange({ + itemId: item.id, + nextStatus: item.status, + previousStatus: previousItem.status, + title: item.title, + }); + } + } + /** Shuts down desktop runtime. */ shutdown(): Promise { if (this.shutdownPromise) { @@ -238,3 +290,29 @@ export class DesktopRuntimeController { }); } } + +function isWorkflowItemStatus(value: unknown): value is WorkflowItemStatus { + return ( + value === 'inbox' + || value === 'ready' + || value === 'active' + || value === 'review' + || value === 'acceptance' + || value === 'done' + ); +} + +function isWorkflowSnapshotItems(value: unknown): value is { + items: Array<{ id: string; status: WorkflowItemStatus; title: string }>; +} { + if (!isPlainObject(value) || !Array.isArray(value.items)) { + return false; + } + + return value.items.every((item) => + isPlainObject(item) + && typeof item.id === 'string' + && typeof item.title === 'string' + && isWorkflowItemStatus(item.status), + ); +} diff --git a/src/electron/main/runtime/telegram-bridge.ts b/src/electron/main/runtime/telegram-bridge.ts index b9bfee6..7499633 100644 --- a/src/electron/main/runtime/telegram-bridge.ts +++ b/src/electron/main/runtime/telegram-bridge.ts @@ -531,6 +531,36 @@ export class TelegramBridge { return patches; } + /** Sends a host-level notification message through any connected observer. */ + async sendNotificationMessage(chatId: string, text: string) { + const trimmedChatId = chatId.trim(); + const trimmedText = text.trim(); + const jid = trimmedChatId.startsWith('tg:') ? trimmedChatId : `tg:${trimmedChatId}`; + + if (!trimmedChatId || !trimmedText) { + return false; + } + + const connectedObservers = [...this.observers.values()].filter((observer) => + observer.status === 'connected' && observer.driver, + ); + + if (connectedObservers.length === 0) { + return false; + } + + for (const observer of connectedObservers) { + try { + await observer.driver?.sendMessage(jid, trimmedText); + return true; + } catch (error) { + console.warn('Failed to send a Telegram notification message.', error); + } + } + + return false; + } + // sendReply removed — DuneChannel's external driver handles outbound delivery. /** Disconnects all. */ diff --git a/src/electron/preload.test.ts b/src/electron/preload.test.ts index 9ad7d25..ec8d520 100644 --- a/src/electron/preload.test.ts +++ b/src/electron/preload.test.ts @@ -70,6 +70,7 @@ describe('preload bridge', () => { await desktopBridge?.getRuntimeSnapshot?.(); await desktopBridge?.applyNetworkSettings?.(); await desktopBridge?.cancelTelegramSetupSession?.('telegram-session-1'); + await desktopBridge?.clearNotificationHistory?.(); await desktopBridge?.createAgent?.({ channelId: 'dune-chat', name: 'Navigator', @@ -83,6 +84,8 @@ describe('preload bridge', () => { await desktopBridge?.getProjectActivityPage?.('project-1', { beforeEntryId: 'event-1', limit: 20 }); await desktopBridge?.listProjectArtifactEntries?.('/tmp/project-1', 'homepage-copy-abcd1234'); await desktopBridge?.copyText?.('@agentlite_test_bot'); + await desktopBridge?.getNotificationHistory?.(); + await desktopBridge?.getNotificationSettings?.(); await desktopBridge?.openExternal?.('https://t.me/BotFather'); await desktopBridge?.openPath?.('/tmp/project-1'); await desktopBridge?.prepareProjectRootPath?.('/tmp/project-1', ['homepage-copy-abcd1234']); @@ -96,6 +99,9 @@ describe('preload bridge', () => { await desktopBridge?.selectProjectDirectory?.(); await desktopBridge?.getTelegramSetupSession?.('telegram-session-1'); await desktopBridge?.startTelegramSetupSession?.({ token: 'bot-token' }); + await desktopBridge?.updateNotificationSettings?.({ + telegramNotifyChatId: '123456', + }); await desktopBridge?.updateAgentChannel?.({ agentId: 'agent-1', channelId: 'dune-chat', @@ -110,6 +116,7 @@ describe('preload bridge', () => { ipcChannels.cancelTelegramSetupSession, 'telegram-session-1', ); + expect(invoke).toHaveBeenCalledWith(ipcChannels.clearNotificationHistory); expect(invoke).toHaveBeenCalledWith(ipcChannels.createAgent, { channelId: 'dune-chat', name: 'Navigator', @@ -144,6 +151,8 @@ describe('preload bridge', () => { 'homepage-copy-abcd1234', ); expect(invoke).toHaveBeenCalledWith(ipcChannels.copyText, '@agentlite_test_bot'); + expect(invoke).toHaveBeenCalledWith(ipcChannels.getNotificationHistory); + expect(invoke).toHaveBeenCalledWith(ipcChannels.getNotificationSettings); expect(invoke).toHaveBeenCalledWith(ipcChannels.openExternal, 'https://t.me/BotFather'); expect(invoke).toHaveBeenCalledWith(ipcChannels.openPath, '/tmp/project-1'); expect(invoke).toHaveBeenCalledWith( @@ -171,6 +180,12 @@ describe('preload bridge', () => { ipcChannels.startTelegramSetupSession, { token: 'bot-token' }, ); + expect(invoke).toHaveBeenCalledWith( + ipcChannels.updateNotificationSettings, + { + telegramNotifyChatId: '123456', + }, + ); expect(invoke).toHaveBeenCalledWith( ipcChannels.updateAgentChannel, { @@ -243,8 +258,12 @@ describe('preload bridge', () => { 'selectProjectDirectory', 'selectAgent', 'sendAgentMessage', + 'clearNotificationHistory', + 'getNotificationHistory', + 'getNotificationSettings', 'startTelegramSetupSession', 'updateAgentChannel', + 'updateNotificationSettings', 'storageDelete', 'storageGet', 'storageKeys', diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 83312ed..aecc089 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -12,6 +12,7 @@ const bridge: DesktopBridge = { applyNetworkSettings: () => ipcRenderer.invoke(ipcChannels.applyNetworkSettings), cancelTelegramSetupSession: (sessionId) => ipcRenderer.invoke(ipcChannels.cancelTelegramSetupSession, sessionId), + clearNotificationHistory: () => ipcRenderer.invoke(ipcChannels.clearNotificationHistory), copyText: (text) => ipcRenderer.invoke(ipcChannels.copyText, text), platform: process.platform, createAgent: (input) => ipcRenderer.invoke(ipcChannels.createAgent, input), @@ -26,6 +27,8 @@ const bridge: DesktopBridge = { projectName, projectRootPath, ), + getNotificationHistory: () => ipcRenderer.invoke(ipcChannels.getNotificationHistory), + getNotificationSettings: () => ipcRenderer.invoke(ipcChannels.getNotificationSettings), getProjectActivityPage: (projectId, options) => ipcRenderer.invoke(ipcChannels.getProjectActivityPage, projectId, options), getAgentTranscriptPage: (agentId, options) => @@ -57,6 +60,8 @@ const bridge: DesktopBridge = { storageKeys: (store) => ipcRenderer.invoke(ipcChannels.storageKeys, store), storageSet: (store, key, value) => ipcRenderer.invoke(ipcChannels.storageSet, store, key, value), selectProjectDirectory: () => ipcRenderer.invoke(ipcChannels.selectProjectDirectory), + updateNotificationSettings: (update) => + ipcRenderer.invoke(ipcChannels.updateNotificationSettings, update), subscribe: (listener) => { /** Handles snapshot. */ const handleSnapshot = ( diff --git a/src/renderer/app/testing/setup.ts b/src/renderer/app/testing/setup.ts index 2782807..d1c9359 100644 --- a/src/renderer/app/testing/setup.ts +++ b/src/renderer/app/testing/setup.ts @@ -4,6 +4,8 @@ import '@testing-library/jest-dom/vitest'; import { afterEach, beforeEach, vi } from 'vitest'; import { cleanup } from '@testing-library/react'; +import { DEFAULT_NOTIFICATION_SETTINGS } from '@/electron/main/notifications/types'; + const listeners = new Set<(event: MediaQueryListEvent) => void>(); Object.defineProperty(window, 'matchMedia', { @@ -62,9 +64,17 @@ Object.defineProperty(window, 'ResizeObserver', { }); beforeEach(() => { + const defaultNotificationSettings = { + triggers: { ...DEFAULT_NOTIFICATION_SETTINGS.triggers }, + channels: { ...DEFAULT_NOTIFICATION_SETTINGS.channels }, + doNotDisturb: { ...DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb }, + telegramNotifyChatId: DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + }; + window.duneDesktop = { applyNetworkSettings: vi.fn(() => Promise.resolve(undefined)), cancelTelegramSetupSession: vi.fn(() => Promise.resolve(undefined)), + clearNotificationHistory: vi.fn(() => Promise.resolve(undefined)), copyText: vi.fn(() => Promise.resolve(undefined)), ensureProjectArtifactFolder: vi.fn(() => Promise.resolve('/tmp/project/item-123')), ensureProjectMainAgent: vi.fn(() => Promise.resolve('agent-project-main')), @@ -81,6 +91,8 @@ beforeEach(() => { selectedAgentId: null, telegramSetupSessions: [], })), + getNotificationHistory: vi.fn(() => Promise.resolve([])), + getNotificationSettings: vi.fn(() => Promise.resolve(defaultNotificationSettings)), getTelegramSetupSession: vi.fn(() => Promise.resolve(null)), listProjectArtifactEntries: vi.fn(() => Promise.resolve([])), openExternal: vi.fn(() => Promise.resolve(undefined)), @@ -95,6 +107,7 @@ beforeEach(() => { storageGet: vi.fn(() => Promise.resolve(null)), storageKeys: vi.fn(() => Promise.resolve([])), storageSet: vi.fn(() => Promise.resolve(undefined)), + updateNotificationSettings: vi.fn(() => Promise.resolve(defaultNotificationSettings)), }; document.documentElement.dataset.theme = 'light'; }); diff --git a/src/renderer/features/agents/types.ts b/src/renderer/features/agents/types.ts index 4681d58..33db956 100644 --- a/src/renderer/features/agents/types.ts +++ b/src/renderer/features/agents/types.ts @@ -253,6 +253,7 @@ export interface Agent extends Pick { channel: AgentChannelBinding; codingEngineEvents: CodingEngineEvent[]; definition: AgentDefinition; + lastActiveAt?: number | null; note: string; projectId: string | null; status: AgentStatus; @@ -271,6 +272,7 @@ export interface PresentedAgent extends AgentSummary { codingEngineEvents: CodingEngineEvent[]; definition: AgentDefinition; id: string; + lastActiveAt?: number | null; note: string; projectId: string | null; status: AgentStatus; diff --git a/src/renderer/features/settings/components/SettingsNav.tsx b/src/renderer/features/settings/components/SettingsNav.tsx index 35a9098..eca88e2 100644 --- a/src/renderer/features/settings/components/SettingsNav.tsx +++ b/src/renderer/features/settings/components/SettingsNav.tsx @@ -45,7 +45,8 @@ export function SettingsNav({ onClick={() => onSelectRoute(section.id)} type="button" > - + + {section.title} diff --git a/src/renderer/features/settings/config/settings-sections.test.ts b/src/renderer/features/settings/config/settings-sections.test.ts index 2b2104b..bd739c0 100644 --- a/src/renderer/features/settings/config/settings-sections.test.ts +++ b/src/renderer/features/settings/config/settings-sections.test.ts @@ -2,24 +2,28 @@ import { describe, expect, it } from 'vitest'; -import type { SettingsRoute } from '@/renderer/features/settings/types'; - import { settingsSectionRegistry, settingsSections } from './settings-sections'; -const allRoutes: SettingsRoute[] = ['appearance', 'models', 'network', 'shortcuts', 'nuclear']; - describe('settingsSectionRegistry', () => { it('has an entry for every SettingsRoute', () => { - for (const route of allRoutes) { - expect(settingsSectionRegistry[route]).toBeDefined(); - } + expect(Object.keys(settingsSectionRegistry).sort()).toEqual([ + 'appearance', + 'artifacts', + 'models', + 'network', + 'notifications', + 'nuclear', + 'shortcuts', + ]); }); - it('renders network below models in the section order', () => { + it('renders notifications below artifacts in the section order', () => { expect(settingsSections.map((section) => section.id)).toEqual([ 'appearance', 'models', 'network', + 'artifacts', + 'notifications', 'shortcuts', 'nuclear', ]); diff --git a/src/renderer/features/settings/config/settings-sections.ts b/src/renderer/features/settings/config/settings-sections.ts index 6fbe04e..41c04b9 100644 --- a/src/renderer/features/settings/config/settings-sections.ts +++ b/src/renderer/features/settings/config/settings-sections.ts @@ -1,6 +1,16 @@ // Settings section configuration. import type { JSX } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { + Bell, + Bot, + Flame, + FolderKanban, + Keyboard, + Paintbrush, + Waypoints, +} from 'lucide-react'; import { AppearanceSettings } from '@/renderer/features/settings/components/AppearanceSettings'; import { ArtifactsSettings } from '@/renderer/features/settings/components/ArtifactsSettings'; @@ -8,6 +18,7 @@ import { ModelsSettings } from '@/renderer/features/settings/components/ModelsSe import { NuclearSettings } from '@/renderer/features/settings/components/NuclearSettings'; import { NetworkSettings } from '@/renderer/features/settings/components/NetworkSettings'; import { ShortcutsSettings } from '@/renderer/features/settings/components/ShortcutsSettings'; +import { NotificationsSettingsPanel } from '@/renderer/features/settings/notifications'; import type { SettingsRoute, @@ -32,6 +43,7 @@ export interface SettingsSectionComponentProps { /** Settings section definition shape. */ interface SettingsSectionDefinition extends SettingsSection { Component: (props: SettingsSectionComponentProps) => JSX.Element; + icon: LucideIcon; } /** Lists settings sections. */ @@ -41,36 +53,49 @@ export const settingsSections: SettingsSectionDefinition[] = [ title: 'Appearance', description: 'Theme and visual tone', Component: AppearanceSettings, + icon: Paintbrush, }, { id: 'models', title: 'Models', description: 'LLM provider catalog', Component: ModelsSettings, + icon: Bot, }, { id: 'network', title: 'Network', description: 'Proxy and transport path', Component: NetworkSettings, + icon: Waypoints, }, { id: 'artifacts', title: 'Artifacts', description: 'Agent templates and prompts', Component: ArtifactsSettings, + icon: FolderKanban, + }, + { + id: 'notifications', + title: 'Notifications', + description: 'Alerts, delivery, and quiet hours', + Component: NotificationsSettingsPanel, + icon: Bell, }, { id: 'shortcuts', title: 'Shortcuts', description: 'Keyboard-first reference', Component: ShortcutsSettings, + icon: Keyboard, }, { id: 'nuclear', title: 'Nuclear', description: 'Delete local data', Component: NuclearSettings, + icon: Flame, }, ]; diff --git a/src/renderer/features/settings/notifications/NotificationsSettingsPanel.tsx b/src/renderer/features/settings/notifications/NotificationsSettingsPanel.tsx new file mode 100644 index 0000000..b768aec --- /dev/null +++ b/src/renderer/features/settings/notifications/NotificationsSettingsPanel.tsx @@ -0,0 +1,452 @@ +// Notifications settings UI. + +import type { ComponentType } from 'react'; +import { useEffect, useState } from 'react'; +import { + Bell, + ChevronDown, + ChevronUp, + Send, +} from 'lucide-react'; + +import type { SettingsSectionComponentProps } from '@/renderer/features/settings/config/settings-sections'; +import { cn } from '@/renderer/shared/lib/utils'; +import { Button } from '@/renderer/shared/ui/button'; +import { Input } from '@/renderer/shared/ui/input'; +import { + DEFAULT_NOTIFICATION_SETTINGS, + mergeNotificationSettings, + NotificationChannel, + type NotificationRecord, + type NotificationSettings, + type NotificationSettingsUpdate, + NotificationTrigger, +} from '@/electron/main/notifications/types'; + +import { SettingsSectionIntro } from '../components/SettingsSectionIntro'; + +const triggerOptions = [ + { + description: 'Alert when a work item enters review.', + id: NotificationTrigger.item_review, + label: 'Item moved to review', + }, + { + description: 'Alert when a work item reaches acceptance.', + id: NotificationTrigger.item_acceptance, + label: 'Item moved to acceptance', + }, + { + description: 'Alert when an agent task or run fails.', + id: NotificationTrigger.agent_error, + label: 'Agent error', + }, + { + description: 'Reserved for budget threshold warnings as budget signals are added.', + id: NotificationTrigger.budget_warning, + label: 'Budget warning', + }, + { + description: 'Alert when an agent has been inactive for more than 30 minutes.', + id: NotificationTrigger.agent_idle, + label: 'Agent idle > 30 min', + }, +] as const; + +const channelOptions = [ + { + description: 'Use the native macOS notification center.', + icon: Bell, + id: NotificationChannel.macos, + label: 'macOS system notification', + }, + { + description: 'Mirror notifications to a Telegram chat when a bridge is configured.', + icon: Send, + id: NotificationChannel.telegram, + label: 'Telegram message', + }, +] as const; + +type FeedbackState = string | null; + +function formatHourLabel(hour: number) { + const normalized = ((hour % 24) + 24) % 24; + const suffix = normalized >= 12 ? 'PM' : 'AM'; + const twelveHour = normalized % 12 || 12; + return `${twelveHour}:00 ${suffix}`; +} + +function formatHistoryTimestamp(timestamp: number) { + return new Date(timestamp).toLocaleString(); +} + +function createDefaultSettings() { + return { + triggers: { ...DEFAULT_NOTIFICATION_SETTINGS.triggers }, + channels: { ...DEFAULT_NOTIFICATION_SETTINGS.channels }, + doNotDisturb: { ...DEFAULT_NOTIFICATION_SETTINGS.doNotDisturb }, + telegramNotifyChatId: DEFAULT_NOTIFICATION_SETTINGS.telegramNotifyChatId, + } satisfies NotificationSettings; +} + +interface ToggleRowProps { + checked: boolean; + description: string; + disabled?: boolean; + icon?: ComponentType<{ className?: string }>; + label: string; + onToggle: () => void; +} + +function ToggleRow({ + checked, + description, + disabled = false, + icon: Icon, + label, + onToggle, +}: ToggleRowProps) { + return ( +
+
+
+ {Icon ? : null} +

{label}

+
+

{description}

+
+ + +
+ ); +} + +/** Renders the notifications settings UI. */ +export function NotificationsSettingsPanel(props: SettingsSectionComponentProps) { + void props; + const [settings, setSettings] = useState(createDefaultSettings); + const [history, setHistory] = useState([]); + const [historyOpen, setHistoryOpen] = useState(false); + const [isClearingHistory, setClearingHistory] = useState(false); + const [isLoading, setLoading] = useState(true); + const [feedback, setFeedback] = useState(null); + + useEffect(() => { + let disposed = false; + + const load = async () => { + try { + const [loadedSettings, loadedHistory] = await Promise.all([ + window.duneDesktop?.getNotificationSettings?.(), + window.duneDesktop?.getNotificationHistory?.(), + ]); + + if (disposed) { + return; + } + + setSettings(loadedSettings ?? createDefaultSettings()); + setHistory(loadedHistory ?? []); + } catch (error) { + if (!disposed) { + setFeedback(`Failed to load notifications settings. ${String(error)}`); + } + } finally { + if (!disposed) { + setLoading(false); + } + } + }; + + void load(); + + return () => { + disposed = true; + }; + }, []); + + const saveSettings = async (update: NotificationSettingsUpdate) => { + const desktopBridge = window.duneDesktop; + + if (typeof desktopBridge?.updateNotificationSettings !== 'function') { + setSettings((current) => mergeNotificationSettings(current, update)); + return; + } + + setFeedback(null); + setSettings((current) => mergeNotificationSettings(current, update)); + + try { + const saved = await desktopBridge.updateNotificationSettings(update); + setSettings(saved); + } catch (error) { + setFeedback(`Failed to save notifications settings. ${String(error)}`); + } + }; + + const clearHistory = async () => { + if (typeof window.duneDesktop?.clearNotificationHistory !== 'function') { + setHistory([]); + return; + } + + setClearingHistory(true); + setFeedback(null); + + try { + await window.duneDesktop.clearNotificationHistory(); + setHistory([]); + } catch (error) { + setFeedback(`Failed to clear notification history. ${String(error)}`); + } finally { + setClearingHistory(false); + } + }; + + return ( + <> + + +
+ Changes save immediately. Item notifications are throttled to one alert per item every five minutes. +
+ +
+
+

Triggers

+

Pick which events generate notifications.

+
+ + {triggerOptions.map((option) => ( + { + void saveSettings({ + triggers: { + [option.id]: !settings.triggers[option.id], + }, + }); + }} + /> + ))} +
+ +
+
+

Delivery channels

+

Choose where Dune sends each notification.

+
+ + {channelOptions.map((option) => ( + { + void saveSettings({ + channels: { + [option.id]: !settings.channels[option.id], + }, + }); + }} + /> + ))} + + {settings.channels.telegram ? ( +
+ + { + void saveSettings({ + telegramNotifyChatId: event.target.value, + }); + }} + placeholder="123456789 or tg:123456789" + value={settings.telegramNotifyChatId} + /> +

+ Use the numeric chat id or the full `tg:` jid for the destination chat. +

+
+ ) : null} +
+ +
+
+

Do Not Disturb

+

Pause notifications during the hours you choose.

+
+ + { + void saveSettings({ + doNotDisturb: { + enabled: !settings.doNotDisturb.enabled, + }, + }); + }} + /> + +
+
+ + +
+ +
+ + +
+
+
+ +
+
+
+

History

+

Last 50 delivered notifications.

+
+ +
+ + +
+
+ + {historyOpen ? ( + history.length > 0 ? ( +
+ {history.map((record) => ( +
+
+ + {record.channel} + + {formatHistoryTimestamp(record.timestamp)} +
+

{record.title}

+

{record.body}

+
+ ))} +
+ ) : ( +
+ No notifications have been delivered in this session yet. +
+ ) + ) : null} +
+ + {feedback ? ( +
+ {feedback} +
+ ) : null} + + ); +} diff --git a/src/renderer/features/settings/notifications/index.ts b/src/renderer/features/settings/notifications/index.ts new file mode 100644 index 0000000..9c96da4 --- /dev/null +++ b/src/renderer/features/settings/notifications/index.ts @@ -0,0 +1 @@ +export { NotificationsSettingsPanel } from './NotificationsSettingsPanel'; diff --git a/src/renderer/features/settings/types.ts b/src/renderer/features/settings/types.ts index 5d5f72f..75bc4fb 100644 --- a/src/renderer/features/settings/types.ts +++ b/src/renderer/features/settings/types.ts @@ -6,6 +6,7 @@ export type SettingsRoute = | 'artifacts' | 'models' | 'network' + | 'notifications' | 'shortcuts' | 'nuclear'; /** Theme preference shape. */ diff --git a/src/shared/electron/desktop-bridge.ts b/src/shared/electron/desktop-bridge.ts index fa699ab..53360f2 100644 --- a/src/shared/electron/desktop-bridge.ts +++ b/src/shared/electron/desktop-bridge.ts @@ -1,6 +1,11 @@ // Shared Electron desktop bridge contract. import type { AgentServiceSnapshot } from '@/shared/agents/agent-runtime'; +import type { + NotificationRecord, + NotificationSettings, + NotificationSettingsUpdate, +} from '@/electron/main/notifications/types'; import type { AgentDefinition, AgentTranscriptPage, @@ -18,6 +23,7 @@ import type { ProjectArtifactEntry } from '@/shared/workflow/project-artifacts'; export interface DesktopBridge { applyNetworkSettings?: () => Promise; cancelTelegramSetupSession?: (sessionId: string) => Promise; + clearNotificationHistory?: () => Promise; copyText?: (text: string) => Promise; platform: NodeJS.Platform; createAgent?: (input: CreateAgentInput) => Promise; @@ -29,6 +35,8 @@ export interface DesktopBridge { projectName: string, projectRootPath?: string | null, ) => Promise; + getNotificationHistory?: () => Promise; + getNotificationSettings?: () => Promise; getProjectActivityPage?: ( projectId: string, options?: { beforeEntryId?: string | null; limit?: number }, @@ -66,6 +74,9 @@ export interface DesktopBridge { subscribeItemActivity?: ( listener: (payload: { itemId: string; isWorking: boolean }) => void, ) => () => void; + updateNotificationSettings?: ( + update: NotificationSettingsUpdate, + ) => Promise; updateAgentChannel?: (input: UpdateAgentChannelInput) => Promise; updateAgentDefinition?: (agentId: string, definition: AgentDefinition) => Promise; } diff --git a/src/shared/electron/ipc-channels.ts b/src/shared/electron/ipc-channels.ts index 04d2db3..f6e0ec2 100644 --- a/src/shared/electron/ipc-channels.ts +++ b/src/shared/electron/ipc-channels.ts @@ -4,6 +4,7 @@ export const ipcChannels = { applyNetworkSettings: 'dune:runtime:apply-network-settings', cancelTelegramSetupSession: 'dune:runtime:cancel-telegram-setup-session', + clearNotificationHistory: 'dune:notifications:clear-history', copyText: 'dune:runtime:copy-text', createAgent: 'dune:runtime:create-agent', deleteLocalData: 'dune:runtime:delete-local-data', @@ -11,6 +12,8 @@ export const ipcChannels = { ensureProjectArtifactFolder: 'dune:runtime:ensure-project-artifact-folder', ensureProjectMainAgent: 'dune:runtime:ensure-project-main-agent', getAgentTranscriptPage: 'dune:runtime:get-agent-transcript-page', + getNotificationHistory: 'dune:notifications:get-history', + getNotificationSettings: 'dune:notifications:get-settings', getProjectActivityPage: 'dune:workflow:get-project-activity-page', getRuntimeSnapshot: 'dune:runtime:get-snapshot', getTelegramSetupSession: 'dune:runtime:get-telegram-setup-session', @@ -35,4 +38,5 @@ export const ipcChannels = { storageGet: 'dune:storage:get', storageKeys: 'dune:storage:keys', storageSet: 'dune:storage:set', + updateNotificationSettings: 'dune:notifications:update-settings', } as const;