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>
This commit is contained in:
Kyle Carberry
2026-02-28 23:40:17 -05:00
committed by GitHub
co-authored by Coder
parent 68e4155fed
commit c9ed1e17fc
6 changed files with 102 additions and 0 deletions
+32
View File
@@ -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 {
+1
View File
@@ -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)
@@ -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);
}
@@ -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<AgentDetailTopBarProps> = ({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<WebPushButton />
</div>
<div className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
<UserDropdown
+2
View File
@@ -53,6 +53,7 @@ import {
hasConfiguredModelsInCatalog,
} from "./modelOptions";
import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings";
import { WebPushButton } from "./WebPushButton";
const emptyInputStorageKey = "agents.empty-input";
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
@@ -434,6 +435,7 @@ const AgentsPage: FC = () => {
)}
<div className="flex min-w-0 flex-1 items-center" />
<div className="flex items-center gap-2">
<WebPushButton />
{isAgentsAdmin && (
<Button
variant="subtle"
@@ -0,0 +1,62 @@
import { getErrorMessage } from "api/errors";
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useWebpushNotifications } from "contexts/useWebpushNotifications";
import { BellIcon, BellOffIcon, Loader2Icon } from "lucide-react";
import type { FC } from "react";
import { toast } from "sonner";
export const WebPushButton: FC = () => {
const webPush = useWebpushNotifications();
if (!webPush.enabled) {
return null;
}
const handleClick = async () => {
try {
if (webPush.subscribed) {
await webPush.unsubscribe();
toast.success("Notifications disabled.");
} else {
await webPush.subscribe();
toast.success("Notifications enabled.");
}
} catch (error) {
if (webPush.subscribed) {
toast.error(getErrorMessage(error, "Failed to disable notifications."));
} else {
toast.error(getErrorMessage(error, "Failed to enable notifications."));
}
}
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
disabled={webPush.loading}
onClick={handleClick}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{webPush.loading ? (
<Loader2Icon className="animate-spin" />
) : webPush.subscribed ? (
<BellIcon className="text-content-success" />
) : (
<BellOffIcon className="text-content-secondary" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{webPush.subscribed ? "Disable notifications" : "Enable notifications"}
</TooltipContent>
</Tooltip>
);
};