Skip to content
Merged
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
36 changes: 27 additions & 9 deletions apps/desktop/src/electron/ElectronNotification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { vi } from "vite-plus/test";

const { notificationInstances, notificationIsSupported } = vi.hoisted(() => ({
notificationInstances: [] as Array<{
options: unknown;
listeners: Map<string, () => void>;
show: ReturnType<typeof vi.fn>;
}>,
notificationIsSupported: vi.fn(() => true),
}));
const { notificationInstances, notificationIsSupported, notificationShowOutcome } = vi.hoisted(
() => ({
notificationInstances: [] as Array<{
options: unknown;
listeners: Map<string, () => void>;
show: ReturnType<typeof vi.fn>;
}>,
notificationIsSupported: vi.fn(() => true),
notificationShowOutcome: { current: "show" as "show" | "failed" },
}),
);

vi.mock("electron", () => ({
Notification: class Notification {
static isSupported = notificationIsSupported;
readonly listeners = new Map<string, () => void>();
readonly show = vi.fn();
readonly show = vi.fn(() => {
this.listeners.get(notificationShowOutcome.current)?.();
});
readonly options: unknown;

constructor(options: unknown) {
Expand All @@ -41,6 +46,7 @@ describe("ElectronNotification", () => {
it.effect("shows a silent native notification on macOS and handles its click", () => {
const onClick = vi.fn();
notificationInstances.length = 0;
notificationShowOutcome.current = "show";

return Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
Expand Down Expand Up @@ -70,4 +76,16 @@ describe("ElectronNotification", () => {
assert.equal(notificationInstances.length, 0);
}).pipe(Effect.provide(notificationLayer("linux")));
});

it.effect("reports a native failure instead of acknowledging delivery", () => {
notificationInstances.length = 0;
notificationShowOutcome.current = "failed";
return Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
assert.isFalse(
yield* notifications.show({ title: "Thread finished", body: "Refactor", onClick: vi.fn() }),
);
assert.equal(notificationInstances.length, 1);
}).pipe(Effect.provide(notificationLayer("darwin")));
});
});
75 changes: 51 additions & 24 deletions apps/desktop/src/electron/ElectronNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as Layer from "effect/Layer";

import * as Electron from "electron";

const NOTIFICATION_ACKNOWLEDGEMENT_TIMEOUT_MS = 5_000;

export interface ElectronNotificationInput {
readonly title: string;
readonly body: string;
Expand All @@ -26,31 +28,56 @@ export const layer = Layer.effect(

return ElectronNotification.of({
show: (input) =>
Effect.sync(() => {
if (platform !== "darwin" || !Electron.Notification.isSupported()) {
return false;
}
platform !== "darwin" || !Electron.Notification.isSupported()
? Effect.succeed(false)
: Effect.callback<boolean>((resume) => {
let notification: Electron.Notification;
try {
notification = new Electron.Notification({
title: input.title,
body: input.body,
silent: true,
});
} catch {
resume(Effect.succeed(false));
return;
}

try {
const notification = new Electron.Notification({
title: input.title,
body: input.body,
silent: true,
});
const release = () => activeNotifications.delete(notification);
notification.once("click", () => {
input.onClick();
release();
});
notification.once("close", release);
notification.once("failed", release);
activeNotifications.add(notification);
notification.show();
return true;
} catch {
return false;
}
}),
let acknowledged = false;
const acknowledge = (shown: boolean) => {
if (acknowledged) return;
acknowledged = true;
resume(Effect.succeed(shown));
};
const release = () => activeNotifications.delete(notification);
notification.once("show", () => acknowledge(true));
notification.once("click", () => {
acknowledge(true);
input.onClick();
release();
});
notification.once("close", () => {
acknowledge(false);
release();
});
notification.once("failed", () => {
acknowledge(false);
release();
});
activeNotifications.add(notification);
try {
notification.show();
} catch {
release();
acknowledge(false);
}
return Effect.sync(release);
}).pipe(
Effect.timeoutOrElse({
duration: NOTIFICATION_ACKNOWLEDGEMENT_TIMEOUT_MS,
orElse: () => Effect.succeed(false),
}),
),
});
}),
);
13 changes: 0 additions & 13 deletions apps/web/src/interactionSounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
COMPLETION_SOUND_VOLUME,
deriveInteractionSoundCues,
deriveThreadFeedbackEvents,
shouldPostThreadCompletionNotification,
} from "./interactionSounds";

function makeThread(overrides: Partial<EnvironmentThreadShell> = {}): EnvironmentThreadShell {
Expand Down Expand Up @@ -93,18 +92,6 @@ describe("interaction sounds", () => {
expect(COMPLETION_SOUND_VOLUME).toBe(1.1);
});

it("posts completion notifications only when the flag and desktop bridge are available", () => {
expect(
shouldPostThreadCompletionNotification({ enabled: true, desktopBridgeAvailable: true }),
).toBe(true);
expect(
shouldPostThreadCompletionNotification({ enabled: false, desktopBridgeAvailable: true }),
).toBe(false);
expect(
shouldPostThreadCompletionNotification({ enabled: true, desktopBridgeAvailable: false }),
).toBe(false);
});

it("plays bloom when a thread starts requesting user input", () => {
const thread = makeThread();

Expand Down
7 changes: 0 additions & 7 deletions apps/web/src/interactionSounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,3 @@ export function deriveInteractionSoundCues(
): InteractionSoundCue[] {
return deriveThreadFeedbackEvents(previous, threads).map((event) => event.cue);
}

export function shouldPostThreadCompletionNotification(input: {
readonly enabled: boolean;
readonly desktopBridgeAvailable: boolean;
}): boolean {
return input.enabled && input.desktopBridgeAvailable;
}
Loading
Loading