From caeeab1a239f1a18f9a2ed40396caa55e82702b9 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 26 Oct 2025 22:49:56 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20Add=20completion=20notificat?= =?UTF-8?q?ions=20for=20desktop,=20web,=20and=20mobile=20PWA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NotificationService with VAPID key generation and push support - Desktop: Native Electron notifications with click-to-focus - Web: Browser notifications when tab is backgrounded - Mobile PWA: Push notifications when app is closed - Command palette toggle: Enable/Disable Completion Notifications - Notifications trigger on stream-end if enabled and document hidden - Service worker handles push events and notification clicks - Push subscriptions managed per-workspace with auto-cleanup Implementation: - Backend: NotificationService generates VAPID keys, stores subscriptions - IPC: 4 new channels (subscribe, unsubscribe, getVapidKey, send) - Frontend: Push subscription utilities with permission handling - Service Worker: Push event handlers with notification display - WorkspaceStore: Checks preference and triggers on stream-end - Command Palette: Settings section with notification toggle Generated with `cmux` --- bun.lock | 32 ++++- package.json | 2 + public/service-worker.js | 45 ++++++ src/App.stories.tsx | 6 + src/browser/api.ts | 9 ++ src/constants/ipc-constants.ts | 6 + src/constants/storage.ts | 7 + src/main-server.ts | 2 +- src/preload.ts | 9 ++ src/services/NotificationService.ts | 151 ++++++++++++++++++++ src/services/ipcMain.ts | 72 +++++++++- src/stores/WorkspaceStore.ts | 15 +- src/types/ipc.ts | 6 + src/types/notification.ts | 32 +++++ src/utils/commands/sources.ts | 40 ++++++ src/utils/notifications/pushSubscription.ts | 100 +++++++++++++ 16 files changed, 528 insertions(+), 6 deletions(-) create mode 100644 src/services/NotificationService.ts create mode 100644 src/types/notification.ts create mode 100644 src/utils/notifications/pushSubscription.ts diff --git a/bun.lock b/bun.lock index 525904f5c..47e21eaad 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "minimist": "^1.2.8", "source-map-support": "^0.5.21", "undici": "^7.16.0", + "web-push": "^3.6.7", "write-file-atomic": "^6.0.0", "ws": "^8.18.3", "zod": "^4.1.11", @@ -58,6 +59,7 @@ "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/web-push": "^3.6.4", "@types/write-file-atomic": "^4.0.3", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.1", @@ -902,6 +904,8 @@ "@types/wait-on": ["@types/wait-on@5.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/write-file-atomic": ["@types/write-file-atomic@4.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -1008,7 +1012,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], @@ -1062,6 +1066,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -1118,6 +1124,8 @@ "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="], + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -1140,6 +1148,8 @@ "buffer-equal": ["buffer-equal@1.0.1", "", {}, "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "builder-util": ["builder-util@24.13.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "4.0.0", "bluebird-lst": "^1.0.9", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0" } }, "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA=="], @@ -1446,6 +1456,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -1754,7 +1766,9 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1996,6 +2010,10 @@ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="], + "katex": ["katex@0.16.25", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -2230,6 +2248,8 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2868,6 +2888,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -3096,6 +3118,8 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "builder-util/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "caching-transform/write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", "signal-exit": "^3.0.2", "typedarray-to-buffer": "^3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="], "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -3198,6 +3222,8 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "istanbul-lib-processinfo/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -3622,6 +3648,8 @@ "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "builder-util/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "caching-transform/write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "concurrently/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/package.json b/package.json index c327bec83..736948b4c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "minimist": "^1.2.8", "source-map-support": "^0.5.21", "undici": "^7.16.0", + "web-push": "^3.6.7", "write-file-atomic": "^6.0.0", "ws": "^8.18.3", "zod": "^4.1.11", @@ -98,6 +99,7 @@ "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/web-push": "^3.6.4", "@types/write-file-atomic": "^4.0.3", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.1", diff --git a/public/service-worker.js b/public/service-worker.js index 4ac48d421..dc79d03c4 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -51,3 +51,48 @@ self.addEventListener('fetch', (event) => { ); }); +// Push event - show notification +self.addEventListener('push', (event) => { + if (!event.data) { + return; + } + + try { + const data = event.data.json(); + + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: data.workspaceId, + data: { workspaceId: data.workspaceId }, + }) + ); + } catch (error) { + console.error('Error handling push event:', error); + } +}); + +// Notification click - focus app and navigate to workspace +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // If a window is already open, focus it + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + return client.focus(); + } + } + // Otherwise open a new window + if (clients.openWindow) { + return clients.openWindow(`/?workspace=${event.notification.data.workspaceId}`); + } + }) + ); +}); + + diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 0a44d052f..d9b1b738a 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -84,6 +84,12 @@ function setupMockAPI(options: { install: () => undefined, onStatus: () => () => undefined, }, + notification: { + subscribePush: () => Promise.resolve(undefined), + unsubscribePush: () => Promise.resolve(undefined), + getVapidKey: () => Promise.resolve(null), + send: () => Promise.resolve(undefined), + }, ...options.apiOverrides, }; diff --git a/src/browser/api.ts b/src/browser/api.ts index 4be41e43d..ce9e7ec97 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -270,6 +270,15 @@ const webApi: IPCApi = { return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); }, }, + notification: { + subscribePush: (workspaceId, subscription) => + invokeIPC(IPC_CHANNELS.NOTIFICATION_SUBSCRIBE_PUSH, workspaceId, subscription), + unsubscribePush: (workspaceId, endpoint) => + invokeIPC(IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, workspaceId, endpoint), + getVapidKey: () => invokeIPC(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY), + send: (workspaceId, workspaceName) => + invokeIPC(IPC_CHANNELS.NOTIFICATION_SEND, workspaceId, workspaceName), + }, }; if (typeof window.api === "undefined") { diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index be42fd9ad..c1767bce0 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -49,6 +49,12 @@ export const IPC_CHANNELS = { UPDATE_STATUS: "update:status", UPDATE_STATUS_SUBSCRIBE: "update:status:subscribe", + // Notification channels + NOTIFICATION_SUBSCRIBE_PUSH: "notification:subscribePush", + NOTIFICATION_UNSUBSCRIBE_PUSH: "notification:unsubscribePush", + NOTIFICATION_GET_VAPID_KEY: "notification:getVapidKey", + NOTIFICATION_SEND: "notification:send", + // Dynamic channel prefixes WORKSPACE_CHAT_PREFIX: "workspace:chat:", WORKSPACE_METADATA: "workspace:metadata", diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 0dbf81318..6b2c5d5b7 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -69,6 +69,13 @@ export function getModeKey(workspaceId: string): string { */ export const USE_1M_CONTEXT_KEY = "use1MContext"; +/** + * Get the localStorage key for completion notification preference (global) + * Format: "notifications:completionEnabled" + */ +export const NOTIFICATION_ENABLED_KEY = "notifications:completionEnabled"; + + /** * Get the localStorage key for the preferred compaction model (global) * Format: "preferredCompactionModel" diff --git a/src/main-server.ts b/src/main-server.ts index 626371be9..e813b2e02 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -109,7 +109,7 @@ app.use(express.json({ limit: "50mb" })); // Initialize config and IPC service const config = new Config(); -const ipcMainService = new IpcMain(config); +const ipcMainService = new IpcMain(config, false); // false = not desktop (web/mobile) // Track WebSocket clients and their subscriptions const clients: Clients = new Map(); diff --git a/src/preload.ts b/src/preload.ts index a42e597e9..a4775e0ee 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -136,6 +136,15 @@ const api: IPCApi = { }; }, }, + notification: { + subscribePush: (workspaceId: string, subscription: unknown) => + ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_SUBSCRIBE_PUSH, workspaceId, subscription), + unsubscribePush: (workspaceId: string, endpoint: string) => + ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, workspaceId, endpoint), + getVapidKey: () => ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY), + send: (workspaceId: string, workspaceName: string) => + ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_SEND, workspaceId, workspaceName), + }, }; // Expose the API along with platform/versions diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts new file mode 100644 index 000000000..d6a42e94e --- /dev/null +++ b/src/services/NotificationService.ts @@ -0,0 +1,151 @@ +import * as fs from "fs"; +import * as path from "path"; +import webpush from "web-push"; +import type { PushSubscription, VapidKeys, NotificationPayload } from "../types/notification.js"; +import { log } from "./log.js"; + +/** + * NotificationService manages completion notifications for both desktop and web/mobile. + * - Desktop: Shows Electron Notification + * - Web/Mobile: Sends web push notifications to subscribed clients + */ +export class NotificationService { + private isDesktop: boolean; + private vapidKeys: VapidKeys | null = null; + private subscriptions = new Map(); // workspaceId -> subscriptions + private vapidKeysPath: string; + + constructor(configDir: string, isDesktop: boolean) { + this.isDesktop = isDesktop; + this.vapidKeysPath = path.join(configDir, "vapid.json"); + + // Load or generate VAPID keys for web push + if (!isDesktop) { + this.initializeVapidKeys(); + } + } + + /** + * Initialize VAPID keys for web push authentication + * Generates new keys if they don't exist, otherwise loads from disk + */ + private initializeVapidKeys(): void { + try { + if (fs.existsSync(this.vapidKeysPath)) { + const keysJson = fs.readFileSync(this.vapidKeysPath, "utf-8"); + this.vapidKeys = JSON.parse(keysJson); + log.info("Loaded existing VAPID keys"); + } else { + const keys = webpush.generateVAPIDKeys(); + this.vapidKeys = { + publicKey: keys.publicKey, + privateKey: keys.privateKey, + }; + fs.writeFileSync(this.vapidKeysPath, JSON.stringify(this.vapidKeys, null, 2)); + log.info("Generated and saved new VAPID keys"); + } + + // Configure web-push with VAPID details + if (this.vapidKeys) { + webpush.setVapidDetails( + "mailto:support@cmux.io", + this.vapidKeys.publicKey, + this.vapidKeys.privateKey + ); + } + } catch (error) { + log.error("Failed to initialize VAPID keys:", error); + } + } + + /** + * Get the public VAPID key for client-side subscription + */ + getVapidPublicKey(): string | null { + return this.vapidKeys?.publicKey ?? null; + } + + /** + * Subscribe a client to push notifications + */ + subscribePush(workspaceId: string, subscription: PushSubscription): void { + const existing = this.subscriptions.get(workspaceId) ?? []; + + // Check if subscription already exists (by endpoint) + const isDuplicate = existing.some(sub => sub.endpoint === subscription.endpoint); + if (isDuplicate) { + log.debug(`Subscription already exists for workspace ${workspaceId}`); + return; + } + + existing.push(subscription); + this.subscriptions.set(workspaceId, existing); + log.info(`Added push subscription for workspace ${workspaceId}`); + } + + /** + * Unsubscribe a client from push notifications + */ + unsubscribePush(workspaceId: string, endpoint: string): void { + const existing = this.subscriptions.get(workspaceId) ?? []; + const filtered = existing.filter(sub => sub.endpoint !== endpoint); + + if (filtered.length < existing.length) { + this.subscriptions.set(workspaceId, filtered); + log.info(`Removed push subscription for workspace ${workspaceId}`); + } + } + + /** + * Send a completion notification + * Desktop: Shows Electron notification (handled by caller) + * Web/Mobile: Sends push notification to all subscribed clients + */ + async sendCompletionNotification(workspaceId: string, workspaceName: string): Promise { + if (this.isDesktop) { + // Desktop notifications are handled by the caller (main-desktop.ts) + // This method is only called for web/mobile push notifications + return; + } + + const subscriptions = this.subscriptions.get(workspaceId) ?? []; + if (subscriptions.length === 0) { + log.debug(`No push subscriptions for workspace ${workspaceId}`); + return; + } + + const payload: NotificationPayload = { + title: "Completion", + body: `${workspaceName} has finished`, + workspaceId, + }; + + const payloadString = JSON.stringify(payload); + + // Send to all subscriptions, removing invalid ones + const sendPromises = subscriptions.map(async (subscription) => { + try { + await webpush.sendNotification(subscription, payloadString); + log.debug(`Sent push notification for workspace ${workspaceId}`); + return { success: true, subscription }; + } catch (error) { + log.error(`Failed to send push notification, removing subscription:`, error); + return { success: false, subscription }; + } + }); + + const results = await Promise.allSettled(sendPromises); + + // Remove failed subscriptions + const validSubscriptions = results + .filter((result, index) => { + if (result.status === "fulfilled" && result.value.success) { + return true; + } + return false; + }) + .map((result, index) => subscriptions[index]); + + this.subscriptions.set(workspaceId, validSubscriptions); + } +} diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 465d9ad21..e2486d2e6 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -16,7 +16,9 @@ import { AIService } from "@/services/aiService"; import { HistoryService } from "@/services/historyService"; import { PartialService } from "@/services/partialService"; import { AgentSession } from "@/services/agentSession"; +import { NotificationService } from "@/services/NotificationService"; import type { CmuxMessage } from "@/types/message"; +import type { PushSubscription } from "@/types/notification"; import { log } from "@/services/log"; import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants"; import type { SendMessageError } from "@/types/errors"; @@ -47,6 +49,7 @@ export class IpcMain { private readonly historyService: HistoryService; private readonly partialService: PartialService; private readonly aiService: AIService; + private readonly notificationService: NotificationService; private readonly sessions = new Map(); private readonly sessionSubscriptions = new Map< string, @@ -54,12 +57,15 @@ export class IpcMain { >(); private mainWindow: BrowserWindow | null = null; private registered = false; + private readonly isDesktop: boolean; - constructor(config: Config) { + constructor(config: Config, isDesktop = true) { this.config = config; + this.isDesktop = isDesktop; this.historyService = new HistoryService(config); this.partialService = new PartialService(config, this.historyService); this.aiService = new AIService(config, this.historyService, this.partialService); + this.notificationService = new NotificationService(config.rootDir, isDesktop); } private getOrCreateSession(workspaceId: string): AgentSession { @@ -143,6 +149,7 @@ export class IpcMain { this.registerWindowHandlers(ipcMain); this.registerWorkspaceHandlers(ipcMain); this.registerProviderHandlers(ipcMain); + this.registerNotificationHandlers(ipcMain); this.registerProjectHandlers(ipcMain); this.registerSubscriptionHandlers(ipcMain); this.registered = true; @@ -1057,6 +1064,69 @@ export class IpcMain { }); } + + private registerNotificationHandlers(ipcMain: ElectronIpcMain): void { + // Get VAPID public key for push subscription + ipcMain.handle(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY, () => { + return this.notificationService.getVapidPublicKey(); + }); + + // Subscribe to push notifications + ipcMain.handle( + IPC_CHANNELS.NOTIFICATION_SUBSCRIBE_PUSH, + (_event, workspaceId: string, subscription: unknown) => { + try { + this.notificationService.subscribePush(workspaceId, subscription as PushSubscription); + } catch (error) { + log.error("Failed to subscribe to push notifications:", error); + } + } + ); + + // Unsubscribe from push notifications + ipcMain.handle( + IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, + (_event, workspaceId: string, endpoint: string) => { + try { + this.notificationService.unsubscribePush(workspaceId, endpoint); + } catch (error) { + log.error("Failed to unsubscribe from push notifications:", error); + } + } + ); + + // Send notification (for desktop or push) + ipcMain.handle( + IPC_CHANNELS.NOTIFICATION_SEND, + async (_event, workspaceId: string, workspaceName: string) => { + try { + if (this.isDesktop) { + // For desktop, we'll import Notification dynamically and show it + const { Notification } = await import("electron"); + const notification = new Notification({ + title: "Completion", + body: `${workspaceName} has finished`, + }); + notification.on("click", () => { + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + } + }); + notification.show(); + } else { + // For web/mobile, send push notifications + await this.notificationService.sendCompletionNotification(workspaceId, workspaceName); + } + } catch (error) { + log.error("Failed to send notification:", error); + } + } + ); + } + private registerProjectHandlers(ipcMain: ElectronIpcMain): void { ipcMain.handle(IPC_CHANNELS.PROJECT_CREATE, (_event, projectPath: string) => { try { diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index b91782b32..22228e508 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -5,8 +5,8 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; -import { updatePersistedState } from "@/hooks/usePersistedState"; -import { getRetryStateKey } from "@/constants/storage"; +import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; +import { getRetryStateKey, NOTIFICATION_ENABLED_KEY } from "@/constants/storage"; import { CUSTOM_EVENTS } from "@/constants/events"; import { useSyncExternalStore } from "react"; import { @@ -904,6 +904,17 @@ export class WorkspaceStore { // MUST happen after aggregator.handleStreamEnd() stores the metadata this.finalizeUsageStats(workspaceId, data.metadata); + // Trigger completion notification if enabled + const notificationsEnabled = readPersistedState(NOTIFICATION_ENABLED_KEY, false); + if (notificationsEnabled) { + // Only notify if document is hidden (tab backgrounded) or on desktop + const shouldNotify = typeof document === "undefined" || document.hidden; + if (shouldNotify) { + // Use workspaceId as the display name for notifications + void window.api.notification.send(workspaceId, workspaceId); + } + } + return; } diff --git a/src/types/ipc.ts b/src/types/ipc.ts index e513ba3b7..54ccd8f75 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -248,6 +248,12 @@ export interface IPCApi { install(): void; onStatus(callback: (status: UpdateStatus) => void): () => void; }; + notification: { + subscribePush(workspaceId: string, subscription: unknown): Promise; + unsubscribePush(workspaceId: string, endpoint: string): Promise; + getVapidKey(): Promise; + send(workspaceId: string, workspaceName: string): Promise; + }; } // Update status type (matches updater service) diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 000000000..59f69eda1 --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,32 @@ +/** + * Notification types for completion notifications + */ + +/** + * Push notification subscription object + * Standard Web Push API subscription format + */ +export interface PushSubscription { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +/** + * Notification payload sent to service worker + */ +export interface NotificationPayload { + title: string; + body: string; + workspaceId: string; +} + +/** + * VAPID keys for web push authentication + */ +export interface VapidKeys { + publicKey: string; + privateKey: string; +} diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 9c5fa9793..303aee6e2 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -6,6 +6,9 @@ import { CUSTOM_EVENTS } from "@/constants/events"; import type { ProjectConfig } from "@/config"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { BranchListResult } from "@/types/ipc"; +import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; +import { NOTIFICATION_ENABLED_KEY } from "@/constants/storage"; +import { subscribeToPush } from "@/utils/notifications/pushSubscription"; export interface BuildSourcesParams { projects: Map; @@ -54,6 +57,7 @@ const section = { navigation: "Navigation", chat: "Chat", mode: "Modes & Model", + settings: "Settings", help: "Help", projects: "Projects", }; @@ -419,6 +423,42 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return list; }); + // Settings + actions.push(() => { + const notificationsEnabled = readPersistedState(NOTIFICATION_ENABLED_KEY, false); + + return [ + { + id: "settings:toggle-notifications", + title: notificationsEnabled + ? "Disable Completion Notifications" + : "Enable Completion Notifications", + subtitle: notificationsEnabled + ? "Currently enabled" + : "Get notified when streams complete", + section: section.settings, + run: async () => { + const newValue = !notificationsEnabled; + updatePersistedState(NOTIFICATION_ENABLED_KEY, newValue); + + // If enabling on web, request permission and subscribe + if (newValue && typeof navigator !== "undefined" && "serviceWorker" in navigator) { + // For web/mobile, subscribe to push notifications for the current workspace + const selectedWorkspace = p.selectedWorkspace; + if (selectedWorkspace) { + const result = await subscribeToPush(selectedWorkspace.workspaceId); + if (!result.success) { + // Revert preference on failure + updatePersistedState(NOTIFICATION_ENABLED_KEY, false); + alert(`Failed to enable notifications: ${result.error ?? "Unknown error"}`); + } + } + } + }, + }, + ]; + }); + // Help / Docs actions.push(() => [ { diff --git a/src/utils/notifications/pushSubscription.ts b/src/utils/notifications/pushSubscription.ts new file mode 100644 index 000000000..c9c0ba95a --- /dev/null +++ b/src/utils/notifications/pushSubscription.ts @@ -0,0 +1,100 @@ +/** + * Push subscription utilities for web/mobile notifications + */ + +/** + * Convert VAPID key from base64 to Uint8Array for subscription + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +/** + * Subscribe to push notifications for a workspace + * @param workspaceId - Workspace to subscribe to + * @returns Success status and optional error message + */ +export async function subscribeToPush( + workspaceId: string +): Promise<{ success: boolean; error?: string }> { + // Check if browser supports notifications + if (!("Notification" in window)) { + return { success: false, error: "Notifications not supported" }; + } + + // Check if service worker is supported + if (!("serviceWorker" in navigator)) { + return { success: false, error: "Service workers not supported" }; + } + + // Request permission if not granted + let permission = Notification.permission; + if (permission === "default") { + permission = await Notification.requestPermission(); + } + + if (permission !== "granted") { + return { success: false, error: "Notification permission denied" }; + } + + try { + // Get VAPID key from backend + const vapidKey = await window.api.notification.getVapidKey(); + if (!vapidKey) { + return { success: false, error: "VAPID key not available" }; + } + + // Get service worker registration + const registration = await navigator.serviceWorker.ready; + + // Subscribe to push + const applicationServerKey = urlBase64ToUint8Array(vapidKey); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey as BufferSource, + }); + + // Send subscription to backend + await window.api.notification.subscribePush(workspaceId, subscription.toJSON()); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} + +/** + * Unsubscribe from push notifications for a workspace + */ +export async function unsubscribeFromPush( + workspaceId: string +): Promise<{ success: boolean; error?: string }> { + try { + if (!("serviceWorker" in navigator)) { + return { success: false, error: "Service workers not supported" }; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await window.api.notification.unsubscribePush(workspaceId, subscription.endpoint); + await subscription.unsubscribe(); + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} From 67e28f74693f933109c67577aea911ed2096b9c8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 26 Oct 2025 22:59:43 -0400 Subject: [PATCH 2/5] Fix lint errors: readonly fields, sync fs comments, unused vars, dynamic import --- src/constants/storage.ts | 1 - src/services/NotificationService.ts | 25 +++++++++++++++---------- src/services/ipcMain.ts | 4 ++-- src/utils/commands/sources.ts | 6 ++---- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 7e55795be..a60506012 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -84,7 +84,6 @@ export const USE_1M_CONTEXT_KEY = "use1MContext"; */ export const NOTIFICATION_ENABLED_KEY = "notifications:completionEnabled"; - /** * Get the localStorage key for the preferred compaction model (global) * Format: "preferredCompactionModel" diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index d6a42e94e..b23906e36 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -10,15 +10,15 @@ import { log } from "./log.js"; * - Web/Mobile: Sends web push notifications to subscribed clients */ export class NotificationService { - private isDesktop: boolean; + private readonly isDesktop: boolean; private vapidKeys: VapidKeys | null = null; private subscriptions = new Map(); // workspaceId -> subscriptions - private vapidKeysPath: string; + private readonly vapidKeysPath: string; constructor(configDir: string, isDesktop: boolean) { this.isDesktop = isDesktop; this.vapidKeysPath = path.join(configDir, "vapid.json"); - + // Load or generate VAPID keys for web push if (!isDesktop) { this.initializeVapidKeys(); @@ -28,11 +28,15 @@ export class NotificationService { /** * Initialize VAPID keys for web push authentication * Generates new keys if they don't exist, otherwise loads from disk + * Note: Uses sync fs methods during startup initialization (before async operations start) */ private initializeVapidKeys(): void { try { + // eslint-disable-next-line local/no-sync-fs-methods -- Startup initialization needs sync if (fs.existsSync(this.vapidKeysPath)) { + // eslint-disable-next-line local/no-sync-fs-methods -- Startup initialization needs sync const keysJson = fs.readFileSync(this.vapidKeysPath, "utf-8"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- JSON parse is safe for VAPID keys this.vapidKeys = JSON.parse(keysJson); log.info("Loaded existing VAPID keys"); } else { @@ -41,6 +45,7 @@ export class NotificationService { publicKey: keys.publicKey, privateKey: keys.privateKey, }; + // eslint-disable-next-line local/no-sync-fs-methods -- Startup initialization needs sync fs.writeFileSync(this.vapidKeysPath, JSON.stringify(this.vapidKeys, null, 2)); log.info("Generated and saved new VAPID keys"); } @@ -70,9 +75,9 @@ export class NotificationService { */ subscribePush(workspaceId: string, subscription: PushSubscription): void { const existing = this.subscriptions.get(workspaceId) ?? []; - + // Check if subscription already exists (by endpoint) - const isDuplicate = existing.some(sub => sub.endpoint === subscription.endpoint); + const isDuplicate = existing.some((sub) => sub.endpoint === subscription.endpoint); if (isDuplicate) { log.debug(`Subscription already exists for workspace ${workspaceId}`); return; @@ -88,8 +93,8 @@ export class NotificationService { */ unsubscribePush(workspaceId: string, endpoint: string): void { const existing = this.subscriptions.get(workspaceId) ?? []; - const filtered = existing.filter(sub => sub.endpoint !== endpoint); - + const filtered = existing.filter((sub) => sub.endpoint !== endpoint); + if (filtered.length < existing.length) { this.subscriptions.set(workspaceId, filtered); log.info(`Removed push subscription for workspace ${workspaceId}`); @@ -135,16 +140,16 @@ export class NotificationService { }); const results = await Promise.allSettled(sendPromises); - + // Remove failed subscriptions const validSubscriptions = results - .filter((result, index) => { + .filter((result) => { if (result.status === "fulfilled" && result.value.success) { return true; } return false; }) - .map((result, index) => subscriptions[index]); + .map((_result, index) => subscriptions[index]); this.subscriptions.set(workspaceId, validSubscriptions); } diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 55ce589ee..3313adea5 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -1179,7 +1179,6 @@ export class IpcMain { }); } - private registerNotificationHandlers(ipcMain: ElectronIpcMain): void { // Get VAPID public key for push subscription ipcMain.handle(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY, () => { @@ -1216,7 +1215,8 @@ export class IpcMain { async (_event, workspaceId: string, workspaceName: string) => { try { if (this.isDesktop) { - // For desktop, we'll import Notification dynamically and show it + // Dynamic import required: can't statically import electron in server mode + // eslint-disable-next-line no-restricted-syntax -- Dynamic import necessary for server compatibility const { Notification } = await import("electron"); const notification = new Notification({ title: "Completion", diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 9f0d9733f..f86f54faa 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -421,16 +421,14 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Settings actions.push(() => { const notificationsEnabled = readPersistedState(NOTIFICATION_ENABLED_KEY, false); - + return [ { id: "settings:toggle-notifications", title: notificationsEnabled ? "Disable Completion Notifications" : "Enable Completion Notifications", - subtitle: notificationsEnabled - ? "Currently enabled" - : "Get notified when streams complete", + subtitle: notificationsEnabled ? "Currently enabled" : "Get notified when streams complete", section: section.settings, run: async () => { const newValue = !notificationsEnabled; From c47f0674bff758aa52cd77efcaebe32e116f1095 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 26 Oct 2025 23:16:07 -0400 Subject: [PATCH 3/5] Fix import path: use ./log instead of ./log.js --- src/services/NotificationService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index b23906e36..cd0eb150d 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import webpush from "web-push"; import type { PushSubscription, VapidKeys, NotificationPayload } from "../types/notification.js"; -import { log } from "./log.js"; +import { log } from "./log"; /** * NotificationService manages completion notifications for both desktop and web/mobile. From 61123cff9dcdc247a67b1b8e028fb53f9bdf4100 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 26 Oct 2025 23:18:41 -0400 Subject: [PATCH 4/5] Fix Codex P1 issues: - Fix subscription filtering to preserve correct indexes - Move notification trigger to server-side for PWA support - Remove frontend notification logic (now server-side only) --- src/debug/agentSessionCli.ts | 3 +++ src/services/NotificationService.ts | 14 +++++--------- src/services/agentSession.ts | 21 ++++++++++++++++++--- src/services/ipcMain.ts | 1 + src/stores/WorkspaceStore.ts | 17 ++++------------- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/debug/agentSessionCli.ts b/src/debug/agentSessionCli.ts index 5d0db5089..9ebb2fb38 100644 --- a/src/debug/agentSessionCli.ts +++ b/src/debug/agentSessionCli.ts @@ -9,6 +9,7 @@ import { HistoryService } from "@/services/historyService"; import { PartialService } from "@/services/partialService"; import { AIService } from "@/services/aiService"; import { InitStateManager } from "@/services/initStateManager"; +import { NotificationService } from "@/services/NotificationService"; import { AgentSession, type AgentSessionChatEvent } from "@/services/agentSession"; import { isCaughtUpMessage, @@ -211,6 +212,7 @@ async function main(): Promise { const partialService = new PartialService(config, historyService); const aiService = new AIService(config, historyService, partialService); const initStateManager = new InitStateManager(config); + const notificationService = new NotificationService(config.rootDir, true); ensureProvidersConfig(config); const session = new AgentSession({ @@ -220,6 +222,7 @@ async function main(): Promise { partialService, aiService, initStateManager, + notificationService, }); session.ensureMetadata({ diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index cd0eb150d..fda3d4b42 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -141,15 +141,11 @@ export class NotificationService { const results = await Promise.allSettled(sendPromises); - // Remove failed subscriptions - const validSubscriptions = results - .filter((result) => { - if (result.status === "fulfilled" && result.value.success) { - return true; - } - return false; - }) - .map((_result, index) => subscriptions[index]); + // Remove failed subscriptions - filter by original index to preserve correct mapping + const validSubscriptions = subscriptions.filter((_, index) => { + const result = results[index]; + return result.status === "fulfilled" && result.value.success; + }); this.subscriptions.set(workspaceId, validSubscriptions); } diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index ed2d34547..022f7b42b 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -7,6 +7,7 @@ import type { AIService } from "@/services/aiService"; import type { HistoryService } from "@/services/historyService"; import type { PartialService } from "@/services/partialService"; import type { InitStateManager } from "@/services/initStateManager"; +import type { NotificationService } from "@/services/NotificationService"; import type { WorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage, StreamErrorMessage, SendMessageOptions } from "@/types/ipc"; import type { SendMessageError } from "@/types/errors"; @@ -39,6 +40,7 @@ interface AgentSessionOptions { partialService: PartialService; aiService: AIService; initStateManager: InitStateManager; + notificationService: NotificationService; } export class AgentSession { @@ -48,6 +50,7 @@ export class AgentSession { private readonly partialService: PartialService; private readonly aiService: AIService; private readonly initStateManager: InitStateManager; + private readonly notificationService: NotificationService; private readonly emitter = new EventEmitter(); private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; @@ -57,8 +60,15 @@ export class AgentSession { constructor(options: AgentSessionOptions) { assert(options, "AgentSession requires options"); - const { workspaceId, config, historyService, partialService, aiService, initStateManager } = - options; + const { + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + notificationService, + } = options; assert(typeof workspaceId === "string", "workspaceId must be a string"); const trimmedWorkspaceId = workspaceId.trim(); @@ -70,6 +80,7 @@ export class AgentSession { this.partialService = partialService; this.aiService = aiService; this.initStateManager = initStateManager; + this.notificationService = notificationService; this.attachAiListeners(); this.attachInitListeners(); @@ -393,7 +404,11 @@ export class AgentSession { forward("stream-start", (payload) => this.emitChatEvent(payload)); forward("stream-delta", (payload) => this.emitChatEvent(payload)); - forward("stream-end", (payload) => this.emitChatEvent(payload)); + forward("stream-end", (payload) => { + this.emitChatEvent(payload); + // Trigger completion notification (server-side so it works when app is closed) + void this.notificationService.sendCompletionNotification(this.workspaceId, this.workspaceId); + }); forward("tool-call-start", (payload) => this.emitChatEvent(payload)); forward("tool-call-delta", (payload) => this.emitChatEvent(payload)); forward("tool-call-end", (payload) => this.emitChatEvent(payload)); diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 3313adea5..4a410d4dd 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -162,6 +162,7 @@ export class IpcMain { partialService: this.partialService, aiService: this.aiService, initStateManager: this.initStateManager, + notificationService: this.notificationService, }); const chatUnsubscribe = session.onChatEvent((event) => { diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index fe7224998..04eb0bda4 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -5,8 +5,8 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; -import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; -import { getRetryStateKey, NOTIFICATION_ENABLED_KEY } from "@/constants/storage"; +import { updatePersistedState } from "@/hooks/usePersistedState"; +import { getRetryStateKey } from "@/constants/storage"; import { CUSTOM_EVENTS } from "@/constants/events"; import { useSyncExternalStore } from "react"; import { isCaughtUpMessage, isStreamError, isDeleteMessage, isCmuxMessage } from "@/types/ipc"; @@ -154,17 +154,8 @@ export class WorkspaceStore { this.states.bump(workspaceId); this.checkAndBumpRecencyIfChanged(); this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata); - - // Trigger completion notification if enabled - const notificationsEnabled = readPersistedState(NOTIFICATION_ENABLED_KEY, false); - if (notificationsEnabled) { - // Only notify if document is hidden (tab backgrounded) or on desktop - const shouldNotify = typeof document === "undefined" || document.hidden; - if (shouldNotify) { - // Use workspaceId as the display name for notifications - void window.api.notification.send(workspaceId, workspaceId); - } - } + // Note: Completion notifications are now triggered server-side in AgentSession + // to support push notifications when the app is closed }, "stream-abort": (workspaceId, aggregator, data) => { aggregator.clearTokenState((data as { messageId: string }).messageId); From 0db6eb1d63bf9bec5ff02e74f246dc468206a2d9 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 26 Oct 2025 23:52:37 -0400 Subject: [PATCH 5/5] Clean up notification code: fix desktop notifications and remove unused IPC - Move desktop notification logic into NotificationService (was broken) - Desktop notifications now show properly via server-side trigger - Remove unused NOTIFICATION_SEND IPC channel and handler - Remove send() method from notification API (not needed) - Use dynamic import instead of require for electron module - Update all comments to reflect server-side architecture --- src/App.stories.tsx | 1 - src/browser/api.ts | 2 -- src/constants/ipc-constants.ts | 1 - src/preload.ts | 2 -- src/services/NotificationService.ts | 18 ++++++++++++--- src/services/ipcMain.ts | 34 +++-------------------------- src/types/ipc.ts | 2 +- 7 files changed, 19 insertions(+), 41 deletions(-) diff --git a/src/App.stories.tsx b/src/App.stories.tsx index e7b33cd53..193c7808b 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -89,7 +89,6 @@ function setupMockAPI(options: { subscribePush: () => Promise.resolve(undefined), unsubscribePush: () => Promise.resolve(undefined), getVapidKey: () => Promise.resolve(null), - send: () => Promise.resolve(undefined), }, ...options.apiOverrides, }; diff --git a/src/browser/api.ts b/src/browser/api.ts index ce9e7ec97..7937a8c86 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -276,8 +276,6 @@ const webApi: IPCApi = { unsubscribePush: (workspaceId, endpoint) => invokeIPC(IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, workspaceId, endpoint), getVapidKey: () => invokeIPC(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY), - send: (workspaceId, workspaceName) => - invokeIPC(IPC_CHANNELS.NOTIFICATION_SEND, workspaceId, workspaceName), }, }; diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index 68e80f4f4..6dad3fe8d 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -52,7 +52,6 @@ export const IPC_CHANNELS = { NOTIFICATION_SUBSCRIBE_PUSH: "notification:subscribePush", NOTIFICATION_UNSUBSCRIBE_PUSH: "notification:unsubscribePush", NOTIFICATION_GET_VAPID_KEY: "notification:getVapidKey", - NOTIFICATION_SEND: "notification:send", // Dynamic channel prefixes WORKSPACE_CHAT_PREFIX: "workspace:chat:", diff --git a/src/preload.ts b/src/preload.ts index 0b11273c0..6027fdc8e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -148,8 +148,6 @@ const api: IPCApi = { unsubscribePush: (workspaceId: string, endpoint: string) => ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, workspaceId, endpoint), getVapidKey: () => ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY), - send: (workspaceId: string, workspaceName: string) => - ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_SEND, workspaceId, workspaceName), }, }; diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index fda3d4b42..2d362cc39 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -103,13 +103,25 @@ export class NotificationService { /** * Send a completion notification - * Desktop: Shows Electron notification (handled by caller) + * Desktop: Shows Electron notification directly (no push needed) * Web/Mobile: Sends push notification to all subscribed clients */ async sendCompletionNotification(workspaceId: string, workspaceName: string): Promise { if (this.isDesktop) { - // Desktop notifications are handled by the caller (main-desktop.ts) - // This method is only called for web/mobile push notifications + // Desktop: Show native Electron notification + try { + // Dynamic import required: can't statically import electron in server mode + // eslint-disable-next-line no-restricted-syntax -- Dynamic import necessary for server compatibility + const electron = await import("electron"); + const notification = new electron.Notification({ + title: "Completion", + body: `${workspaceName} has finished`, + }); + notification.show(); + log.debug(`Showed desktop notification for workspace ${workspaceId}`); + } catch (error) { + log.error("Failed to show desktop notification:", error); + } return; } diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 4a410d4dd..de4c6d0fe 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -1210,37 +1210,9 @@ export class IpcMain { } ); - // Send notification (for desktop or push) - ipcMain.handle( - IPC_CHANNELS.NOTIFICATION_SEND, - async (_event, workspaceId: string, workspaceName: string) => { - try { - if (this.isDesktop) { - // Dynamic import required: can't statically import electron in server mode - // eslint-disable-next-line no-restricted-syntax -- Dynamic import necessary for server compatibility - const { Notification } = await import("electron"); - const notification = new Notification({ - title: "Completion", - body: `${workspaceName} has finished`, - }); - notification.on("click", () => { - if (this.mainWindow) { - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - } - }); - notification.show(); - } else { - // For web/mobile, send push notifications - await this.notificationService.sendCompletionNotification(workspaceId, workspaceName); - } - } catch (error) { - log.error("Failed to send notification:", error); - } - } - ); + // Note: NOTIFICATION_SEND handler intentionally omitted + // Notifications are now triggered server-side in AgentSession on stream-end + // This ensures push notifications work even when the app is closed (PWA/mobile) } private registerProjectHandlers(ipcMain: ElectronIpcMain): void { diff --git a/src/types/ipc.ts b/src/types/ipc.ts index b3f352cbc..900329a39 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -298,7 +298,7 @@ export interface IPCApi { subscribePush(workspaceId: string, subscription: unknown): Promise; unsubscribePush(workspaceId: string, endpoint: string): Promise; getVapidKey(): Promise; - send(workspaceId: string, workspaceName: string): Promise; + // Note: send() method removed - notifications triggered server-side in AgentSession }; }