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
+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