feat(26-04): create push API client, usePushNotifications hook, and NotificationPermissionPrompt
- Add ui/src/api/push.ts with getVapidPublicKey, subscribe, unsubscribe methods - Add ui/src/hooks/usePushNotifications.ts with SW pushManager subscription flow - urlBase64ToUint8Array utility converts VAPID key for applicationServerKey - NotificationPermissionPrompt shows after 3rd agent response (engagement gate) - Checks nexus.notifPromptDismissed localStorage key for dismiss state - ChatPanel tracks agentResponseCount from assistant messages and renders prompt - Install idb package (missing dependency from plan 26-00 prerequisites)
This commit is contained in:
parent
66bbfbf766
commit
862cf7fef3
6 changed files with 188 additions and 1 deletions
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
ui/src/api/push.ts
Normal file
28
ui/src/api/push.ts
Normal file
|
|
@ -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<PushSubscription["toJSON"]>,
|
||||
meta?: { userId?: string; companyId?: string; deviceLabel?: string },
|
||||
): Promise<void> {
|
||||
return api.post<void>("/push/subscribe", { ...subscription, ...meta });
|
||||
},
|
||||
|
||||
unsubscribe(endpoint: string): Promise<void> {
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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) */}
|
||||
<InstallPromptBanner />
|
||||
|
||||
{/* Push notification permission prompt (shows after 3 agent responses) */}
|
||||
<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
64
ui/src/components/NotificationPermissionPrompt.tsx
Normal file
64
ui/src/components/NotificationPermissionPrompt.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="fixed bottom-20 left-4 right-4 md:bottom-auto md:top-16 md:left-auto md:right-4 md:max-w-sm z-50 bg-card border border-border rounded-lg shadow-lg p-4">
|
||||
<p className="text-sm font-semibold">Stay in the loop</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Get notified when your agents complete tasks or need input.
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" onClick={handleAllow}>
|
||||
Allow notifications
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss}>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
ui/src/hooks/usePushNotifications.ts
Normal file
76
ui/src/hooks/usePushNotifications.ts
Normal file
|
|
@ -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<void>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
} {
|
||||
const [permission, setPermission] = useState<NotificationPermission | "unsupported">(
|
||||
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<PushSubscription["toJSON"]>);
|
||||
}, []);
|
||||
|
||||
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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue