From bcdc35ee3ed4f0d6e068c34fffd33825164fd9c1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 27 Mar 2026 12:15:04 -0400 Subject: [PATCH] feat: add chat read/unread indicator to sidebar (#23129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- coderd/database/db2sdk/db2sdk.go | 31 ++-- coderd/database/db2sdk/db2sdk_test.go | 7 + coderd/database/dbauthz/dbauthz.go | 15 +- coderd/database/dbauthz/dbauthz_test.go | 14 +- coderd/database/dbmetrics/querymetrics.go | 12 +- coderd/database/dbmock/dbmock.go | 22 ++- coderd/database/dump.sql | 3 +- .../000455_chat_last_read_message_id.down.sql | 1 + .../000455_chat_last_read_message_id.up.sql | 9 ++ coderd/database/modelmethods.go | 4 + coderd/database/modelqueries.go | 53 +++---- coderd/database/models.go | 1 + coderd/database/querier.go | 5 +- coderd/database/querier_test.go | 135 ++++++++++++++++-- coderd/database/queries.sql.go | 124 +++++++++++----- coderd/database/queries/chats.sql | 16 ++- coderd/exp_chats.go | 51 ++++++- coderd/x/chatd/chatd_test.go | 4 +- coderd/x/gitsync/worker.go | 9 +- coderd/x/gitsync/worker_test.go | 22 +-- codersdk/chats.go | 4 + site/src/api/queries/chats.test.ts | 1 + site/src/api/typesGenerated.ts | 6 + .../pages/AgentsPage/AgentDetail.stories.tsx | 1 + site/src/pages/AgentsPage/AgentsPage.tsx | 32 ++++- .../AgentsPage/AgentsPageView.stories.tsx | 1 + .../AgentDetail/ChatContext.test.tsx | 1 + .../components/AgentDetail/TopBar.stories.tsx | 1 + .../components/AgentDetailView.stories.tsx | 1 + .../Sidebar/AgentsSidebar.stories.tsx | 64 +++++++++ .../components/Sidebar/AgentsSidebar.test.tsx | 1 + .../components/Sidebar/AgentsSidebar.tsx | 19 ++- 32 files changed, 547 insertions(+), 123 deletions(-) create mode 100644 coderd/database/migrations/000455_chat_last_read_message_id.down.sql create mode 100644 coderd/database/migrations/000455_chat_last_read_message_id.up.sql diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 7f3a7d8c0c..b1af71aabd 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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 } diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 0695ea903b..1a3d3b3c9d 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -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, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index fba344a914..ea3284664b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2748,7 +2748,7 @@ func (q *querier) GetChatWorkspaceTTL(ctx context.Context) (string, error) { return q.db.GetChatWorkspaceTTL(ctx) } -func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) { +func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChat.Type) if err != nil { return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) @@ -5755,6 +5755,17 @@ func (q *querier) UpdateChatLastModelConfigByID(ctx context.Context, arg databas return q.db.UpdateChatLastModelConfigByID(ctx, arg) } +func (q *querier) UpdateChatLastReadMessageID(ctx context.Context, arg database.UpdateChatLastReadMessageIDParams) error { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.UpdateChatLastReadMessageID(ctx, arg) +} + func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) { chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { @@ -7303,6 +7314,6 @@ func (q *querier) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg return q.db.ListAuthorizedAIBridgeSessionThreads(ctx, arg, prepared) } -func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.Chat, error) { +func (q *querier) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, _ rbac.PreparedAuthorized) ([]database.GetChatsRow, error) { return q.GetChats(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f0cf24c9c2..a76e892b44 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -658,13 +658,13 @@ func (s *MethodTestSuite) TestChats() { })) s.Run("GetChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { params := database.GetChatsParams{} - dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes() // No asserts here because SQLFilter. check.Args(params).Asserts() })) s.Run("GetAuthorizedChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { params := database.GetChatsParams{} - dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.Chat{}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes() // No asserts here because it re-routes through GetChats which uses SQLFilter. check.Args(params, emptyPreparedAuthorized{}).Asserts() })) @@ -1204,6 +1204,16 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatMCPServerIDs(gomock.Any(), arg).Return(chat, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat) })) + s.Run("UpdateChatLastReadMessageID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatLastReadMessageIDParams{ + ID: chat.ID, + LastReadMessageID: 42, + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatLastReadMessageID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + })) s.Run("UpdateMCPServerConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) arg := database.UpdateMCPServerConfigParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 64882c15b7..1b8707a08a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1272,7 +1272,7 @@ func (m queryMetricsStore) GetChatWorkspaceTTL(ctx context.Context) (string, err return r0, r1 } -func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) { +func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) { start := time.Now() r0, r1 := m.s.GetChats(ctx, arg) m.queryLatencies.WithLabelValues("GetChats").Observe(time.Since(start).Seconds()) @@ -4112,6 +4112,14 @@ func (m queryMetricsStore) UpdateChatLastModelConfigByID(ctx context.Context, ar return r0, r1 } +func (m queryMetricsStore) UpdateChatLastReadMessageID(ctx context.Context, arg database.UpdateChatLastReadMessageIDParams) error { + start := time.Now() + r0 := m.s.UpdateChatLastReadMessageID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatLastReadMessageID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastReadMessageID").Inc() + return r0 +} + func (m queryMetricsStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) { start := time.Now() r0, r1 := m.s.UpdateChatMCPServerIDs(ctx, arg) @@ -5312,7 +5320,7 @@ func (m queryMetricsStore) ListAuthorizedAIBridgeSessionThreads(ctx context.Cont return r0, r1 } -func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.Chat, error) { +func (m queryMetricsStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedChats(ctx, arg, prepared) m.queryLatencies.WithLabelValues("GetAuthorizedChats").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 31b7843e74..3ea7b17ce7 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1804,10 +1804,10 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared } // GetAuthorizedChats mocks base method. -func (m *MockStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.Chat, error) { +func (m *MockStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAuthorizedChats", ctx, arg, prepared) - ret0, _ := ret[0].([]database.Chat) + ret0, _ := ret[0].([]database.GetChatsRow) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2344,10 +2344,10 @@ func (mr *MockStoreMockRecorder) GetChatWorkspaceTTL(ctx any) *gomock.Call { } // GetChats mocks base method. -func (m *MockStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.Chat, error) { +func (m *MockStore) GetChats(ctx context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetChats", ctx, arg) - ret0, _ := ret[0].([]database.Chat) + ret0, _ := ret[0].([]database.GetChatsRow) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -7775,6 +7775,20 @@ func (mr *MockStoreMockRecorder) UpdateChatLastModelConfigByID(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastModelConfigByID", reflect.TypeOf((*MockStore)(nil).UpdateChatLastModelConfigByID), ctx, arg) } +// UpdateChatLastReadMessageID mocks base method. +func (m *MockStore) UpdateChatLastReadMessageID(ctx context.Context, arg database.UpdateChatLastReadMessageIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatLastReadMessageID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChatLastReadMessageID indicates an expected call of UpdateChatLastReadMessageID. +func (mr *MockStoreMockRecorder) UpdateChatLastReadMessageID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastReadMessageID", reflect.TypeOf((*MockStore)(nil).UpdateChatLastReadMessageID), ctx, arg) +} + // UpdateChatMCPServerIDs mocks base method. func (m *MockStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4f2bad4f45..4205a31566 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1402,7 +1402,8 @@ CREATE TABLE chats ( labels jsonb DEFAULT '{}'::jsonb NOT NULL, build_id uuid, agent_id uuid, - pin_order integer DEFAULT 0 NOT NULL + pin_order integer DEFAULT 0 NOT NULL, + last_read_message_id bigint ); CREATE TABLE connection_logs ( diff --git a/coderd/database/migrations/000455_chat_last_read_message_id.down.sql b/coderd/database/migrations/000455_chat_last_read_message_id.down.sql new file mode 100644 index 0000000000..e2cf40c6b4 --- /dev/null +++ b/coderd/database/migrations/000455_chat_last_read_message_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE chats DROP COLUMN last_read_message_id; diff --git a/coderd/database/migrations/000455_chat_last_read_message_id.up.sql b/coderd/database/migrations/000455_chat_last_read_message_id.up.sql new file mode 100644 index 0000000000..f6527f16a1 --- /dev/null +++ b/coderd/database/migrations/000455_chat_last_read_message_id.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE chats ADD COLUMN last_read_message_id BIGINT; + +-- Backfill existing chats so they don't appear unread after deploy. +-- The has_unread query uses COALESCE(last_read_message_id, 0), so +-- leaving this NULL would mark every existing chat as unread. +UPDATE chats SET last_read_message_id = ( + SELECT MAX(cm.id) FROM chat_messages cm + WHERE cm.chat_id = chats.id AND cm.role = 'assistant' AND cm.deleted = false +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 1fe5ae7e36..3cf68883c3 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -178,6 +178,10 @@ func (c Chat) RBACObject() rbac.Object { return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()) } +func (r GetChatsRow) RBACObject() rbac.Object { + return r.Chat.RBACObject() +} + func (c ChatFile) RBACObject() rbac.Object { return rbac.ResourceChat.WithID(c.ID).WithOwner(c.OwnerID.String()).InOrg(c.OrganizationID) } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 69353a8dcb..9e2f00fc4f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -741,10 +741,10 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun } type chatQuerier interface { - GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]Chat, error) + GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error) } -func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]Chat, error) { +func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, prepared rbac.PreparedAuthorized) ([]GetChatsRow, error) { authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChats()) if err != nil { return nil, xerrors.Errorf("compile authorized filter: %w", err) @@ -769,32 +769,33 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, return nil, err } defer rows.Close() - var items []Chat + var items []GetChatsRow for rows.Next() { - var i Chat + var i GetChatsRow if err := rows.Scan( - &i.ID, - &i.OwnerID, - &i.WorkspaceID, - &i.Title, - &i.Status, - &i.WorkerID, - &i.StartedAt, - &i.HeartbeatAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentChatID, - &i.RootChatID, - &i.LastModelConfigID, - &i.Archived, - &i.LastError, - &i.Mode, - pq.Array(&i.MCPServerIDs), - &i.Labels, - &i.BuildID, - &i.AgentID, - &i.PinOrder, - ); err != nil { + &i.Chat.ID, + &i.Chat.OwnerID, + &i.Chat.WorkspaceID, + &i.Chat.Title, + &i.Chat.Status, + &i.Chat.WorkerID, + &i.Chat.StartedAt, + &i.Chat.HeartbeatAt, + &i.Chat.CreatedAt, + &i.Chat.UpdatedAt, + &i.Chat.ParentChatID, + &i.Chat.RootChatID, + &i.Chat.LastModelConfigID, + &i.Chat.Archived, + &i.Chat.LastError, + &i.Chat.Mode, + pq.Array(&i.Chat.MCPServerIDs), + &i.Chat.Labels, + &i.Chat.BuildID, + &i.Chat.AgentID, + &i.Chat.PinOrder, + &i.Chat.LastReadMessageID, + &i.HasUnread); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/models.go b/coderd/database/models.go index fb168304eb..7bd3c811e5 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4174,6 +4174,7 @@ type Chat struct { BuildID uuid.NullUUID `db:"build_id" json:"build_id"` AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` PinOrder int32 `db:"pin_order" json:"pin_order"` + LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` } type ChatDiffStatus struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4d5bafea57..d45b564173 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -275,7 +275,7 @@ type sqlcQuerier interface { // Returns the global TTL for chat workspaces as a Go duration string. // Returns "0s" (disabled) when no value has been configured. GetChatWorkspaceTTL(ctx context.Context) (string, error) - GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, error) + GetChats(ctx context.Context, arg GetChatsParams) ([]GetChatsRow, error) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]Chat, error) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) @@ -854,6 +854,9 @@ type sqlcQuerier interface { UpdateChatHeartbeat(ctx context.Context, arg UpdateChatHeartbeatParams) (int64, error) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLabelsByIDParams) (Chat, error) UpdateChatLastModelConfigByID(ctx context.Context, arg UpdateChatLastModelConfigByIDParams) (Chat, error) + // Updates the last read message ID for a chat. This is used to track + // which messages the owner has seen, enabling unread indicators. + UpdateChatLastReadMessageID(ctx context.Context, arg UpdateChatLastReadMessageIDParams) error UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index dcc7ee0541..fa254fb627 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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") +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ac3596f3e4..4280a44e68 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4013,7 +4013,7 @@ WHERE $3::int ) RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type AcquireChatsParams struct { @@ -4055,6 +4055,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ); err != nil { return nil, err } @@ -4288,7 +4289,7 @@ func (q *sqlQuerier) DeleteChatUsageLimitUserOverride(ctx context.Context, userI const getChatByID = `-- name: GetChatByID :one SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id FROM chats WHERE @@ -4320,12 +4321,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order FROM chats WHERE id = $1::uuid FOR UPDATE +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id FROM chats WHERE id = $1::uuid FOR UPDATE ` func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) { @@ -4353,6 +4355,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -5264,7 +5267,14 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u const getChats = `-- name: GetChats :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, + EXISTS ( + SELECT 1 FROM chat_messages cm + WHERE cm.chat_id = chats.id + AND cm.role = 'assistant' + AND cm.deleted = false + AND cm.id > COALESCE(chats.last_read_message_id, 0) + ) AS has_unread FROM chats WHERE @@ -5320,7 +5330,12 @@ type GetChatsParams struct { LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } -func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, error) { +type GetChatsRow struct { + Chat Chat `db:"chat" json:"chat"` + HasUnread bool `db:"has_unread" json:"has_unread"` +} + +func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetChatsRow, error) { rows, err := q.db.QueryContext(ctx, getChats, arg.OwnerID, arg.Archived, @@ -5333,31 +5348,33 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, return nil, err } defer rows.Close() - var items []Chat + var items []GetChatsRow for rows.Next() { - var i Chat + var i GetChatsRow if err := rows.Scan( - &i.ID, - &i.OwnerID, - &i.WorkspaceID, - &i.Title, - &i.Status, - &i.WorkerID, - &i.StartedAt, - &i.HeartbeatAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentChatID, - &i.RootChatID, - &i.LastModelConfigID, - &i.Archived, - &i.LastError, - &i.Mode, - pq.Array(&i.MCPServerIDs), - &i.Labels, - &i.BuildID, - &i.AgentID, - &i.PinOrder, + &i.Chat.ID, + &i.Chat.OwnerID, + &i.Chat.WorkspaceID, + &i.Chat.Title, + &i.Chat.Status, + &i.Chat.WorkerID, + &i.Chat.StartedAt, + &i.Chat.HeartbeatAt, + &i.Chat.CreatedAt, + &i.Chat.UpdatedAt, + &i.Chat.ParentChatID, + &i.Chat.RootChatID, + &i.Chat.LastModelConfigID, + &i.Chat.Archived, + &i.Chat.LastError, + &i.Chat.Mode, + pq.Array(&i.Chat.MCPServerIDs), + &i.Chat.Labels, + &i.Chat.BuildID, + &i.Chat.AgentID, + &i.Chat.PinOrder, + &i.Chat.LastReadMessageID, + &i.HasUnread, ); err != nil { return nil, err } @@ -5373,7 +5390,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, } const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id FROM chats WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -5411,6 +5428,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ); err != nil { return nil, err } @@ -5476,7 +5494,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh const getStaleChats = `-- name: GetStaleChats :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id FROM chats WHERE @@ -5517,6 +5535,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ); err != nil { return nil, err } @@ -5598,7 +5617,7 @@ INSERT INTO chats ( COALESCE($11::jsonb, '{}'::jsonb) ) RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type InsertChatParams struct { @@ -5652,6 +5671,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6165,7 +6185,7 @@ UPDATE chats SET updated_at = NOW() WHERE id = $3::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatBuildAgentBindingParams struct { @@ -6199,6 +6219,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6212,7 +6233,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatByIDParams struct { @@ -6245,6 +6266,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6284,7 +6306,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatLabelsByIDParams struct { @@ -6317,6 +6339,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6330,7 +6353,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatLastModelConfigByIDParams struct { @@ -6363,10 +6386,29 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } +const updateChatLastReadMessageID = `-- name: UpdateChatLastReadMessageID :exec +UPDATE chats +SET last_read_message_id = $1::bigint +WHERE id = $2::uuid +` + +type UpdateChatLastReadMessageIDParams struct { + LastReadMessageID int64 `db:"last_read_message_id" json:"last_read_message_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +// Updates the last read message ID for a chat. This is used to track +// which messages the owner has seen, enabling unread indicators. +func (q *sqlQuerier) UpdateChatLastReadMessageID(ctx context.Context, arg UpdateChatLastReadMessageIDParams) error { + _, err := q.db.ExecContext(ctx, updateChatLastReadMessageID, arg.LastReadMessageID, arg.ID) + return err +} + const updateChatMCPServerIDs = `-- name: UpdateChatMCPServerIDs :one UPDATE chats @@ -6376,7 +6418,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatMCPServerIDsParams struct { @@ -6409,6 +6451,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6544,7 +6587,7 @@ SET WHERE id = $6::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatStatusParams struct { @@ -6588,6 +6631,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6605,7 +6649,7 @@ SET WHERE id = $7::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatStatusPreserveUpdatedAtParams struct { @@ -6651,6 +6695,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } @@ -6662,7 +6707,7 @@ UPDATE chats SET agent_id = $3::uuid, updated_at = NOW() WHERE id = $4::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id ` type UpdateChatWorkspaceBindingParams struct { @@ -6702,6 +6747,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.BuildID, &i.AgentID, &i.PinOrder, + &i.LastReadMessageID, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index cd960f3c32..85983ee22d 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -313,7 +313,14 @@ ORDER BY -- name: GetChats :many SELECT - * + sqlc.embed(chats), + EXISTS ( + SELECT 1 FROM chat_messages cm + WHERE cm.chat_id = chats.id + AND cm.role = 'assistant' + AND cm.deleted = false + AND cm.id > COALESCE(chats.last_read_message_id, 0) + ) AS has_unread FROM chats WHERE @@ -1132,3 +1139,10 @@ LEFT JOIN LATERAL ( ) gl ON TRUE WHERE u.id = @user_id::uuid LIMIT 1; + +-- name: UpdateChatLastReadMessageID :exec +-- Updates the last read message ID for a chat. This is used to track +-- which messages the owner has seen, enabling unread indicators. +UPDATE chats +SET last_read_message_id = @last_read_message_id::bigint +WHERE id = @id::uuid; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 34511426f0..fed53e62f6 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -336,7 +336,7 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { LimitOpt: int32(paginationParams.Limit), } - chats, err := api.Database.GetChats(ctx, params) + chatRows, err := api.Database.GetChats(ctx, params) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to list chats.", @@ -345,7 +345,13 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { return } - diffStatusesByChatID, err := api.getChatDiffStatusesByChatID(ctx, chats) + // Extract the Chat objects for diff status lookup. + dbChats := make([]database.Chat, len(chatRows)) + for i, row := range chatRows { + dbChats[i] = row.Chat + } + + diffStatusesByChatID, err := api.getChatDiffStatusesByChatID(ctx, dbChats) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to list chats.", @@ -354,7 +360,7 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chats(chats, diffStatusesByChatID)) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatRows(chatRows, diffStatusesByChatID)) } func (api *API) getChatDiffStatusesByChatID( @@ -1947,6 +1953,39 @@ func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request httpapi.Write(ctx, rw, http.StatusOK, convertChatMessage(promoteResult.PromotedMessage)) } +// markChatAsRead updates the last read message ID for a chat to the +// latest message, so subsequent unread checks treat all current +// messages as seen. This is called on stream connect and disconnect +// to avoid per-message API calls during active streaming. +func (api *API) markChatAsRead(ctx context.Context, chatID uuid.UUID) { + lastMsg, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ + ChatID: chatID, + Role: database.ChatMessageRoleAssistant, + }) + if errors.Is(err, sql.ErrNoRows) { + // No assistant messages yet, nothing to mark as read. + return + } + if err != nil { + api.Logger.Warn(ctx, "failed to get last assistant message for read marker", + slog.F("chat_id", chatID), + slog.Error(err), + ) + return + } + + err = api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{ + ID: chatID, + LastReadMessageID: lastMsg.ID, + }) + if err != nil { + api.Logger.Warn(ctx, "failed to update chat last read message ID", + slog.F("chat_id", chatID), + slog.Error(err), + ) + } +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -2003,6 +2042,12 @@ func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) { }() defer cancel() + // Mark the chat as read when the stream connects and again + // when it disconnects so we avoid per-message API calls while + // messages are actively streaming. + api.markChatAsRead(ctx, chatID) + defer api.markChatAsRead(context.WithoutCancel(ctx), chatID) + sendChatStreamBatch := func(batch []codersdk.ChatStreamEvent) error { if len(batch) == 0 { return nil diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 41a37da20d..a79d121c04 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -3431,8 +3431,8 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { require.NoError(t, err) var children []database.Chat for _, c := range allChats { - if c.ParentChatID.Valid && c.ParentChatID.UUID == chat.ID { - children = append(children, c) + if c.Chat.ParentChatID.Valid && c.Chat.ParentChatID.UUID == chat.ID { + children = append(children, c.Chat) } } require.Len(t, children, 1) diff --git a/coderd/x/gitsync/worker.go b/coderd/x/gitsync/worker.go index fd4f1bf1a6..e082048a4c 100644 --- a/coderd/x/gitsync/worker.go +++ b/coderd/x/gitsync/worker.go @@ -68,7 +68,7 @@ type Store interface { ) (database.ChatDiffStatus, error) GetChats( ctx context.Context, arg database.GetChatsParams, - ) ([]database.Chat, error) + ) ([]database.GetChatsRow, error) } // EventPublisher notifies the frontend of diff status changes. @@ -287,7 +287,7 @@ func (w *Worker) MarkStale( return } - chats, err := w.store.GetChats(ctx, database.GetChatsParams{ + chatRows, err := w.store.GetChats(ctx, database.GetChatsParams{ OwnerID: ownerID, }) if err != nil { @@ -297,6 +297,11 @@ func (w *Worker) MarkStale( return } + chats := make([]database.Chat, len(chatRows)) + for i, row := range chatRows { + chats[i] = row.Chat + } + for _, chat := range filterChatsByWorkspaceID(chats, workspaceID) { _, err := w.store.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{ diff --git a/coderd/x/gitsync/worker_test.go b/coderd/x/gitsync/worker_test.go index 1a7d9910cc..d1e6d80036 100644 --- a/coderd/x/gitsync/worker_test.go +++ b/coderd/x/gitsync/worker_test.go @@ -616,12 +616,12 @@ func TestWorker_MarkStale_UpsertAndPublish(t *testing.T) { store := dbmock.NewMockStore(ctrl) store.EXPECT().GetChats(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, arg database.GetChatsParams) ([]database.Chat, error) { + DoAndReturn(func(_ context.Context, arg database.GetChatsParams) ([]database.GetChatsRow, error) { require.Equal(t, ownerID, arg.OwnerID) - return []database.Chat{ - {ID: chat1, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}, - {ID: chat2, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}, - {ID: chatOther, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}, + return []database.GetChatsRow{ + {Chat: database.Chat{ID: chat1, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}}, + {Chat: database.Chat{ID: chat2, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}}, + {Chat: database.Chat{ID: chatOther, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}}, }, nil }) store.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) { @@ -673,9 +673,9 @@ func TestWorker_MarkStale_NoMatchingChats(t *testing.T) { store := dbmock.NewMockStore(ctrl) store.EXPECT().GetChats(gomock.Any(), gomock.Any()). - Return([]database.Chat{ - {ID: uuid.New(), OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}, - {ID: uuid.New(), OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}, + Return([]database.GetChatsRow{ + {Chat: database.Chat{ID: uuid.New(), OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}}, + {Chat: database.Chat{ID: uuid.New(), OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}}, }, nil) mClock := quartz.NewMock(t) @@ -701,9 +701,9 @@ func TestWorker_MarkStale_UpsertFails_ContinuesNext(t *testing.T) { store := dbmock.NewMockStore(ctrl) store.EXPECT().GetChats(gomock.Any(), gomock.Any()). - Return([]database.Chat{ - {ID: chat1, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}, - {ID: chat2, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}, + Return([]database.GetChatsRow{ + {Chat: database.Chat{ID: chat1, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}}, + {Chat: database.Chat{ID: chat2, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}}}, }, nil) store.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) { diff --git a/codersdk/chats.go b/codersdk/chats.go index ddc06dd670..59e985bd54 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -64,6 +64,10 @@ type Chat struct { PinOrder int32 `json:"pin_order"` MCPServerIDs []uuid.UUID `json:"mcp_server_ids" format:"uuid"` Labels map[string]string `json:"labels"` + // HasUnread is true when assistant messages exist beyond + // the owner's read cursor, which updates on stream + // connect and disconnect. + HasUnread bool `json:"has_unread"` } // ChatMessage represents a single message in a chat. diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index c119d60578..71bf44e0fa 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -90,6 +90,7 @@ const makeChat = ( updated_at: "2025-01-01T00:00:00.000Z", archived: false, pin_order: 0, + has_unread: false, last_error: null, ...overrides, }); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e69b5fd48a..c8d4aef518 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1194,6 +1194,12 @@ export interface Chat { readonly pin_order: number; readonly mcp_server_ids: readonly string[]; readonly labels: Record; + /** + * HasUnread is true when assistant messages exist beyond + * the owner's read cursor, which updates on stream + * connect and disconnect. + */ + readonly has_unread: boolean; } // From codersdk/chats.go diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 8857190a0d..ccc494d031 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -143,6 +143,7 @@ const baseChatFields = { updated_at: "2026-02-18T00:00:00.000Z", archived: false, pin_order: 0, + has_unread: false, last_error: null, } as const; diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index df704964ba..566c65e121 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -393,6 +393,25 @@ const AgentsPage: FC = () => { activeChatIDRef.current = agentId; }); + // Optimistically clear the unread indicator for the active + // chat. The server marks chats as read on stream connect + // and disconnect, but the list cache is not refetched until + // window focus. Without this, navigating away from a chat + // causes its cached has_unread to reappear as a stale dot. + useEffect(() => { + if (!agentId) { + return; + } + updateInfiniteChatsCache(queryClient, (chats) => { + let changed = false; + const next = chats.map((c) => { + if (c.id !== agentId || !c.has_unread) return c; + changed = true; + return { ...c, has_unread: false }; + }); + return changed ? next : chats; + }); + }, [agentId, queryClient]); useEffect(() => { return createReconnectingWebSocket({ connect() { @@ -508,11 +527,21 @@ const AgentsPage: FC = () => { c.updated_at > updatedChat.updated_at ? c.updated_at : updatedChat.updated_at; + // The server's pubsub path does not compute + // has_unread (it always sends false). For + // status_change events on non-active chats, + // optimistically mark as unread since the + // assistant produced new output. + const nextHasUnread = + isStatusEvent && updatedChat.id !== activeChatIDRef.current + ? true + : c.has_unread; if ( nextStatus === c.status && nextTitle === c.title && diffStatusEqual(nextDiffStatus, c.diff_status) && - nextWorkspaceId === c.workspace_id + nextWorkspaceId === c.workspace_id && + nextHasUnread === c.has_unread ) { return c; } @@ -524,6 +553,7 @@ const AgentsPage: FC = () => { diff_status: nextDiffStatus, workspace_id: nextWorkspaceId, updated_at: nextUpdatedAt, + has_unread: nextHasUnread, }; }); return didUpdate ? nextChats : chats; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 8b2f04ed65..bae3792ef7 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -130,6 +130,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ updated_at: oneWeekAgo, archived: false, pin_order: 0, + has_unread: false, last_error: null, ...overrides, }); diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx index dda57cba27..8a6758990f 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ChatContext.test.tsx @@ -208,6 +208,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({ updated_at: "2025-01-01T00:00:00.000Z", archived: false, pin_order: 0, + has_unread: false, last_error: null, }); diff --git a/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx index 8f2e8222c7..34ce9cab2d 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/TopBar.stories.tsx @@ -68,6 +68,7 @@ export const WithParentChat: Story = { updated_at: "2026-02-18T00:00:00.000Z", archived: false, pin_order: 0, + has_unread: false, }, }, }; diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index 8f6d62f9c5..54e6fcd5b4 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -49,6 +49,7 @@ const buildChat = (overrides: Partial = {}): TypesGen.Chat => ({ updated_at: oneWeekAgo, archived: false, pin_order: 0, + has_unread: false, last_error: null, ...overrides, }); diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 294820d1b2..824b477261 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -49,6 +49,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ updated_at: oneWeekAgo, archived: false, pin_order: 0, + has_unread: false, last_error: null, ...overrides, }); @@ -707,6 +708,69 @@ export const WithPRStateIcons: Story = { }, }; +export const WithUnreadChats: Story = { + args: { + chats: [ + buildChat({ + id: "unread-1", + title: "Unread chat with new activity", + has_unread: true, + updated_at: recentTimestamp, + }), + buildChat({ + id: "read-1", + title: "Already read chat", + has_unread: false, + updated_at: recentTimestamp, + }), + buildChat({ + id: "unread-2", + title: "Another unread chat", + has_unread: true, + status: "running", + updated_at: recentTimestamp, + }), + buildChat({ + id: "unread-active", + title: "Unread but currently viewed", + has_unread: true, + updated_at: recentTimestamp, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents/unread-active", + pathParams: { agentId: "unread-active" }, + }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + // Unread indicators should be visible for unread chats + // that are NOT the active chat. + expect( + canvas.getByTestId("unread-indicator-unread-1"), + ).toBeInTheDocument(); + expect( + canvas.getByTestId("unread-indicator-unread-2"), + ).toBeInTheDocument(); + }); + // Read chat should not have an unread indicator. + expect( + canvas.queryByTestId("unread-indicator-read-1"), + ).not.toBeInTheDocument(); + // Unread chat that IS the active chat should not show + // the indicator — the user is already viewing it. + expect( + canvas.queryByTestId("unread-indicator-unread-active"), + ).not.toBeInTheDocument(); + }, +}; + export const ArchivedAgentUnarchiveOption: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index 50d2829f60..dbaa9500b6 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -63,6 +63,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ updated_at: oneWeekAgo, archived: false, pin_order: 0, + has_unread: false, last_error: null, mcp_server_ids: [], labels: {}, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index 331e55597a..43bf102a0d 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -369,6 +369,7 @@ interface ChatTreeContextValue { readonly modelOptions: readonly ModelSelectorOption[]; readonly modelConfigs: readonly ChatModelConfig[]; readonly chatErrorReasons: Record; + readonly activeChatId: string | undefined; readonly isArchiving: boolean; readonly archivingChatId: string | null; readonly isRegeneratingTitle: boolean; @@ -410,6 +411,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { modelOptions, modelConfigs, chatErrorReasons, + activeChatId, isArchiving, archivingChatId, isRegeneratingTitle, @@ -423,6 +425,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { onRegenerateTitle, } = useChatTree(); const chatID = chat.id; + const isActiveChat = activeChatId === chatID; const childIDs = (chatTree.childrenById.get(chatID) ?? []).filter((childID) => visibleChatIDs.has(childID), ); @@ -525,12 +528,16 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { className={cn( "block flex-1 truncate text-[13px] text-content-primary", isActive && "font-medium", + chat.has_unread && !isActiveChat && "font-semibold", // Pulse-only in sidebar (no spinner) — space-constrained card layout. isRegeneratingThisChat && "animate-pulse", )} > {chat.title} + {chat.has_unread && !isActiveChat && ( + (unread) + )} {isRegeneratingThisChat && ( Regenerating title… @@ -573,7 +580,15 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { ) : ( <> - {shortRelativeTime(chat.updated_at)} + {chat.has_unread && !isActiveChat ? ( + @@ -936,6 +951,7 @@ export const AgentsSidebar: FC = (props) => { modelOptions, modelConfigs, chatErrorReasons, + activeChatId, isArchiving, archivingChatId, isRegeneratingTitle, @@ -950,7 +966,6 @@ export const AgentsSidebar: FC = (props) => { }; const subNavTitle = "Settings"; - return (
{/* ── Panel 1: Chats ── */}