Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<typeof setInterval> | null = null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
const activeItemIds = new Set(snapshot.items.map((item) => item.id));
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
},
Expand All @@ -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 }>;
Expand Down Expand Up @@ -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);
});
Expand Down
54 changes: 54 additions & 0 deletions src/electron/main/notifications/macos-notifier.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
28 changes: 28 additions & 0 deletions src/electron/main/notifications/notification-history.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading