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:
Kyle Carberry
2026-03-27 12:15:04 -04:00
committed by GitHub
parent a5c72ba396
commit bcdc35ee3e
32 changed files with 547 additions and 123 deletions
+16 -15
View File
@@ -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
}
+7
View File
@@ -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,
+13 -2
View File
@@ -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)
}
+12 -2
View File
@@ -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{
+10 -2
View File
@@ -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())
+18 -4
View File
@@ -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()
+2 -1
View File
@@ -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 (
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN last_read_message_id;
@@ -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
);
+4
View File
@@ -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)
}
+27 -26
View File
@@ -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)
+1
View File
@@ -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 {
+4 -1
View File
@@ -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)
+126 -9
View File
@@ -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")
}
+85 -39
View File
@@ -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
}
+15 -1
View File
@@ -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;
+48 -3
View File
@@ -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
+2 -2
View File
@@ -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)
+7 -2
View File
@@ -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{
+11 -11
View File
@@ -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) {
+4
View File
@@ -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.
+1
View File
@@ -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,
});
+6
View File
@@ -1194,6 +1194,12 @@ export interface Chat {
readonly pin_order: number;
readonly mcp_server_ids: readonly string[];
readonly labels: Record<string, string>;
/**
* 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
@@ -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;
+31 -1
View File
@@ -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;
@@ -130,6 +130,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
updated_at: oneWeekAgo,
archived: false,
pin_order: 0,
has_unread: false,
last_error: null,
...overrides,
});
@@ -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,
});
@@ -68,6 +68,7 @@ export const WithParentChat: Story = {
updated_at: "2026-02-18T00:00:00.000Z",
archived: false,
pin_order: 0,
has_unread: false,
},
},
};
@@ -49,6 +49,7 @@ const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
updated_at: oneWeekAgo,
archived: false,
pin_order: 0,
has_unread: false,
last_error: null,
...overrides,
});
@@ -49,6 +49,7 @@ const buildChat = (overrides: Partial<Chat> = {}): 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: [
@@ -63,6 +63,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
updated_at: oneWeekAgo,
archived: false,
pin_order: 0,
has_unread: false,
last_error: null,
mcp_server_ids: [],
labels: {},
@@ -369,6 +369,7 @@ interface ChatTreeContextValue {
readonly modelOptions: readonly ModelSelectorOption[];
readonly modelConfigs: readonly ChatModelConfig[];
readonly chatErrorReasons: Record<string, string>;
readonly activeChatId: string | undefined;
readonly isArchiving: boolean;
readonly archivingChatId: string | null;
readonly isRegeneratingTitle: boolean;
@@ -410,6 +411,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
modelOptions,
modelConfigs,
chatErrorReasons,
activeChatId,
isArchiving,
archivingChatId,
isRegeneratingTitle,
@@ -423,6 +425,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ 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<ChatTreeNodeProps> = ({ 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}
</span>
{chat.has_unread && !isActiveChat && (
<span className="sr-only">(unread)</span>
)}
{isRegeneratingThisChat && (
<span className="sr-only" role="status">
Regenerating title
@@ -573,7 +580,15 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
) : (
<>
<span className="flex items-center justify-end text-xs text-content-secondary/50 tabular-nums [@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden">
{shortRelativeTime(chat.updated_at)}
{chat.has_unread && !isActiveChat ? (
<span
className="h-2 w-2 shrink-0 rounded-full bg-content-link"
data-testid={`unread-indicator-${chat.id}`}
aria-hidden="true"
/>
) : (
shortRelativeTime(chat.updated_at)
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -936,6 +951,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
modelOptions,
modelConfigs,
chatErrorReasons,
activeChatId,
isArchiving,
archivingChatId,
isRegeneratingTitle,
@@ -950,7 +966,6 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
};
const subNavTitle = "Settings";
return (
<div className="relative flex h-full w-full min-h-0 border-0 border-r border-solid overflow-hidden">
{/* ── Panel 1: Chats ── */}