fix: re-read chat before publishing status event to preserve AI title (#22402)

## Problem

Chat titles revert to the fallback truncated title after briefly showing
the AI-generated title. Even reloading the page doesn't help — the
correct title flashes then gets overwritten.

## Root Cause

Single bug, two symptoms.

In `processChat` (`coderd/chatd/chatd.go`), the `chat` variable is
passed by value. The flow:

1. `processChat(ctx, chat)` receives `chat` with the initial fallback
title (truncated first message).
2. Inside `runChat`, `maybeGenerateChatTitle` generates an AI title,
writes it to the DB via `UpdateChatByID`, and publishes a `title_change`
event. **The DB has the correct title.** The client briefly displays it.
3. `runChat` returns. The **deferred cleanup** in `processChat`
publishes `publishChatPubsubEvent(chat, StatusChange)` — but `chat` here
is the original value copy that still has the **old fallback title**.
4. The frontend receives the `status_change` SSE event and
**unconditionally applies `title` from every event kind** (see
`AgentsPage.tsx` line ~305: `title: updatedChat.title`). This overwrites
the correct AI title with the stale fallback.

**Why reload doesn't help:** If the chat is still processing when the
page reloads, `listChats` loads the correct title from the DB, but then
the deferred `status_change` event arrives moments later and clobbers
it. The title was always in the DB — it was the pubsub event that kept
overwriting it.

## Fix

Re-read the chat from the database in the deferred cleanup before
publishing the final `status_change` event, so it carries the current
(AI-generated) title.
This commit is contained in:
Kyle Carberry
2026-02-27 15:06:36 -05:00
committed by GitHub
parent d4cfb24a4a
commit ff687aa780
+14 -1
View File
@@ -1689,6 +1689,16 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
}
p.publishStatus(chat.ID, status, uuid.NullUUID{})
// Re-read the chat from the database to pick up any title
// changes made during processing (e.g. AI-generated titles
// from maybeGenerateChatTitle). The local `chat` variable
// is a value copy and won't reflect updates made in runChat.
if freshChat, readErr := p.db.GetChatByID(ctx, chat.ID); readErr == nil {
chat = freshChat
} else {
logger.Warn(ctx, "failed to re-read chat for status event",
slog.F("chat_id", chat.ID), slog.Error(readErr))
}
chat.Status = status
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindStatusChange)
}()
@@ -1729,7 +1739,10 @@ func (p *Server) runChat(
if err != nil {
return xerrors.Errorf("get chat messages: %w", err)
}
p.maybeGenerateChatTitle(ctx, chat, messages, model, logger)
// Fire title generation asynchronously so it doesn't block the
// chat response. It uses a detached context so it can finish
// even after the chat processing context is canceled.
go p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, logger)
prompt, err := chatprompt.ConvertMessages(messages)
if err != nil {