diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 6a5988e31..55170c1bb 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -19,6 +19,10 @@ import { import type { IpcMainInvokeEvent, WebContents } from "electron"; import { UnixWebSocketClient } from "./unixWebSocket.js"; +import { + clearDesktopNotificationBadge, + handleDesktopNotificationFrame, +} from "./notifications.js"; type EngineStatus = "starting" | "ready" | "restarting" | "stopped" | "crashed"; @@ -547,6 +551,7 @@ function createWindow(): BrowserWindow { }); win.once("ready-to-show", () => win.show()); + win.on("focus", clearDesktopNotificationBadge); win.webContents.setWindowOpenHandler(({ url }) => { openExternalIfSafe(url); return { action: "deny" }; @@ -622,7 +627,10 @@ function registerIpcHandlers(): void { const id = randomBytes(12).toString("hex"); const client = new UnixWebSocketClient(runtime.socketPath, url, { onOpen: () => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "open" }), - onMessage: (data) => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "message", data }), + onMessage: (data) => { + handleDesktopNotificationFrame(data, { getWindow: () => mainWindow }); + sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "message", data }); + }, onError: (message) => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "error", message }), onClose: (code, reason) => { hostSockets.delete(id); diff --git a/desktop/src/notifications.ts b/desktop/src/notifications.ts new file mode 100644 index 000000000..6ae2f72ef --- /dev/null +++ b/desktop/src/notifications.ts @@ -0,0 +1,123 @@ +import { + app, + BrowserWindow, + Notification, +} from "electron"; + +type NotificationSource = { + kind?: unknown; + label?: unknown; +}; + +type WsMessageFrame = { + chat_id?: unknown; + event?: unknown; + kind?: unknown; + source?: NotificationSource; + text?: unknown; +}; + +interface DesktopNotifierOptions { + getWindow: () => BrowserWindow | null; +} + +const MAX_NOTIFICATION_BODY_LENGTH = 180; +const MAX_NOTIFICATION_TITLE_LENGTH = 80; + +let unreadNotificationCount = 0; + +export function handleDesktopNotificationFrame( + data: string, + options: DesktopNotifierOptions, +): void { + const frame = parseWsMessageFrame(data); + if (!frame || !isAssistantNotificationFrame(frame)) return; + if (!shouldNotify(options.getWindow())) return; + showDesktopNotification(frame, options); +} + +export function clearDesktopNotificationBadge(): void { + unreadNotificationCount = 0; + app.setBadgeCount(0); +} + +function parseWsMessageFrame(data: string): WsMessageFrame | null { + try { + const parsed = JSON.parse(data) as unknown; + return parsed && typeof parsed === "object" + ? parsed as WsMessageFrame + : null; + } catch { + return null; + } +} + +function isAssistantNotificationFrame(frame: WsMessageFrame): frame is WsMessageFrame & { + chat_id: string; + text: string; +} { + return ( + frame.event === "message" && + typeof frame.chat_id === "string" && + typeof frame.text === "string" && + frame.text.trim().length > 0 && + frame.kind !== "tool_hint" && + frame.kind !== "progress" && + frame.kind !== "reasoning" + ); +} + +function shouldNotify(win: BrowserWindow | null): boolean { + if (!Notification.isSupported()) return false; + if (!win || win.isDestroyed()) return false; + return !win.isFocused(); +} + +function showDesktopNotification( + frame: WsMessageFrame & { chat_id: string; text: string }, + options: DesktopNotifierOptions, +): void { + const notification = new Notification({ + title: notificationTitle(frame.source), + body: notificationBody(frame.text), + subtitle: "nanobot", + }); + notification.on("click", () => openChatFromNotification(frame.chat_id, options)); + notification.show(); + unreadNotificationCount += 1; + app.setBadgeCount(unreadNotificationCount); +} + +function notificationTitle(source: NotificationSource | undefined): string { + if (source?.kind === "cron" && typeof source.label === "string") { + const label = source.label.trim(); + if (label) return truncateText(label, MAX_NOTIFICATION_TITLE_LENGTH); + } + return "nanobot"; +} + +function notificationBody(text: string): string { + const compact = text.replace(/\s+/g, " ").trim(); + return truncateText(compact, MAX_NOTIFICATION_BODY_LENGTH); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 3)}...`; +} + +function openChatFromNotification(chatId: string, options: DesktopNotifierOptions): void { + const win = options.getWindow(); + if (!win || win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); + win.focus(); + clearDesktopNotificationBadge(); + + const sessionKey = `websocket:${chatId}`; + const hash = `#/chat/${encodeURIComponent(sessionKey)}`; + void win.webContents.executeJavaScript( + `window.location.hash = ${JSON.stringify(hash)}`, + true, + ).catch(() => {}); +}