From 12083441e05480b782a5ae0e4f0bf1e7bc1c6319 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 27 Feb 2026 16:46:19 -0500 Subject: [PATCH] feat(chats): archive chats instead of hard-deleting them (#22406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The UI has always labeled the action as "Archive agent" but the backend was performing a hard `DELETE`, permanently destroying chats and all their messages. This change replaces the hard delete with a soft archive, consistent with the pattern used by template versions. ## Changes ### Database - **Migration 000423**: Add `archived boolean DEFAULT false NOT NULL` column to `chats` table - Replace `DeleteChatByID` query with `ArchiveChatByID` (`UPDATE SET archived = true`) - Add `UnarchiveChatByID` query (`UPDATE SET archived = false`) - Filter archived chats from `GetChatsByOwnerID` (`WHERE archived = false`) ### API - Remove `DELETE /api/experimental/chats/{chat}` - Add `POST /api/experimental/chats/{chat}/archive` — archives a chat and all its descendants - Add `POST /api/experimental/chats/{chat}/unarchive` — unarchives a single chat (API only, no UI yet) ### Backend - `archiveChatTree()` recursively archives child chats (replaces `deleteChatTree()` which hard-deleted) - Chat daemon's `ArchiveChat()` archives the full chat tree in a transaction - Authorization uses `ActionUpdate` instead of `ActionDelete` ### SDK - Replace `DeleteChat()` with `ArchiveChat()` and `UnarchiveChat()` - Add `Archived` field to `Chat` struct ### Frontend - `archiveChat` API call uses `POST .../archive` instead of `DELETE` - No UI changes — the "Archive agent" button now actually archives instead of deleting ## Design Decision This follows the **template version archive pattern** (Pattern B in the codebase): - `archived boolean` column (not `deleted boolean`) - Dedicated `POST .../archive` and `POST .../unarchive` routes (not repurposing `DELETE`) - Reversible — users can unarchive via the API (UI for this will come later) --- coderd/apidoc/docs.go | 28 ++++++ coderd/apidoc/swagger.json | 24 +++++ coderd/chatd/chatd.go | 14 +-- coderd/chats.go | 96 ++++++++++--------- coderd/chats_test.go | 24 +++-- coderd/coderd.go | 3 +- coderd/database/dbauthz/dbauthz.go | 33 ++++--- coderd/database/dbauthz/dbauthz_test.go | 12 ++- coderd/database/dbmetrics/querymetrics.go | 24 +++-- coderd/database/dbmock/dbmock.go | 42 +++++--- coderd/database/dump.sql | 3 +- .../migrations/000423_chat_archive.down.sql | 1 + .../migrations/000423_chat_archive.up.sql | 1 + coderd/database/models.go | 1 + coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 64 ++++++++----- coderd/database/queries/chats.sql | 11 ++- codersdk/chats.go | 18 +++- docs/manifest.json | 4 + docs/reference/api/chats.md | 37 +++++++ site/src/api/api.ts | 4 +- site/src/api/queries/chats.ts | 4 +- site/src/api/typesGenerated.ts | 1 + .../pages/AgentsPage/AgentDetail.stories.tsx | 1 + .../AgentDetail/ChatContext.test.tsx | 1 + site/src/pages/AgentsPage/AgentsPage.tsx | 4 +- .../AgentsPage/AgentsSidebar.stories.tsx | 1 + 27 files changed, 319 insertions(+), 140 deletions(-) create mode 100644 coderd/database/migrations/000423_chat_archive.down.sql create mode 100644 coderd/database/migrations/000423_chat_archive.up.sql create mode 100644 docs/reference/api/chats.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0aceb9c411..9d866e7932 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -481,6 +481,34 @@ const docTemplate = `{ } } }, + "/chats/{chat}/archive": { + "post": { + "tags": [ + "Chats" + ], + "summary": "Archive a chat", + "operationId": "archive-chat", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/chats/{chat}/unarchive": { + "post": { + "tags": [ + "Chats" + ], + "summary": "Unarchive a chat", + "operationId": "unarchive-chat", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/connectionlog": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a61da85d04..a3cc695b3e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -410,6 +410,30 @@ } } }, + "/chats/{chat}/archive": { + "post": { + "tags": ["Chats"], + "summary": "Archive a chat", + "operationId": "archive-chat", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/chats/{chat}/unarchive": { + "post": { + "tags": ["Chats"], + "summary": "Unarchive a chat", + "operationId": "unarchive-chat", + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/connectionlog": { "get": { "security": [ diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 4f4f79be88..55f7a2fdb6 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -505,8 +505,8 @@ func (p *Server) EditMessage( return result, nil } -// DeleteChat removes a chat and all descendants, then broadcasts a deleted event. -func (p *Server) DeleteChat(ctx context.Context, chatID uuid.UUID) error { +// ArchiveChat archives a chat and all descendants, then broadcasts a deleted event. +func (p *Server) ArchiveChat(ctx context.Context, chatID uuid.UUID) error { if chatID == uuid.Nil { return xerrors.New("chat_id is required") } @@ -517,7 +517,7 @@ func (p *Server) DeleteChat(ctx context.Context, chatID uuid.UUID) error { } err = p.db.InTx(func(tx database.Store) error { - // Collect descendants breadth-first, then delete from leaves upward. + // Collect descendants breadth-first, then archive from leaves upward. descendantIDs := make([]uuid.UUID, 0) queue := []uuid.UUID{chatID} for len(queue) > 0 { @@ -535,13 +535,13 @@ func (p *Server) DeleteChat(ctx context.Context, chatID uuid.UUID) error { } for i := len(descendantIDs) - 1; i >= 0; i-- { - if err := tx.DeleteChatByID(ctx, descendantIDs[i]); err != nil { - return xerrors.Errorf("delete descendant chat %s: %w", descendantIDs[i], err) + if err := tx.ArchiveChatByID(ctx, descendantIDs[i]); err != nil { + return xerrors.Errorf("archive descendant chat %s: %w", descendantIDs[i], err) } } - if err := tx.DeleteChatByID(ctx, chatID); err != nil { - return xerrors.Errorf("delete chat: %w", err) + if err := tx.ArchiveChatByID(ctx, chatID); err != nil { + return xerrors.Errorf("archive chat: %w", err) } return nil diff --git a/coderd/chats.go b/coderd/chats.go index 5fcbd84fb7..908acc30fb 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -394,21 +394,31 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) { }) } -// EXPERIMENTAL: this endpoint is experimental and is subject to change. -func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) { +// @Summary Archive a chat +// @ID archive-chat +// @Tags Chats +// @Success 204 +// @Router /chats/{chat}/archive [post] +func (api *API) archiveChat(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() chat := httpmw.ChatParam(r) - chatID := chat.ID + + if chat.Archived { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Chat is already archived.", + }) + return + } var err error if api.chatDaemon != nil { - err = api.chatDaemon.DeleteChat(ctx, chatID) + err = api.chatDaemon.ArchiveChat(ctx, chat.ID) } else { - err = deleteChatTree(ctx, api.Database, chatID) + err = archiveChatTree(ctx, api.Database, chat.ID) } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to delete chat.", + Message: "Failed to archive chat.", Detail: err.Error(), }) return @@ -417,47 +427,45 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } -func deleteChatTree( - ctx context.Context, - store database.Store, - chatID uuid.UUID, -) error { - // Child chats (sub-agent chats) reference their parent via - // parent_chat_id with ON DELETE SET NULL, so without explicit - // cleanup they would become orphaned root-level items. - return store.InTx(func(tx database.Store) error { - // Recursively collect all descendant chat IDs. - var descendantIDs []uuid.UUID - queue := []uuid.UUID{chatID} - for len(queue) > 0 { - parentID := queue[0] - queue = queue[1:] - children, err := tx.ListChildChatsByParentID(ctx, parentID) - if err != nil { - return xerrors.Errorf("list children of chat %s: %w", parentID, err) - } - for _, child := range children { - descendantIDs = append(descendantIDs, child.ID) - queue = append(queue, child.ID) - } - } +// @Summary Unarchive a chat +// @ID unarchive-chat +// @Tags Chats +// @Success 204 +// @Router /chats/{chat}/unarchive [post] +func (api *API) unarchiveChat(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) - // Delete descendants first. The FK is ON DELETE SET NULL so - // order doesn't strictly matter, but deleting children before - // parents is cleaner. - for i := len(descendantIDs) - 1; i >= 0; i-- { - if err := tx.DeleteChatByID(ctx, descendantIDs[i]); err != nil { - return xerrors.Errorf("delete descendant chat %s: %w", descendantIDs[i], err) - } - } + if !chat.Archived { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Chat is not archived.", + }) + return + } - // Delete the target chat itself. - if err := tx.DeleteChatByID(ctx, chatID); err != nil { - return xerrors.Errorf("delete chat: %w", err) - } + err := api.Database.UnarchiveChatByID(ctx, chat.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unarchive chat.", + Detail: err.Error(), + }) + return + } - return nil - }, nil) + rw.WriteHeader(http.StatusNoContent) +} + +func archiveChatTree(ctx context.Context, store database.Store, chatID uuid.UUID) error { + children, err := store.ListChildChatsByParentID(ctx, chatID) + if err != nil { + return xerrors.Errorf("list child chats: %w", err) + } + for _, child := range children { + if err := archiveChatTree(ctx, store, child.ID); err != nil { + return err + } + } + return store.ArchiveChatByID(ctx, chatID) } // EXPERIMENTAL: this endpoint is experimental and is subject to change. diff --git a/coderd/chats_test.go b/coderd/chats_test.go index 41129109bb..4ad12787ad 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -1158,7 +1158,7 @@ func TestGetChat(t *testing.T) { }) } -func TestDeleteChat(t *testing.T) { +func TestArchiveChat(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { @@ -1169,11 +1169,11 @@ func TestDeleteChat(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) _ = createChatModelConfig(t, client) - chatToDelete, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + chatToArchive, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, - Text: "delete me", + Text: "archive me", }, }, }) @@ -1189,20 +1189,18 @@ func TestDeleteChat(t *testing.T) { }) require.NoError(t, err) - chatsBeforeDelete, err := client.ListChats(ctx) + chatsBeforeArchive, err := client.ListChats(ctx) require.NoError(t, err) - require.Len(t, chatsBeforeDelete, 2) + require.Len(t, chatsBeforeArchive, 2) - err = client.DeleteChat(ctx, chatToDelete.ID) + err = client.ArchiveChat(ctx, chatToArchive.ID) require.NoError(t, err) - _, err = client.GetChat(ctx, chatToDelete.ID) - requireSDKError(t, err, http.StatusNotFound) - - chatsAfterDelete, err := client.ListChats(ctx) + // Archived chats should not appear in the list. + chatsAfterArchive, err := client.ListChats(ctx) require.NoError(t, err) - require.Len(t, chatsAfterDelete, 1) - require.Equal(t, chatToKeep.ID, chatsAfterDelete[0].ID) + require.Len(t, chatsAfterArchive, 1) + require.Equal(t, chatToKeep.ID, chatsAfterArchive[0].ID) }) t.Run("NotFound", func(t *testing.T) { @@ -1212,7 +1210,7 @@ func TestDeleteChat(t *testing.T) { client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client) - err := client.DeleteChat(ctx, uuid.New()) + err := client.ArchiveChat(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f5377819b..019b1967b3 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1127,7 +1127,8 @@ func New(options *Options) *API { r.Route("/{chat}", func(r chi.Router) { r.Use(httpmw.ExtractChatParam(options.Database)) r.Get("/", api.getChat) - r.Delete("/", api.deleteChat) + r.Post("/archive", api.archiveChat) + r.Post("/unarchive", api.unarchiveChat) r.Post("/messages", api.postChatMessages) r.Patch("/messages/{message}", api.patchChatMessage) r.Get("/stream", api.streamChat) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index efb5fb61b2..e3a1ad6391 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1528,6 +1528,17 @@ func (q *querier) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UU return q.db.AllUserIDs(ctx, includeSystem) } +func (q *querier) ArchiveChatByID(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.ArchiveChatByID(ctx, id) +} + func (q *querier) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { tpl, err := q.db.GetTemplateByID(ctx, arg.TemplateID) if err != nil { @@ -1757,17 +1768,6 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } -func (q *querier) DeleteChatByID(ctx context.Context, id uuid.UUID) error { - chat, err := q.db.GetChatByID(ctx, id) - if err != nil { - return err - } - if err := q.authorizeContext(ctx, policy.ActionDelete, chat); err != nil { - return err - } - return q.db.DeleteChatByID(ctx, id) -} - func (q *querier) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { // Authorize update on the parent chat. chat, err := q.db.GetChatByID(ctx, arg.ChatID) @@ -5259,6 +5259,17 @@ func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) { return q.db.TryAcquireLock(ctx, id) } +func (q *querier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.UnarchiveChatByID(ctx, id) +} + func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { v, err := q.db.GetTemplateVersionByID(ctx, arg.TemplateVersionID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0d96bfea5c..8e8bd0d2fe 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -387,11 +387,17 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().DeleteAllChatQueuedMessages(gomock.Any(), chat.ID).Return(nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() })) - s.Run("DeleteChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("ArchiveChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().DeleteChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() - check.Args(chat.ID).Asserts(chat, policy.ActionDelete).Returns() + dbm.EXPECT().ArchiveChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() + })) + s.Run("UnarchiveChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() })) s.Run("DeleteChatMessagesByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 7550944ff4..e7bc3562e5 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -152,6 +152,14 @@ func (m queryMetricsStore) AllUserIDs(ctx context.Context, includeSystem bool) ( return r0, r1 } +func (m queryMetricsStore) ArchiveChatByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.ArchiveChatByID(ctx, id) + m.queryLatencies.WithLabelValues("ArchiveChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ArchiveChatByID").Inc() + return r0 +} + func (m queryMetricsStore) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { start := time.Now() r0, r1 := m.s.ArchiveUnusedTemplateVersions(ctx, arg) @@ -352,14 +360,6 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return r0 } -func (m queryMetricsStore) DeleteChatByID(ctx context.Context, id uuid.UUID) error { - start := time.Now() - r0 := m.s.DeleteChatByID(ctx, id) - m.queryLatencies.WithLabelValues("DeleteChatByID").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatByID").Inc() - return r0 -} - func (m queryMetricsStore) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { start := time.Now() r0 := m.s.DeleteChatMessagesAfterID(ctx, arg) @@ -3663,6 +3663,14 @@ func (m queryMetricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXact return r0, r1 } +func (m queryMetricsStore) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.UnarchiveChatByID(ctx, id) + m.queryLatencies.WithLabelValues("UnarchiveChatByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnarchiveChatByID").Inc() + return r0 +} + func (m queryMetricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { start := time.Now() r0 := m.s.UnarchiveTemplateVersion(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e69addee1b..6e1476a3d0 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -132,6 +132,20 @@ func (mr *MockStoreMockRecorder) AllUserIDs(ctx, includeSystem any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllUserIDs", reflect.TypeOf((*MockStore)(nil).AllUserIDs), ctx, includeSystem) } +// ArchiveChatByID mocks base method. +func (m *MockStore) ArchiveChatByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ArchiveChatByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// ArchiveChatByID indicates an expected call of ArchiveChatByID. +func (mr *MockStoreMockRecorder) ArchiveChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ArchiveChatByID", reflect.TypeOf((*MockStore)(nil).ArchiveChatByID), ctx, id) +} + // ArchiveUnusedTemplateVersions mocks base method. func (m *MockStore) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { m.ctrl.T.Helper() @@ -540,20 +554,6 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } -// DeleteChatByID mocks base method. -func (m *MockStore) DeleteChatByID(ctx context.Context, id uuid.UUID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteChatByID", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteChatByID indicates an expected call of DeleteChatByID. -func (mr *MockStoreMockRecorder) DeleteChatByID(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatByID", reflect.TypeOf((*MockStore)(nil).DeleteChatByID), ctx, id) -} - // DeleteChatMessagesAfterID mocks base method. func (m *MockStore) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { m.ctrl.T.Helper() @@ -6901,6 +6901,20 @@ func (mr *MockStoreMockRecorder) TryAcquireLock(ctx, pgTryAdvisoryXactLock any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TryAcquireLock", reflect.TypeOf((*MockStore)(nil).TryAcquireLock), ctx, pgTryAdvisoryXactLock) } +// UnarchiveChatByID mocks base method. +func (m *MockStore) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnarchiveChatByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnarchiveChatByID indicates an expected call of UnarchiveChatByID. +func (mr *MockStoreMockRecorder) UnarchiveChatByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnarchiveChatByID", reflect.TypeOf((*MockStore)(nil).UnarchiveChatByID), ctx, id) +} + // UnarchiveTemplateVersion mocks base method. func (m *MockStore) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 23816111d2..83c80a425d 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1273,7 +1273,8 @@ CREATE TABLE chats ( updated_at timestamp with time zone DEFAULT now() NOT NULL, parent_chat_id uuid, root_chat_id uuid, - last_model_config_id uuid NOT NULL + last_model_config_id uuid NOT NULL, + archived boolean DEFAULT false NOT NULL ); CREATE TABLE connection_logs ( diff --git a/coderd/database/migrations/000423_chat_archive.down.sql b/coderd/database/migrations/000423_chat_archive.down.sql new file mode 100644 index 0000000000..d49bc1a6b2 --- /dev/null +++ b/coderd/database/migrations/000423_chat_archive.down.sql @@ -0,0 +1 @@ +ALTER TABLE chats DROP COLUMN archived; diff --git a/coderd/database/migrations/000423_chat_archive.up.sql b/coderd/database/migrations/000423_chat_archive.up.sql new file mode 100644 index 0000000000..1eef52dfe1 --- /dev/null +++ b/coderd/database/migrations/000423_chat_archive.up.sql @@ -0,0 +1 @@ +ALTER TABLE chats ADD COLUMN archived boolean DEFAULT false NOT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index 30868c2d1d..8fc7649a5f 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3900,6 +3900,7 @@ type Chat struct { ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + Archived bool `db:"archived" json:"archived"` } type ChatDiffStatus struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 11fbbd544e..bc207cd6cf 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -53,6 +53,7 @@ type sqlcQuerier interface { ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error // AllUserIDs returns all UserIDs regardless of user status or deletion. AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) + ArchiveChatByID(ctx context.Context, id uuid.UUID) error // Archiving templates is a soft delete action, so is reversible. // Archiving prevents the version from being used and discovered // by listing. @@ -92,7 +93,6 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error - DeleteChatByID(ctx context.Context, id uuid.UUID) error DeleteChatMessagesAfterID(ctx context.Context, arg DeleteChatMessagesAfterIDParams) error DeleteChatMessagesByChatID(ctx context.Context, chatID uuid.UUID) error DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error @@ -727,6 +727,7 @@ type sqlcQuerier interface { // This must be called from within a transaction. The lock will be automatically // released when the transaction ends. TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) + UnarchiveChatByID(ctx context.Context, id uuid.UUID) error // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c33f1c06b6..6c30113fb3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2842,7 +2842,7 @@ WHERE 1 ) RETURNING - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived ` type AcquireChatParams struct { @@ -2870,10 +2870,20 @@ func (q *sqlQuerier) AcquireChat(ctx context.Context, arg AcquireChatParams) (Ch &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } +const archiveChatByID = `-- name: ArchiveChatByID :exec +UPDATE chats SET archived = true, updated_at = NOW() WHERE id = $1::uuid +` + +func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, archiveChatByID, id) + return err +} + const deleteAllChatQueuedMessages = `-- name: DeleteAllChatQueuedMessages :exec DELETE FROM chat_queued_messages WHERE chat_id = $1 ` @@ -2883,18 +2893,6 @@ func (q *sqlQuerier) DeleteAllChatQueuedMessages(ctx context.Context, chatID uui return err } -const deleteChatByID = `-- name: DeleteChatByID :exec -DELETE FROM - chats -WHERE - id = $1::uuid -` - -func (q *sqlQuerier) DeleteChatByID(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, deleteChatByID, id) - return err -} - const deleteChatMessagesAfterID = `-- name: DeleteChatMessagesAfterID :exec DELETE FROM chat_messages @@ -2941,7 +2939,7 @@ func (q *sqlQuerier) DeleteChatQueuedMessage(ctx context.Context, arg DeleteChat const getChatByID = `-- name: GetChatByID :one SELECT - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE @@ -2966,12 +2964,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one -SELECT id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id FROM chats WHERE id = $1::uuid FOR UPDATE +SELECT id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE id = $1::uuid FOR UPDATE ` func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) { @@ -2992,6 +2991,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } @@ -3288,11 +3288,12 @@ func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID const getChatsByOwnerID = `-- name: GetChatsByOwnerID :many SELECT - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE owner_id = $1::uuid + AND archived = false ORDER BY updated_at DESC ` @@ -3321,6 +3322,7 @@ func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ( &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ); err != nil { return nil, err } @@ -3337,7 +3339,7 @@ func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) ( const getStaleChats = `-- name: GetStaleChats :many SELECT - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE @@ -3371,6 +3373,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ); err != nil { return nil, err } @@ -3404,7 +3407,7 @@ INSERT INTO chats ( $7::text ) RETURNING - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived ` type InsertChatParams struct { @@ -3443,6 +3446,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } @@ -3568,7 +3572,7 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat const listChatsByRootID = `-- name: ListChatsByRootID :many SELECT - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE @@ -3601,6 +3605,7 @@ func (q *sqlQuerier) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ); err != nil { return nil, err } @@ -3617,7 +3622,7 @@ func (q *sqlQuerier) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID const listChildChatsByParentID = `-- name: ListChildChatsByParentID :many SELECT - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE @@ -3650,6 +3655,7 @@ func (q *sqlQuerier) ListChildChatsByParentID(ctx context.Context, parentChatID &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ); err != nil { return nil, err } @@ -3687,6 +3693,15 @@ func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) return i, err } +const unarchiveChatByID = `-- name: UnarchiveChatByID :exec +UPDATE chats SET archived = false, updated_at = NOW() WHERE id = $1::uuid +` + +func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, unarchiveChatByID, id) + return err +} + const updateChatByID = `-- name: UpdateChatByID :one UPDATE chats @@ -3696,7 +3711,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived ` type UpdateChatByIDParams struct { @@ -3722,6 +3737,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } @@ -3805,7 +3821,7 @@ SET WHERE id = $5::uuid RETURNING - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived ` type UpdateChatStatusParams struct { @@ -3840,6 +3856,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } @@ -3854,7 +3871,7 @@ SET WHERE id = $3::uuid RETURNING - id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id + id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived ` type UpdateChatWorkspaceParams struct { @@ -3881,6 +3898,7 @@ func (q *sqlQuerier) UpdateChatWorkspace(ctx context.Context, arg UpdateChatWork &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.Archived, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 9c30797731..890849e3a6 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1,8 +1,8 @@ --- name: DeleteChatByID :exec -DELETE FROM - chats -WHERE - id = @id::uuid; +-- name: ArchiveChatByID :exec +UPDATE chats SET archived = true, updated_at = NOW() WHERE id = @id::uuid; + +-- name: UnarchiveChatByID :exec +UPDATE chats SET archived = false, updated_at = NOW() WHERE id = @id::uuid; -- name: DeleteChatMessagesByChatID :exec DELETE FROM @@ -108,6 +108,7 @@ FROM chats WHERE owner_id = @owner_id::uuid + AND archived = false ORDER BY updated_at DESC; diff --git a/codersdk/chats.go b/codersdk/chats.go index c655182f73..f3751e05d3 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -41,6 +41,7 @@ type Chat struct { DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` + Archived bool `json:"archived"` } // ChatMessage represents a single message in a chat. @@ -780,9 +781,20 @@ func (c *Client) GetChat(ctx context.Context, chatID uuid.UUID) (ChatWithMessage return chat, json.NewDecoder(res.Body).Decode(&chat) } -// DeleteChat deletes a chat by ID. -func (c *Client) DeleteChat(ctx context.Context, chatID uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/%s", chatID), nil) +func (c *Client) ArchiveChat(ctx context.Context, chatID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/archive", chatID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +func (c *Client) UnarchiveChat(ctx context.Context, chatID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/unarchive", chatID), nil) if err != nil { return err } diff --git a/docs/manifest.json b/docs/manifest.json index f60af02a2a..be0108191f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1363,6 +1363,10 @@ "title": "Builds", "path": "./reference/api/builds.md" }, + { + "title": "Chats", + "path": "./reference/api/chats.md" + }, { "title": "Debug", "path": "./reference/api/debug.md" diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md new file mode 100644 index 0000000000..d75ae7ddf7 --- /dev/null +++ b/docs/reference/api/chats.md @@ -0,0 +1,37 @@ +# Chats + +## Archive a chat + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/chats/{chat}/archive + +``` + +`POST /chats/{chat}/archive` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +## Unarchive a chat + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/chats/{chat}/unarchive + +``` + +`POST /chats/{chat}/unarchive` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4023bbdeae..0b17e2fc2e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2930,8 +2930,8 @@ class ApiMethods { return response.data; }; - deleteChat = async (chatId: string): Promise => { - await this.axios.delete(`/api/experimental/chats/${chatId}`); + archiveChat = async (chatId: string): Promise => { + await this.axios.post(`/api/experimental/chats/${chatId}/archive`); }; createChatMessage = async ( diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 0f51dc375a..1065204723 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -22,8 +22,8 @@ export const createChat = (queryClient: QueryClient) => ({ }, }); -export const deleteChat = (queryClient: QueryClient) => ({ - mutationFn: (chatId: string) => API.deleteChat(chatId), +export const archiveChat = (queryClient: QueryClient) => ({ + mutationFn: (chatId: string) => API.archiveChat(chatId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: chatsKey }); }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 773e47026b..2afdc875ed 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1063,6 +1063,7 @@ export interface Chat { readonly diff_status?: ChatDiffStatus; readonly created_at: string; readonly updated_at: string; + readonly archived: boolean; } // From codersdk/chats.go diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index b2f0c5a174..ae3b9ed77c 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -121,6 +121,7 @@ const baseChatFields = { last_model_config_id: "model-config-1", created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", + archived: false, } as const; // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx b/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx index b39942f182..bef11c7167 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx @@ -112,6 +112,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({ status: "running", created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", + archived: false, }); const makeMessage = ( diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index dc96586cc0..0ade6e5e2e 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -1,13 +1,13 @@ import { watchChats } from "api/api"; import { getErrorMessage } from "api/errors"; import { + archiveChat, chatKey, chatModelConfigs, chatModels, chats, chatsKey, createChat, - deleteChat, } from "api/queries/chats"; import { workspaces } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; @@ -120,7 +120,7 @@ const AgentsPage: FC = () => { const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); const createMutation = useMutation(createChat(queryClient)); - const archiveMutation = useMutation(deleteChat(queryClient)); + const archiveMutation = useMutation(archiveChat(queryClient)); const [archivingChatId, setArchivingChatId] = useState(null); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); diff --git a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx index da06f3dad7..18ee765d4f 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx @@ -38,6 +38,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ last_model_config_id: defaultModelConfigs[0].id, created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", + archived: false, ...overrides, });