Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
enableNotifications: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/hooks/useNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from "react";

import {
getNotificationPermission,
requestNotificationPermission,
} from "../lib/nativeNotifications";

export function useNotification() {
const [permission, setPermission] = useState(getNotificationPermission());

const refresh = useCallback(() => {
setPermission(getNotificationPermission());
}, []);

const requestPermission = useCallback(async () => {
const next = await requestNotificationPermission();
setPermission(next);
}, []);

useEffect(() => {
refresh();
window.addEventListener("focus", refresh);

return () => {
window.removeEventListener("focus", refresh);
};
}, [refresh]);

return { permission, requestPermission, refresh };
}
132 changes: 132 additions & 0 deletions apps/web/src/lib/nativeNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
canShowNativeNotification,
getNotificationPermission,
requestNotificationPermission,
showNativeNotification,
} from "./nativeNotifications";

type TestWindow = Window & typeof globalThis & { desktopBridge?: unknown; nativeApi?: unknown };

const getTestWindow = (): TestWindow => {
const testGlobal = globalThis as typeof globalThis & { window?: TestWindow };
if (!testGlobal.window) {
testGlobal.window = {} as TestWindow;
}
return testGlobal.window;
};

const createNotificationMock = () => {
const ctorSpy = vi.fn();

class MockNotification {
static permission: NotificationPermission = "default";
static requestPermission = vi.fn(async () => "default" as NotificationPermission);

constructor(title: string, options?: NotificationOptions) {
ctorSpy({ title, options });
}
}

return { MockNotification, ctorSpy };
};

beforeEach(() => {
vi.resetModules();
const win = getTestWindow();
delete win.desktopBridge;
delete win.nativeApi;
});

afterEach(() => {
delete (globalThis as { Notification?: unknown }).Notification;
});

describe("nativeNotifications", () => {
it("returns unsupported permission when Notification is unavailable", () => {
delete (globalThis as { Notification?: unknown }).Notification;
expect(getNotificationPermission()).toBe("unsupported");
});

it("returns permission when Notification is available", () => {
const { MockNotification } = createNotificationMock();
MockNotification.permission = "granted";
(globalThis as { Notification?: unknown }).Notification = MockNotification;

expect(getNotificationPermission()).toBe("granted");
});

it("requests permission when supported", async () => {
const { MockNotification } = createNotificationMock();
MockNotification.requestPermission = vi.fn(async () => "granted");
(globalThis as { Notification?: unknown }).Notification = MockNotification;

await expect(requestNotificationPermission()).resolves.toBe("granted");
expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1);
});

it("falls back to current permission when request throws", async () => {
const { MockNotification } = createNotificationMock();
MockNotification.permission = "denied";
MockNotification.requestPermission = vi.fn(async () => {
throw new Error("no");
});
(globalThis as { Notification?: unknown }).Notification = MockNotification;

await expect(requestNotificationPermission()).resolves.toBe("denied");
});

it("canShowNativeNotification respects permission in web context", () => {
const { MockNotification } = createNotificationMock();
MockNotification.permission = "denied";
(globalThis as { Notification?: unknown }).Notification = MockNotification;

expect(canShowNativeNotification()).toBe(false);
MockNotification.permission = "granted";
expect(canShowNativeNotification()).toBe(true);
});

it("canShowNativeNotification is allowed in desktop context when supported", () => {
const { MockNotification } = createNotificationMock();
MockNotification.permission = "denied";
(globalThis as { Notification?: unknown }).Notification = MockNotification;
(getTestWindow() as unknown as Record<string, unknown>).desktopBridge = {};

expect(canShowNativeNotification()).toBe(true);
});

it("showNativeNotification returns false when permission is not granted", () => {
const { MockNotification, ctorSpy } = createNotificationMock();
MockNotification.permission = "denied";
(globalThis as { Notification?: unknown }).Notification = MockNotification;

expect(showNativeNotification({ title: "Test" })).toBe(false);
expect(ctorSpy).not.toHaveBeenCalled();
});

it("showNativeNotification sends a notification when allowed", () => {
const { MockNotification, ctorSpy } = createNotificationMock();
MockNotification.permission = "granted";
(globalThis as { Notification?: unknown }).Notification = MockNotification;

expect(
showNativeNotification({
title: "Test",
body: "Hello",
tag: "tag-1",
}),
).toBe(true);
expect(ctorSpy).toHaveBeenCalledTimes(1);
});

it("showNativeNotification sends a notification in desktop mode", () => {
const { MockNotification, ctorSpy } = createNotificationMock();
MockNotification.permission = "denied";
(globalThis as { Notification?: unknown }).Notification = MockNotification;
(getTestWindow() as unknown as Record<string, unknown>).nativeApi = {};

expect(showNativeNotification({ title: "Test" })).toBe(true);
expect(ctorSpy).toHaveBeenCalledTimes(1);
});
});
57 changes: 57 additions & 0 deletions apps/web/src/lib/nativeNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export function isAppBackgrounded(): boolean {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return true;
if (typeof document.hasFocus === "function") {
return !document.hasFocus();
}
return false;
}

export function canShowNativeNotification(): boolean {
if (typeof Notification === "undefined") return false;
if (
typeof window !== "undefined" &&
(window.desktopBridge !== undefined || window.nativeApi !== undefined)
) {
return true;
}
return Notification.permission === "granted";
}

export function getNotificationPermission(): NotificationPermission | "unsupported" {
if (typeof Notification === "undefined") return "unsupported";
return Notification.permission;
}

export async function requestNotificationPermission(): Promise<
NotificationPermission | "unsupported"
> {
if (typeof Notification === "undefined") return "unsupported";
try {
return await Notification.requestPermission();
} catch {
return Notification.permission;
}
}

export function showNativeNotification(input: {
title: string;
body?: string;
tag?: string;
}): boolean {
if (!canShowNativeNotification()) return false;
try {
const options: NotificationOptions = {};
if (input.body !== undefined) {
options.body = input.body;
}
if (input.tag !== undefined) {
options.tag = input.tag;
}
const notification = new Notification(input.title, options);
void notification;
return true;
} catch {
return false;
}
}
70 changes: 69 additions & 1 deletion apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ThreadId } from "@t3tools/contracts";
import {
ThreadId,
type OrchestrationReadModel,
type OrchestrationSessionStatus,
} from "@t3tools/contracts";
import {
Outlet,
createRootRouteWithContext,
Expand All @@ -16,6 +20,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import { useAppSettings } from "../appSettings";
import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
import { useStore } from "../store";
import { useTerminalStateStore } from "../terminalStateStore";
Expand All @@ -24,6 +29,7 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
import { providerQueryKeys } from "../lib/providerReactQuery";
import { projectQueryKeys } from "../lib/projectReactQuery";
import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
import { isAppBackgrounded, showNativeNotification } from "../lib/nativeNotifications";

export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
Expand Down Expand Up @@ -136,11 +142,16 @@ function EventRouter() {
const removeOrphanedTerminalStates = useTerminalStateStore(
(store) => store.removeOrphanedTerminalStates,
);
const { settings } = useAppSettings();
const queryClient = useQueryClient();
const navigate = useNavigate();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const pathnameRef = useRef(pathname);
const handledBootstrapThreadIdRef = useRef<string | null>(null);
const lastSessionByThreadRef = useRef(
new Map<string, { status: OrchestrationSessionStatus; activeTurnId: string | null }>(),
);
const lastNotifiedTurnByThreadRef = useRef(new Map<string, string>());

pathnameRef.current = pathname;

Expand All @@ -153,10 +164,66 @@ function EventRouter() {
let pending = false;
let needsProviderInvalidation = false;

const maybeNotifyForTurnCompletion = (snapshot: OrchestrationReadModel) => {
// Only notify when the app is backgrounded and the user has enabled notifications.
const shouldNotify = isAppBackgrounded() && settings.enableNotifications;
const seenThreadIds = new Set<string>();
for (const thread of snapshot.threads) {
seenThreadIds.add(thread.id);
const session = thread.session;
const previous = lastSessionByThreadRef.current.get(thread.id);

// A completed/failed turn transitions from running with an activeTurnId
// to a session with no active turn and status ready/error.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should also notify on input/approval requested?

also maybe have the setting be more configurable than a boolean flag so users can set more granular levels of when they wanna be notified

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to push out a mvp first for just task status notification, but I can definitely work on adding it for input/approval

can you give me a bit more info regarding fine tuning the notifications? some example scenarios / levels that you want to add

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which style do you prefer

image

Codex App current style:
image

if (
shouldNotify &&
session &&
previous &&
previous.status === "running" &&
previous.activeTurnId &&
session.activeTurnId === null &&
(session.status === "ready" || session.status === "error")
) {
const lastNotifiedTurnId = lastNotifiedTurnByThreadRef.current.get(thread.id);

if (lastNotifiedTurnId !== previous.activeTurnId) {
const title = session.status === "error" ? "Task failed" : "Task completed";
const detail =
session.status === "error" && session.lastError ? session.lastError : thread.title;
const body = detail.length > 180 ? `${detail.slice(0, 177)}...` : detail;
const tag = `t3code:${thread.id}:${previous.activeTurnId}:${session.status}`;

if (showNativeNotification({ title, body, tag })) {
lastNotifiedTurnByThreadRef.current.set(thread.id, previous.activeTurnId);
}
}
}

if (session) {
// Persist latest session state so we can detect transitions next time.
lastSessionByThreadRef.current.set(thread.id, {
status: session.status,
activeTurnId: session.activeTurnId ?? null,
});
} else {
lastSessionByThreadRef.current.delete(thread.id);
}
}

// Drop state for threads that no longer exist in the snapshot.
for (const threadId of lastSessionByThreadRef.current.keys()) {
if (!seenThreadIds.has(threadId)) {
lastSessionByThreadRef.current.delete(threadId);
lastNotifiedTurnByThreadRef.current.delete(threadId);
}
}
};

const flushSnapshotSync = async (): Promise<void> => {
const snapshot = await api.orchestration.getSnapshot();
if (disposed) return;
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
maybeNotifyForTurnCompletion(snapshot);
syncServerReadModel(snapshot);
clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id)));
const draftThreadIds = Object.keys(
Expand Down Expand Up @@ -315,6 +382,7 @@ function EventRouter() {
queryClient,
removeOrphanedTerminalStates,
setProjectExpanded,
settings.enableNotifications,
syncServerReadModel,
]);

Expand Down
Loading