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"} + + + ); +};