fix: exclude subagent chats from sidebar pagination (#24404)

GetChats now returns only root chats (parent_chat_id IS NULL).
A new GetChildChatsByParentIDs query fetches children for visible
roots and embeds them in each parent's Children field. The
singular getChat endpoint does the same.

Archive invariant is one-way: parent archived implies child
archived. Parent archive/unarchive cascades via root_chat_id.
Individual child archive is permitted; child unarchive while the
parent is archived is rejected atomically (row lock on child,
re-read parent inside the transaction). Embedded children are
filtered by the caller's archive state so individually-archived
children stay hidden from active-parent views.

Gitsync MarkStale uses GetChatsByWorkspaceIDs directly;
MarkStaleParams.OwnerID removed (dead after the switch).

Frontend: buildChatTree reads from the embedded children field,
WebSocket handlers route child events into the parent's children
array, and archiving a child strips it from the parent cache.
This commit is contained in:
Mathias Fredriksson
2026-04-20 13:19:59 +03:00
committed by GitHub
parent df429b7f60
commit fc2493780f
30 changed files with 1514 additions and 225 deletions
+53 -9
View File
@@ -1609,6 +1609,11 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
parentChatID := c.ParentChatID.UUID
chat.ParentChatID = &parentChatID
}
// Always initialize Children to an empty slice so the JSON
// field serializes as [] rather than null. Root chats may
// later have children populated; child chats remain empty
// because nesting depth is capped at 1.
chat.Children = []codersdk.Chat{}
switch {
case c.RootChatID.Valid:
rootChatID := c.RootChatID.UUID
@@ -1756,19 +1761,21 @@ func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep {
}
}
// 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]
// ChildChatRows converts child chat rows to codersdk.Chat values,
// resolving diff statuses from the shared map. When diffStatuses
// is non-nil, children without an entry receive an empty DiffStatus.
func ChildChatRows(
children []database.GetChildChatsByParentIDsRow,
diffStatuses map[uuid.UUID]database.ChatDiffStatus,
) []codersdk.Chat {
result := make([]codersdk.Chat, len(children))
for i, row := range children {
diffStatus, ok := diffStatuses[row.Chat.ID]
if ok {
result[i] = Chat(row.Chat, &diffStatus, nil)
} else {
result[i] = Chat(row.Chat, nil, nil)
if diffStatusesByChatID != nil {
if diffStatuses != nil {
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
}
@@ -1778,6 +1785,43 @@ func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]da
return result
}
// ChatRowsWithChildren converts root chat rows and their child rows
// into codersdk.Chat values with children embedded under each parent.
// Both root and child diff statuses are resolved from the shared map.
func ChatRowsWithChildren(
roots []database.GetChatsRow,
children []database.GetChildChatsByParentIDsRow,
diffStatuses map[uuid.UUID]database.ChatDiffStatus,
) []codersdk.Chat {
// Group children by parent ID.
childrenByParent := make(map[uuid.UUID][]database.GetChildChatsByParentIDsRow, len(children))
for _, row := range children {
parentID := row.Chat.ParentChatID.UUID
childrenByParent[parentID] = append(childrenByParent[parentID], row)
}
result := make([]codersdk.Chat, len(roots))
for i, row := range roots {
diffStatus, ok := diffStatuses[row.Chat.ID]
if ok {
result[i] = Chat(row.Chat, &diffStatus, nil)
} else {
result[i] = Chat(row.Chat, nil, nil)
if diffStatuses != nil {
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
result[i].DiffStatus = &emptyDiffStatus
}
}
result[i].HasUnread = row.HasUnread
// Embed child chats.
if childRows, ok := childrenByParent[row.Chat.ID]; ok {
result[i].Children = ChildChatRows(childRows, diffStatuses)
}
}
return result
}
// ChatDiffStatus converts a database.ChatDiffStatus to a
// codersdk.ChatDiffStatus. When status is nil an empty value
// containing only the chatID is returned.
+1 -1
View File
@@ -856,7 +856,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
v := reflect.ValueOf(got)
typ := v.Type()
// HasUnread is populated by ChatRows (which joins the
// HasUnread is populated by ChatRowsWithChildren (which joins the
// read-cursor query), not by Chat. Warnings is a transient
// field populated by handlers, not the converter. Both are
// expected to remain zero here.
+8
View File
@@ -2944,6 +2944,14 @@ func (q *querier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time.Ti
return q.db.GetChatsUpdatedAfter(ctx, updatedAfter)
}
func (q *querier) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
// Each child is independently authorized via post-filter.
// The handler calls this after GetChats already authorized
// the parent chats, but we still verify read access on
// every child row for defense in depth.
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChildChatsByParentIDs)(ctx, arg)
}
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
// Just like with the audit logs query, shortcut if the user is an owner.
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
+21
View File
@@ -820,6 +820,27 @@ func (s *MethodTestSuite) TestChats() {
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("GetChildChatsByParentIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
parentA := testutil.Fake(s.T(), faker, database.Chat{})
parentB := testutil.Fake(s.T(), faker, database.Chat{})
childA := testutil.Fake(s.T(), faker, database.Chat{
ParentChatID: uuid.NullUUID{UUID: parentA.ID, Valid: true},
})
childB := testutil.Fake(s.T(), faker, database.Chat{
ParentChatID: uuid.NullUUID{UUID: parentB.ID, Valid: true},
})
parentIDs := []uuid.UUID{parentA.ID, parentB.ID}
params := database.GetChildChatsByParentIDsParams{
ParentIds: parentIDs,
Archived: sql.NullBool{Bool: false, Valid: true},
}
rows := []database.GetChildChatsByParentIDsRow{
{Chat: childA},
{Chat: childB},
}
dbm.EXPECT().GetChildChatsByParentIDs(gomock.Any(), params).Return(rows, nil).AnyTimes()
check.Args(params).Asserts(childA, policy.ActionRead, childB, policy.ActionRead).Returns(rows)
}))
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.GetChatsRow{}, nil).AnyTimes()
@@ -1456,6 +1456,14 @@ func (m queryMetricsStore) GetChatsUpdatedAfter(ctx context.Context, updatedAfte
return r0, r1
}
func (m queryMetricsStore) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
start := time.Now()
r0, r1 := m.s.GetChildChatsByParentIDs(ctx, arg)
m.queryLatencies.WithLabelValues("GetChildChatsByParentIDs").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChildChatsByParentIDs").Inc()
return r0, r1
}
func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
start := time.Now()
r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg)
+15
View File
@@ -2687,6 +2687,21 @@ func (mr *MockStoreMockRecorder) GetChatsUpdatedAfter(ctx, updatedAfter any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).GetChatsUpdatedAfter), ctx, updatedAfter)
}
// GetChildChatsByParentIDs mocks base method.
func (m *MockStore) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChildChatsByParentIDs", ctx, arg)
ret0, _ := ret[0].([]database.GetChildChatsByParentIDsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChildChatsByParentIDs indicates an expected call of GetChildChatsByParentIDs.
func (mr *MockStoreMockRecorder) GetChildChatsByParentIDs(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChildChatsByParentIDs", reflect.TypeOf((*MockStore)(nil).GetChildChatsByParentIDs), ctx, arg)
}
// GetConnectionLogsOffset mocks base method.
func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
m.ctrl.T.Helper()
+4
View File
@@ -182,6 +182,10 @@ func (r GetChatsRow) RBACObject() rbac.Object {
return r.Chat.RBACObject()
}
func (r GetChildChatsByParentIDsRow) 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)
}
+5
View File
@@ -349,6 +349,11 @@ type sqlcQuerier interface {
// snapshot collection. Uses updated_at so that long-running chats
// still appear in each snapshot window while they are active.
GetChatsUpdatedAfter(ctx context.Context, updatedAfter time.Time) ([]GetChatsUpdatedAfterRow, error)
// Fetches child chats of the given parents, optionally filtered by
// archive state (NULL = all, true/false = match). The archive
// invariant (parent archived implies child archived) is enforced
// at write time, not here.
GetChildChatsByParentIDs(ctx context.Context, arg GetChildChatsByParentIDsParams) ([]GetChildChatsByParentIDsRow, error)
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
GetCryptoKeys(ctx context.Context) ([]CryptoKey, error)
+94
View File
@@ -6620,6 +6620,11 @@ WHERE
WHEN $4::jsonb IS NOT NULL THEN chats.labels @> $4::jsonb
ELSE true
END
-- Paginate over root chats only. Children are fetched
-- separately via GetChildChatsByParentIDs and embedded under
-- each parent. Other callers that need the full set should
-- use a narrower query (e.g. GetChatsByWorkspaceIDs).
AND chats.parent_chat_id IS NULL
-- Authorize Filter clause will be injected below in GetAuthorizedChats
-- @authorize_filter
ORDER BY
@@ -6838,6 +6843,95 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
return items, nil
}
const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many
SELECT
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, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type,
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
chats.parent_chat_id = ANY($1 :: uuid[])
AND CASE
WHEN $2 :: boolean IS NULL THEN true
ELSE chats.archived = $2 :: boolean
END
ORDER BY
chats.created_at ASC,
chats.id ASC
`
type GetChildChatsByParentIDsParams struct {
ParentIds []uuid.UUID `db:"parent_ids" json:"parent_ids"`
Archived sql.NullBool `db:"archived" json:"archived"`
}
type GetChildChatsByParentIDsRow struct {
Chat Chat `db:"chat" json:"chat"`
HasUnread bool `db:"has_unread" json:"has_unread"`
}
// Fetches child chats of the given parents, optionally filtered by
// archive state (NULL = all, true/false = match). The archive
// invariant (parent archived implies child archived) is enforced
// at write time, not here.
func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildChatsByParentIDsParams) ([]GetChildChatsByParentIDsRow, error) {
rows, err := q.db.QueryContext(ctx, getChildChatsByParentIDs, pq.Array(arg.ParentIds), arg.Archived)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetChildChatsByParentIDsRow
for rows.Next() {
var i GetChildChatsByParentIDsRow
if err := rows.Scan(
&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.Chat.LastInjectedContext,
&i.Chat.DynamicTools,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
&i.Chat.ClientType,
&i.HasUnread,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one
SELECT
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id
+31
View File
@@ -373,6 +373,11 @@ WHERE
WHEN sqlc.narg('label_filter')::jsonb IS NOT NULL THEN chats.labels @> sqlc.narg('label_filter')::jsonb
ELSE true
END
-- Paginate over root chats only. Children are fetched
-- separately via GetChildChatsByParentIDs and embedded under
-- each parent. Other callers that need the full set should
-- use a narrower query (e.g. GetChatsByWorkspaceIDs).
AND chats.parent_chat_id IS NULL
-- Authorize Filter clause will be injected below in GetAuthorizedChats
-- @authorize_filter
ORDER BY
@@ -390,6 +395,32 @@ LIMIT
-- Default to 50 to prevent accidental excessively large queries.
COALESCE(NULLIF(@limit_opt :: int, 0), 50);
-- name: GetChildChatsByParentIDs :many
-- Fetches child chats of the given parents, optionally filtered by
-- archive state (NULL = all, true/false = match). The archive
-- invariant (parent archived implies child archived) is enforced
-- at write time, not here.
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
chats.parent_chat_id = ANY(@parent_ids :: uuid[])
AND CASE
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
ELSE chats.archived = sqlc.narg('archived') :: boolean
END
ORDER BY
chats.created_at ASC,
chats.id ASC;
-- name: InsertChat :one
INSERT INTO chats (
organization_id,