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
138 changes: 138 additions & 0 deletions src/shared/hooks/__tests__/useCompletionNotifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,4 +699,142 @@ describe("useCompletionNotifications", () => {
),
);
});

it("preserves completion timestamps when suppression resolves out of order", async () => {
const { subscribeToCompletionNotificationEvents } = await import(
"@/shared/notifications/completionEvents"
);
const seen: { sessionId: string; occurredAt: string }[] = [];
const unsubscribe = subscribeToCompletionNotificationEvents((event) => {
seen.push({ sessionId: event.sessionId, occurredAt: event.occurredAt });
});
const suppressions = {
"session-first": deferred<boolean>(),
"session-second": deferred<boolean>(),
};
mocks.invoke.mockImplementation(
(command: string, args: { sessionId: string }) =>
command === "should_suppress_completion_notification"
? suppressions[args.sessionId as keyof typeof suppressions].promise
: Promise.resolve(undefined),
);

let focusChanged: ((event: { payload: boolean }) => void) | null = null;
mocks.getCurrentWindow.mockReturnValue({
onFocusChanged: vi.fn((handler) => {
focusChanged = handler;
return Promise.resolve(vi.fn());
}),
unminimize: vi.fn().mockResolvedValue(undefined),
show: vi.fn().mockResolvedValue(undefined),
setFocus: vi.fn().mockResolvedValue(undefined),
});

try {
renderHook(() => useCompletionNotifications(vi.fn()));
await waitFor(() => expect(focusChanged).toBeTruthy());

for (const sessionId of Object.keys(suppressions)) {
useChatSessionStore.getState().addSession({
id: sessionId,
title: sessionId,
createdAt: "2026-06-05T00:00:00.000Z",
updatedAt: "2026-06-05T00:00:00.000Z",
messageCount: 1,
});
useChatStore.getState().setMessages(sessionId, [makeMsg("completed")]);
}

const firstCompletionMs = 1_750_000_000_001;
const secondCompletionMs = 1_750_000_000_002;
const nowSpy = vi
.spyOn(Date, "now")
.mockReturnValueOnce(firstCompletionMs)
.mockReturnValueOnce(secondCompletionMs);
try {
act(() => {
focusChanged?.({ payload: false });
useChatStore.getState().setChatState("session-first", "streaming");
useChatStore.getState().setChatState("session-first", "idle");
useChatStore.getState().setChatState("session-second", "streaming");
useChatStore.getState().setChatState("session-second", "idle");
});
} finally {
nowSpy.mockRestore();
}

await waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2));
await act(async () => {
suppressions["session-second"].resolve(false);
});
await waitFor(() => expect(seen).toHaveLength(1));
await act(async () => {
suppressions["session-first"].resolve(false);
});
await waitFor(() => expect(seen).toHaveLength(2));

expect(seen).toEqual([
{
sessionId: "session-second",
occurredAt: new Date(secondCompletionMs).toISOString(),
},
{
sessionId: "session-first",
occurredAt: new Date(firstCompletionMs).toISOString(),
},
]);
} finally {
unsubscribe();
}
});

it("emits a typed completion event with session/project/navigation refs on delivery", async () => {
const { subscribeToCompletionNotificationEvents } = await import(
"@/shared/notifications/completionEvents"
);
const seen = vi.fn();
const unsubscribe = subscribeToCompletionNotificationEvents(seen);

let focusChanged: ((event: { payload: boolean }) => void) | null = null;
mocks.getCurrentWindow.mockReturnValue({
onFocusChanged: vi.fn((handler) => {
focusChanged = handler;
return Promise.resolve(vi.fn());
}),
unminimize: vi.fn().mockResolvedValue(undefined),
show: vi.fn().mockResolvedValue(undefined),
setFocus: vi.fn().mockResolvedValue(undefined),
});

renderHook(() => useCompletionNotifications(vi.fn()));
await waitFor(() => expect(focusChanged).toBeTruthy());

useChatSessionStore.getState().addSession({
id: "session-5",
title: "Event source",
createdAt: "2026-06-05T00:00:00.000Z",
updatedAt: "2026-06-05T00:00:00.000Z",
messageCount: 1,
projectId: "project-7",
});
useChatStore.getState().setMessages("session-5", [makeMsg("completed")]);

act(() => {
focusChanged?.({ payload: false });
useChatStore.getState().setChatState("session-5", "streaming");
useChatStore.getState().setChatState("session-5", "idle");
});

await waitFor(() => expect(seen).toHaveBeenCalledTimes(1));
expect(seen).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-5",
projectId: "project-7",
outcome: "completed",
body: "Event source finished",
navigation: { kind: "session", sessionId: "session-5" },
}),
);
unsubscribe();
});
});
20 changes: 20 additions & 0 deletions src/shared/hooks/useCompletionNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { getNotificationPrefs } from "@/features/settings/lib/notificationPrefs"
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
import { isDefaultChatTitle } from "@/features/chat/lib/sessionTitle";
import { showCompletionNotificationToast } from "@/shared/notifications/CompletionNotificationToast";
import {
createCompletionNotificationEvent,
emitCompletionNotificationEvent,
} from "@/shared/notifications/completionEvents";
import {
getNotificationSoundResource,
playNotificationSound,
Expand Down Expand Up @@ -210,6 +214,7 @@ export function useCompletionNotifications(
prev === "thinking")
) {
pendingSessions.delete(sessionId);
const occurredAtMs = Date.now();

const chatStoreState = useChatStore.getState();
const isViewingThisSession = isSessionActivelyViewed(
Expand Down Expand Up @@ -243,6 +248,21 @@ export function useCompletionNotifications(
) {
return;
}
// The completion is notification-eligible (enabled, not
// suppressed, not an actively viewed focused session).
// Publish the typed event here so consumers (e.g. a future
// notification center) see it regardless of which channel
// pref (desktop/in-app) ultimately renders it — note a
// disabled channel pref below can still skip rendering.
emitCompletionNotificationEvent(
createCompletionNotificationEvent({
sessionId,
projectId: session?.projectId ?? null,
outcome,
body,
occurredAtMs,
}),
);
if (!windowFocused) {
if (!currentPrefs.desktop) return;
import("@tauri-apps/api/core").then(({ invoke }) => {
Expand Down
111 changes: 111 additions & 0 deletions src/shared/notifications/__tests__/completionEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from "vitest";

import {
createCompletionNotificationEvent,
emitCompletionNotificationEvent,
subscribeToCompletionNotificationEvents,
} from "../completionEvents";

describe("createCompletionNotificationEvent", () => {
it("assigns distinct IDs to same-session events with the same timestamp", () => {
const first = createCompletionNotificationEvent({
sessionId: "session-1",
outcome: "completed",
body: "Agent finished",
occurredAtMs: 1_750_000_000_000,
});
const duplicate = createCompletionNotificationEvent({
sessionId: "session-1",
outcome: "completed",
body: "Agent finished",
occurredAtMs: 1_750_000_000_000,
});
const other = createCompletionNotificationEvent({
sessionId: "session-2",
outcome: "completed",
body: "Agent finished",
occurredAtMs: 1_750_000_000_000,
});

expect(first.id).not.toBe(duplicate.id);
expect(first.id).not.toBe(other.id);
expect(first.occurredAt).toBe(duplicate.occurredAt);
expect(first.occurredAt).toBe(new Date(1_750_000_000_000).toISOString());
});

it("carries session, project, outcome, and navigation references", () => {
const event = createCompletionNotificationEvent({
sessionId: "session-1",
projectId: "project-9",
outcome: "error",
body: "Agent encountered an error",
occurredAtMs: 1_750_000_000_000,
});

expect(event).toMatchObject({
sessionId: "session-1",
projectId: "project-9",
outcome: "error",
body: "Agent encountered an error",
navigation: { kind: "session", sessionId: "session-1" },
});
expect(event.occurredAt).toBe(new Date(1_750_000_000_000).toISOString());
});

it("defaults projectId to null for unattached sessions", () => {
const event = createCompletionNotificationEvent({
sessionId: "session-1",
outcome: "stopped",
body: "Agent was stopped",
occurredAtMs: 1,
});
expect(event.projectId).toBeNull();
});
});

describe("completion notification event bus", () => {
it("delivers events to subscribers and honors unsubscribe", () => {
const seen = vi.fn();
const unsubscribe = subscribeToCompletionNotificationEvents(seen);
const event = createCompletionNotificationEvent({
sessionId: "session-1",
outcome: "completed",
body: "Agent finished",
occurredAtMs: 2,
});

emitCompletionNotificationEvent(event);
expect(seen).toHaveBeenCalledExactlyOnceWith(event);

unsubscribe();
emitCompletionNotificationEvent(event);
expect(seen).toHaveBeenCalledTimes(1);
});

it("isolates listener failures from other listeners", () => {
const errorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const unsubscribeThrowing = subscribeToCompletionNotificationEvents(() => {
throw new Error("listener exploded");
});
const healthy = vi.fn();
const unsubscribeHealthy = subscribeToCompletionNotificationEvents(healthy);

emitCompletionNotificationEvent(
createCompletionNotificationEvent({
sessionId: "session-1",
outcome: "completed",
body: "Agent finished",
occurredAtMs: 3,
}),
);

expect(healthy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalled();

unsubscribeThrowing();
unsubscribeHealthy();
errorSpy.mockRestore();
});
});
Loading
Loading