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:
Nexus Dev 2026-04-02 02:32:11 +00:00
parent 66bbfbf766
commit 862cf7fef3
6 changed files with 188 additions and 1 deletions

8
pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -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
View 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}`);
}
});
},
};

View file

@ -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>
);
}

View 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>
);
}

View 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 };
}