diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 096b2856f3..c3c8aacfcf 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -1815,6 +1815,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) { Title: chat.Title, Body: "Agent has finished running.", Icon: "/favicon.ico", + Data: map[string]string{"url": fmt.Sprintf("/agents/%s", chat.ID)}, } if status == database.ChatStatusError { pushMsg.Body = "Agent encountered an error." diff --git a/coderd/chatd/chatd_test.go b/coderd/chatd/chatd_test.go index f297e27612..9d66fba899 100644 --- a/coderd/chatd/chatd_test.go +++ b/coderd/chatd/chatd_test.go @@ -1462,10 +1462,17 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) { // mockWebpushDispatcher implements webpush.Dispatcher and records Dispatch calls. type mockWebpushDispatcher struct { dispatchCount atomic.Int32 + mu sync.Mutex + lastMessage codersdk.WebpushMessage + lastUserID uuid.UUID } -func (m *mockWebpushDispatcher) Dispatch(_ context.Context, _ uuid.UUID, _ codersdk.WebpushMessage) error { +func (m *mockWebpushDispatcher) Dispatch(_ context.Context, userID uuid.UUID, msg codersdk.WebpushMessage) error { m.dispatchCount.Add(1) + m.mu.Lock() + m.lastMessage = msg + m.lastUserID = userID + m.mu.Unlock() return nil } @@ -1477,6 +1484,78 @@ func (*mockWebpushDispatcher) PublicKey() string { return "test-vapid-public-key" } +func TestSuccessfulChatSendsWebPushWithNavigationData(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // Set up a mock OpenAI that returns a simple successful response. + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("done")..., + ) + }) + + // Mock webpush dispatcher that captures the dispatched message. + mockPush := &mockWebpushDispatcher{} + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := chatd.New(chatd.Config{ + Logger: logger, + Database: db, + ReplicaID: uuid.New(), + Pubsub: ps, + PendingChatAcquireInterval: 10 * time.Millisecond, + InFlightChatStaleAfter: testutil.WaitSuperLong, + WebpushDispatcher: mockPush, + }) + t.Cleanup(func() { + require.NoError(t, server.Close()) + }) + + user, model := seedChatDependencies(ctx, t, db) + setOpenAIProviderBaseURL(ctx, t, db, openAIURL) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + Title: "push-nav-test", + ModelConfigID: model.ID, + InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}}, + }) + require.NoError(t, err) + + // Wait for the chat to complete and return to waiting status. + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + fromDB, dbErr := db.GetChatByID(ctx, chat.ID) + if dbErr != nil { + return false + } + return fromDB.Status == database.ChatStatusWaiting && !fromDB.WorkerID.Valid + }, testutil.IntervalFast) + + // Verify a web push notification was dispatched exactly once. + require.Equal(t, int32(1), mockPush.dispatchCount.Load(), + "expected exactly one web push dispatch for a completed chat") + + // Verify the notification was sent to the correct user. + mockPush.mu.Lock() + capturedMsg := mockPush.lastMessage + capturedUserID := mockPush.lastUserID + mockPush.mu.Unlock() + + require.Equal(t, user.ID, capturedUserID, + "web push should be dispatched to the chat owner") + + // Verify the Data field contains the correct navigation URL. + expectedURL := fmt.Sprintf("/agents/%s", chat.ID) + require.Equal(t, expectedURL, capturedMsg.Data["url"], + "web push Data should contain the chat navigation URL") +} + func TestCloseDuringShutdownContextCanceledShouldRetryOnNewReplica(t *testing.T) { t.Parallel() diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 9128c4cce2..76479cd15d 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -225,6 +225,7 @@ type WebpushMessage struct { Title string `json:"title"` Body string `json:"body"` Actions []WebpushMessageAction `json:"actions"` + Data map[string]string `json:"data,omitempty"` } type WebpushSubscription struct { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6b40d18bb9..0d74a0705b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6799,6 +6799,7 @@ export interface WebpushMessage { readonly title: string; readonly body: string; readonly actions: readonly WebpushMessageAction[]; + readonly data?: Record; } // From codersdk/notifications.go diff --git a/site/src/serviceWorker.ts b/site/src/serviceWorker.ts index f61bdfc90d..c8200ff2a0 100644 --- a/site/src/serviceWorker.ts +++ b/site/src/serviceWorker.ts @@ -29,23 +29,26 @@ self.addEventListener("push", (event) => { self.registration.showNotification(payload.title, { body: payload.body || "", icon: payload.icon || "/favicon.ico", + data: payload.data, }), ); }); -// Handle notification click — navigate to the agents page +// Handle notification click — navigate to the specific chat or agents page. self.addEventListener("notificationclick", (event) => { event.notification.close(); + const targetUrl: string = event.notification.data?.url || "/agents"; event.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) .then((clientList) => { for (const client of clientList) { if (client.url.includes("/agents") && "focus" in client) { + client.navigate(targetUrl); return client.focus(); } } - return self.clients.openWindow("/agents"); + return self.clients.openWindow(targetUrl); }), ); });