mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user