refactor: migrate to shell-env and custom desktop notifications bridge

This commit is contained in:
Mohamed Boudra
2026-03-13 12:26:14 +07:00
parent ee8a81d2e1
commit 013f573cff
19 changed files with 436 additions and 531 deletions
+28 -8
View File
@@ -50,6 +50,7 @@ import { getIsTauri } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -89,20 +90,39 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission().then((granted) => {
console.log(
"[OSNotifications][Tauri] Startup permission preflight result:",
granted ? "granted" : "not-granted"
);
});
void ensureOsNotificationPermission();
let disposed = false;
let unlisten: (() => void) | null = null;
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
}
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);
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
};
target.addEventListener(
+4 -11
View File
@@ -41,6 +41,7 @@ import {
import { useDraftStore } from "@/stores/draft-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import {
getInitKey,
getInitDeferred,
@@ -570,18 +571,10 @@ function SessionProviderInternal({
if (params.reason === "error") {
return;
}
const isActive = appState ? appState === "active" : true;
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
const isActivelyVisible = getIsAppActivelyVisible(appState);
const isAwayFromAgent =
!isActivelyVisible || focusedAgentId !== params.agentId;
if (!isAwayFromAgent) {
console.log(
"[OSNotifications] Skipping attention notification: user already focused on agent",
{
agentId: params.agentId,
reason: params.reason,
appState,
focusedAgentId,
}
);
return;
}
@@ -0,0 +1,58 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri } from "@/utils/tauri";
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
export interface DesktopNotificationInput {
title: string;
body?: string;
data?: Record<string, unknown>;
}
export interface DesktopNotificationClickPayload {
data?: Record<string, unknown>;
}
export type DesktopNotificationClickHandler = (
payload: DesktopNotificationClickPayload
) => void;
export type DesktopNotificationClickUnlisten = () => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function sendDesktopNotification(
input: DesktopNotificationInput
): Promise<boolean> {
try {
await invokeDesktopCommand("send_desktop_notification", { input });
return true;
} catch (error) {
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
return false;
}
}
export async function listenToDesktopNotificationClicks(
handler: DesktopNotificationClickHandler
): Promise<DesktopNotificationClickUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
data: isRecord(payload.data) ? payload.data : undefined,
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}
@@ -5,6 +5,7 @@ import {
shouldClearAgentAttention,
type AgentAttentionClearTrigger,
} from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -23,20 +24,6 @@ interface AgentAttentionClearController {
clearOnAgentBlur: () => void;
}
function getIsAppVisible(): boolean {
const isAppStateActive = AppState.currentState === "active";
if (Platform.OS !== "web") {
return isAppStateActive;
}
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const windowFocused =
typeof document === "undefined" ||
typeof document.hasFocus !== "function" ||
document.hasFocus();
return isAppStateActive && documentVisible && windowFocused;
}
export function useAgentAttentionClear({
agentId,
client,
@@ -45,12 +32,14 @@ export function useAgentAttentionClear({
attentionReason,
isScreenFocused,
}: UseAgentAttentionClearParams): AgentAttentionClearController {
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppVisible());
const [isAppVisible, setIsAppVisible] = useState<boolean>(() =>
getIsAppActivelyVisible()
);
const deferredFocusEntryClearRef = useRef(false);
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppVisible());
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
const prevScreenFocusedRef = useRef(false);
const prevAppVisibleRef = useRef(getIsAppVisible());
const prevAppVisibleRef = useRef(getIsAppActivelyVisible());
const clearAttention = useCallback(
(trigger: AgentAttentionClearTrigger) => {
@@ -78,7 +67,7 @@ export function useAgentAttentionClear({
useEffect(() => {
const updateVisibility = () => {
setIsAppVisible(getIsAppVisible());
setIsAppVisible(getIsAppActivelyVisible());
};
const appStateSubscription = AppState.addEventListener(
+22
View File
@@ -0,0 +1,22 @@
import { AppState, Platform } from "react-native";
export function getIsAppActivelyVisible(
appState: string = AppState.currentState
): boolean {
if (appState !== "active") {
return false;
}
if (Platform.OS !== "web") {
return true;
}
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const windowFocused =
typeof document === "undefined" ||
typeof document.hasFocus !== "function" ||
document.hasFocus();
return documentVisible && windowFocused;
}
@@ -71,6 +71,7 @@ describe("sendOsNotification", () => {
});
afterEach(() => {
vi.doUnmock("@/desktop/notifications/desktop-notifications");
vi.doUnmock("react-native");
vi.restoreAllMocks();
vi.resetModules();
@@ -167,18 +168,13 @@ describe("sendOsNotification", () => {
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
});
it("uses Tauri notification module when available", async () => {
const isPermissionGranted = vi.fn(async () => false);
const requestPermission = vi.fn(async () => "granted");
const sendNotification = vi.fn(async () => undefined);
it("uses the desktop notification bridge when Tauri is available", async () => {
const sendDesktopNotification = vi.fn(async () => true);
vi.doMock("@/desktop/notifications/desktop-notifications", () => ({
sendDesktopNotification,
}));
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
notification: {
isPermissionGranted,
requestPermission,
sendNotification,
},
};
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {};
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
@@ -188,11 +184,10 @@ describe("sendOsNotification", () => {
});
expect(sent).toBe(true);
expect(isPermissionGranted).toHaveBeenCalledTimes(1);
expect(requestPermission).toHaveBeenCalledTimes(1);
expect(sendNotification).toHaveBeenCalledWith({
expect(sendDesktopNotification).toHaveBeenCalledWith({
title: "Agent finished",
body: "Done",
data: { serverId: "srv-1", agentId: "agent-1" },
});
});
});
+36 -55
View File
@@ -1,4 +1,5 @@
import { Platform } from "react-native";
import { sendDesktopNotification } from "@/desktop/notifications/desktop-notifications";
import { buildNotificationRoute } from "./notification-routing";
import { getTauri, type TauriNotificationApi } from "@/utils/tauri";
@@ -14,13 +15,16 @@ export type WebNotificationClickDetail = {
type WebNotificationInstance = {
onclick?: ((event: Event) => void) | null;
onclose?: ((event: Event) => void) | null;
addEventListener?: (type: string, listener: (event: Event) => void) => void;
removeEventListener?: (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;
const activeWebNotifications = new Set<WebNotificationInstance>();
function getTauriNotificationModule(): TauriNotificationApi | null {
if (Platform.OS !== "web") {
@@ -113,35 +117,6 @@ async function ensureTauriNotificationPermission(
}
}
async function sendTauriNotification(
payload: OsNotificationPayload,
notificationModule: TauriNotificationApi
): Promise<boolean> {
if (typeof notificationModule.sendNotification !== "function") {
console.warn(
"[OSNotifications][Tauri] notification.sendNotification is unavailable"
);
return false;
}
const granted = await ensureTauriNotificationPermission(notificationModule);
if (!granted) {
console.log("[OSNotifications][Tauri] Permission not granted");
return false;
}
try {
await notificationModule.sendNotification({
title: payload.title,
body: payload.body,
});
return true;
} catch (error) {
console.warn("[OSNotifications][Tauri] Failed to send notification", error);
return false;
}
}
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
@@ -183,6 +158,12 @@ function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined
): void {
activeWebNotifications.add(notification);
const cleanup = () => {
activeWebNotifications.delete(notification);
};
const onClick = () => {
const focus = (globalThis as { focus?: () => void }).focus;
if (typeof focus === "function") {
@@ -194,17 +175,24 @@ function attachWebClickHandler(
fallbackNavigateToNotificationTarget(data);
}
cleanup();
if (typeof notification.close === "function") {
notification.close();
}
};
const onClose = () => {
cleanup();
};
if (typeof notification.addEventListener === "function") {
notification.addEventListener("click", onClick);
notification.addEventListener("close", onClose);
return;
}
notification.onclick = onClick;
notification.onclose = onClose;
}
export async function sendOsNotification(
@@ -215,34 +203,27 @@ export async function sendOsNotification(
return false;
}
const tauriNotification = getTauriNotificationModule();
if (tauriNotification) {
console.log("[OSNotifications] Using Tauri notification module");
return await sendTauriNotification(payload, tauriNotification);
if (getTauri()) {
return await sendDesktopNotification(payload);
}
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
console.log(
"[OSNotifications][Web] Notification constructor unavailable",
typeof window !== "undefined" && window.location
? window.location.origin
: "unknown-origin"
);
return false;
if (NotificationConstructor) {
const granted = await ensureNotificationPermission();
if (!granted) {
console.log(
"[OSNotifications][Web] Permission not granted:",
NotificationConstructor.permission
);
} else {
const notification = new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
}) as WebNotificationInstance;
attachWebClickHandler(notification, payload.data);
return true;
}
}
const granted = await ensureOsNotificationPermission();
if (!granted) {
console.log(
"[OSNotifications][Web] Permission not granted:",
NotificationConstructor.permission
);
return false;
}
const notification = new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
}) as WebNotificationInstance;
attachWebClickHandler(notification, payload.data);
return true;
return false;
}
@@ -34,7 +34,7 @@ export function attachConsole(): void {
console.log = (...args: unknown[]) => {
originalLog.apply(console, args);
forwardToRust(0, args); // debug
forwardToRust(1, args); // info
};
console.info = (...args: unknown[]) => {
originalInfo.apply(console, args);