Files
coder/site/src/contexts/useWebpushNotifications.ts
T
Kyle CarberryandCoder c9ed1e17fc feat(agents): add desktop notifications via VAPID web push (#22454)
## Summary

Wire VAPID web push notifications into the Agents (chat) system so users
get desktop notifications when an agent finishes running.

### Backend

- Add `webpush.Dispatcher` to `chatd.Server` and pass it through from
`coderd.Options.WebPushDispatcher`
- In `processChat()`'s deferred cleanup, dispatch a web push
notification when the chat reaches a terminal state:
  - **`waiting`** (success): "Agent has finished running."
- **`error`** (failure): the error message, or "Agent encountered an
error."
- Sub-agent chats (`ParentChatID.Valid`) are skipped to avoid
notification spam from internal delegation
- Gracefully no-ops when the dispatcher is nil (web push disabled)

### Frontend

- New `WebPushButton` component — a bell icon that uses the existing
`useWebpushNotifications` hook
  - Returns `null` when the `web-push` experiment is off
- Three states: loading spinner, green bell (subscribed), muted bell-off
(unsubscribed)
  - Tooltip + toast feedback on toggle
- Added to both the Agents page empty state top bar and the AgentDetail
top bar
- The Agents page has its own layout (no standard Navbar), so it needs
its own subscribe button

### End-to-end flow

1. User clicks the bell icon on `/agents` → browser subscribes via VAPID
2. User starts an agent chat → chat enters `running` status
3. Agent finishes → `processChat` defer sets status to `waiting`/`error`
→ dispatches web push
4. Browser service worker shows a desktop notification with the chat
title and status

---------

Co-authored-by: Coder <coder@users.noreply.github.com>
2026-02-28 23:40:17 -05:00

114 lines
3.0 KiB
TypeScript

import { API } from "api/api";
import { buildInfo } from "api/queries/buildInfo";
import { experiments } from "api/queries/experiments";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { useEffect, useState } from "react";
import { useQuery } from "react-query";
interface WebpushNotifications {
readonly enabled: boolean;
readonly subscribed: boolean;
readonly loading: boolean;
subscribe(): Promise<void>;
unsubscribe(): Promise<void>;
}
export const useWebpushNotifications = (): WebpushNotifications => {
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const enabledExperimentsQuery = useQuery(experiments(metadata.experiments));
const [subscribed, setSubscribed] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [enabled, setEnabled] = useState<boolean>(false);
useEffect(() => {
// Check if the experiment is enabled.
if (enabledExperimentsQuery.data?.includes("web-push")) {
setEnabled(true);
}
// Check if browser supports push notifications
if (!("Notification" in window) || !("serviceWorker" in navigator)) {
setSubscribed(false);
setLoading(false);
return;
}
const checkSubscription = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setSubscribed(!!subscription);
} catch (error) {
console.error("Error checking push subscription:", error);
setSubscribed(false);
} finally {
setLoading(false);
}
};
checkSubscription();
}, [enabledExperimentsQuery.data]);
const subscribe = async (): Promise<void> => {
try {
setLoading(true);
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = buildInfoQuery.data?.webpush_public_key;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
const json = subscription.toJSON();
if (!json.keys || !json.endpoint) {
throw new Error("No keys or endpoint found");
}
await API.createWebPushSubscription("me", {
endpoint: json.endpoint,
auth_key: json.keys.auth,
p256dh_key: json.keys.p256dh,
});
setSubscribed(true);
} catch (error) {
console.error("Subscription failed:", error);
throw error;
} finally {
setLoading(false);
}
};
const unsubscribe = async (): Promise<void> => {
try {
setLoading(true);
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await API.deleteWebPushSubscription("me", {
endpoint: subscription.endpoint,
});
await subscription.unsubscribe();
setSubscribed(false);
}
} catch (error) {
console.error("Unsubscription failed:", error);
throw error;
} finally {
setLoading(false);
}
};
return {
subscribed,
enabled,
loading: loading || buildInfoQuery.isLoading,
subscribe,
unsubscribe,
};
};