diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dbd1de3..e5953936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -672,6 +672,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + idb: + specifier: ^8.0.3 + version: 8.0.3 lexical: specifier: 0.35.0 version: 0.35.0 @@ -4702,6 +4705,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -10817,6 +10823,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@8.0.3: {} + ieee754@1.2.1: {} inherits@2.0.4: {} diff --git a/ui/package.json b/ui/package.json index 4268bc30..8584594d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -51,6 +51,7 @@ "diff": "^8.0.4", "hermes-paperclip-adapter": "^0.2.0", "highlight.js": "^11.11.1", + "idb": "^8.0.3", "lexical": "0.35.0", "lucide-react": "^0.574.0", "mermaid": "^11.12.0", @@ -65,6 +66,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.7", + "@testing-library/react": "^16.0.0", "@types/diff": "^8.0.0", "@types/node": "^25.2.3", "@types/react": "^19.0.8", @@ -73,7 +75,6 @@ "tailwindcss": "^4.0.7", "typescript": "^5.7.3", "vite": "^6.1.0", - "@testing-library/react": "^16.0.0", "vitest": "^3.0.5" } } diff --git a/ui/src/api/push.ts b/ui/src/api/push.ts new file mode 100644 index 00000000..08814dab --- /dev/null +++ b/ui/src/api/push.ts @@ -0,0 +1,28 @@ +import { api } from "./client"; + +export const pushApi = { + getVapidPublicKey(): Promise<{ publicKey: string | null }> { + return api.get<{ publicKey: string | null }>("/push/vapid-public-key"); + }, + + subscribe( + subscription: ReturnType, + meta?: { userId?: string; companyId?: string; deviceLabel?: string }, + ): Promise { + return api.post("/push/subscribe", { ...subscription, ...meta }); + }, + + unsubscribe(endpoint: string): Promise { + // DELETE with body requires a manual fetch since api.delete() doesn't support body + return fetch("/api/push/subscribe", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ endpoint }), + }).then((res) => { + if (!res.ok && res.status !== 204) { + throw new Error(`Unsubscribe failed: ${res.status}`); + } + }); + }, +}; diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index bec3bb78..b60076aa 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -15,6 +15,7 @@ import { ChatBookmarkList } from "./ChatBookmarkList"; import { MobileChatView } from "./MobileChatView"; import { InstallPromptBanner } from "./InstallPromptBanner"; import { OfflineBanner } from "./OfflineBanner"; +import { NotificationPermissionPrompt } from "./NotificationPermissionPrompt"; import { Button } from "@/components/ui/button"; import { chatApi } from "../api/chat"; import { agentsApi } from "../api/agents"; @@ -48,6 +49,12 @@ export function ChatPanel() { const brainstormerDefaultId = useBrainstormerDefault(); + // Count assistant messages for the notification engagement gate + const agentResponseCount = useMemo( + () => messages.filter((m) => m.role === "assistant").length, + [messages], + ); + // Listen for nexus:open-chat-search custom event (dispatched by CommandPalette) useEffect(() => { const handler = () => setSearchOpen(true); @@ -430,6 +437,9 @@ export function ChatPanel() { {/* PWA install prompt banner (self-contained show/hide logic) */} + + {/* Push notification permission prompt (shows after 3 agent responses) */} + ); } diff --git a/ui/src/components/NotificationPermissionPrompt.tsx b/ui/src/components/NotificationPermissionPrompt.tsx new file mode 100644 index 00000000..c78f0af5 --- /dev/null +++ b/ui/src/components/NotificationPermissionPrompt.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { usePushNotifications } from "../hooks/usePushNotifications"; + +const DISMISS_KEY = "nexus.notifPromptDismissed"; + +interface NotificationPermissionPromptProps { + agentResponseCount: number; +} + +/** + * Notification permission prompt banner. + * + * Shows when: + * - Push notifications are supported in this browser + * - Permission is still "default" (not granted or denied) + * - User has not previously dismissed this prompt + * - User has received at least 3 agent responses (engagement gate) + */ +export function NotificationPermissionPrompt({ agentResponseCount }: NotificationPermissionPromptProps) { + const { isSupported, permission, subscribe } = usePushNotifications(); + const [dismissed, setDismissed] = useState(() => { + try { + return localStorage.getItem(DISMISS_KEY) === "true"; + } catch { + return false; + } + }); + + if (!isSupported) return null; + if (permission !== "default") return null; + if (dismissed) return null; + if (agentResponseCount < 3) return null; + + const handleAllow = async () => { + await subscribe(); + }; + + const handleDismiss = () => { + try { + localStorage.setItem(DISMISS_KEY, "true"); + } catch { + // localStorage unavailable — dismiss for session only + } + setDismissed(true); + }; + + return ( +
+

Stay in the loop

+

+ Get notified when your agents complete tasks or need input. +

+
+ + +
+
+ ); +} diff --git a/ui/src/hooks/usePushNotifications.ts b/ui/src/hooks/usePushNotifications.ts new file mode 100644 index 00000000..977a467c --- /dev/null +++ b/ui/src/hooks/usePushNotifications.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from "react"; +import { pushApi } from "../api/push"; + +/** + * Converts a base64url-encoded VAPID public key to a Uint8Array + * as required by pushManager.subscribe({ applicationServerKey }). + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +const isSupported = + typeof navigator !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window; + +export function usePushNotifications(): { + isSupported: boolean; + permission: NotificationPermission | "unsupported"; + subscribe: () => Promise; + unsubscribe: () => Promise; +} { + const [permission, setPermission] = useState( + isSupported ? Notification.permission : "unsupported", + ); + + // Poll for permission changes (e.g., user revokes permission in browser settings) + useEffect(() => { + if (!isSupported) return; + const interval = setInterval(() => { + const current = Notification.permission; + setPermission((prev) => (prev !== current ? current : prev)); + }, 2000); + return () => clearInterval(interval); + }, []); + + const subscribe = useCallback(async () => { + if (!isSupported) return; + + const perm = await Notification.requestPermission(); + setPermission(perm); + if (perm !== "granted") return; + + const { publicKey } = await pushApi.getVapidPublicKey(); + if (!publicKey) return; + + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer, + }); + + await pushApi.subscribe(sub.toJSON() as ReturnType); + }, []); + + const unsubscribe = useCallback(async () => { + if (!isSupported) return; + + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (!sub) return; + + await sub.unsubscribe(); + await pushApi.unsubscribe(sub.endpoint); + }, []); + + return { isSupported, permission, subscribe, unsubscribe }; +}