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
96 changes: 96 additions & 0 deletions apps/desktop/src/electron/ElectronNotification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { queryObjects } from "node:v8";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { vi } from "vite-plus/test";

const { NativeNotification } = vi.hoisted(() => {
class NativeNotification {
static closed = 0;
static eventOnShow: string | undefined;
static closeReason: string | undefined;
private handlers = new Map<string, () => void>();
static isSupported() {
return true;
}
on(event: string, callback: (event: { reason: string | undefined }) => void) {
this.handlers.set(event, () => callback({ reason: NativeNotification.closeReason }));
return this;
}
once(event: string, callback: () => void) {
return this.on(event, callback);
}
show() {
if (NativeNotification.eventOnShow) this.handlers.get(NativeNotification.eventOnShow)?.();
}
close() {
NativeNotification.closed++;
}
}
return { NativeNotification };
});

vi.mock("electron", () => ({ Notification: NativeNotification }));

import * as ElectronNotification from "./ElectronNotification.ts";

describe("ElectronNotification lifetime", () => {
it("keeps displayed notifications alive for clicks and releases them at shutdown", () => {
// queryObjects performs a full collection before counting; no timing or GC polling.
const baseline = queryObjects(NativeNotification);
if (typeof baseline !== "number") throw new Error("Expected an instance count");
const closedBefore = NativeNotification.closed;
Effect.runSync(
Effect.gen(function* () {
const service = yield* ElectronNotification.ElectronNotification;
yield* service.show({ title: "Thread", body: "Agent finished", onClick: () => {} });
assert.strictEqual(queryObjects(NativeNotification), baseline + 1);
}).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped),
);
assert.strictEqual(NativeNotification.closed, closedBefore + 1);
assert.strictEqual(queryObjects(NativeNotification), baseline);
});
for (const event of ["click", "close", "failed"]) {
it(`releases a notification after ${event}`, () => {
const baseline = queryObjects(NativeNotification);
let clicked = 0;
NativeNotification.eventOnShow = event;
try {
Effect.runSync(
Effect.gen(function* () {
const service = yield* ElectronNotification.ElectronNotification;
yield* service.show({
title: "Thread",
body: "Done",
onClick: () => {
clicked++;
},
});
assert.strictEqual(queryObjects(NativeNotification), baseline);
assert.strictEqual(clicked, event === "click" ? 1 : 0);
}).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped),
);
} finally {
NativeNotification.eventOnShow = undefined;
}
});
}

it("retains a Windows notification moved into Action Center", () => {
const baseline = queryObjects(NativeNotification);
if (typeof baseline !== "number") throw new Error("Expected an instance count");
NativeNotification.eventOnShow = "close";
NativeNotification.closeReason = "timedOut";
try {
Effect.runSync(
Effect.gen(function* () {
const service = yield* ElectronNotification.ElectronNotification;
yield* service.show({ title: "Thread", body: "Done", onClick: () => {} });
assert.strictEqual(queryObjects(NativeNotification), baseline + 1);
}).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped),
);
} finally {
NativeNotification.eventOnShow = undefined;
NativeNotification.closeReason = undefined;
}
});
});
62 changes: 62 additions & 0 deletions apps/desktop/src/electron/ElectronNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as Electron from "electron";

export class ElectronNotification extends Context.Service<
ElectronNotification,
{
/** False on Linux without a notification daemon; the feature no-ops. */
readonly isSupported: Effect.Effect<boolean>;
readonly show: (input: {
readonly title: string;
readonly body: string;
readonly onClick: () => void;
}) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronNotification") {}

export const make = Effect.gen(function* () {
// Electron drops the native event delegate when the JS notification is collected.
const pending = new Set<Electron.Notification>();
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const notification of pending) notification.close();
pending.clear();
}),
);

return ElectronNotification.of({
isSupported: Effect.sync(() => Electron.Notification.isSupported()),
show: (input) =>
Effect.sync(() => {
const notification = new Electron.Notification({ title: input.title, body: input.body });
pending.add(notification);
const release = () => {
pending.delete(notification);
};
notification.once("click", () => {
try {
input.onClick();
} finally {
release();
}
});
notification.on("close", (event) => {
// Windows can move a banner into Action Center while it remains clickable.
if (event.reason === "timedOut" || event.reason === "applicationHidden") return;
release();
});
notification.once("failed", release);
try {
notification.show();
} catch (cause) {
release();
throw cause;
}
}),
});
});

export const layer = Layer.effect(ElectronNotification, make);
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import * as PreviewIpc from "./methods/preview.ts";
import * as AppActivationIpc from "./methods/appActivation.ts";
import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";

import { notifyAgentAwareness, sendTestNotification } from "./methods/notifications.ts";

export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
yield* PreviewIpc.installPreviewEventForwarding();
Expand Down Expand Up @@ -92,6 +94,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(pickFolder);
yield* ipc.handle(pickProjectFavicon);
yield* ipc.handle(pickThemeFiles);
yield* ipc.handle(notifyAgentAwareness);
yield* ipc.handle(sendTestNotification);
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,8 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";

export const NOTIFICATION_NAVIGATE_CHANNEL = "desktop:notification-navigate";

export const NOTIFY_AGENT_AWARENESS_CHANNEL = "desktop:notify-agent-awareness";
export const SEND_TEST_NOTIFICATION_CHANNEL = "desktop:send-test-notification";
27 changes: 27 additions & 0 deletions apps/desktop/src/ipc/methods/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { DesktopNotificationCandidate } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as DesktopNotifications from "../../notifications/DesktopNotifications.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

export const notifyAgentAwareness = DesktopIpc.makeIpcMethod({
channel: IpcChannels.NOTIFY_AGENT_AWARENESS_CHANNEL,
payload: Schema.Array(DesktopNotificationCandidate),
result: Schema.Void,
handler: Effect.fn("desktop.ipc.notifications.notifyAgentAwareness")(function* (candidates) {
const notifications = yield* DesktopNotifications.DesktopNotifications;
yield* notifications.deliver(candidates);
}),
});

export const sendTestNotification = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SEND_TEST_NOTIFICATION_CHANNEL,
payload: Schema.Undefined,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.notifications.sendTestNotification")(function* () {
const notifications = yield* DesktopNotifications.DesktopNotifications;
return yield* notifications.sendTest;
}),
});
4 changes: 4 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import serverPackageJson from "../../server/package.json" with { type: "json" };
import * as DesktopIpc from "./ipc/DesktopIpc.ts";
import * as ElectronApp from "./electron/ElectronApp.ts";
import * as ElectronDialog from "./electron/ElectronDialog.ts";
import * as ElectronNotification from "./electron/ElectronNotification.ts";
import * as DesktopNotifications from "./notifications/DesktopNotifications.ts";
import * as ElectronMenu from "./electron/ElectronMenu.ts";
import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
Expand Down Expand Up @@ -121,6 +123,7 @@ const electronLayer = Layer.mergeAll(
ElectronApp.layer,
ElectronDialog.layer,
ElectronMenu.layer,
ElectronNotification.layer,
ElectronPowerMonitor.layer,
ElectronProtocol.layer,
ElectronSafeStorage.layer,
Expand Down Expand Up @@ -195,6 +198,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(
const desktopApplicationLayer = Layer.mergeAll(
DesktopLifecycle.layer,
desktopAppActivationLayer,
DesktopNotifications.layer.pipe(Layer.provide(desktopWindowLayer)),
DesktopApplicationMenu.layer,
DesktopLinuxUrlHandler.layer,
DesktopShellEnvironment.layer,
Expand Down
141 changes: 141 additions & 0 deletions apps/desktop/src/notifications/DesktopNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { vi } from "vite-plus/test";
import type * as Electron from "electron";

import * as ElectronNotification from "../electron/ElectronNotification.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import { NOTIFICATION_NAVIGATE_CHANNEL } from "../ipc/channels.ts";
import * as DesktopNotifications from "./DesktopNotifications.ts";

const candidates = [
{ environmentId: "env-1", threadId: "t1", title: "First — Pylon", body: "Agent finished" },
{ environmentId: "env-2", threadId: "t2", title: "Second — Pylon", body: "Approval needed" },
];

function harness(
options: { supported?: boolean; focused?: boolean; noWindow?: boolean; onSend?: () => void } = {},
) {
const shown: Array<Parameters<ElectronNotification.ElectronNotification["Service"]["show"]>[0]> =
[];
const send = vi.fn(() => options.onSend?.());
const window = {
isFocused: () => options.focused ?? false,
webContents: { send },
} as unknown as Electron.BrowserWindow;
const reveal = vi.fn(() => window);
const dependencies = Layer.mergeAll(
Layer.succeed(ElectronNotification.ElectronNotification, {
isSupported: Effect.succeed(options.supported ?? true),
show: (input) =>
Effect.sync(() => {
shown.push(input);
}),
}),
Layer.mock(ElectronWindow.ElectronWindow)({
focusedMainOrFirst: Effect.succeed(options.noWindow ? Option.none() : Option.some(window)),
}),
Layer.mock(DesktopWindow.DesktopWindow)({ revealOrCreateMain: Effect.sync(reveal) }),
);
return {
shown,
send,
reveal,
layer: DesktopNotifications.layer.pipe(Layer.provide(dependencies)),
};
}

describe("DesktopNotifications", () => {
it.effect("drops all candidates while a Pylon window is focused", () => {
const h = harness({ focused: true });
return Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
yield* service.deliver(candidates);
assert.deepEqual(h.shown, []);
}).pipe(Effect.provide(h.layer));
});

it.effect("delivers each candidate when the fallback main window is unfocused", () => {
const h = harness();
return Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
yield* service.deliver(candidates);
assert.deepEqual(
h.shown.map(({ title, body }) => ({ title, body })),
candidates.map(({ title, body }) => ({ title, body })),
);
}).pipe(Effect.provide(h.layer));
});

it.effect("delivers when the focused-window lookup is empty", () => {
const h = harness({ noWindow: true });
return Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
yield* service.deliver(candidates);
assert.lengthOf(h.shown, 2);
}).pipe(Effect.provide(h.layer));
});

it.effect("silently drops candidates when unsupported", () => {
const h = harness({ supported: false });
return Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
yield* service.deliver(candidates);
assert.deepEqual(h.shown, []);
}).pipe(Effect.provide(h.layer));
});

it.effect("returns false for an unsupported test notification", () => {
const h = harness({ supported: false });
return Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
assert.isFalse(yield* service.sendTest);
assert.deepEqual(h.shown, []);
}).pipe(Effect.provide(h.layer));
});

it.effect("shows the test notification even while focused", () => {
const h = harness({ focused: true });
return Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
assert.isTrue(yield* service.sendTest);
assert.lengthOf(h.shown, 1);
assert.strictEqual(h.shown[0]?.title, "Pylon");
assert.strictEqual(
h.shown[0]?.body,
"Notifications are working. Pylon will tell you when an agent needs you.",
);
h.shown[0]?.onClick();
assert.strictEqual(h.reveal.mock.calls.length, 0);
}).pipe(Effect.provide(h.layer));
});

it.effect("reveals the window before sending the clicked candidate's route parameters", () =>
Effect.gen(function* () {
const sent = yield* Deferred.make<void>();
const runSync = Effect.runSyncWith(yield* Effect.context<never>());
const h = harness({
onSend: () => {
runSync(Deferred.succeed(sent, undefined));
},
});
yield* Effect.gen(function* () {
const service = yield* DesktopNotifications.DesktopNotifications;
yield* service.deliver(candidates);
const second = h.shown[1];
assert.isDefined(second);
second!.onClick();
yield* Deferred.await(sent);
assert.strictEqual(h.reveal.mock.calls.length, 1);
assert.deepEqual(h.send.mock.calls, [
[NOTIFICATION_NAVIGATE_CHANNEL, { environmentId: "env-2", threadId: "t2" }],
]);
assert.isBelow(h.reveal.mock.invocationCallOrder[0]!, h.send.mock.invocationCallOrder[0]!);
}).pipe(Effect.provide(h.layer));
}),
);
});
Loading
Loading