mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## 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>
114 lines
3.0 KiB
TypeScript
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,
|
|
};
|
|
};
|