feat(chats): archive chats instead of hard-deleting them (#22406)

## 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)
This commit is contained in:
Kyle Carberry
2026-02-27 16:46:19 -05:00
committed by GitHub
parent 52dad56462
commit 12083441e0
27 changed files with 319 additions and 140 deletions
+28
View File
@@ -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": [
+24
View File
@@ -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": [
+7 -7
View File
@@ -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
+52 -44
View File
@@ -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.
+11 -13
View File
@@ -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)
})
}
+2 -1
View File
@@ -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)
+22 -11
View File
@@ -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 {
+9 -3
View File
@@ -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{})
+16 -8
View File
@@ -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)
+28 -14
View File
@@ -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()
+2 -1
View File
@@ -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 (
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN archived;
@@ -0,0 +1 @@
ALTER TABLE chats ADD COLUMN archived boolean DEFAULT false NOT NULL;
+1
View File
@@ -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 {
+2 -1
View File
@@ -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
+41 -23
View File
@@ -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
}
+6 -5
View File
@@ -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;
+15 -3
View File
@@ -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
}
+4
View File
@@ -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"
+37
View File
@@ -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 | |
+2 -2
View File
@@ -2930,8 +2930,8 @@ class ApiMethods {
return response.data;
};
deleteChat = async (chatId: string): Promise<void> => {
await this.axios.delete(`/api/experimental/chats/${chatId}`);
archiveChat = async (chatId: string): Promise<void> => {
await this.axios.post(`/api/experimental/chats/${chatId}/archive`);
};
createChatMessage = async (
+2 -2
View File
@@ -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 });
},
+1
View File
@@ -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
@@ -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;
// ---------------------------------------------------------------------------
@@ -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 = (
+2 -2
View File
@@ -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<string | null>(null);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -38,6 +38,7 @@ const buildChat = (overrides: Partial<Chat> = {}): 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,
});