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:
@@ -1311,7 +1311,7 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, memberRows, 2)
|
||||
for _, row := range memberRows {
|
||||
require.Equal(t, member.ID, row.OwnerID, "member should only see own chats")
|
||||
require.Equal(t, member.ID, row.Chat.OwnerID, "member should only see own chats")
|
||||
}
|
||||
|
||||
// Owner should see at least the 5 pre-created chats (site-wide
|
||||
@@ -1381,7 +1381,7 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, memberRows, 2)
|
||||
for _, row := range memberRows {
|
||||
require.Equal(t, member.ID, row.OwnerID, "member should only see own chats")
|
||||
require.Equal(t, member.ID, row.Chat.OwnerID, "member should only see own chats")
|
||||
}
|
||||
|
||||
// As owner: should see at least the 5 pre-created chats.
|
||||
@@ -1429,13 +1429,13 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page1, 2)
|
||||
for _, row := range page1 {
|
||||
require.Equal(t, paginationUser.ID, row.OwnerID, "paginated results must belong to pagination user")
|
||||
require.Equal(t, paginationUser.ID, row.Chat.OwnerID, "paginated results must belong to pagination user")
|
||||
}
|
||||
|
||||
// Fetch remaining pages and collect all chat IDs.
|
||||
allIDs := make(map[uuid.UUID]struct{})
|
||||
for _, row := range page1 {
|
||||
allIDs[row.ID] = struct{}{}
|
||||
allIDs[row.Chat.ID] = struct{}{}
|
||||
}
|
||||
offset := int32(2)
|
||||
for {
|
||||
@@ -1445,8 +1445,8 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
}, preparedMember)
|
||||
require.NoError(t, err)
|
||||
for _, row := range page {
|
||||
require.Equal(t, paginationUser.ID, row.OwnerID, "paginated results must belong to pagination user")
|
||||
allIDs[row.ID] = struct{}{}
|
||||
require.Equal(t, paginationUser.ID, row.Chat.OwnerID, "paginated results must belong to pagination user")
|
||||
allIDs[row.Chat.ID] = struct{}{}
|
||||
}
|
||||
if len(page) < 2 {
|
||||
break
|
||||
@@ -10849,7 +10849,7 @@ func TestChatLabels(t *testing.T) {
|
||||
|
||||
titles := make([]string, 0, len(results))
|
||||
for _, c := range results {
|
||||
titles = append(titles, c.Title)
|
||||
titles = append(titles, c.Chat.Title)
|
||||
}
|
||||
require.Contains(t, titles, "filter-a")
|
||||
require.Contains(t, titles, "filter-b")
|
||||
@@ -10867,8 +10867,7 @@ func TestChatLabels(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "filter-a", results[0].Title)
|
||||
|
||||
require.Equal(t, "filter-a", results[0].Chat.Title)
|
||||
// No filter — should return all chats for this owner.
|
||||
allChats, err := db.GetChats(ctx, database.GetChatsParams{
|
||||
OwnerID: owner.ID,
|
||||
@@ -10877,3 +10876,121 @@ func TestChatLabels(t *testing.T) {
|
||||
require.GreaterOrEqual(t, len(allChats), 3)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatHasUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
dbgen.Organization(t, store, database.Organization{})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
|
||||
_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
Provider: "openai",
|
||||
Model: "test-model-" + uuid.NewString(),
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 80,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "test-chat-" + uuid.NewString(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
getHasUnread := func() bool {
|
||||
rows, err := store.GetChats(ctx, database.GetChatsParams{
|
||||
OwnerID: user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for _, row := range rows {
|
||||
if row.Chat.ID == chat.ID {
|
||||
return row.HasUnread
|
||||
}
|
||||
}
|
||||
t.Fatal("chat not found in GetChats result")
|
||||
return false
|
||||
}
|
||||
|
||||
// New chat with no messages: not unread.
|
||||
require.False(t, getHasUnread(), "new chat with no messages should not be unread")
|
||||
|
||||
// Helper to insert a single chat message.
|
||||
insertMsg := func(role database.ChatMessageRole, text string) {
|
||||
t.Helper()
|
||||
_, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{
|
||||
ChatID: chat.ID,
|
||||
CreatedBy: []uuid.UUID{user.ID},
|
||||
ModelConfigID: []uuid.UUID{modelCfg.ID},
|
||||
Role: []database.ChatMessageRole{role},
|
||||
Content: []string{fmt.Sprintf(`[{"type":"text","text":%q}]`, text)},
|
||||
ContentVersion: []int16{0},
|
||||
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
|
||||
InputTokens: []int64{0},
|
||||
OutputTokens: []int64{0},
|
||||
TotalTokens: []int64{0},
|
||||
ReasoningTokens: []int64{0},
|
||||
CacheCreationTokens: []int64{0},
|
||||
CacheReadTokens: []int64{0},
|
||||
ContextLimit: []int64{0},
|
||||
Compressed: []bool{false},
|
||||
TotalCostMicros: []int64{0},
|
||||
RuntimeMs: []int64{0},
|
||||
ProviderResponseID: []string{""},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Insert an assistant message: becomes unread.
|
||||
insertMsg(database.ChatMessageRoleAssistant, "hello")
|
||||
require.True(t, getHasUnread(), "chat with unread assistant message should be unread")
|
||||
|
||||
// Mark as read: no longer unread.
|
||||
lastMsg, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
|
||||
ChatID: chat.ID,
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = store.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
|
||||
ID: chat.ID,
|
||||
LastReadMessageID: lastMsg.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, getHasUnread(), "chat should not be unread after marking as read")
|
||||
|
||||
// Insert another assistant message: becomes unread again.
|
||||
insertMsg(database.ChatMessageRoleAssistant, "new message")
|
||||
require.True(t, getHasUnread(), "new assistant message after read should be unread")
|
||||
|
||||
// Mark as read again, then verify user messages don't
|
||||
// trigger unread.
|
||||
lastMsg, err = store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
|
||||
ChatID: chat.ID,
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = store.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
|
||||
ID: chat.ID,
|
||||
LastReadMessageID: lastMsg.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
insertMsg(database.ChatMessageRoleUser, "user msg")
|
||||
require.False(t, getHasUnread(), "user messages should not trigger unread")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user