From c9ed1e17fc8d73f5040246e101a50196eb53e77d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 28 Feb 2026 23:40:17 -0500 Subject: [PATCH] feat(agents): add desktop notifications via VAPID web push (#22454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- coderd/chatd/chatd.go | 32 ++++++++++ coderd/coderd.go | 1 + site/src/contexts/useWebpushNotifications.ts | 3 + .../pages/AgentsPage/AgentDetail/TopBar.tsx | 2 + site/src/pages/AgentsPage/AgentsPage.tsx | 2 + site/src/pages/AgentsPage/WebPushButton.tsx | 62 +++++++++++++++++++ 6 files changed, 102 insertions(+) create mode 100644 site/src/pages/AgentsPage/WebPushButton.tsx diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 06fa5b7f8d..8cd5d3149f 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -26,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -66,6 +67,7 @@ type Server struct { agentConnFn AgentConnFunc createWorkspaceFn chattool.CreateWorkspaceFn pubsub pubsub.Pubsub + webpushDispatcher webpush.Dispatcher providerAPIKeys chatprovider.ProviderAPIKeys // streamMu guards chatStreams which tracks in-flight chat @@ -842,6 +844,7 @@ type Config struct { CreateWorkspace chattool.CreateWorkspaceFn Pubsub pubsub.Pubsub ProviderAPIKeys chatprovider.ProviderAPIKeys + WebpushDispatcher webpush.Dispatcher } // New creates a new chat processor. The processor polls for pending @@ -875,6 +878,7 @@ func New(cfg Config) *Server { agentConnFn: cfg.AgentConn, createWorkspaceFn: cfg.CreateWorkspace, pubsub: cfg.Pubsub, + webpushDispatcher: cfg.WebpushDispatcher, providerAPIKeys: cfg.ProviderAPIKeys, chatStreams: make(map[uuid.UUID]*chatStreamState), instructionCache: make(map[uuid.UUID]cachedInstruction), @@ -1749,6 +1753,34 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) { } chat.Status = status p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindStatusChange) + + // Send a web push notification when the agent finishes + // processing. We only notify for terminal states (waiting + // = success, error = failure) and skip sub-agent chats to + // avoid spamming the user with notifications for internal + // delegation. + if p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != "" && !chat.ParentChatID.Valid { + if status == database.ChatStatusWaiting || status == database.ChatStatusError { + pushMsg := codersdk.WebpushMessage{ + Title: chat.Title, + Body: "Agent has finished running.", + Icon: "/favicon.ico", + } + if status == database.ChatStatusError { + pushMsg.Body = "Agent encountered an error." + if lastError != "" { + pushMsg.Body = lastError + } + } + if err := p.webpushDispatcher.Dispatch(cleanupCtx, chat.OwnerID, pushMsg); err != nil { + logger.Warn(cleanupCtx, "failed to send chat completion web push", + slog.F("chat_id", chat.ID), + slog.F("status", status), + slog.Error(err), + ) + } + } + } }() if err := p.runChat(chatCtx, chat, logger); err != nil { diff --git a/coderd/coderd.go b/coderd/coderd.go index 019b1967b3..5cffb32319 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -767,6 +767,7 @@ func New(options *Options) *API { AgentConn: api.agentProvider.AgentConn, CreateWorkspace: api.chatCreateWorkspace, Pubsub: options.Pubsub, + WebpushDispatcher: options.WebPushDispatcher, }) if options.DeploymentValues.Prometheus.Enable { options.PrometheusRegistry.MustRegister(stn) diff --git a/site/src/contexts/useWebpushNotifications.ts b/site/src/contexts/useWebpushNotifications.ts index 0f3949135c..74453a7003 100644 --- a/site/src/contexts/useWebpushNotifications.ts +++ b/site/src/contexts/useWebpushNotifications.ts @@ -89,6 +89,9 @@ export const useWebpushNotifications = (): WebpushNotifications => { const subscription = await registration.pushManager.getSubscription(); if (subscription) { + await API.deleteWebPushSubscription("me", { + endpoint: subscription.endpoint, + }); await subscription.unsubscribe(); setSubscribed(false); } diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index 12267d3918..e914766b08 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -23,6 +23,7 @@ import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; import { useNavigate } from "react-router"; +import { WebPushButton } from "../WebPushButton"; interface DiffStatsBadgeProps { status: ChatDiffStatusResponse; @@ -200,6 +201,7 @@ export const AgentDetailTopBar: FC = ({ +
{ )}
+ {isAgentsAdmin && ( + + + {webPush.subscribed ? "Disable notifications" : "Enable notifications"} + + + ); +};