Update files

This commit is contained in:
Mohamed Boudra
2026-02-10 11:43:07 +07:00
parent 7001928f4f
commit ade4fb2e45
15 changed files with 1026 additions and 77 deletions
+24 -9
View File
@@ -38,6 +38,11 @@ import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { queryClient } from "@/query/query-client";
import {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing";
polyfillCrypto();
@@ -47,7 +52,24 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
return;
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
const route = buildNotificationRoute(customEvent.detail?.data);
event.preventDefault();
router.push(route as any);
};
target.addEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
return () => {
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
};
}
Notifications.setNotificationHandler({
@@ -71,14 +93,7 @@ function PushNotificationRouter() {
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
const agentId = typeof data?.agentId === "string" ? data.agentId : null;
if (agentId) {
// Legacy route resolves agent -> host once sessions reconnect.
router.push(`/agent/${agentId}` as any);
} else {
router.push("/agents" as any);
}
router.push(buildNotificationRoute(data) as any);
};
const subscription =
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
buildNotificationRoute,
resolveNotificationTarget,
} from "./notification-routing";
describe("resolveNotificationTarget", () => {
it("extracts non-empty server and agent ids", () => {
expect(
resolveNotificationTarget({
serverId: " server-123 ",
agentId: " agent-456 ",
})
).toEqual({
serverId: "server-123",
agentId: "agent-456",
});
});
it("returns null for missing/empty ids", () => {
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
serverId: null,
agentId: null,
});
expect(resolveNotificationTarget(undefined)).toEqual({
serverId: null,
agentId: null,
});
});
});
describe("buildNotificationRoute", () => {
it("routes directly to server-scoped agent path when both ids are present", () => {
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
"/agent/srv-1/agent-1"
);
});
it("falls back to legacy agent route when serverId is absent", () => {
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy");
});
it("falls back to agents list when no agent id is present", () => {
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents");
});
it("encodes path segments", () => {
expect(
buildNotificationRoute({
serverId: "srv/with/slash",
agentId: "agent with space",
})
).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space");
});
});
@@ -0,0 +1,34 @@
type NotificationData = Record<string, unknown> | null | undefined;
function readNonEmptyString(
data: NotificationData,
key: string
): string | null {
const value = data?.[key];
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveNotificationTarget(data: NotificationData): {
serverId: string | null;
agentId: string | null;
} {
return {
serverId: readNonEmptyString(data, "serverId"),
agentId: readNonEmptyString(data, "agentId"),
};
}
export function buildNotificationRoute(data: NotificationData): string {
const { serverId, agentId } = resolveNotificationTarget(data);
if (serverId && agentId) {
return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`;
}
if (agentId) {
return `/agent/${encodeURIComponent(agentId)}`;
}
return "/agents";
}
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockNotificationOptions = {
body?: string;
data?: Record<string, unknown>;
};
type MockNotificationInstance = {
title: string;
options?: MockNotificationOptions;
onclick: ((event: Event) => void) | null;
close: ReturnType<typeof vi.fn>;
};
type GlobalSnapshot = {
Notification: unknown;
CustomEvent: unknown;
dispatchEvent: unknown;
focus: unknown;
location: unknown;
};
const originalGlobals: GlobalSnapshot = {
Notification: (globalThis as { Notification?: unknown }).Notification,
CustomEvent: (globalThis as { CustomEvent?: unknown }).CustomEvent,
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
focus: (globalThis as { focus?: unknown }).focus,
location: (globalThis as { location?: unknown }).location,
};
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
vi.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
return import("./os-notifications");
}
function restoreGlobals(): void {
(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification;
(globalThis as { CustomEvent?: unknown }).CustomEvent = originalGlobals.CustomEvent;
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
(globalThis as { location?: unknown }).location = originalGlobals.location;
}
describe("sendOsNotification", () => {
beforeEach(() => {
class MockCustomEvent<T = unknown> {
type: string;
detail: T;
cancelable: boolean;
defaultPrevented = false;
constructor(type: string, init?: { detail?: T; cancelable?: boolean }) {
this.type = type;
this.detail = (init?.detail ?? null) as T;
this.cancelable = init?.cancelable ?? false;
}
preventDefault(): void {
if (this.cancelable) {
this.defaultPrevented = true;
}
}
}
(globalThis as { CustomEvent?: unknown }).CustomEvent = MockCustomEvent;
(globalThis as { focus?: unknown }).focus = vi.fn();
});
afterEach(() => {
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
restoreGlobals();
});
it("dispatches a click event that the app can handle", async () => {
const created: MockNotificationInstance[] = [];
class MockNotification implements MockNotificationInstance {
static permission = "granted";
static requestPermission = vi.fn(async () => "granted");
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
created.push(this);
}
}
const dispatchEvent = vi.fn((event: unknown) => {
void event;
return false;
});
const assign = vi.fn();
(globalThis as { Notification?: unknown }).Notification = MockNotification;
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
(globalThis as { location?: unknown }).location = { assign };
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } =
await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
body: "Done",
data: { serverId: "srv-1", agentId: "agent-1" },
});
expect(sent).toBe(true);
expect(created).toHaveLength(1);
const clicked = created[0];
expect(clicked.onclick).toBeTypeOf("function");
clicked.onclick?.({} as Event);
expect(dispatchEvent).toHaveBeenCalledTimes(1);
const event = dispatchEvent.mock.calls[0]?.[0] as unknown as {
type?: string;
detail?: { data?: Record<string, unknown> };
};
expect(event?.type).toBe(WEB_NOTIFICATION_CLICK_EVENT);
expect(event?.detail).toEqual({
data: { serverId: "srv-1", agentId: "agent-1" },
});
expect(assign).not.toHaveBeenCalled();
});
it("falls back to route navigation when no listener handles the click", async () => {
const created: MockNotificationInstance[] = [];
class MockNotification implements MockNotificationInstance {
static permission = "granted";
static requestPermission = vi.fn(async () => "granted");
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
created.push(this);
}
}
const dispatchEvent = vi.fn((event: unknown) => {
void event;
return true;
});
const assign = vi.fn();
(globalThis as { Notification?: unknown }).Notification = MockNotification;
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
(globalThis as { location?: unknown }).location = { assign };
const { sendOsNotification } = await loadModuleForPlatform("web");
await sendOsNotification({
title: "Agent finished",
data: { serverId: "srv with space", agentId: "agent/1" },
});
const clicked = created[0];
expect(clicked.onclick).toBeTypeOf("function");
clicked.onclick?.({} as Event);
expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1");
});
});
+81 -2
View File
@@ -1,4 +1,5 @@
import { Platform } from "react-native";
import { buildNotificationRoute } from "./notification-routing";
type OsNotificationPayload = {
title: string;
@@ -6,6 +7,18 @@ type OsNotificationPayload = {
data?: Record<string, unknown>;
};
export type WebNotificationClickDetail = {
data?: Record<string, unknown>;
};
type WebNotificationInstance = {
onclick?: ((event: Event) => void) | null;
addEventListener?: (type: string, listener: (event: Event) => void) => void;
close?: () => void;
};
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
function getWebNotificationConstructor(): {
@@ -41,6 +54,71 @@ async function ensureNotificationPermission(): Promise<boolean> {
return result;
}
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
.CustomEvent;
if (typeof dispatch !== "function" || !CustomEventConstructor) {
return false;
}
const event = new CustomEventConstructor<WebNotificationClickDetail>(
WEB_NOTIFICATION_CLICK_EVENT,
{
detail,
cancelable: true,
}
);
return dispatch(event) === false;
}
function fallbackNavigateToNotificationTarget(
data: Record<string, unknown> | undefined
): void {
const route = buildNotificationRoute(data);
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
.location;
if (!location) {
return;
}
if (typeof location.assign === "function") {
location.assign(route);
return;
}
if (typeof location.href === "string") {
location.href = route;
}
}
function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined
): void {
const onClick = () => {
const focus = (globalThis as { focus?: () => void }).focus;
if (typeof focus === "function") {
focus();
}
const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) {
fallbackNavigateToNotificationTarget(data);
}
if (typeof notification.close === "function") {
notification.close();
}
};
if (typeof notification.addEventListener === "function") {
notification.addEventListener("click", onClick);
return;
}
notification.onclick = onClick;
}
export async function sendOsNotification(
payload: OsNotificationPayload
): Promise<boolean> {
@@ -57,9 +135,10 @@ export async function sendOsNotification(
if (!granted) {
return false;
}
new NotificationConstructor(payload.title, {
const notification = new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
});
}) as WebNotificationInstance;
attachWebClickHandler(notification, payload.data);
return true;
}