diff --git a/.changeset/fn-9014-message-archive.md b/.changeset/fn-9014-message-archive.md new file mode 100644 index 0000000000..10716927eb --- /dev/null +++ b/.changeset/fn-9014-message-archive.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add archive and restore views for mailbox messages and chat conversations. +category: feature +dev: Adds project.messages.archived via migration 0058 and POST /messages/:id/archive|unarchive routes. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4d9e9d7067..f02775dbd6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -681,7 +681,12 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne ## Chat View -Chat view provides project-scoped conversations with agents. +Chat view provides project-scoped conversations with agents. The default conversation list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action. + +## Mailbox archive + +Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unread badges ignore it. Select **Archived** to review archived messages and restore them; Archive is the default removal action and Delete remains available as an explicit destructive action. + - Direct Chat, Chat Room responders, and task-detail Planner Chat have coding workspace tools at the interactive project checkout: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. They can make user-directed edits and run shell investigation; a bound durable agent remains subject to its permanent-agent file-write and command-execution permission policy. These Chat sessions keep the checkout branch sticky unless you explicitly ask to switch it. Planning/mission interviews and WhatsApp plugin chat remain readonly. diff --git a/docs/storage.md b/docs/storage.md index 40c7cf1a11..0006ca92d5 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -535,7 +535,7 @@ The `tasks.cumulativeActiveMs` and `tasks.executionCompletedAt` columns are the | `todo_lists` | Project-scoped todo list metadata (`projectId`, title, created/updated timestamps). | | `todo_items` | Todo list items (`listId` FK) with completion state, completion timestamp, and deterministic `sortOrder`. | | `ai_sessions` *(migration-created)* | Persisted AI interactive sessions (planning/interview/subtask) with status and conversation history. Deletion is final within a bounded tombstone window (FN-7949) — see below. | -| `messages` *(migration-created)* | Inter-agent/user message mailbox storage. | +| `messages` *(migration-created)* | Inter-agent/user message mailbox storage with an `archived` flag; archived mail is retained for restore but excluded from default mailbox reads and unread counts. | | `agentRatings` *(migration-created)* | Agent performance ratings (1-5), optional reviewer metadata, and run/task attribution. | | `chat_sessions` *(migration-created)* | Chat session metadata (agent/project/model/status/title timestamps). | | `chat_messages` *(migration-created)* | Chat message history per session (`role`, `content`, thinking output, metadata). | diff --git a/packages/core/src/__tests__/postgres/message-store.pg.test.ts b/packages/core/src/__tests__/postgres/message-store.pg.test.ts index 76d33731e1..7f7887a6f6 100644 --- a/packages/core/src/__tests__/postgres/message-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/message-store.pg.test.ts @@ -74,6 +74,40 @@ pgTest("MessageStore send (PostgreSQL backend mode)", () => { expect((await store.getMessage(msg.id))?.content).toBe("hi user"); }); + it("archives correspondence by default and restores it on unarchive", async () => { + const { MessageStore } = await import("../../stores/message-store.js"); + const store = new MessageStore(null, { asyncLayer: h.layer() }); + const message = await store.sendMessage({ + fromId: "agent-a", + fromType: "agent", + toId: "agent-b", + toType: "agent", + content: "retain this correspondence", + type: "agent-to-agent", + }); + + await expect(store.archiveMessage(message.id)).resolves.toMatchObject({ id: message.id, archived: true }); + expect((await store.getInbox("agent-b", "agent")).map(({ id }) => id)).not.toContain(message.id); + expect((await store.getOutbox("agent-a", "agent")).map(({ id }) => id)).not.toContain(message.id); + expect((await store.getConversation({ id: "agent-a", type: "agent" }, { id: "agent-b", type: "agent" })).map(({ id }) => id)).not.toContain(message.id); + expect((await store.getAllAgentToAgentMessages()).map(({ id }) => id)).not.toContain(message.id); + expect((await store.getMailbox("agent-b", "agent")).unreadCount).toBe(0); + expect((await store.getInbox("agent-b", "agent", { archived: true })).map(({ id }) => id)).toContain(message.id); + + await expect(store.unarchiveMessage(message.id)).resolves.toMatchObject({ id: message.id, archived: false }); + expect((await store.getInbox("agent-b", "agent")).map(({ id }) => id)).toContain(message.id); + await expect(store.archiveMessage("missing-message")).rejects.toThrow("Message missing-message not found"); + }); + + it("treats legacy NULL archive values as active correspondence", async () => { + const { MessageStore } = await import("../../stores/message-store.js"); + const store = new MessageStore(null, { asyncLayer: h.layer() }); + const id = "legacy-null-archive"; + await h.adminSql()`INSERT INTO project.messages (project_id, id, from_id, from_type, to_id, to_type, content, type, read, archived, created_at, updated_at) + VALUES ('', ${id}, 'agent-a', 'agent', 'agent-b', 'agent', 'legacy', 'agent-to-agent', 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`; + expect((await store.getInbox("agent-b", "agent")).map(({ id: messageId }) => messageId)).toContain(id); + }); + it("round-trips native structure embeds through mailbox metadata", async () => { const { MessageStore } = await import("../../stores/message-store.js"); const store = new MessageStore(null, { asyncLayer: h.layer() }); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 05e3a9d762..90e800336c 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -100,6 +100,7 @@ import { AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, + MESSAGE_ARCHIVE_SCHEMA_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -133,7 +134,8 @@ describe("schema-applier: immutable migration identities", () => { expect(AGENT_RATINGS_PROJECT_PARTITION_VERSION).toBe("0055"); expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056"); expect(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION).toBe("0057"); - expect(SCHEMA_BASELINE_VERSION).toBe("0057"); + expect(MESSAGE_ARCHIVE_SCHEMA_VERSION).toBe("0058"); + expect(SCHEMA_BASELINE_VERSION).toBe("0058"); }); it("keeps monitor and approval isolation assigned to version 0003", () => { @@ -1806,6 +1808,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, + MESSAGE_ARCHIVE_SCHEMA_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1889,6 +1892,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, + MESSAGE_ARCHIVE_SCHEMA_VERSION, ]); }); @@ -2105,6 +2109,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, + MESSAGE_ARCHIVE_SCHEMA_VERSION, ]); }); @@ -2202,6 +2207,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, + MESSAGE_ARCHIVE_SCHEMA_VERSION, ]); }); @@ -2299,6 +2305,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, + MESSAGE_ARCHIVE_SCHEMA_VERSION, ]); }); }); diff --git a/packages/core/src/async-stores/async-message-store.ts b/packages/core/src/async-stores/async-message-store.ts index c373a2f105..8ce89222ab 100644 --- a/packages/core/src/async-stores/async-message-store.ts +++ b/packages/core/src/async-stores/async-message-store.ts @@ -43,6 +43,7 @@ interface MessageRow { content: string; type: string; read: number | null; + archived: number | null; metadata: Record | null; createdAt: string; updatedAt: string; @@ -61,6 +62,7 @@ const messageColumns = { content: schema.project.messages.content, type: schema.project.messages.type, read: schema.project.messages.read, + archived: schema.project.messages.archived, metadata: schema.project.messages.metadata, createdAt: schema.project.messages.createdAt, updatedAt: schema.project.messages.updatedAt, @@ -76,6 +78,7 @@ function rowToMessage(row: MessageRow): Message { content: row.content, type: row.type as MessageType, read: (row.read ?? 0) === 1, + archived: (row.archived ?? 0) === 1, metadata: row.metadata ?? undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -89,6 +92,13 @@ function participantIdsForLookup(ownerId: string, ownerType: ParticipantType): s return [ownerId]; } +/** Archived correspondence stays out of default reads; NULL preserves pre-migration rows. */ +function archivedCondition(archived?: boolean) { + if (archived === true) return eq(schema.project.messages.archived, 1); + if (archived === false) return or(eq(schema.project.messages.archived, 0), sql`${schema.project.messages.archived} IS NULL`)!; + return or(eq(schema.project.messages.archived, 0), sql`${schema.project.messages.archived} IS NULL`)!; +} + /** * FNXC:MessageStore 2026-06-24-07:00: * Create (send) a message. Non-destructive INSERT. @@ -115,6 +125,7 @@ export async function sendMessage( content: sanitizedContent, type: message.type, read: message.read ? 1 : 0, + archived: message.archived ? 1 : 0, metadata: sanitizedMetadata, createdAt: message.createdAt, updatedAt: message.updatedAt, @@ -124,6 +135,7 @@ export async function sendMessage( content: sanitizedContent, metadata: sanitizedMetadata, read: message.read ? 1 : 0, + archived: message.archived ? 1 : 0, }); } @@ -146,6 +158,7 @@ export async function sendMessageOnce( content: message.content, type: message.type, read: message.read ? 1 : 0, + archived: message.archived ? 1 : 0, metadata: message.metadata, createdAt: message.createdAt, updatedAt: message.updatedAt, @@ -219,7 +232,7 @@ export async function queryMessagesByParticipant( const idCol = direction === "to" ? schema.project.messages.toId : schema.project.messages.fromId; const typeCol = direction === "to" ? schema.project.messages.toType : schema.project.messages.fromType; const participantIds = participantIdsForLookup(ownerId, ownerType); - const conditions: ReturnType[] = [ + const conditions = [ inArray(idCol, participantIds), eq(typeCol, ownerType), ]; @@ -229,6 +242,7 @@ export async function queryMessagesByParticipant( if (filter?.read !== undefined) { conditions.push(eq(schema.project.messages.read, filter.read ? 1 : 0)); } + conditions.push(archivedCondition(filter?.archived)); const limit = filter?.limit ?? 100; const offset = filter?.offset ?? 0; const rows = await handle @@ -264,6 +278,22 @@ export async function markMessageAsRead( * Mark all inbox messages as read for a participant. Returns the count of * messages that were unread before the update. */ +export async function setMessageArchived( + handle: QueryHandle, + id: string, + archived: boolean, +): Promise { + const existing = await getMessage(handle, id); + if (!existing) return null; + if (existing.archived === archived) return existing; + const rows = await handle + .update(schema.project.messages) + .set({ archived: archived ? 1 : 0, updatedAt: new Date().toISOString() }) + .where(eq(schema.project.messages.id, id)) + .returning(messageColumns); + return rows[0] ? rowToMessage(rows[0] as MessageRow) : null; +} + export async function markAllMessagesAsRead( handle: QueryHandle, ownerId: string, @@ -279,6 +309,7 @@ export async function markAllMessagesAsRead( inArray(schema.project.messages.toId, participantIds), eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.read, 0), + archivedCondition(), ), ); const count = countRows[0]?.count ?? 0; @@ -290,6 +321,7 @@ export async function markAllMessagesAsRead( inArray(schema.project.messages.toId, participantIds), eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.read, 0), + archivedCondition(), ), ); return count; @@ -343,7 +375,7 @@ export async function getConversation( handle: QueryHandle, participantA: { id: string; type: ParticipantType }, participantB: { id: string; type: ParticipantType }, - options?: { limit?: number }, + options?: Pick, ): Promise { const limit = Math.max(1, options?.limit ?? DEFAULT_CONVERSATION_LIMIT); const aIds = participantIdsForLookup(participantA.id, participantA.type); @@ -352,19 +384,22 @@ export async function getConversation( .select(messageColumns) .from(schema.project.messages) .where( - or( - and( - inArray(schema.project.messages.fromId, aIds), - eq(schema.project.messages.fromType, participantA.type), - inArray(schema.project.messages.toId, bIds), - eq(schema.project.messages.toType, participantB.type), - ), - and( - inArray(schema.project.messages.fromId, bIds), - eq(schema.project.messages.fromType, participantB.type), - inArray(schema.project.messages.toId, aIds), - eq(schema.project.messages.toType, participantA.type), + and( + or( + and( + inArray(schema.project.messages.fromId, aIds), + eq(schema.project.messages.fromType, participantA.type), + inArray(schema.project.messages.toId, bIds), + eq(schema.project.messages.toType, participantB.type), + ), + and( + inArray(schema.project.messages.fromId, bIds), + eq(schema.project.messages.fromType, participantB.type), + inArray(schema.project.messages.toId, aIds), + eq(schema.project.messages.toType, participantA.type), + ), ), + archivedCondition(options?.archived), ), ) .orderBy(desc(schema.project.messages.createdAt)) @@ -390,6 +425,7 @@ export async function getMailbox( inArray(schema.project.messages.toId, participantIds), eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.read, 0), + archivedCondition(), ), ); const unreadCount = unreadRows[0]?.count ?? 0; @@ -400,6 +436,7 @@ export async function getMailbox( and( inArray(schema.project.messages.toId, participantIds), eq(schema.project.messages.toType, ownerType), + archivedCondition(), ), ) .orderBy(desc(schema.project.messages.createdAt), desc(schema.project.messages.id)) @@ -415,11 +452,14 @@ export async function getMailbox( /** * Get all agent-to-agent messages (newest first). */ -export async function getAllAgentToAgentMessages(handle: QueryHandle): Promise { +export async function getAllAgentToAgentMessages( + handle: QueryHandle, + filter?: Pick, +): Promise { const rows = await handle .select(messageColumns) .from(schema.project.messages) - .where(eq(schema.project.messages.type, "agent-to-agent")) + .where(and(eq(schema.project.messages.type, "agent-to-agent"), archivedCondition(filter?.archived))) .orderBy(desc(schema.project.messages.createdAt), desc(schema.project.messages.id)); return rows.map((row) => rowToMessage(row as MessageRow)); } @@ -435,6 +475,7 @@ export async function getUnreadAgentToAgentCount(handle: QueryHandle): Promise [ primaryKey({ columns: [t.projectId, t.id] }), index("idxMessagesTo").on(t.toId, t.toType, t.read), + index("idxMessagesToArchived").on(t.toId, t.toType, t.archived), index("idxMessagesFrom").on(t.fromId, t.fromType), index("idxMessagesCreatedAt").on(t.createdAt), ]); diff --git a/packages/core/src/stores/message-store.ts b/packages/core/src/stores/message-store.ts index e088a11859..e0b8e82e91 100644 --- a/packages/core/src/stores/message-store.ts +++ b/packages/core/src/stores/message-store.ts @@ -33,6 +33,10 @@ export interface MessageStoreEvents { "message:received": [message: Message]; /** Emitted when a message is marked as read */ "message:read": [message: Message]; + /** Emitted when a message is archived */ + "message:archived": [message: Message]; + /** Emitted when a message is restored from archive */ + "message:unarchived": [message: Message]; /** Emitted when a message is deleted */ "message:deleted": [messageId: string]; /** Emitted when proposal metadata changes without creating a new message. */ @@ -51,6 +55,7 @@ interface MessageRow { content: string; type: string; read: number; + archived: number | null; metadata: string | null; createdAt: string; updatedAt: string; @@ -109,12 +114,12 @@ export class MessageStore extends EventEmitter { // Prepare frequently-run statements (SQLite path) const sqliteDb = this.db!; this.stmtInsert = sqliteDb.prepare(` - INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, archived, metadata, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); this.stmtInsertOnce = sqliteDb.prepare(` - INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, archived, metadata, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); this.stmtGetById = sqliteDb.prepare(` @@ -150,6 +155,7 @@ export class MessageStore extends EventEmitter { content: row.content, type: row.type as MessageType, read: row.read === 1, + archived: (row.archived ?? 0) === 1, metadata: fromJson(row.metadata), createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -188,6 +194,7 @@ export class MessageStore extends EventEmitter { content: sanitizeTextValue(input.content), type: input.type, read: false, + archived: false, metadata: sanitizeJsonbValue(input.metadata), createdAt: now, updatedAt: now, @@ -204,6 +211,7 @@ export class MessageStore extends EventEmitter { content: message.content, type: message.type, read: message.read, + archived: message.archived, metadata: message.metadata ?? null, createdAt: message.createdAt, updatedAt: message.updatedAt, @@ -218,6 +226,7 @@ export class MessageStore extends EventEmitter { message.content, message.type, message.read ? 1 : 0, + message.archived ? 1 : 0, toJsonNullable(message.metadata), message.createdAt, message.updatedAt, @@ -295,6 +304,7 @@ export class MessageStore extends EventEmitter { content: sanitizeTextValue(input.content), type: input.type, read: false, + archived: false, metadata: sanitizeJsonbValue(input.metadata), createdAt: now, updatedAt: now, @@ -316,6 +326,7 @@ export class MessageStore extends EventEmitter { message.content, message.type, 0, + 0, toJsonNullable(message.metadata), message.createdAt, message.updatedAt, @@ -467,6 +478,7 @@ export class MessageStore extends EventEmitter { whereClauses.push("read = ?"); params.push(filter.read ? 1 : 0); } + whereClauses.push(filter?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)"); const whereSql = whereClauses.join(" AND "); const limit = filter?.limit ?? 100; @@ -533,19 +545,47 @@ export class MessageStore extends EventEmitter { // Get count of unread messages before updating const unreadRow = this.db!.prepare(` - SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0 + SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0 AND (archived = 0 OR archived IS NULL) `).get(...participantIds, ownerType) as { count: number } | undefined; const count = unreadRow?.count ?? 0; // Mark all as read this.db!.prepare(` - UPDATE messages SET read = 1, updatedAt = ? WHERE ${toIdPredicate} AND toType = ? AND read = 0 + UPDATE messages SET read = 1, updatedAt = ? WHERE ${toIdPredicate} AND toType = ? AND read = 0 AND (archived = 0 OR archived IS NULL) `).run(now, ...participantIds, ownerType); this.db!.bumpLastModified(); return count; } + /** Archive a message without destroying its correspondence history. */ + async archiveMessage(id: string): Promise { + return this.setMessageArchived(id, true); + } + + /** Restore a previously archived message to default mailbox lists. */ + async unarchiveMessage(id: string): Promise { + return this.setMessageArchived(id, false); + } + + private async setMessageArchived(id: string, archived: boolean): Promise { + if (this.asyncLayer) { + const updated = await asyncMessageStore.setMessageArchived(this.asyncLayer.db, id, archived); + if (!updated) throw new Error(`Message ${id} not found`); + this.emit(archived ? "message:archived" : "message:unarchived", updated); + return updated; + } + const existing = await this.getMessage(id); + if (!existing) throw new Error(`Message ${id} not found`); + if (existing.archived === archived) return existing; + this.db!.prepare("UPDATE messages SET archived = ?, updatedAt = ? WHERE id = ?") + .run(archived ? 1 : 0, new Date().toISOString(), id); + this.db!.bumpLastModified(); + const updated = (await this.getMessage(id))!; + this.emit(archived ? "message:archived" : "message:unarchived", updated); + return updated; + } + /** * Delete a message by ID. * @param id - The message ID @@ -632,7 +672,7 @@ export class MessageStore extends EventEmitter { async getConversation( participantA: { id: string; type: ParticipantType }, participantB: { id: string; type: ParticipantType }, - options?: { limit?: number }, + options?: Pick, ): Promise { if (this.asyncLayer) { return asyncMessageStore.getConversation(this.asyncLayer.db, participantA, participantB, options); @@ -659,7 +699,7 @@ export class MessageStore extends EventEmitter { (${participantAFromPredicate} AND fromType = ? AND ${participantBToPredicate} AND toType = ?) OR (${participantBFromPredicate} AND fromType = ? AND ${participantAToPredicate} AND toType = ?) - ) + ) AND ${options?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)"} ORDER BY createdAt DESC LIMIT ? `).all( @@ -702,12 +742,12 @@ export class MessageStore extends EventEmitter { : `toId IN (${participantIds.map(() => "?").join(", ")})`; const unreadRow = this.db!.prepare(` - SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0 + SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0 AND (archived = 0 OR archived IS NULL) `).get(...participantIds, ownerType) as { count: number } | undefined; const unreadCount = unreadRow?.count ?? 0; const lastRow = this.db!.prepare(` - SELECT * FROM messages WHERE ${toIdPredicate} AND toType = ? ORDER BY createdAt DESC, rowid DESC LIMIT 1 + SELECT * FROM messages WHERE ${toIdPredicate} AND toType = ? AND (archived = 0 OR archived IS NULL) ORDER BY createdAt DESC, rowid DESC LIMIT 1 `).get(...participantIds, ownerType) as unknown as MessageRow | undefined; const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined; @@ -723,13 +763,13 @@ export class MessageStore extends EventEmitter { * Get all agent-to-agent messages across all agents. * @returns Array of messages (newest first) */ - async getAllAgentToAgentMessages(): Promise { + async getAllAgentToAgentMessages(filter?: Pick): Promise { if (this.asyncLayer) { - return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db); + return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db, filter); } const rows = this.db!.prepare(` SELECT * FROM messages - WHERE type = ? + WHERE type = ? AND ${filter?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)"} ORDER BY createdAt DESC, rowid DESC `).all("agent-to-agent"); @@ -745,7 +785,7 @@ export class MessageStore extends EventEmitter { } const row = this.db!.prepare(` SELECT COUNT(*) as count FROM messages - WHERE type = ? AND read = 0 + WHERE type = ? AND read = 0 AND (archived = 0 OR archived IS NULL) `).get("agent-to-agent") as { count: number } | undefined; return row?.count ?? 0; diff --git a/packages/core/src/types/messaging/messages.ts b/packages/core/src/types/messaging/messages.ts index 923c5550a9..b5f18d7264 100644 --- a/packages/core/src/types/messaging/messages.ts +++ b/packages/core/src/types/messaging/messages.ts @@ -151,6 +151,13 @@ export interface Message { type: MessageType; /** Whether the recipient has read this message */ read: boolean; + /* + FNXC:MessageArchive 2026-08-12-22:14: + Archive is the default non-destructive removal action; delete remains an explicit + operator choice. Default message reads exclude archived correspondence. + */ + /** Whether the message is archived */ + archived: boolean; /** Optional extra data */ metadata?: MessageMetadata; /** ISO-8601 timestamp of creation */ @@ -183,6 +190,8 @@ export interface MessageFilter { type?: MessageType; /** Filter by read status */ read?: boolean; + /** Filter by archived status; omitted excludes archived messages. */ + archived?: boolean; /** Maximum number of messages to return */ limit?: number; /** Number of messages to skip (for pagination) */ diff --git a/packages/dashboard/app/api/chat/messaging.ts b/packages/dashboard/app/api/chat/messaging.ts index b6c91fe679..824e94f8a8 100644 --- a/packages/dashboard/app/api/chat/messaging.ts +++ b/packages/dashboard/app/api/chat/messaging.ts @@ -123,13 +123,14 @@ export interface ApprovalListResponse { /** Fetch inbox messages for the current user. */ export function fetchInbox( - options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType }, + options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType; archived?: boolean }, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.offset !== undefined) params.set("offset", String(options.offset)); if (options?.unreadOnly) params.set("unreadOnly", "true"); + if (options?.archived) params.set("archived", "true"); if (options?.type) params.set("type", options.type); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; @@ -138,12 +139,13 @@ export function fetchInbox( /** Fetch sent messages for the current user. */ export function fetchOutbox( - options?: { limit?: number; offset?: number; type?: MessageType }, + options?: { limit?: number; offset?: number; type?: MessageType; archived?: boolean }, projectId?: string, ): Promise { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.offset !== undefined) params.set("offset", String(options.offset)); + if (options?.archived) params.set("archived", "true"); if (options?.type) params.set("type", options.type); if (projectId) params.set("projectId", projectId); const query = params.size > 0 ? `?${params.toString()}` : ""; @@ -187,6 +189,16 @@ export function markAllMessagesRead(projectId?: string): Promise { + return api(withProjectId(`/messages/${encodeURIComponent(id)}/archive`, projectId), { method: "POST" }); +} + +/** Restore a previously archived message. */ +export function unarchiveMessage(id: string, projectId?: string): Promise { + return api(withProjectId(`/messages/${encodeURIComponent(id)}/unarchive`, projectId), { method: "POST" }); +} + /** Delete a message. */ export function deleteMessage(id: string, projectId?: string): Promise { return api(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), { @@ -199,19 +211,30 @@ export function fetchConversation( participantId: string, participantType: ParticipantType, projectId?: string, + options?: { archived?: boolean }, ): Promise { const path = `/messages/conversation/${encodeURIComponent(participantType)}/${encodeURIComponent(participantId)}`; - return api(withProjectId(path, projectId)); + const params = new URLSearchParams(); + if (options?.archived) params.set("archived", "true"); + if (projectId) params.set("projectId", projectId); + return api(`${path}${params.size ? `?${params.toString()}` : ""}`); } /** Fetch an agent's mailbox (admin read-only view). */ -export function fetchAgentMailbox(agentId: string, projectId?: string): Promise { - return api(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId)); +export function fetchAgentMailbox(agentId: string, projectId?: string, options?: { archived?: boolean }): Promise { + const path = `/agents/${encodeURIComponent(agentId)}/mailbox`; + const params = new URLSearchParams(); + if (options?.archived) params.set("archived", "true"); + if (projectId) params.set("projectId", projectId); + return api(`${path}${params.size ? `?${params.toString()}` : ""}`); } /** Fetch aggregate mailbox across all agent-to-agent messages (admin read-only view). */ -export function fetchAllAgentMailbox(projectId?: string): Promise { - return api(withProjectId("/agents/mailbox/all", projectId)); +export function fetchAllAgentMailbox(projectId?: string, options?: { archived?: boolean }): Promise { + const params = new URLSearchParams(); + if (options?.archived) params.set("archived", "true"); + if (projectId) params.set("projectId", projectId); + return api(`/agents/mailbox/all${params.size ? `?${params.toString()}` : ""}`); } export function fetchApprovals( diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index c2eca72e39..eef2273cc5 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1287,6 +1287,7 @@ export type { */ export { addAgentRating, + archiveMessage, createProposedTask, decideApproval, deleteAgentRating, @@ -1311,6 +1312,7 @@ export { resetAgentBudget, sendMessage, triggerAgentReflection, + unarchiveMessage, } from "./chat/messaging.js"; export type { AgentMailboxResponse, diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 6f6d8f059b..7ddf696e9a 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -626,6 +626,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout selectSession, createSession, archiveSession, + archivedSessions, + refreshArchivedSessions, + unarchiveSession, renameSession, pinSession, pinnedCount, @@ -679,6 +682,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return getPersistedChatDraft(initialDraftKey); }); const [contextMenu, setContextMenu] = useState<{ sessionId: string; anchorX: number; anchorY: number; anchorRight: boolean; x: number; y: number } | null>(null); + const [showArchivedSessions, setShowArchivedSessions] = useState(false); const contextMenuRef = useRef(null); /* FNXC:ChatSidebar 2026-07-17-00:12: @@ -2491,6 +2495,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout [archiveSession, addToast], ); + const handleRestoreArchived = useCallback(async (id: string) => { + try { await unarchiveSession(id); addToast(t("chat.conversationRestored", "Conversation restored"), "success"); } + catch { addToast(t("chat.failedToRestoreConversation", "Failed to restore conversation"), "error"); } + }, [unarchiveSession, addToast, t]); + const openRenameDialog = useCallback( (id: string) => { const session = filteredSessions.find((item) => item.id === id) ?? (activeSession?.id === id ? activeSession : null); @@ -3211,8 +3220,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout FNXC:ChatHeader 2026-06-22-18:44: Very narrow chat headers collapse Direct/Rooms to icons while retaining aria-selected tabs and text labels for wider headers. The segmented control must stay height-aligned with the ViewHeader action row, so icon+label markup is stable and CSS hides only the label. */ - const pinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt != null); - const unpinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt == null); + const visibleSidebarSessions = showArchivedSessions ? archivedSessions : filteredSessions; + const pinnedFilteredSessions = visibleSidebarSessions.filter((session) => session.pinnedAt != null); + const unpinnedFilteredSessions = visibleSidebarSessions.filter((session) => session.pinnedAt == null); const contextMenuSession = contextMenu ? filteredSessions.find((session) => session.id === contextMenu.sessionId) ?? (activeSession?.id === contextMenu.sessionId ? activeSession : undefined) : undefined; @@ -3301,6 +3311,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout > {session.pinnedAt ? : } +
{sessionsLoading ? (
{t("chat.loadingConversations", "Loading...")}
- ) : filteredSessions.length === 0 ? ( + ) : ((showArchivedSessions ? archivedSessions : filteredSessions).length === 0) ? (
{t("chat.noConversationsYet", "No conversations yet")}
) : ( <> @@ -3534,7 +3546,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout e.preventDefault(); openSessionMenu(session.id, e.clientX, e.clientY); }} - data-testid={`chat-session-${session.id}`} + data-testid={showArchivedSessions ? `chat-archived-session-${session.id}` : `chat-session-${session.id}`} > {/* FNXC:ChatSidebar 2026-07-16-00:00: @@ -3579,6 +3591,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })}
) : null} + {showArchivedSessions ? : null}
{sessionResolvedModel?.provider ? : null} diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index bf1643ad4d..bdf53191fe 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -10,6 +10,7 @@ import { Inbox as InboxIcon, Bot, Trash2, + Archive, CheckCheck, Loader2, RefreshCw, @@ -27,6 +28,8 @@ import { fetchAllAgentMailbox, markMessageRead, markAllMessagesRead, + archiveMessage, + unarchiveMessage, deleteMessage, fetchConversation, fetchMessage, @@ -52,7 +55,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; // ── Types ───────────────────────────────────────────────────────────────── -type MailboxTab = "inbox" | "outbox" | "agents"; +type MailboxTab = "inbox" | "outbox" | "archived" | "agents"; const ALL_AGENTS_MAILBOX_ID = "__all_agents__"; @@ -302,9 +305,11 @@ export function MailboxModal({ const [inbox, setInbox] = useState(() => initialInbox ?? null); const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all"); const [outbox, setOutbox] = useState(() => initialOutbox ?? null); + const [archivedInbox, setArchivedInbox] = useState(null); const [unreadCount, setUnreadCount] = useState(initialUnreadCount ?? 0); const [isLoading, setIsLoading] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); + const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState(null); const [conversationMessages, setConversationMessages] = useState([]); const [showComposer, setShowComposer] = useState(false); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); @@ -385,6 +390,25 @@ export function MailboxModal({ } }, [inboxCacheKey, projectId, unreadCountCacheKey]); + const loadArchivedInbox = useCallback(async () => { + setIsLoading(true); + try { + /* + FNXC:MessageArchive 2026-08-12-22:38: + Archived mail must remain restorable regardless of whether it originated in the inbox, outbox, or an agent mailbox. + Deduplicate the combined source results because aggregate agent queries can overlap a participant-specific response. + */ + const [inbox, outbox, agentMailbox] = await Promise.all([ + fetchInbox({ limit: 50, archived: true }, projectId), + fetchOutbox({ limit: 50, archived: true }, projectId), + fetchAllAgentMailbox(projectId, { archived: true }), + ]); + const messages = [...inbox.messages, ...outbox.messages, ...agentMailbox.messages] + .filter((message, index, all) => all.findIndex(({ id }) => id === message.id) === index); + setArchivedInbox({ messages, total: messages.length, unreadCount: 0 }); + } finally { setIsLoading(false); } + }, [projectId]); + const loadOutbox = useCallback(async () => { const shouldSkipOpenSpinner = skipOpenSpinnerOutboxRef.current; if (!shouldSkipOpenSpinner) { @@ -464,7 +488,8 @@ export function MailboxModal({ if (!isOpen) return; if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); - }, [isOpen, activeTab, loadInbox, loadOutbox]); + else if (activeTab === "archived") loadArchivedInbox(); + }, [isOpen, activeTab, loadInbox, loadOutbox, loadArchivedInbox]); // Load agent mailbox when selected useEffect(() => { @@ -669,8 +694,28 @@ export function MailboxModal({ } }, [addToast, inboxCacheKey, projectId, unreadCountCacheKey, t]); + /* FNXC:MessageArchive 2026-08-12-22:14: Archive is the default removal action; deletion is explicit and destructive. */ + const handleArchiveMessage = useCallback(async (id: string) => { + try { + await archiveMessage(id, projectId); + handleCloseMessage(); + if (activeTab === "inbox") loadInbox(); + else if (activeTab === "outbox") loadOutbox(); + else if (activeTab === "archived") loadArchivedInbox(); + else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); + else if (selectedAgentId) loadAgentMailbox(selectedAgentId); + void refreshUnreadCount(); + addToast?.("Message archived", "success"); + } catch { addToast?.("Failed to archive message", "error"); } + }, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadArchivedInbox, loadAgentMailbox, loadAllAgentsMailbox, refreshUnreadCount, addToast, handleCloseMessage]); + const handleUnarchiveMessage = useCallback(async (id: string) => { + try { await unarchiveMessage(id, projectId); handleCloseMessage(); loadArchivedInbox(); void refreshUnreadCount(); addToast?.("Message restored", "success"); } + catch { addToast?.("Failed to restore message", "error"); } + }, [projectId, loadArchivedInbox, refreshUnreadCount, addToast, handleCloseMessage]); + const handleDeleteMessage = useCallback(async (id: string) => { consumeCurrentDeepLink(); + setPendingDeleteMessageId(null); try { await deleteMessage(id, projectId); setSelectedMessage(null); @@ -860,6 +905,7 @@ export function MailboxModal({ {t("mailbox.outboxTab", "Outbox")} + )} - + {selectedMessage.archived ? : } + {pendingDeleteMessageId === selectedMessage.id ? ( + <> + {/* FNXC:MessageArchive 2026-08-12-22:51: Deletion requires a second intentional click so archive remains the safe default removal action. */} + + + + ) : ( + + )}
@@ -1044,6 +1092,12 @@ export function MailboxModal({ {!selectedMessage && !showComposer && ( <> {/* Inbox Tab */} + {activeTab === "archived" && ( +
+ {archivedInbox?.messages.length === 0 &&
No archived messages
} + {archivedInbox?.messages.map((message) => )} +
+ )} {activeTab === "inbox" && (
diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 6ce3f07735..57e2d3bedf 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -8,6 +8,7 @@ import { Inbox as InboxIcon, Bot, Trash2, + Archive, CheckCheck, Loader2, RefreshCw, @@ -23,6 +24,8 @@ import { fetchAllAgentMailbox, markMessageRead, markAllMessagesRead, + archiveMessage, + unarchiveMessage, deleteMessage, fetchConversation, fetchAgents, @@ -57,7 +60,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; // ── Types ───────────────────────────────────────────────────────────────── -type MailboxTab = "inbox" | "outbox" | "agents" | "approvals"; +type MailboxTab = "inbox" | "outbox" | "archived" | "agents" | "approvals"; interface MailboxViewProps { projectId?: string; @@ -240,9 +243,11 @@ export function MailboxView({ const consumedComposePrefillNonceRef = useRef(null); const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all"); const [outbox, setOutbox] = useState(null); + const [archivedInbox, setArchivedInbox] = useState(null); const [unreadCount, setUnreadCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); + const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState(null); const [conversationMessages, setConversationMessages] = useState([]); const [showComposer, setShowComposer] = useState(false); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); @@ -456,6 +461,28 @@ export function MailboxView({ } }, [projectId, onUnreadCountChange, captureMailboxScroll]); + const loadArchivedInbox = useCallback(async () => { + captureMailboxScroll(); + setIsLoading(true); + try { + /* + FNXC:MessageArchive 2026-08-12-22:38: + The archive is a restore surface for every mailbox source, including sent and agent mail. + Combine the source-specific archive queries and deduplicate IDs so archiving never strands a message outside its restore view. + */ + const [inbox, outbox, agentMailbox] = await Promise.all([ + fetchInbox({ limit: 50, archived: true }, projectId), + fetchOutbox({ limit: 50, archived: true }, projectId), + fetchAllAgentMailbox(projectId, { archived: true }), + ]); + const messages = [...inbox.messages, ...outbox.messages, ...agentMailbox.messages] + .filter((message, index, all) => all.findIndex(({ id }) => id === message.id) === index); + setArchivedInbox({ messages, total: messages.length, unreadCount: 0 }); + } finally { + setIsLoading(false); + } + }, [projectId, captureMailboxScroll]); + const loadOutbox = useCallback(async () => { captureMailboxScroll(); setIsLoading(true); @@ -546,11 +573,12 @@ export function MailboxView({ useEffect(() => { if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); + else if (activeTab === "archived") loadArchivedInbox(); else if (activeTab === "agents") loadAgents(); else if (activeTab === "approvals") { void loadApprovals(approvalSubTab); } - }, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]); + }, [activeTab, loadInbox, loadOutbox, loadArchivedInbox, loadAgents, loadApprovals, approvalSubTab]); // Load agent mailbox when selected useEffect(() => { @@ -746,14 +774,45 @@ export function MailboxView({ } }, [projectId, addToast, onUnreadCountChange]); + /* + FNXC:MessageArchive 2026-08-12-22:14: + Archive is the default mailbox removal action. Delete remains an explicit destructive choice. + */ + const handleArchiveMessage = useCallback(async (id: string) => { + consumeCurrentDeepLink(); + try { + await archiveMessage(id, projectId); + dismissMessage(); + if (activeTab === "archived") loadArchivedInbox(); + else if (activeTab === "outbox") loadOutbox(); + else if (activeTab === "inbox") loadInbox(); + else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); + else if (selectedAgentId) loadAgentMailbox(selectedAgentId); + refreshUnreadCount(); + addToast?.("Message archived", "success"); + } catch { addToast?.("Failed to archive message", "error"); } + }, [projectId, activeTab, selectedAgentId, loadArchivedInbox, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, refreshUnreadCount, addToast, consumeCurrentDeepLink, dismissMessage]); + + const handleUnarchiveMessage = useCallback(async (id: string) => { + try { + await unarchiveMessage(id, projectId); + dismissMessage(); + loadArchivedInbox(); + refreshUnreadCount(); + addToast?.("Message restored", "success"); + } catch { addToast?.("Failed to restore message", "error"); } + }, [projectId, loadArchivedInbox, refreshUnreadCount, addToast, dismissMessage]); + const handleDeleteMessage = useCallback(async (id: string) => { consumeCurrentDeepLink(); + setPendingDeleteMessageId(null); try { await deleteMessage(id, projectId); dismissMessage(); // Refresh current tab if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); + else if (activeTab === "archived") loadArchivedInbox(); else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (selectedAgentId) loadAgentMailbox(selectedAgentId); addToast?.("Message deleted", "success"); @@ -942,14 +1001,30 @@ export function MailboxView({ {t("mailbox.reply", "Reply")} )} - + {selectedMessage.archived ? ( + + ) : ( + + )} + {pendingDeleteMessageId === selectedMessage.id ? ( + <> + {/* FNXC:MessageArchive 2026-08-12-22:51: Hard deletion needs a second deliberate click because archive is the default safe removal action. */} + + + + ) : ( + + )}
@@ -1059,6 +1134,17 @@ export function MailboxView({ const renderListPane = () => ( <> + {activeTab === "archived" && ( +
+ {isLoading && !archivedInbox && } + {archivedInbox?.messages.length === 0 &&
No archived messages
} + {archivedInbox?.messages.map((message) => ( + + ))} +
+ )} {activeTab === "inbox" && (
@@ -1490,6 +1576,7 @@ export function MailboxView({ onClick={() => { if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); + else if (activeTab === "archived") loadArchivedInbox(); else if (activeTab === "approvals") loadApprovals(approvalSubTab); else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (selectedAgentId) loadAgentMailbox(selectedAgentId); @@ -1523,6 +1610,7 @@ export function MailboxView({ {t("mailbox.outbox", "Outbox")} +