feat: add diff_status_change event to /chats/watch pubsub stream (#22419)

## Summary

Adds a new `diff_status_change` event kind to the `/chats/watch` pubsub
stream so the sidebar can update diff status (PR created, files changed,
branch info) without a full page reload.

### Problem

When a chat's diff status changes (e.g. PR created via GitHub, git
branch pushed), the sidebar didn't update because:
1. The backend `publishChatPubsubEvent` didn't include diff status data
2. The frontend watch handler only merged `status`, `title`, and
`updated_at` from events

### Solution

A **notify-only** approach: a new `ChatEventKindDiffStatusChange` event
kind tells the frontend "diff status changed for chat X" — the frontend
then invalidates the relevant React Query cache entries to re-fetch.

### Backend changes

- **`coderd/pubsub/chatevent.go`**: New `ChatEventKindDiffStatusChange =
"diff_status_change"` constant
- **`coderd/chatd/chatd.go`**: New `PublishDiffStatusChange(ctx,
chatID)` method on `Server`
- **`coderd/chats.go`**: New `publishChatDiffStatusEvent` helper.
Published from:
- `refreshWorkspaceChatDiffStatuses` — after each chat's diff status is
refreshed via GitHub API
- `storeChatGitRef` — after persisting git branch/origin info from
workspace agent

### Frontend changes

- **`AgentsPage.tsx`**: Handle `diff_status_change` event by
invalidating `chatDiffStatusKey` and `chatDiffContentsKey` queries
- **`ChatContext.ts`**: Remove redundant diff status invalidation that
fired on every chat status change (the new event kind handles this
properly)
This commit is contained in:
Kyle Carberry
2026-02-27 18:06:54 -05:00
committed by GitHub
parent b0c6a6dc25
commit 4b5ec8a9a4
5 changed files with 57 additions and 22 deletions
+18
View File
@@ -1430,6 +1430,24 @@ func (p *Server) publishChatPubsubEvent(chat database.Chat, kind coderdpubsub.Ch
}
}
// PublishDiffStatusChange broadcasts a diff_status_change event for
// the given chat so that watching clients know to re-fetch the diff
// status. This is called from the HTTP layer after the diff status
// is updated in the database.
func (p *Server) PublishDiffStatusChange(ctx context.Context, chatID uuid.UUID) error {
if p.pubsub == nil {
return nil
}
chat, err := p.db.GetChatByID(ctx, chatID)
if err != nil {
return xerrors.Errorf("get chat: %w", err)
}
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindDiffStatusChange)
return nil
}
func (p *Server) publishError(chatID uuid.UUID, message string) {
message = strings.TrimSpace(message)
if message == "" {
+16
View File
@@ -1068,7 +1068,9 @@ func (api *API) storeChatGitRef(ctx context.Context, workspaceID, workspaceOwner
slog.F("workspace_id", workspaceID),
slog.Error(err),
)
continue
}
api.publishChatDiffStatusEvent(ctx, chat.ID)
}
}
@@ -1109,6 +1111,7 @@ func (api *API) refreshWorkspaceChatDiffStatuses(ctx context.Context, workspaceI
}
api.publishChatStatusEvent(ctx, chat.ID)
api.publishChatDiffStatusEvent(ctx, chat.ID)
}
return allHavePR
@@ -1138,6 +1141,19 @@ func (api *API) publishChatStatusEvent(ctx context.Context, chatID uuid.UUID) {
}
}
func (api *API) publishChatDiffStatusEvent(ctx context.Context, chatID uuid.UUID) {
if api.chatDaemon == nil {
return
}
if err := api.chatDaemon.PublishDiffStatusChange(ctx, chatID); err != nil {
api.Logger.Debug(ctx, "failed to publish chat diff status change",
slog.F("chat_id", chatID),
slog.Error(err),
)
}
}
func (api *API) resolveChatDiffContents(
ctx context.Context,
chat database.Chat,
+5 -4
View File
@@ -39,8 +39,9 @@ type ChatEvent struct {
type ChatEventKind string
const (
ChatEventKindStatusChange ChatEventKind = "status_change"
ChatEventKindTitleChange ChatEventKind = "title_change"
ChatEventKindCreated ChatEventKind = "created"
ChatEventKindDeleted ChatEventKind = "deleted"
ChatEventKindStatusChange ChatEventKind = "status_change"
ChatEventKindTitleChange ChatEventKind = "title_change"
ChatEventKindCreated ChatEventKind = "created"
ChatEventKindDeleted ChatEventKind = "deleted"
ChatEventKindDiffStatusChange ChatEventKind = "diff_status_change"
)
@@ -1,10 +1,5 @@
import { watchChat } from "api/api";
import {
chatDiffContentsKey,
chatDiffStatusKey,
chatKey,
chatsKey,
} from "api/queries/chats";
import { chatKey, chatsKey } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils";
import {
@@ -614,7 +609,6 @@ export const useChatStore = (
continue;
}
const previousStatus = store.getSnapshot().chatStatus;
store.setChatStatus(nextStatus);
if (nextStatus === "pending" || nextStatus === "waiting") {
store.clearStreamState();
@@ -627,16 +621,6 @@ export const useChatStore = (
status: nextStatus,
updated_at: new Date().toISOString(),
}));
if (previousStatus !== nextStatus) {
void Promise.all([
queryClient.invalidateQueries({
queryKey: chatDiffStatusKey(chatID),
}),
queryClient.invalidateQueries({
queryKey: chatDiffContentsKey(chatID),
}),
]);
}
continue;
}
@@ -681,7 +665,6 @@ export const useChatStore = (
cancelScheduledStreamReset,
chatID,
clearChatErrorReason,
queryClient,
scheduleStreamReset,
setChatErrorReason,
store,
+17
View File
@@ -2,6 +2,8 @@ import { watchChats } from "api/api";
import { getErrorMessage } from "api/errors";
import {
archiveChat,
chatDiffContentsKey,
chatDiffStatusKey,
chatKey,
chatModelConfigs,
chatModels,
@@ -291,6 +293,21 @@ const AgentsPage: FC = () => {
return;
}
if (chatEvent.kind === "diff_status_change") {
void Promise.all([
queryClient.invalidateQueries({
queryKey: chatsKey,
}),
queryClient.invalidateQueries({
queryKey: chatDiffStatusKey(updatedChat.id),
}),
queryClient.invalidateQueries({
queryKey: chatDiffContentsKey(updatedChat.id),
}),
]);
return;
}
queryClient.setQueryData(
chatsKey,
(prev: TypesGen.Chat[] | undefined) => {