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:
|
highlight.js:
|
||||||
specifier: ^11.11.1
|
specifier: ^11.11.1
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
|
idb:
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
lexical:
|
lexical:
|
||||||
specifier: 0.35.0
|
specifier: 0.35.0
|
||||||
version: 0.35.0
|
version: 0.35.0
|
||||||
|
|
@ -4702,6 +4705,9 @@ packages:
|
||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
idb@8.0.3:
|
||||||
|
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
|
|
@ -10817,6 +10823,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
idb@8.0.3: {}
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
"diff": "^8.0.4",
|
"diff": "^8.0.4",
|
||||||
"hermes-paperclip-adapter": "^0.2.0",
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"lexical": "0.35.0",
|
"lexical": "0.35.0",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"mermaid": "^11.12.0",
|
"mermaid": "^11.12.0",
|
||||||
|
|
@ -65,6 +66,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.7",
|
"@tailwindcss/vite": "^4.0.7",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/diff": "^8.0.0",
|
"@types/diff": "^8.0.0",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
|
|
@ -73,7 +75,6 @@
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
|
||||||
"vitest": "^3.0.5"
|
"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 { MobileChatView } from "./MobileChatView";
|
||||||
import { InstallPromptBanner } from "./InstallPromptBanner";
|
import { InstallPromptBanner } from "./InstallPromptBanner";
|
||||||
import { OfflineBanner } from "./OfflineBanner";
|
import { OfflineBanner } from "./OfflineBanner";
|
||||||
|
import { NotificationPermissionPrompt } from "./NotificationPermissionPrompt";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chatApi } from "../api/chat";
|
import { chatApi } from "../api/chat";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
@ -48,6 +49,12 @@ export function ChatPanel() {
|
||||||
|
|
||||||
const brainstormerDefaultId = useBrainstormerDefault();
|
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)
|
// Listen for nexus:open-chat-search custom event (dispatched by CommandPalette)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setSearchOpen(true);
|
const handler = () => setSearchOpen(true);
|
||||||
|
|
@ -430,6 +437,9 @@ export function ChatPanel() {
|
||||||
|
|
||||||
{/* PWA install prompt banner (self-contained show/hide logic) */}
|
{/* PWA install prompt banner (self-contained show/hide logic) */}
|
||||||
<InstallPromptBanner />
|
<InstallPromptBanner />
|
||||||
|
|
||||||
|
{/* Push notification permission prompt (shows after 3 agent responses) */}
|
||||||
|
<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />
|
||||||
</aside>
|
</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