mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add chat read/unread indicator to sidebar (#23129)
## Summary Adds read/unread tracking for chats so users can see which agent conversations have new assistant messages they haven't viewed. ## Backend Changes - Adds `last_read_message_id` column to the `chats` table (migration 000439). - Computes `has_unread` as a virtual column in `GetChatsByOwnerID` using an `EXISTS` subquery checking for assistant messages beyond the read cursor. - Exposes `has_unread` on the `codersdk.Chat` struct and auto-generated TypeScript types. - Updates `last_read_message_id` on stream connect/disconnect in `streamChat`, avoiding per-message API calls during active streaming. - Uses `context.WithoutCancel` for the deferred disconnect write so the DB update succeeds even after the client disconnects. ## Frontend Changes - Bold title (`font-semibold`) for unread chats in the sidebar. - Small blue dot indicator next to the relative timestamp. - Suppresses unread indicator for the currently active chat via `isActive` from NavLink. ## Design Decisions - Only `assistant` messages count as unread — the user's own messages don't trigger the indicator. - No foreign key on `last_read_message_id` since messages can be deleted (via rollback/truncation) and the column is just a high-water mark. - Zero API calls during streaming: exactly 2 DB writes per stream session (connect + disconnect). - Unread state refreshes on chat list load and window focus. The `watchChats` WebSocket optimistically marks non-active chats as unread on `status_change` events, but does not carry a server-computed `has_unread` field. Navigating to a chat optimistically clears its unread indicator in the cache.
This commit is contained in:
@@ -1575,23 +1575,24 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
|
||||
return chat
|
||||
}
|
||||
|
||||
// Chats converts a slice of database.Chat to codersdk.Chat, looking
|
||||
// up diff statuses from the provided map. When diffStatusesByChatID
|
||||
// is non-nil, chats without an entry receive an empty DiffStatus.
|
||||
func Chats(chats []database.Chat, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
|
||||
result := make([]codersdk.Chat, len(chats))
|
||||
for i, c := range chats {
|
||||
diffStatus, ok := diffStatusesByChatID[c.ID]
|
||||
// ChatRows converts a slice of database.GetChatsRow (which embeds
|
||||
// Chat plus HasUnread) to codersdk.Chat, looking up diff statuses
|
||||
// from the provided map. When diffStatusesByChatID is non-nil,
|
||||
// chats without an entry receive an empty DiffStatus.
|
||||
func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
|
||||
result := make([]codersdk.Chat, len(rows))
|
||||
for i, row := range rows {
|
||||
diffStatus, ok := diffStatusesByChatID[row.Chat.ID]
|
||||
if ok {
|
||||
result[i] = Chat(c, &diffStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
result[i] = Chat(c, nil)
|
||||
if diffStatusesByChatID != nil {
|
||||
emptyDiffStatus := ChatDiffStatus(c.ID, nil)
|
||||
result[i].DiffStatus = &emptyDiffStatus
|
||||
result[i] = Chat(row.Chat, &diffStatus)
|
||||
} else {
|
||||
result[i] = Chat(row.Chat, nil)
|
||||
if diffStatusesByChatID != nil {
|
||||
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
|
||||
result[i].DiffStatus = &emptyDiffStatus
|
||||
}
|
||||
}
|
||||
result[i].HasUnread = row.HasUnread
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -554,8 +554,15 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
|
||||
|
||||
v := reflect.ValueOf(got)
|
||||
typ := v.Type()
|
||||
// HasUnread is populated by ChatRows (which joins the
|
||||
// read-cursor query), not by Chat, so it is expected
|
||||
// to remain zero here.
|
||||
skip := map[string]bool{"HasUnread": true}
|
||||
for i := range typ.NumField() {
|
||||
field := typ.Field(i)
|
||||
if skip[field.Name] {
|
||||
continue
|
||||
}
|
||||
require.False(t, v.Field(i).IsZero(),
|
||||
"codersdk.Chat field %q is zero-valued — db2sdk.Chat may not be populating it",
|
||||
field.Name,
|
||||
|
||||
Reference in New Issue
Block a user