Repository navigation
feat(web): notify agent updates with Web Push #6308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
vkpdeveloper
wants to merge
9
commits into
pingdotgg:main
from
vkpdeveloper:feat/web-push-notifications
Closed
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
aab5b3d
feat(desktop): native notifications for agent tasks
vkpdeveloper 9e433b0
Merge pull request #1 from vkpdeveloper/feat/agent-task-notifications
vkpdeveloper e20aabc
feat(desktop): sharpen notification copy and disable auto-updates
vkpdeveloper 45e6be3
feat(web): make task alerts survive Do Not Disturb
vkpdeveloper 25b818f
fix(web): bound how long a thread alert splash stays on screen
vkpdeveloper cd0caae
feat(worktree): add automated cleanup for stale worktrees
vkpdeveloper 902ff53
feat(web): make Vercel Dark the install default
vkpdeveloper 31e0c83
Merge pull request #2 from vkpdeveloper/feat/vercel-dark-theme
vkpdeveloper 432561a
feat(web): notify agent updates with Web Push
vkpdeveloper File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
apps/desktop/src/electron/ElectronNotifications.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
| import * as Effect from "effect/Effect"; | ||
| import { beforeEach, vi } from "vite-plus/test"; | ||
|
|
||
| const { isSupportedMock, showMock, notificationConstructorMock, handlersByEvent } = vi.hoisted( | ||
| () => ({ | ||
| isSupportedMock: vi.fn(), | ||
| showMock: vi.fn(), | ||
| notificationConstructorMock: vi.fn(), | ||
| handlersByEvent: new Map<string, () => void>(), | ||
| }), | ||
| ); | ||
|
|
||
| vi.mock("electron", () => { | ||
| class Notification { | ||
| static isSupported = isSupportedMock; | ||
|
|
||
| constructor(options: unknown) { | ||
| notificationConstructorMock(options); | ||
| } | ||
|
|
||
| on(event: string, handler: () => void) { | ||
| handlersByEvent.set(event, handler); | ||
| return this; | ||
| } | ||
|
|
||
| show() { | ||
| showMock(); | ||
| } | ||
| } | ||
|
|
||
| return { Notification }; | ||
| }); | ||
|
|
||
| import * as ElectronNotifications from "./ElectronNotifications.ts"; | ||
|
|
||
| describe("ElectronNotifications", () => { | ||
| beforeEach(() => { | ||
| isSupportedMock.mockReset(); | ||
| showMock.mockReset(); | ||
| notificationConstructorMock.mockReset(); | ||
| handlersByEvent.clear(); | ||
| isSupportedMock.mockReturnValue(true); | ||
| }); | ||
|
|
||
| it.effect("shows a notification with the requested copy", () => | ||
| Effect.gen(function* () { | ||
| const notifications = yield* ElectronNotifications.ElectronNotifications; | ||
| yield* notifications.show({ | ||
| title: "Fix flaky auth test", | ||
| body: "Agent finished · t3code", | ||
| silent: true, | ||
| onActivate: () => {}, | ||
| }); | ||
|
|
||
| assert.deepEqual(notificationConstructorMock.mock.calls, [ | ||
| [{ title: "Fix flaky auth test", body: "Agent finished · t3code", silent: true }], | ||
| ]); | ||
| assert.equal(showMock.mock.calls.length, 1); | ||
| }).pipe(Effect.provide(ElectronNotifications.layer)), | ||
| ); | ||
|
|
||
| it.effect("does nothing when the platform has no notification support", () => | ||
| Effect.gen(function* () { | ||
| isSupportedMock.mockReturnValue(false); | ||
|
|
||
| const notifications = yield* ElectronNotifications.ElectronNotifications; | ||
| yield* notifications.show({ | ||
| title: "Fix flaky auth test", | ||
| body: "Agent finished", | ||
| silent: false, | ||
| onActivate: () => {}, | ||
| }); | ||
|
|
||
| assert.equal(notificationConstructorMock.mock.calls.length, 0); | ||
| assert.equal(showMock.mock.calls.length, 0); | ||
| }).pipe(Effect.provide(ElectronNotifications.layer)), | ||
| ); | ||
|
|
||
| it.effect("invokes onActivate when the notification is clicked", () => | ||
| Effect.gen(function* () { | ||
| let activated = 0; | ||
|
|
||
| const notifications = yield* ElectronNotifications.ElectronNotifications; | ||
| yield* notifications.show({ | ||
| title: "Fix flaky auth test", | ||
| body: "Agent finished", | ||
| silent: false, | ||
| onActivate: () => { | ||
| activated += 1; | ||
| }, | ||
| }); | ||
|
|
||
| handlersByEvent.get("click")?.(); | ||
| assert.equal(activated, 1); | ||
| }).pipe(Effect.provide(ElectronNotifications.layer)), | ||
| ); | ||
|
|
||
| it.effect("survives a construction failure instead of taking the app down", () => | ||
| Effect.gen(function* () { | ||
| isSupportedMock.mockImplementation(() => { | ||
| throw new Error("notification center unavailable"); | ||
| }); | ||
|
|
||
| const notifications = yield* ElectronNotifications.ElectronNotifications; | ||
| yield* notifications.show({ | ||
| title: "Fix flaky auth test", | ||
| body: "Agent finished", | ||
| silent: false, | ||
| onActivate: () => {}, | ||
| }); | ||
|
|
||
| assert.equal(showMock.mock.calls.length, 0); | ||
| }).pipe(Effect.provide(ElectronNotifications.layer)), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import * as Context from "effect/Context"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
|
|
||
| import * as Electron from "electron"; | ||
|
|
||
| import { makeComponentLogger } from "../app/DesktopObservability.ts"; | ||
|
|
||
| const { logWarning: logNotificationWarning } = makeComponentLogger("desktop-notifications"); | ||
|
|
||
| export interface ShowNotificationInput { | ||
| readonly title: string; | ||
| readonly body: string; | ||
| readonly silent: boolean; | ||
| readonly onActivate: () => void; | ||
| } | ||
|
|
||
| export class ElectronNotifications extends Context.Service< | ||
| ElectronNotifications, | ||
| { | ||
| /** | ||
| * Raises a native OS notification. Never fails: a notification that cannot | ||
| * be shown is a cosmetic loss, and taking the app down over one would be | ||
| * strictly worse than staying quiet. | ||
| */ | ||
| readonly show: (input: ShowNotificationInput) => Effect.Effect<void>; | ||
| } | ||
| >()("@t3tools/desktop/electron/ElectronNotifications") {} | ||
|
|
||
| /** | ||
| * Notifications are handed to the OS asynchronously. Dropping the last JS | ||
| * reference before the platform is done with it lets GC collect the object | ||
| * mid-flight, which silently loses the banner (and its click handler) on both | ||
| * macOS and Windows. Entries are released on click or close. | ||
| */ | ||
| const liveNotifications = new Set<Electron.Notification>(); | ||
|
|
||
| export const make = ElectronNotifications.of({ | ||
| show: (input) => | ||
| Effect.sync(() => { | ||
| if (!Electron.Notification.isSupported()) { | ||
| return; | ||
| } | ||
|
|
||
| const notification = new Electron.Notification({ | ||
| title: input.title, | ||
| body: input.body, | ||
| silent: input.silent, | ||
| }); | ||
| liveNotifications.add(notification); | ||
|
|
||
| notification.on("click", () => { | ||
| liveNotifications.delete(notification); | ||
| input.onActivate(); | ||
| }); | ||
| notification.on("close", () => { | ||
| liveNotifications.delete(notification); | ||
| }); | ||
|
|
||
| notification.show(); | ||
| }).pipe( | ||
| Effect.catchCause((cause) => | ||
| logNotificationWarning("Failed to show a native notification.", { cause }), | ||
| ), | ||
| ), | ||
| }); | ||
|
|
||
| export const layer = Layer.succeed(ElectronNotifications, make); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
| import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| import type Electron from "electron"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
|
|
||
| import * as ElectronNotifications from "../../electron/ElectronNotifications.ts"; | ||
| import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ||
| import { NOTIFICATION_ACTIVATED_CHANNEL } from "../channels.ts"; | ||
| import { showNotification } from "./notifications.ts"; | ||
|
|
||
| const THREAD_REF = { | ||
| environmentId: "env-1" as EnvironmentId, | ||
| threadId: "thread-1" as ThreadId, | ||
| }; | ||
|
|
||
| const PAYLOAD = { | ||
| kind: "task-completed", | ||
| title: "Fix flaky auth test", | ||
| body: "Agent finished · t3code", | ||
| silent: false, | ||
| threadRef: THREAD_REF, | ||
| } as const; | ||
|
|
||
| describe("showNotification", () => { | ||
| it.effect("passes the renderer's copy straight through to the OS", () => { | ||
| const shown: Array<{ title: string; body: string; silent: boolean }> = []; | ||
|
|
||
| return Effect.gen(function* () { | ||
| yield* showNotification.handler(PAYLOAD); | ||
|
|
||
| assert.deepEqual(shown, [ | ||
| { title: "Fix flaky auth test", body: "Agent finished · t3code", silent: false }, | ||
| ]); | ||
| }).pipe( | ||
| Effect.provide( | ||
| Layer.mergeAll( | ||
| Layer.mock(ElectronNotifications.ElectronNotifications)({ | ||
| show: (input) => | ||
| Effect.sync(() => { | ||
| shown.push({ title: input.title, body: input.body, silent: input.silent }); | ||
| }), | ||
| }), | ||
| Layer.mock(ElectronWindow.ElectronWindow)({ | ||
| currentMainOrFirst: Effect.succeed(Option.none()), | ||
| reveal: () => Effect.void, | ||
| sendAll: () => Effect.void, | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| }); | ||
|
|
||
| it.effect("reveals the window and reports the thread when clicked", () => { | ||
| const window = {} as Electron.BrowserWindow; | ||
| const revealed: Array<Electron.BrowserWindow> = []; | ||
| const sent: Array<readonly [string, ReadonlyArray<unknown>]> = []; | ||
| let activate: (() => void) | null = null; | ||
|
|
||
| return Effect.gen(function* () { | ||
| yield* showNotification.handler(PAYLOAD); | ||
|
|
||
| assert.isNotNull(activate); | ||
| activate?.(); | ||
| // The activation runs as its own promise, so let the microtask queue drain. | ||
| yield* Effect.promise(() => Promise.resolve()); | ||
|
|
||
| assert.deepEqual(revealed, [window]); | ||
| assert.deepEqual(sent, [[NOTIFICATION_ACTIVATED_CHANNEL, [{ threadRef: THREAD_REF }]]]); | ||
| }).pipe( | ||
| Effect.provide( | ||
| Layer.mergeAll( | ||
| Layer.mock(ElectronNotifications.ElectronNotifications)({ | ||
| show: (input) => | ||
| Effect.sync(() => { | ||
| activate = input.onActivate; | ||
| }), | ||
| }), | ||
| Layer.mock(ElectronWindow.ElectronWindow)({ | ||
| currentMainOrFirst: Effect.succeed(Option.some(window)), | ||
| reveal: (target) => | ||
| Effect.sync(() => { | ||
| revealed.push(target); | ||
| }), | ||
| sendAll: (channel, ...args) => | ||
| Effect.sync(() => { | ||
| sent.push([channel, args]); | ||
| }), | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { DesktopNotificationInputSchema } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Schema from "effect/Schema"; | ||
|
|
||
| import * as ElectronNotifications from "../../electron/ElectronNotifications.ts"; | ||
| import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ||
| import * as IpcChannels from "../channels.ts"; | ||
| import * as DesktopIpc from "../DesktopIpc.ts"; | ||
|
|
||
| export const showNotification = DesktopIpc.makeIpcMethod({ | ||
| channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL, | ||
| payload: DesktopNotificationInputSchema, | ||
| result: Schema.Void, | ||
| handler: Effect.fn("desktop.ipc.notifications.show")(function* (input) { | ||
| const notifications = yield* ElectronNotifications.ElectronNotifications; | ||
| const electronWindow = yield* ElectronWindow.ElectronWindow; | ||
| // The click arrives from Electron outside any fiber, so capture the | ||
| // context here and run the activation as its own promise. | ||
| const context = yield* Effect.context<ElectronWindow.ElectronWindow>(); | ||
| const runPromise = Effect.runPromiseWith(context); | ||
|
|
||
| yield* notifications.show({ | ||
| title: input.title, | ||
| body: input.body, | ||
| silent: input.silent, | ||
| onActivate: () => { | ||
| void runPromise( | ||
| Effect.gen(function* () { | ||
| // `currentMainOrFirst`, not `focusedMainOrFirst`: a notification is | ||
| // only clicked when the app is unfocused, so nothing is focused yet. | ||
| const window = yield* electronWindow.currentMainOrFirst; | ||
| if (Option.isSome(window)) { | ||
| yield* electronWindow.reveal(window.value); | ||
| } | ||
| yield* electronWindow.sendAll(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, { | ||
| threadRef: input.threadRef, | ||
| }); | ||
| }), | ||
| ); | ||
| }, | ||
| }); | ||
| }), | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
electron/ElectronNotifications.ts:52Notifications that fail to deliver are never removed from
liveNotifications, so the module-levelSetgrows without bound and each entry'sonActivateclosure is retained forever. Electron emits afailedevent when native delivery fails and does not guarantee acloseevent (common for unsigned local macOS builds), so entries accumulate for every failed notification. Consider adding afailedhandler that removes the notification fromliveNotifications, and also remove it ifnotification.show()throws.🤖 Copy this AI Prompt to have your agent fix this: