FN-9014: add chat and mailbox message archiving

Add archived-message lifecycle support across chat and mailbox views.

- Persist archive state with a PostgreSQL migration and message-store APIs.
- Expose archive, restore, and permanent-delete messaging endpoints.
- Add archived-message views and archive-by-default actions with regression coverage.
- Document storage behavior and add a release changeset.

Files changed:
 .changeset/fn-9014-message-archive.md              |   7 ++
 docs/dashboard-guide.md                            |   7 +-
 docs/storage.md                                    |   2 +-
 .../__tests__/postgres/message-store.pg.test.ts    |  34 +++++++
 .../src/__tests__/postgres/schema-applier.test.ts  |   9 +-
 .../core/src/async-stores/async-message-store.ts   |  73 +++++++++++---
 .../migrations/0058_fn_9014_message_archive.sql    |  13 +++
 packages/core/src/postgres/schema-applier.ts       |  14 ++-
 packages/core/src/postgres/schema/project.ts       |   2 +
 packages/core/src/stores/message-store.ts          |  68 ++++++++++---
 packages/core/src/types/messaging/messages.ts      |   9 ++
 packages/dashboard/app/api/chat/messaging.ts       |  37 +++++--
 packages/dashboard/app/api/legacy.ts               |   2 +
 packages/dashboard/app/components/ChatView.tsx     |  21 +++-
 packages/dashboard/app/components/MailboxModal.tsx |  74 ++++++++++++--
 packages/dashboard/app/components/MailboxView.tsx  | 108 +++++++++++++++++++--
 .../__tests__/ChatView.core-contracts.test.tsx     |  24 +++++
 .../components/__tests__/ChatView.test-harness.tsx |   3 +
 .../app/components/__tests__/MailboxModal.test.tsx |   6 +-
 .../app/components/__tests__/MailboxView.test.tsx  |  10 +-
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |  35 ++++++-
 packages/dashboard/app/hooks/useChat.ts            |  38 +++++++-
 .../__tests__/messaging-archive-routes.test.ts     |  86 ++++++++++++++++
 .../src/routes/register-messaging-scripts.ts       |  33 ++++++-
 24 files changed, 635 insertions(+), 80 deletions(-)

Fusion-Task-Id: FN-9014

Fusion-Task-Lineage: 1dbd02c3-1180-4d72-8cbc-9594e5f355dc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 16:03:30 -07:00
parent 8699ff41e2
commit fc56161c9d
24 changed files with 635 additions and 80 deletions

View File

@@ -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.

View File

@@ -681,7 +681,12 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
## Chat View ## 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. - 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.

View File

@@ -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_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`. | | `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. | | `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. | | `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_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). | | `chat_messages` *(migration-created)* | Chat message history per session (`role`, `content`, thinking output, metadata). |

View File

@@ -74,6 +74,40 @@ pgTest("MessageStore send (PostgreSQL backend mode)", () => {
expect((await store.getMessage(msg.id))?.content).toBe("hi user"); 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 () => { it("round-trips native structure embeds through mailbox metadata", async () => {
const { MessageStore } = await import("../../stores/message-store.js"); const { MessageStore } = await import("../../stores/message-store.js");
const store = new MessageStore(null, { asyncLayer: h.layer() }); const store = new MessageStore(null, { asyncLayer: h.layer() });

View File

@@ -100,6 +100,7 @@ import {
AGENT_RATINGS_PROJECT_PARTITION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
} from "../../postgres/schema-applier.js"; } from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.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(AGENT_RATINGS_PROJECT_PARTITION_VERSION).toBe("0055");
expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056"); expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056");
expect(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION).toBe("0057"); 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", () => { 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, AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
]); ]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); 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, AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_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, AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_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, AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_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, AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
]); ]);
}); });
}); });

View File

@@ -43,6 +43,7 @@ interface MessageRow {
content: string; content: string;
type: string; type: string;
read: number | null; read: number | null;
archived: number | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -61,6 +62,7 @@ const messageColumns = {
content: schema.project.messages.content, content: schema.project.messages.content,
type: schema.project.messages.type, type: schema.project.messages.type,
read: schema.project.messages.read, read: schema.project.messages.read,
archived: schema.project.messages.archived,
metadata: schema.project.messages.metadata, metadata: schema.project.messages.metadata,
createdAt: schema.project.messages.createdAt, createdAt: schema.project.messages.createdAt,
updatedAt: schema.project.messages.updatedAt, updatedAt: schema.project.messages.updatedAt,
@@ -76,6 +78,7 @@ function rowToMessage(row: MessageRow): Message {
content: row.content, content: row.content,
type: row.type as MessageType, type: row.type as MessageType,
read: (row.read ?? 0) === 1, read: (row.read ?? 0) === 1,
archived: (row.archived ?? 0) === 1,
metadata: row.metadata ?? undefined, metadata: row.metadata ?? undefined,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
@@ -89,6 +92,13 @@ function participantIdsForLookup(ownerId: string, ownerType: ParticipantType): s
return [ownerId]; 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: * FNXC:MessageStore 2026-06-24-07:00:
* Create (send) a message. Non-destructive INSERT. * Create (send) a message. Non-destructive INSERT.
@@ -115,6 +125,7 @@ export async function sendMessage(
content: sanitizedContent, content: sanitizedContent,
type: message.type, type: message.type,
read: message.read ? 1 : 0, read: message.read ? 1 : 0,
archived: message.archived ? 1 : 0,
metadata: sanitizedMetadata, metadata: sanitizedMetadata,
createdAt: message.createdAt, createdAt: message.createdAt,
updatedAt: message.updatedAt, updatedAt: message.updatedAt,
@@ -124,6 +135,7 @@ export async function sendMessage(
content: sanitizedContent, content: sanitizedContent,
metadata: sanitizedMetadata, metadata: sanitizedMetadata,
read: message.read ? 1 : 0, read: message.read ? 1 : 0,
archived: message.archived ? 1 : 0,
}); });
} }
@@ -146,6 +158,7 @@ export async function sendMessageOnce(
content: message.content, content: message.content,
type: message.type, type: message.type,
read: message.read ? 1 : 0, read: message.read ? 1 : 0,
archived: message.archived ? 1 : 0,
metadata: message.metadata, metadata: message.metadata,
createdAt: message.createdAt, createdAt: message.createdAt,
updatedAt: message.updatedAt, updatedAt: message.updatedAt,
@@ -219,7 +232,7 @@ export async function queryMessagesByParticipant(
const idCol = direction === "to" ? schema.project.messages.toId : schema.project.messages.fromId; 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 typeCol = direction === "to" ? schema.project.messages.toType : schema.project.messages.fromType;
const participantIds = participantIdsForLookup(ownerId, ownerType); const participantIds = participantIdsForLookup(ownerId, ownerType);
const conditions: ReturnType<typeof eq>[] = [ const conditions = [
inArray(idCol, participantIds), inArray(idCol, participantIds),
eq(typeCol, ownerType), eq(typeCol, ownerType),
]; ];
@@ -229,6 +242,7 @@ export async function queryMessagesByParticipant(
if (filter?.read !== undefined) { if (filter?.read !== undefined) {
conditions.push(eq(schema.project.messages.read, filter.read ? 1 : 0)); conditions.push(eq(schema.project.messages.read, filter.read ? 1 : 0));
} }
conditions.push(archivedCondition(filter?.archived));
const limit = filter?.limit ?? 100; const limit = filter?.limit ?? 100;
const offset = filter?.offset ?? 0; const offset = filter?.offset ?? 0;
const rows = await handle 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 * Mark all inbox messages as read for a participant. Returns the count of
* messages that were unread before the update. * messages that were unread before the update.
*/ */
export async function setMessageArchived(
handle: QueryHandle,
id: string,
archived: boolean,
): Promise<Message | null> {
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( export async function markAllMessagesAsRead(
handle: QueryHandle, handle: QueryHandle,
ownerId: string, ownerId: string,
@@ -279,6 +309,7 @@ export async function markAllMessagesAsRead(
inArray(schema.project.messages.toId, participantIds), inArray(schema.project.messages.toId, participantIds),
eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.toType, ownerType),
eq(schema.project.messages.read, 0), eq(schema.project.messages.read, 0),
archivedCondition(),
), ),
); );
const count = countRows[0]?.count ?? 0; const count = countRows[0]?.count ?? 0;
@@ -290,6 +321,7 @@ export async function markAllMessagesAsRead(
inArray(schema.project.messages.toId, participantIds), inArray(schema.project.messages.toId, participantIds),
eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.toType, ownerType),
eq(schema.project.messages.read, 0), eq(schema.project.messages.read, 0),
archivedCondition(),
), ),
); );
return count; return count;
@@ -343,7 +375,7 @@ export async function getConversation(
handle: QueryHandle, handle: QueryHandle,
participantA: { id: string; type: ParticipantType }, participantA: { id: string; type: ParticipantType },
participantB: { id: string; type: ParticipantType }, participantB: { id: string; type: ParticipantType },
options?: { limit?: number }, options?: Pick<MessageFilter, "limit" | "archived">,
): Promise<Message[]> { ): Promise<Message[]> {
const limit = Math.max(1, options?.limit ?? DEFAULT_CONVERSATION_LIMIT); const limit = Math.max(1, options?.limit ?? DEFAULT_CONVERSATION_LIMIT);
const aIds = participantIdsForLookup(participantA.id, participantA.type); const aIds = participantIdsForLookup(participantA.id, participantA.type);
@@ -352,19 +384,22 @@ export async function getConversation(
.select(messageColumns) .select(messageColumns)
.from(schema.project.messages) .from(schema.project.messages)
.where( .where(
or( and(
and( or(
inArray(schema.project.messages.fromId, aIds), and(
eq(schema.project.messages.fromType, participantA.type), inArray(schema.project.messages.fromId, aIds),
inArray(schema.project.messages.toId, bIds), eq(schema.project.messages.fromType, participantA.type),
eq(schema.project.messages.toType, participantB.type), inArray(schema.project.messages.toId, bIds),
), eq(schema.project.messages.toType, participantB.type),
and( ),
inArray(schema.project.messages.fromId, bIds), and(
eq(schema.project.messages.fromType, participantB.type), inArray(schema.project.messages.fromId, bIds),
inArray(schema.project.messages.toId, aIds), eq(schema.project.messages.fromType, participantB.type),
eq(schema.project.messages.toType, participantA.type), inArray(schema.project.messages.toId, aIds),
eq(schema.project.messages.toType, participantA.type),
),
), ),
archivedCondition(options?.archived),
), ),
) )
.orderBy(desc(schema.project.messages.createdAt)) .orderBy(desc(schema.project.messages.createdAt))
@@ -390,6 +425,7 @@ export async function getMailbox(
inArray(schema.project.messages.toId, participantIds), inArray(schema.project.messages.toId, participantIds),
eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.toType, ownerType),
eq(schema.project.messages.read, 0), eq(schema.project.messages.read, 0),
archivedCondition(),
), ),
); );
const unreadCount = unreadRows[0]?.count ?? 0; const unreadCount = unreadRows[0]?.count ?? 0;
@@ -400,6 +436,7 @@ export async function getMailbox(
and( and(
inArray(schema.project.messages.toId, participantIds), inArray(schema.project.messages.toId, participantIds),
eq(schema.project.messages.toType, ownerType), eq(schema.project.messages.toType, ownerType),
archivedCondition(),
), ),
) )
.orderBy(desc(schema.project.messages.createdAt), desc(schema.project.messages.id)) .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). * Get all agent-to-agent messages (newest first).
*/ */
export async function getAllAgentToAgentMessages(handle: QueryHandle): Promise<Message[]> { export async function getAllAgentToAgentMessages(
handle: QueryHandle,
filter?: Pick<MessageFilter, "archived">,
): Promise<Message[]> {
const rows = await handle const rows = await handle
.select(messageColumns) .select(messageColumns)
.from(schema.project.messages) .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)); .orderBy(desc(schema.project.messages.createdAt), desc(schema.project.messages.id));
return rows.map((row) => rowToMessage(row as MessageRow)); return rows.map((row) => rowToMessage(row as MessageRow));
} }
@@ -435,6 +475,7 @@ export async function getUnreadAgentToAgentCount(handle: QueryHandle): Promise<n
and( and(
eq(schema.project.messages.type, "agent-to-agent"), eq(schema.project.messages.type, "agent-to-agent"),
eq(schema.project.messages.read, 0), eq(schema.project.messages.read, 0),
archivedCondition(),
), ),
); );
return rows[0]?.count ?? 0; return rows[0]?.count ?? 0;

View File

@@ -0,0 +1,13 @@
/*
FNXC:MessageArchive 2026-08-12-22:14:
Mailbox archive is the default non-destructive removal action. Existing correspondence remains
visible as non-archived until an operator explicitly archives it, while delete stays available.
*/
DO $$
BEGIN
IF to_regclass('project.messages') IS NOT NULL THEN
ALTER TABLE project.messages ADD COLUMN IF NOT EXISTS archived integer DEFAULT 0;
CREATE INDEX IF NOT EXISTS "idxMessagesToArchived"
ON project.messages (to_id, to_type, archived);
END IF;
END $$;

View File

@@ -62,8 +62,8 @@ capacity-model table drop that landed while this PR was open.
/* FNXC:MemoryRecall 2026-08-10-11:03: Explicit baseline registration prevents the recall migration from being silently skipped. */ /* FNXC:MemoryRecall 2026-08-10-11:03: Explicit baseline registration prevents the recall migration from being silently skipped. */
/* FNXC:SpecLockMissionAlignment 2026-08-10-16:17: advance the schema ceiling so SQLite and PostgreSQL feature projections retain reconciled drift alignment. */ /* FNXC:SpecLockMissionAlignment 2026-08-10-16:17: advance the schema ceiling so SQLite and PostgreSQL feature projections retain reconciled drift alignment. */
/* FNXC:MultiProjectIsolation 2026-08-11-10:25: schema startup must register project-local agent ratings before bound stores scope their mutations. */ /* FNXC:MultiProjectIsolation 2026-08-11-10:25: schema startup must register project-local agent ratings before bound stores scope their mutations. */
/* FNXC:MultiProjectIsolation 2026-08-12-15:43: 0057 reconciles the post-0006 GitHub check-state ownership default on upgrades. */ /* FNXC:MessageArchive 2026-08-12-22:14: 0058 persists non-destructive mailbox archival on upgrades. */
export const SCHEMA_BASELINE_VERSION = "0057"; export const SCHEMA_BASELINE_VERSION = "0058";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000"; const INITIAL_SCHEMA_VERSION = "0000";
@@ -220,6 +220,8 @@ export const AGENT_RATINGS_PROJECT_PARTITION_VERSION = "0055";
export const PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION = "0056"; export const PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION = "0056";
/** FNXC:MultiProjectIsolation 2026-08-12-15:43: register the 0048 default reconciliation explicitly for upgrades. */ /** FNXC:MultiProjectIsolation 2026-08-12-15:43: register the 0048 default reconciliation explicitly for upgrades. */
export const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION = "0057"; export const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION = "0057";
/** FNXC:MessageArchive 2026-08-12-22:14: explicit registration prevents the archived-message migration from being skipped. */
export const MESSAGE_ARCHIVE_SCHEMA_VERSION = "0058";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -451,6 +453,7 @@ const AGENT_RATING_PROJECT_ISOLATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0054
const AGENT_RATINGS_PROJECT_PARTITION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0055_fn_8988_agent_ratings_project_partition.sql"); const AGENT_RATINGS_PROJECT_PARTITION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0055_fn_8988_agent_ratings_project_partition.sql");
const PROJECT_OWNERSHIP_DECLARATION_DRIFT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0056_fn_8997_project_ownership_declaration_drift.sql"); const PROJECT_OWNERSHIP_DECLARATION_DRIFT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0056_fn_8997_project_ownership_declaration_drift.sql");
const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0057_fn_9004_project_ownership_default_reconciliation.sql"); const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0057_fn_9004_project_ownership_default_reconciliation.sql");
const MESSAGE_ARCHIVE_SCHEMA_MIGRATION_PATH = join(MIGRATIONS_DIR, "0058_fn_9014_message_archive.sql");
/** /**
* Ensure the migration bookkeeping table exists. Lives in the public schema so * Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -578,6 +581,7 @@ export async function applySchemaBaseline(
const agentRatingsProjectPartitionAlreadyApplied = applied.includes(AGENT_RATINGS_PROJECT_PARTITION_VERSION); const agentRatingsProjectPartitionAlreadyApplied = applied.includes(AGENT_RATINGS_PROJECT_PARTITION_VERSION);
const projectOwnershipDeclarationDriftAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION); const projectOwnershipDeclarationDriftAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION);
const projectOwnershipDefaultReconciliationAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION); const projectOwnershipDefaultReconciliationAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION);
const messageArchiveSchemaAlreadyApplied = applied.includes(MESSAGE_ARCHIVE_SCHEMA_VERSION);
assertBinaryNotOlderThanDatabase(applied); assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false; let schemaChanged = false;
@@ -1272,6 +1276,12 @@ export async function applySchemaBaseline(
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION}) ON CONFLICT (version) DO NOTHING`); await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true; schemaChanged = true;
} }
if (!messageArchiveSchemaAlreadyApplied) {
const migrationSql = await readFile(MESSAGE_ARCHIVE_SCHEMA_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MESSAGE_ARCHIVE_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
}); });
} }

View File

@@ -2098,12 +2098,14 @@ export const messages = projectSchema.table("messages", {
content: text("content").notNull(), content: text("content").notNull(),
type: text("type").notNull(), type: text("type").notNull(),
read: integer("read").default(0), read: integer("read").default(0),
archived: integer("archived").default(0),
metadata: jsonb("metadata"), metadata: jsonb("metadata"),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
}, (t) => [ }, (t) => [
primaryKey({ columns: [t.projectId, t.id] }), primaryKey({ columns: [t.projectId, t.id] }),
index("idxMessagesTo").on(t.toId, t.toType, t.read), 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("idxMessagesFrom").on(t.fromId, t.fromType),
index("idxMessagesCreatedAt").on(t.createdAt), index("idxMessagesCreatedAt").on(t.createdAt),
]); ]);

View File

@@ -33,6 +33,10 @@ export interface MessageStoreEvents {
"message:received": [message: Message]; "message:received": [message: Message];
/** Emitted when a message is marked as read */ /** Emitted when a message is marked as read */
"message:read": [message: Message]; "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 */ /** Emitted when a message is deleted */
"message:deleted": [messageId: string]; "message:deleted": [messageId: string];
/** Emitted when proposal metadata changes without creating a new message. */ /** Emitted when proposal metadata changes without creating a new message. */
@@ -51,6 +55,7 @@ interface MessageRow {
content: string; content: string;
type: string; type: string;
read: number; read: number;
archived: number | null;
metadata: string | null; metadata: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -109,12 +114,12 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
// Prepare frequently-run statements (SQLite path) // Prepare frequently-run statements (SQLite path)
const sqliteDb = this.db!; const sqliteDb = this.db!;
this.stmtInsert = sqliteDb.prepare(` this.stmtInsert = sqliteDb.prepare(`
INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt) INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, archived, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
this.stmtInsertOnce = sqliteDb.prepare(` this.stmtInsertOnce = sqliteDb.prepare(`
INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt) INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, archived, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
this.stmtGetById = sqliteDb.prepare(` this.stmtGetById = sqliteDb.prepare(`
@@ -150,6 +155,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
content: row.content, content: row.content,
type: row.type as MessageType, type: row.type as MessageType,
read: row.read === 1, read: row.read === 1,
archived: (row.archived ?? 0) === 1,
metadata: fromJson<Message["metadata"]>(row.metadata), metadata: fromJson<Message["metadata"]>(row.metadata),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
@@ -188,6 +194,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
content: sanitizeTextValue(input.content), content: sanitizeTextValue(input.content),
type: input.type, type: input.type,
read: false, read: false,
archived: false,
metadata: sanitizeJsonbValue(input.metadata), metadata: sanitizeJsonbValue(input.metadata),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -204,6 +211,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
content: message.content, content: message.content,
type: message.type, type: message.type,
read: message.read, read: message.read,
archived: message.archived,
metadata: message.metadata ?? null, metadata: message.metadata ?? null,
createdAt: message.createdAt, createdAt: message.createdAt,
updatedAt: message.updatedAt, updatedAt: message.updatedAt,
@@ -218,6 +226,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
message.content, message.content,
message.type, message.type,
message.read ? 1 : 0, message.read ? 1 : 0,
message.archived ? 1 : 0,
toJsonNullable(message.metadata), toJsonNullable(message.metadata),
message.createdAt, message.createdAt,
message.updatedAt, message.updatedAt,
@@ -295,6 +304,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
content: sanitizeTextValue(input.content), content: sanitizeTextValue(input.content),
type: input.type, type: input.type,
read: false, read: false,
archived: false,
metadata: sanitizeJsonbValue(input.metadata), metadata: sanitizeJsonbValue(input.metadata),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -316,6 +326,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
message.content, message.content,
message.type, message.type,
0, 0,
0,
toJsonNullable(message.metadata), toJsonNullable(message.metadata),
message.createdAt, message.createdAt,
message.updatedAt, message.updatedAt,
@@ -467,6 +478,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
whereClauses.push("read = ?"); whereClauses.push("read = ?");
params.push(filter.read ? 1 : 0); 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 whereSql = whereClauses.join(" AND ");
const limit = filter?.limit ?? 100; const limit = filter?.limit ?? 100;
@@ -533,19 +545,47 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
// Get count of unread messages before updating // Get count of unread messages before updating
const unreadRow = this.db!.prepare(` 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; `).get(...participantIds, ownerType) as { count: number } | undefined;
const count = unreadRow?.count ?? 0; const count = unreadRow?.count ?? 0;
// Mark all as read // Mark all as read
this.db!.prepare(` 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); `).run(now, ...participantIds, ownerType);
this.db!.bumpLastModified(); this.db!.bumpLastModified();
return count; return count;
} }
/** Archive a message without destroying its correspondence history. */
async archiveMessage(id: string): Promise<Message> {
return this.setMessageArchived(id, true);
}
/** Restore a previously archived message to default mailbox lists. */
async unarchiveMessage(id: string): Promise<Message> {
return this.setMessageArchived(id, false);
}
private async setMessageArchived(id: string, archived: boolean): Promise<Message> {
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. * Delete a message by ID.
* @param id - The message ID * @param id - The message ID
@@ -632,7 +672,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
async getConversation( async getConversation(
participantA: { id: string; type: ParticipantType }, participantA: { id: string; type: ParticipantType },
participantB: { id: string; type: ParticipantType }, participantB: { id: string; type: ParticipantType },
options?: { limit?: number }, options?: Pick<MessageFilter, "limit" | "archived">,
): Promise<Message[]> { ): Promise<Message[]> {
if (this.asyncLayer) { if (this.asyncLayer) {
return asyncMessageStore.getConversation(this.asyncLayer.db, participantA, participantB, options); return asyncMessageStore.getConversation(this.asyncLayer.db, participantA, participantB, options);
@@ -659,7 +699,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
(${participantAFromPredicate} AND fromType = ? AND ${participantBToPredicate} AND toType = ?) (${participantAFromPredicate} AND fromType = ? AND ${participantBToPredicate} AND toType = ?)
OR OR
(${participantBFromPredicate} AND fromType = ? AND ${participantAToPredicate} AND toType = ?) (${participantBFromPredicate} AND fromType = ? AND ${participantAToPredicate} AND toType = ?)
) ) AND ${options?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)"}
ORDER BY createdAt DESC ORDER BY createdAt DESC
LIMIT ? LIMIT ?
`).all( `).all(
@@ -702,12 +742,12 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
: `toId IN (${participantIds.map(() => "?").join(", ")})`; : `toId IN (${participantIds.map(() => "?").join(", ")})`;
const unreadRow = this.db!.prepare(` 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; `).get(...participantIds, ownerType) as { count: number } | undefined;
const unreadCount = unreadRow?.count ?? 0; const unreadCount = unreadRow?.count ?? 0;
const lastRow = this.db!.prepare(` 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; `).get(...participantIds, ownerType) as unknown as MessageRow | undefined;
const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined; const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined;
@@ -723,13 +763,13 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
* Get all agent-to-agent messages across all agents. * Get all agent-to-agent messages across all agents.
* @returns Array of messages (newest first) * @returns Array of messages (newest first)
*/ */
async getAllAgentToAgentMessages(): Promise<Message[]> { async getAllAgentToAgentMessages(filter?: Pick<MessageFilter, "archived">): Promise<Message[]> {
if (this.asyncLayer) { if (this.asyncLayer) {
return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db); return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db, filter);
} }
const rows = this.db!.prepare(` const rows = this.db!.prepare(`
SELECT * FROM messages 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 ORDER BY createdAt DESC, rowid DESC
`).all("agent-to-agent"); `).all("agent-to-agent");
@@ -745,7 +785,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
} }
const row = this.db!.prepare(` const row = this.db!.prepare(`
SELECT COUNT(*) as count FROM messages 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; `).get("agent-to-agent") as { count: number } | undefined;
return row?.count ?? 0; return row?.count ?? 0;

View File

@@ -151,6 +151,13 @@ export interface Message {
type: MessageType; type: MessageType;
/** Whether the recipient has read this message */ /** Whether the recipient has read this message */
read: boolean; 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 */ /** Optional extra data */
metadata?: MessageMetadata; metadata?: MessageMetadata;
/** ISO-8601 timestamp of creation */ /** ISO-8601 timestamp of creation */
@@ -183,6 +190,8 @@ export interface MessageFilter {
type?: MessageType; type?: MessageType;
/** Filter by read status */ /** Filter by read status */
read?: boolean; read?: boolean;
/** Filter by archived status; omitted excludes archived messages. */
archived?: boolean;
/** Maximum number of messages to return */ /** Maximum number of messages to return */
limit?: number; limit?: number;
/** Number of messages to skip (for pagination) */ /** Number of messages to skip (for pagination) */

View File

@@ -123,13 +123,14 @@ export interface ApprovalListResponse {
/** Fetch inbox messages for the current user. */ /** Fetch inbox messages for the current user. */
export function fetchInbox( 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, projectId?: string,
): Promise<InboxResponse> { ): Promise<InboxResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.offset !== undefined) params.set("offset", String(options.offset)); if (options?.offset !== undefined) params.set("offset", String(options.offset));
if (options?.unreadOnly) params.set("unreadOnly", "true"); if (options?.unreadOnly) params.set("unreadOnly", "true");
if (options?.archived) params.set("archived", "true");
if (options?.type) params.set("type", options.type); if (options?.type) params.set("type", options.type);
if (projectId) params.set("projectId", projectId); if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : ""; const query = params.size > 0 ? `?${params.toString()}` : "";
@@ -138,12 +139,13 @@ export function fetchInbox(
/** Fetch sent messages for the current user. */ /** Fetch sent messages for the current user. */
export function fetchOutbox( export function fetchOutbox(
options?: { limit?: number; offset?: number; type?: MessageType }, options?: { limit?: number; offset?: number; type?: MessageType; archived?: boolean },
projectId?: string, projectId?: string,
): Promise<OutboxResponse> { ): Promise<OutboxResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.offset !== undefined) params.set("offset", String(options.offset)); 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 (options?.type) params.set("type", options.type);
if (projectId) params.set("projectId", projectId); if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : ""; const query = params.size > 0 ? `?${params.toString()}` : "";
@@ -187,6 +189,16 @@ export function markAllMessagesRead(projectId?: string): Promise<MarkAllReadResp
}); });
} }
/** Archive a message without permanently deleting it. */
export function archiveMessage(id: string, projectId?: string): Promise<Message> {
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}/archive`, projectId), { method: "POST" });
}
/** Restore a previously archived message. */
export function unarchiveMessage(id: string, projectId?: string): Promise<Message> {
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}/unarchive`, projectId), { method: "POST" });
}
/** Delete a message. */ /** Delete a message. */
export function deleteMessage(id: string, projectId?: string): Promise<void> { export function deleteMessage(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), { return api<void>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), {
@@ -199,19 +211,30 @@ export function fetchConversation(
participantId: string, participantId: string,
participantType: ParticipantType, participantType: ParticipantType,
projectId?: string, projectId?: string,
options?: { archived?: boolean },
): Promise<Message[]> { ): Promise<Message[]> {
const path = `/messages/conversation/${encodeURIComponent(participantType)}/${encodeURIComponent(participantId)}`; const path = `/messages/conversation/${encodeURIComponent(participantType)}/${encodeURIComponent(participantId)}`;
return api<Message[]>(withProjectId(path, projectId)); const params = new URLSearchParams();
if (options?.archived) params.set("archived", "true");
if (projectId) params.set("projectId", projectId);
return api<Message[]>(`${path}${params.size ? `?${params.toString()}` : ""}`);
} }
/** Fetch an agent's mailbox (admin read-only view). */ /** Fetch an agent's mailbox (admin read-only view). */
export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<AgentMailboxResponse> { export function fetchAgentMailbox(agentId: string, projectId?: string, options?: { archived?: boolean }): Promise<AgentMailboxResponse> {
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId)); 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<AgentMailboxResponse>(`${path}${params.size ? `?${params.toString()}` : ""}`);
} }
/** Fetch aggregate mailbox across all agent-to-agent messages (admin read-only view). */ /** Fetch aggregate mailbox across all agent-to-agent messages (admin read-only view). */
export function fetchAllAgentMailbox(projectId?: string): Promise<AllAgentsMailboxResponse> { export function fetchAllAgentMailbox(projectId?: string, options?: { archived?: boolean }): Promise<AllAgentsMailboxResponse> {
return api<AllAgentsMailboxResponse>(withProjectId("/agents/mailbox/all", projectId)); const params = new URLSearchParams();
if (options?.archived) params.set("archived", "true");
if (projectId) params.set("projectId", projectId);
return api<AllAgentsMailboxResponse>(`/agents/mailbox/all${params.size ? `?${params.toString()}` : ""}`);
} }
export function fetchApprovals( export function fetchApprovals(

View File

@@ -1287,6 +1287,7 @@ export type {
*/ */
export { export {
addAgentRating, addAgentRating,
archiveMessage,
createProposedTask, createProposedTask,
decideApproval, decideApproval,
deleteAgentRating, deleteAgentRating,
@@ -1311,6 +1312,7 @@ export {
resetAgentBudget, resetAgentBudget,
sendMessage, sendMessage,
triggerAgentReflection, triggerAgentReflection,
unarchiveMessage,
} from "./chat/messaging.js"; } from "./chat/messaging.js";
export type { export type {
AgentMailboxResponse, AgentMailboxResponse,

View File

@@ -626,6 +626,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
selectSession, selectSession,
createSession, createSession,
archiveSession, archiveSession,
archivedSessions,
refreshArchivedSessions,
unarchiveSession,
renameSession, renameSession,
pinSession, pinSession,
pinnedCount, pinnedCount,
@@ -679,6 +682,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
return getPersistedChatDraft(initialDraftKey); return getPersistedChatDraft(initialDraftKey);
}); });
const [contextMenu, setContextMenu] = useState<{ sessionId: string; anchorX: number; anchorY: number; anchorRight: boolean; x: number; y: number } | null>(null); 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<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
/* /*
FNXC:ChatSidebar 2026-07-17-00:12: FNXC:ChatSidebar 2026-07-17-00:12:
@@ -2491,6 +2495,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
[archiveSession, addToast], [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( const openRenameDialog = useCallback(
(id: string) => { (id: string) => {
const session = filteredSessions.find((item) => item.id === id) ?? (activeSession?.id === id ? activeSession : null); 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: 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. 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 visibleSidebarSessions = showArchivedSessions ? archivedSessions : filteredSessions;
const unpinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt == null); const pinnedFilteredSessions = visibleSidebarSessions.filter((session) => session.pinnedAt != null);
const unpinnedFilteredSessions = visibleSidebarSessions.filter((session) => session.pinnedAt == null);
const contextMenuSession = contextMenu const contextMenuSession = contextMenu
? filteredSessions.find((session) => session.id === contextMenu.sessionId) ?? (activeSession?.id === contextMenu.sessionId ? activeSession : undefined) ? filteredSessions.find((session) => session.id === contextMenu.sessionId) ?? (activeSession?.id === contextMenu.sessionId ? activeSession : undefined)
: undefined; : undefined;
@@ -3301,6 +3311,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
> >
{session.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />} {session.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />}
</button> </button>
<button type="button" className="btn-icon chat-mobile-session-archive" data-testid={`chat-mobile-session-archive-${session.id}`} aria-label={t("chat.archive", "Archive")} onClick={() => void handleArchive(session.id)}><Archive size={14} /></button>
<button <button
type="button" type="button"
className="btn-icon chat-mobile-session-rename" className="btn-icon chat-mobile-session-rename"
@@ -3495,10 +3506,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</label> </label>
</div> </div>
{/* Session list section */} {/* Session list section */}
<div className="chat-archived-toggle"><button type="button" className="btn btn-sm btn-secondary" data-testid="chat-archived-toggle" onClick={() => { const next = !showArchivedSessions; setShowArchivedSessions(next); if (next) void refreshArchivedSessions(); }}>{showArchivedSessions ? "Active conversations" : "Archived conversations"}</button></div>
<div className="chat-session-list chat-sidebar-list"> <div className="chat-session-list chat-sidebar-list">
{sessionsLoading ? ( {sessionsLoading ? (
<div className="chat-empty-state chat-empty-state--padded">{t("chat.loadingConversations", "Loading...")}</div> <div className="chat-empty-state chat-empty-state--padded">{t("chat.loadingConversations", "Loading...")}</div>
) : filteredSessions.length === 0 ? ( ) : ((showArchivedSessions ? archivedSessions : filteredSessions).length === 0) ? (
<div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div> <div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div>
) : ( ) : (
<> <>
@@ -3534,7 +3546,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
e.preventDefault(); e.preventDefault();
openSessionMenu(session.id, e.clientX, e.clientY); 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: 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 })} {t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })}
</div> </div>
) : null} ) : null}
{showArchivedSessions ? <button type="button" className="btn btn-sm btn-secondary" data-testid={`chat-archived-restore-${session.id}`} onClick={(event) => { event.stopPropagation(); void handleRestoreArchived(session.id); }}>Restore</button> : null}
<div className="chat-session-meta"> <div className="chat-session-meta">
<span className="chat-session-meta-model"> <span className="chat-session-meta-model">
{sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null} {sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null}

View File

@@ -10,6 +10,7 @@ import {
Inbox as InboxIcon, Inbox as InboxIcon,
Bot, Bot,
Trash2, Trash2,
Archive,
CheckCheck, CheckCheck,
Loader2, Loader2,
RefreshCw, RefreshCw,
@@ -27,6 +28,8 @@ import {
fetchAllAgentMailbox, fetchAllAgentMailbox,
markMessageRead, markMessageRead,
markAllMessagesRead, markAllMessagesRead,
archiveMessage,
unarchiveMessage,
deleteMessage, deleteMessage,
fetchConversation, fetchConversation,
fetchMessage, fetchMessage,
@@ -52,7 +55,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
type MailboxTab = "inbox" | "outbox" | "agents"; type MailboxTab = "inbox" | "outbox" | "archived" | "agents";
const ALL_AGENTS_MAILBOX_ID = "__all_agents__"; const ALL_AGENTS_MAILBOX_ID = "__all_agents__";
@@ -302,9 +305,11 @@ export function MailboxModal({
const [inbox, setInbox] = useState<InboxResponse | null>(() => initialInbox ?? null); const [inbox, setInbox] = useState<InboxResponse | null>(() => initialInbox ?? null);
const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all"); const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all");
const [outbox, setOutbox] = useState<OutboxResponse | null>(() => initialOutbox ?? null); const [outbox, setOutbox] = useState<OutboxResponse | null>(() => initialOutbox ?? null);
const [archivedInbox, setArchivedInbox] = useState<InboxResponse | null>(null);
const [unreadCount, setUnreadCount] = useState(initialUnreadCount ?? 0); const [unreadCount, setUnreadCount] = useState(initialUnreadCount ?? 0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null); const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState<string | null>(null);
const [conversationMessages, setConversationMessages] = useState<Message[]>([]); const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
@@ -385,6 +390,25 @@ export function MailboxModal({
} }
}, [inboxCacheKey, projectId, unreadCountCacheKey]); }, [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 loadOutbox = useCallback(async () => {
const shouldSkipOpenSpinner = skipOpenSpinnerOutboxRef.current; const shouldSkipOpenSpinner = skipOpenSpinnerOutboxRef.current;
if (!shouldSkipOpenSpinner) { if (!shouldSkipOpenSpinner) {
@@ -464,7 +488,8 @@ export function MailboxModal({
if (!isOpen) return; if (!isOpen) return;
if (activeTab === "inbox") loadInbox(); if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "outbox") loadOutbox();
}, [isOpen, activeTab, loadInbox, loadOutbox]); else if (activeTab === "archived") loadArchivedInbox();
}, [isOpen, activeTab, loadInbox, loadOutbox, loadArchivedInbox]);
// Load agent mailbox when selected // Load agent mailbox when selected
useEffect(() => { useEffect(() => {
@@ -669,8 +694,28 @@ export function MailboxModal({
} }
}, [addToast, inboxCacheKey, projectId, unreadCountCacheKey, t]); }, [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) => { const handleDeleteMessage = useCallback(async (id: string) => {
consumeCurrentDeepLink(); consumeCurrentDeepLink();
setPendingDeleteMessageId(null);
try { try {
await deleteMessage(id, projectId); await deleteMessage(id, projectId);
setSelectedMessage(null); setSelectedMessage(null);
@@ -860,6 +905,7 @@ export function MailboxModal({
<Send size={14} /> <Send size={14} />
<span>{t("mailbox.outboxTab", "Outbox")}</span> <span>{t("mailbox.outboxTab", "Outbox")}</span>
</button> </button>
<button className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "archived" ? "active" : ""}`} onClick={() => { consumeCurrentDeepLink(); setActiveTab("archived"); setSelectedMessage(null); }} data-testid="mailbox-tab-archived"><Archive size={14} /><span>Archived</span></button>
<button <button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`} className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
onClick={() => { consumeCurrentDeepLink(); setActiveTab("agents"); setSelectedMessage(null); }} onClick={() => { consumeCurrentDeepLink(); setActiveTab("agents"); setSelectedMessage(null); }}
@@ -899,14 +945,16 @@ export function MailboxModal({
<span>{t("mailbox.replyButton", "Reply")}</span> <span>{t("mailbox.replyButton", "Reply")}</span>
</button> </button>
)} )}
<button {selectedMessage.archived ? <button className="btn btn-sm btn-secondary" onClick={() => handleUnarchiveMessage(selectedMessage.id)} data-testid="mailbox-unarchive"><Archive size={14} /><span>Restore</span></button> : <button className="btn btn-sm btn-secondary" onClick={() => handleArchiveMessage(selectedMessage.id)} data-testid="mailbox-archive"><Archive size={14} /><span>Archive</span></button>}
className="btn btn-sm btn-secondary" {pendingDeleteMessageId === selectedMessage.id ? (
onClick={() => handleDeleteMessage(selectedMessage.id)} <>
data-testid="mailbox-delete" {/* FNXC:MessageArchive 2026-08-12-22:51: Deletion requires a second intentional click so archive remains the safe default removal action. */}
> <button className="btn btn-sm btn-secondary" onClick={() => void handleDeleteMessage(selectedMessage.id)} data-testid="mailbox-delete-confirm"><Trash2 size={14} /><span>{t("mailbox.confirmDelete", "Confirm delete")}</span></button>
<Trash2 size={14} /> <button className="btn btn-sm btn-secondary" onClick={() => setPendingDeleteMessageId(null)} data-testid="mailbox-delete-cancel"><span>{t("common.cancel", "Cancel")}</span></button>
<span>{t("mailbox.deleteButton", "Delete")}</span> </>
</button> ) : (
<button className="btn btn-sm btn-secondary" onClick={() => setPendingDeleteMessageId(selectedMessage.id)} data-testid="mailbox-delete"><Trash2 size={14} /><span>{t("mailbox.deleteButton", "Delete")}</span></button>
)}
</div> </div>
</div> </div>
<div className="mailbox-message-participants"> <div className="mailbox-message-participants">
@@ -1044,6 +1092,12 @@ export function MailboxModal({
{!selectedMessage && !showComposer && ( {!selectedMessage && !showComposer && (
<> <>
{/* Inbox Tab */} {/* Inbox Tab */}
{activeTab === "archived" && (
<div className="mailbox-list" data-testid="mailbox-archived-list">
{archivedInbox?.messages.length === 0 && <div className="mailbox-empty" data-testid="mailbox-archived-empty">No archived messages</div>}
{archivedInbox?.messages.map((message) => <button type="button" className="mailbox-item" key={message.id} onClick={() => void handleOpenMessage(message)} data-testid={`mailbox-item-${message.id}`}>{message.content}</button>)}
</div>
)}
{activeTab === "inbox" && ( {activeTab === "inbox" && (
<div className="mailbox-list" data-testid="mailbox-inbox-list"> <div className="mailbox-list" data-testid="mailbox-inbox-list">
<div className="mailbox-structural-filter" role="group" aria-label="Inbox filter"> <div className="mailbox-structural-filter" role="group" aria-label="Inbox filter">

View File

@@ -8,6 +8,7 @@ import {
Inbox as InboxIcon, Inbox as InboxIcon,
Bot, Bot,
Trash2, Trash2,
Archive,
CheckCheck, CheckCheck,
Loader2, Loader2,
RefreshCw, RefreshCw,
@@ -23,6 +24,8 @@ import {
fetchAllAgentMailbox, fetchAllAgentMailbox,
markMessageRead, markMessageRead,
markAllMessagesRead, markAllMessagesRead,
archiveMessage,
unarchiveMessage,
deleteMessage, deleteMessage,
fetchConversation, fetchConversation,
fetchAgents, fetchAgents,
@@ -57,7 +60,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
type MailboxTab = "inbox" | "outbox" | "agents" | "approvals"; type MailboxTab = "inbox" | "outbox" | "archived" | "agents" | "approvals";
interface MailboxViewProps { interface MailboxViewProps {
projectId?: string; projectId?: string;
@@ -240,9 +243,11 @@ export function MailboxView({
const consumedComposePrefillNonceRef = useRef<number | null>(null); const consumedComposePrefillNonceRef = useRef<number | null>(null);
const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all"); const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all");
const [outbox, setOutbox] = useState<OutboxResponse | null>(null); const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
const [archivedInbox, setArchivedInbox] = useState<InboxResponse | null>(null);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null); const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState<string | null>(null);
const [conversationMessages, setConversationMessages] = useState<Message[]>([]); const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
@@ -456,6 +461,28 @@ export function MailboxView({
} }
}, [projectId, onUnreadCountChange, captureMailboxScroll]); }, [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 () => { const loadOutbox = useCallback(async () => {
captureMailboxScroll(); captureMailboxScroll();
setIsLoading(true); setIsLoading(true);
@@ -546,11 +573,12 @@ export function MailboxView({
useEffect(() => { useEffect(() => {
if (activeTab === "inbox") loadInbox(); if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "archived") loadArchivedInbox();
else if (activeTab === "agents") loadAgents(); else if (activeTab === "agents") loadAgents();
else if (activeTab === "approvals") { else if (activeTab === "approvals") {
void loadApprovals(approvalSubTab); void loadApprovals(approvalSubTab);
} }
}, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]); }, [activeTab, loadInbox, loadOutbox, loadArchivedInbox, loadAgents, loadApprovals, approvalSubTab]);
// Load agent mailbox when selected // Load agent mailbox when selected
useEffect(() => { useEffect(() => {
@@ -746,14 +774,45 @@ export function MailboxView({
} }
}, [projectId, addToast, onUnreadCountChange]); }, [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) => { const handleDeleteMessage = useCallback(async (id: string) => {
consumeCurrentDeepLink(); consumeCurrentDeepLink();
setPendingDeleteMessageId(null);
try { try {
await deleteMessage(id, projectId); await deleteMessage(id, projectId);
dismissMessage(); dismissMessage();
// Refresh current tab // Refresh current tab
if (activeTab === "inbox") loadInbox(); if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "archived") loadArchivedInbox();
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId); else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
addToast?.("Message deleted", "success"); addToast?.("Message deleted", "success");
@@ -942,14 +1001,30 @@ export function MailboxView({
<span>{t("mailbox.reply", "Reply")}</span> <span>{t("mailbox.reply", "Reply")}</span>
</button> </button>
)} )}
<button {selectedMessage.archived ? (
className="btn btn-sm btn-secondary" <button className="btn btn-sm btn-secondary" onClick={() => handleUnarchiveMessage(selectedMessage.id)} data-testid="mailbox-unarchive">
onClick={() => handleDeleteMessage(selectedMessage.id)} <Archive size={14} /><span>Restore</span>
data-testid="mailbox-delete" </button>
> ) : (
<Trash2 size={14} /> <button className="btn btn-sm btn-secondary" onClick={() => handleArchiveMessage(selectedMessage.id)} data-testid="mailbox-archive">
<span>{t("mailbox.delete", "Delete")}</span> <Archive size={14} /><span>Archive</span>
</button> </button>
)}
{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. */}
<button className="btn btn-sm btn-secondary" onClick={() => void handleDeleteMessage(selectedMessage.id)} data-testid="mailbox-delete-confirm">
<Trash2 size={14} /><span>{t("mailbox.confirmDelete", "Confirm delete")}</span>
</button>
<button className="btn btn-sm btn-secondary" onClick={() => setPendingDeleteMessageId(null)} data-testid="mailbox-delete-cancel">
<span>{t("common.cancel", "Cancel")}</span>
</button>
</>
) : (
<button className="btn btn-sm btn-secondary" onClick={() => setPendingDeleteMessageId(selectedMessage.id)} data-testid="mailbox-delete">
<Trash2 size={14} /><span>{t("mailbox.delete", "Delete")}</span>
</button>
)}
</div> </div>
</div> </div>
<div className="mailbox-message-participants"> <div className="mailbox-message-participants">
@@ -1059,6 +1134,17 @@ export function MailboxView({
const renderListPane = () => ( const renderListPane = () => (
<> <>
{activeTab === "archived" && (
<div className="mailbox-list" data-testid="mailbox-archived-list">
{isLoading && !archivedInbox && <MailboxSkeleton />}
{archivedInbox?.messages.length === 0 && <div className="mailbox-empty" data-testid="mailbox-archived-empty">No archived messages</div>}
{archivedInbox?.messages.map((message) => (
<button type="button" className="mailbox-item" key={message.id} onClick={() => void handleOpenMessage(message)} data-testid={`mailbox-item-${message.id}`}>
<span className="mailbox-item-preview">{message.content}</span>
</button>
))}
</div>
)}
{activeTab === "inbox" && ( {activeTab === "inbox" && (
<div className="mailbox-list" data-testid="mailbox-inbox-list"> <div className="mailbox-list" data-testid="mailbox-inbox-list">
<div className="mailbox-structural-filter" role="group" aria-label="Inbox filter"> <div className="mailbox-structural-filter" role="group" aria-label="Inbox filter">
@@ -1490,6 +1576,7 @@ export function MailboxView({
onClick={() => { onClick={() => {
if (activeTab === "inbox") loadInbox(); if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "archived") loadArchivedInbox();
else if (activeTab === "approvals") loadApprovals(approvalSubTab); else if (activeTab === "approvals") loadApprovals(approvalSubTab);
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId); else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
@@ -1523,6 +1610,7 @@ export function MailboxView({
<Send size={14} /> <Send size={14} />
<span>{t("mailbox.outbox", "Outbox")}</span> <span>{t("mailbox.outbox", "Outbox")}</span>
</button> </button>
<button className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "archived" ? "active" : ""}`} onClick={() => handleSelectTab("archived")} data-testid="mailbox-tab-archived">Archived</button>
<button <button
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`} className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
onClick={() => handleSelectTab("agents")} onClick={() => handleSelectTab("agents")}

View File

@@ -340,6 +340,30 @@ describe("Chat Session Action Menu", () => {
expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument(); expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument();
}); });
it("keeps archived sessions out of the default sidebar and restores them from Archived", async () => {
const refreshArchivedSessions = vi.fn().mockResolvedValue(undefined);
const unarchiveSession = vi.fn().mockResolvedValue(undefined);
const active = { id: "session-active", agentId: "agent-001", status: "active" as const, title: "Active conversation", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
const archived = { id: "session-archived", agentId: "agent-002", status: "archived" as const, title: "Archived conversation", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" };
setupMockChat({
sessions: [active],
filteredSessions: [active],
archivedSessions: [archived],
refreshArchivedSessions,
unarchiveSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("Active conversation")).toBeInTheDocument();
expect(screen.queryByText("Archived conversation")).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-archived-toggle"));
expect(refreshArchivedSessions).toHaveBeenCalledOnce();
expect(screen.getByTestId("chat-archived-session-session-archived")).toHaveTextContent("Archived conversation");
await userEvent.click(screen.getByTestId("chat-archived-restore-session-archived"));
expect(unarchiveSession).toHaveBeenCalledWith("session-archived");
});
it("clicking the action menu button does not select the session", async () => { it("clicking the action menu button does not select the session", async () => {
const selectSession = vi.fn(); const selectSession = vi.fn();
setupMockChat({ setupMockChat({

View File

@@ -63,6 +63,9 @@ export const defaultChatState: UseChatReturn = {
selectSession: vi.fn(), selectSession: vi.fn(),
createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__", status: "active", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" } satisfies ChatSessionInfo), createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__", status: "active", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" } satisfies ChatSessionInfo),
archiveSession: vi.fn(), archiveSession: vi.fn(),
archivedSessions: [],
refreshArchivedSessions: vi.fn().mockResolvedValue(undefined),
unarchiveSession: vi.fn().mockResolvedValue(undefined),
renameSession: vi.fn(), renameSession: vi.fn(),
setSessionThinkingLevel: vi.fn(), setSessionThinkingLevel: vi.fn(),
deleteSession: vi.fn(), deleteSession: vi.fn(),

View File

@@ -40,6 +40,7 @@ vi.mock("lucide-react", () => ({
Inbox: () => <span data-testid="icon-inbox">Inbox</span>, Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
Bot: () => <span data-testid="icon-bot">Bot</span>, Bot: () => <span data-testid="icon-bot">Bot</span>,
Trash2: () => <span data-testid="icon-trash">Trash</span>, Trash2: () => <span data-testid="icon-trash">Trash</span>,
Archive: () => <span data-testid="icon-archive">Archive</span>,
Check: () => <span data-testid="icon-check">Check</span>, Check: () => <span data-testid="icon-check">Check</span>,
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>, CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
Loader2: ({ className }: { className?: string }) => ( Loader2: ({ className }: { className?: string }) => (
@@ -679,7 +680,7 @@ describe("MailboxModal", () => {
}); });
}); });
it("deletes message when clicking delete in detail view", async () => { it("requires explicit confirmation before deleting a message in detail view", async () => {
render(<MailboxModal {...defaultProps} />); render(<MailboxModal {...defaultProps} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined(); expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
@@ -693,6 +694,9 @@ describe("MailboxModal", () => {
expect(deleteButton).toHaveClass("btn", "btn-sm", "btn-secondary"); expect(deleteButton).toHaveClass("btn", "btn-sm", "btn-secondary");
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
expect(mockDeleteMessage).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
await waitFor(() => { await waitFor(() => {
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined); expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
}); });

View File

@@ -72,6 +72,7 @@ vi.mock("lucide-react", () => ({
Inbox: () => <span data-testid="icon-inbox">Inbox</span>, Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
Bot: () => <span data-testid="icon-bot">Bot</span>, Bot: () => <span data-testid="icon-bot">Bot</span>,
Trash2: () => <span data-testid="icon-trash">Trash</span>, Trash2: () => <span data-testid="icon-trash">Trash</span>,
Archive: () => <span data-testid="icon-archive">Archive</span>,
Check: () => <span data-testid="icon-check">Check</span>, Check: () => <span data-testid="icon-check">Check</span>,
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>, CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
Loader2: ({ className }: { className?: string }) => ( Loader2: ({ className }: { className?: string }) => (
@@ -1219,7 +1220,10 @@ describe("MailboxView", () => {
it.each([ it.each([
["the in-pane Back button", async () => fireEvent.click(screen.getByTestId("mailbox-back-to-list"))], ["the in-pane Back button", async () => fireEvent.click(screen.getByTestId("mailbox-back-to-list"))],
["delete", async () => fireEvent.click(screen.getByTestId("mailbox-delete"))], ["delete", async () => {
fireEvent.click(screen.getByTestId("mailbox-delete"));
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
}],
["an agent tab switch", async () => fireEvent.click(screen.getByTestId("mailbox-tab-outbox"))], ["an agent tab switch", async () => fireEvent.click(screen.getByTestId("mailbox-tab-outbox"))],
])("consumes the message entry before %s", async (_label, close) => { ])("consumes the message entry before %s", async (_label, close) => {
mockUseViewportMode.mockReturnValue("mobile"); mockUseViewportMode.mockReturnValue("mobile");
@@ -1575,7 +1579,11 @@ describe("MailboxView", () => {
await act(async () => { await act(async () => {
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
}); });
expect(mockDeleteMessage).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
});
await waitFor(() => { await waitFor(() => {
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined); expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
}); });

View File

@@ -168,7 +168,7 @@ describe("useChat", () => {
const { result } = renderHook(() => useChat("proj-123")); const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123"); expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
}); });
await waitFor(() => { await waitFor(() => {
@@ -210,7 +210,7 @@ describe("useChat", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith(projectId); expect(mockFetchChatSessions).toHaveBeenCalledWith(projectId, "active");
}); });
}); });
@@ -390,13 +390,13 @@ describe("useChat", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("p1"); expect(mockFetchChatSessions).toHaveBeenCalledWith("p1", "active");
}); });
rerender({ projectId: "p2" }); rerender({ projectId: "p2" });
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("p2"); expect(mockFetchChatSessions).toHaveBeenCalledWith("p2", "active");
}); });
expect(mockFetchChatSessions).toHaveBeenCalledTimes(2); expect(mockFetchChatSessions).toHaveBeenCalledTimes(2);
}); });
@@ -945,6 +945,31 @@ describe("useChat", () => {
}); });
}); });
it("keeps archived sessions out of the default refresh and restores them from the archived list", async () => {
const active = makeSession({ id: "session-active", agentId: "agent-001", title: "Active" });
const archived = makeSession({ id: "session-archived", agentId: "agent-002", title: "Archived", status: "archived" });
mockFetchChatSessions.mockResolvedValue({ sessions: [active, archived] });
mockUpdateChatSession.mockResolvedValue({ session: active });
const { result } = renderHook(() => useChat("proj-archive"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-archive", "active");
expect(result.current.sessions.map((session) => session.id)).toEqual(["session-active"]);
});
await act(async () => {
await result.current.refreshArchivedSessions();
});
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-archive", "archived");
expect(result.current.archivedSessions.map((session) => session.id)).toEqual(["session-archived"]);
await act(async () => {
await result.current.unarchiveSession("session-archived");
});
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-archived", { status: "active" }, "proj-archive");
});
it("renames a session optimistically, trims the API title, and updates the active header state", async () => { it("renames a session optimistically, trims the API title, and updates the active header state", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Old title" }); const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Old title" });
const renamedSession = makeSession({ const renamedSession = makeSession({
@@ -3245,7 +3270,7 @@ describe("useChat", () => {
const { result } = renderHook(() => useChat("proj-123")); const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123"); expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
}); });
expect(() => { expect(() => {

View File

@@ -136,6 +136,9 @@ export interface UseChatReturn {
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }, input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string },
) => Promise<ChatSessionInfo>; ) => Promise<ChatSessionInfo>;
archiveSession: (id: string) => Promise<void>; archiveSession: (id: string) => Promise<void>;
archivedSessions: ChatSessionInfo[];
refreshArchivedSessions: () => Promise<void>;
unarchiveSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>; renameSession: (id: string, title: string) => Promise<void>;
pinSession: (id: string, pinned: boolean) => Promise<void>; pinSession: (id: string, pinned: boolean) => Promise<void>;
pinnedCount: number; pinnedCount: number;
@@ -434,14 +437,18 @@ export function useChat(
/* /*
FNXC:ChatModal 2026-07-01-00:00: FNXC:ChatModal 2026-07-01-00:00:
Server settings decide whether task-planner sessions belong in the common feed. Do not hydrate cached task chats before that filtered list returns, otherwise a stale cache can briefly expose hidden task-detail conversations and their controls. Server settings decide whether task-planner sessions belong in the common feed. Do not hydrate cached task chats before that filtered list returns, otherwise a stale cache can briefly expose hidden task-detail conversations and their controls.
FNXC:MessageArchive 2026-08-12-22:36:
Archived sessions must not flash from a cached list before the active-only refresh completes.
*/ */
return cachedSessions.filter((session) => !isTaskPlannerSession(session)); return cachedSessions.filter((session) => !isTaskPlannerSession(session) && session.status !== "archived");
}, },
[getChatSessionsCacheKey], [getChatSessionsCacheKey],
); );
// Session state // Session state
const [sessions, setSessions] = useState<ChatSessionInfo[]>(() => readCachedSessions(projectId)); const [sessions, setSessions] = useState<ChatSessionInfo[]>(() => readCachedSessions(projectId));
const [archivedSessions, setArchivedSessions] = useState<ChatSessionInfo[]>([]);
const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null); const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0); const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0);
const [tags, setTags] = useState<ChatTag[]>([]); const [tags, setTags] = useState<ChatTag[]>([]);
@@ -536,8 +543,12 @@ export function useChat(
setSessionsLoading(true); setSessionsLoading(true);
} }
try { try {
const data: ChatSessionListResponse = await fetchChatSessions(projectId); const data: ChatSessionListResponse = await fetchChatSessions(projectId, "active");
const sorted = sortChatSessions(data.sessions); /*
FNXC:MessageArchive 2026-08-12-22:36:
The default sidebar excludes archived sessions even when an intermediary ignores status=active.
*/
const sorted = sortChatSessions(data.sessions.filter((session) => session.status !== "archived"));
setSessions(sorted); setSessions(sorted);
const cacheKey = getChatSessionsCacheKey(projectId); const cacheKey = getChatSessionsCacheKey(projectId);
if (cacheKey) { if (cacheKey) {
@@ -1132,6 +1143,21 @@ export function useChat(
[projectId, resetTransientComposerState, selectSession], [projectId, resetTransientComposerState, selectSession],
); );
const refreshArchivedSessions = useCallback(async () => {
const data = await fetchChatSessions(projectId, "archived");
/*
FNXC:MessageArchive 2026-08-12-22:38:
The Archived view is a restore surface, so it filters a stale/proxied response locally when status=archived is ignored.
*/
setArchivedSessions(sortChatSessions(data.sessions.filter((session) => session.status === "archived")));
}, [projectId]);
const unarchiveSession = useCallback(async (id: string) => {
await updateChatSession(id, { status: "active" }, projectId);
setArchivedSessions((previous) => previous.filter((session) => session.id !== id));
await refreshSessions();
}, [projectId, refreshSessions]);
// Archive a session // Archive a session
const archiveSession = useCallback( const archiveSession = useCallback(
async (id: string) => { async (id: string) => {
@@ -1713,6 +1739,7 @@ export function useChat(
void (async () => { void (async () => {
try { try {
const data = await fetchChatSessions(projectId, undefined, { const data = await fetchChatSessions(projectId, undefined, {
status: "active",
q: trimmedSearchQuery, q: trimmedSearchQuery,
titleOnly: false, titleOnly: false,
}); });
@@ -1824,7 +1851,7 @@ export function useChat(
} }
try { try {
const data: ChatSessionListResponse = await fetchChatSessions(projectId); const data: ChatSessionListResponse = await fetchChatSessions(projectId, "active");
const session = data.sessions.find((candidate) => candidate.id === activeSessionRef.current?.id); const session = data.sessions.find((candidate) => candidate.id === activeSessionRef.current?.id);
if (!session?.isGenerating) { if (!session?.isGenerating) {
clearInterval(interval); clearInterval(interval);
@@ -2140,6 +2167,9 @@ export function useChat(
selectSession, selectSession,
createSession, createSession,
archiveSession, archiveSession,
archivedSessions,
refreshArchivedSessions,
unarchiveSession,
renameSession, renameSession,
pinSession, pinSession,
pinnedCount, pinnedCount,

View File

@@ -0,0 +1,86 @@
// @vitest-environment node
import express from "express";
import { DASHBOARD_USER_ID, type Message, type TaskStore } from "@fusion/core";
import { describe, expect, it, vi } from "vitest";
import { request } from "../../test-request.js";
import { ApiError } from "../../api-error.js";
import { registerMessagingScriptRoutes } from "../register-messaging-scripts.js";
import type { ApiRoutesContext } from "../types.js";
function message(archived = false): Message {
return {
id: "message-archive", fromId: "agent-1", fromType: "agent", toId: DASHBOARD_USER_ID, toType: "user",
content: "Archive me", type: "agent-to-user", read: false, archived, createdAt: "2026-08-12T00:00:00.000Z", updatedAt: "2026-08-12T00:00:00.000Z",
};
}
function setup() {
const app = express();
app.use(express.json());
let current: Message | undefined = message();
const messageStore = {
archiveMessage: vi.fn(async (id: string) => {
if (id !== current?.id) throw new Error(`Message ${id} not found`);
current = { ...current, archived: true };
return current;
}),
unarchiveMessage: vi.fn(async (id: string) => {
if (id !== current?.id) throw new Error(`Message ${id} not found`);
current = { ...current, archived: false };
return current;
}),
deleteMessage: vi.fn(async (id: string) => {
if (id !== current?.id) throw new Error(`Message ${id} not found`);
current = undefined;
}),
getInbox: vi.fn(async (_id: string, _type: string, filter: { archived?: boolean }) =>
current && current.archived === (filter.archived === true) ? [current] : []),
getMailbox: vi.fn(async () => ({ unreadCount: current && !current.archived && !current.read ? 1 : 0 })),
};
const store = { getRootDir: () => "/test" } as unknown as TaskStore;
const context = {
router: express.Router(), store,
getProjectContext: async () => ({ store, engine: { getMessageStore: () => messageStore }, projectId: undefined }),
rethrowAsApiError: (error: unknown): never => { throw error; }, runtimeLogger: { warn: vi.fn() }, planningLogger: {}, chatLogger: {},
} as unknown as ApiRoutesContext;
registerMessagingScriptRoutes(context);
app.use("/api", context.router);
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({ error: error instanceof Error ? error.message : String(error) });
});
return { app, messageStore };
}
describe("messaging archive routes", () => {
it("archives, filters, restores, and excludes archive mail from unread counts", async () => {
const { app } = setup();
const archived = await request(app, "POST", "/api/messages/message-archive/archive");
expect(archived.status).toBe(200);
expect(archived.body.archived).toBe(true);
const inbox = await request(app, "GET", "/api/messages/inbox");
expect(inbox.body.messages).toEqual([]);
expect(inbox.body.unreadCount).toBe(0);
const archivedInbox = await request(app, "GET", "/api/messages/inbox?archived=true");
expect(archivedInbox.body.messages).toHaveLength(1);
expect(archivedInbox.body.messages[0].id).toBe("message-archive");
const restored = await request(app, "POST", "/api/messages/message-archive/unarchive");
expect(restored.status).toBe(200);
expect(restored.body.archived).toBe(false);
});
it("preserves hard delete as an explicit route and maps unknown archive IDs to 404", async () => {
const { app, messageStore } = setup();
const missing = await request(app, "POST", "/api/messages/missing/archive");
expect(missing.status).toBe(404);
const deleted = await request(app, "DELETE", "/api/messages/message-archive");
expect(deleted.status).toBe(204);
expect(messageStore.deleteMessage).toHaveBeenCalledWith("message-archive");
});
});

View File

@@ -236,6 +236,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
limit: parseInt(req.query.limit as string) || 20, limit: parseInt(req.query.limit as string) || 20,
offset: parseInt(req.query.offset as string) || 0, offset: parseInt(req.query.offset as string) || 0,
read: req.query.unreadOnly === "true" ? false : undefined, read: req.query.unreadOnly === "true" ? false : undefined,
archived: req.query.archived === "true",
type: req.query.type as MessageType | undefined, type: req.query.type as MessageType | undefined,
}; };
const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter); const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter);
@@ -255,6 +256,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
const filter = { const filter = {
limit: parseInt(req.query.limit as string) || 20, limit: parseInt(req.query.limit as string) || 20,
offset: parseInt(req.query.offset as string) || 0, offset: parseInt(req.query.offset as string) || 0,
archived: req.query.archived === "true",
type: req.query.type as MessageType | undefined, type: req.query.type as MessageType | undefined,
}; };
const messages = await msgStore.getOutbox(DASHBOARD_USER_ID, "user", filter); const messages = await msgStore.getOutbox(DASHBOARD_USER_ID, "user", filter);
@@ -391,6 +393,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
const messages = await msgStore.getConversation( const messages = await msgStore.getConversation(
{ id: DASHBOARD_USER_ID, type: "user" }, { id: DASHBOARD_USER_ID, type: "user" },
{ id: participantId, type: participantType as ParticipantType }, { id: participantId, type: participantType as ParticipantType },
{ archived: req.query.archived === "true" },
); );
res.json(messages); res.json(messages);
} catch (err: unknown) { } catch (err: unknown) {
@@ -518,6 +521,29 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
} }
}); });
// IMPORTANT: Register archive actions before the generic /messages/:id route.
router.post("/messages/:id/archive", async (req, res) => {
try {
const message = await (await getMessageStore(req)).archiveMessage(req.params.id);
res.json(message);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if ((err instanceof Error ? err.message : String(err)).includes("not found")) throw notFound(err instanceof Error ? err.message : String(err));
rethrowAsApiError(err);
}
});
router.post("/messages/:id/unarchive", async (req, res) => {
try {
const message = await (await getMessageStore(req)).unarchiveMessage(req.params.id);
res.json(message);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if ((err instanceof Error ? err.message : String(err)).includes("not found")) throw notFound(err instanceof Error ? err.message : String(err));
rethrowAsApiError(err);
}
});
router.post("/messages/:id/read", async (req, res) => { router.post("/messages/:id/read", async (req, res) => {
try { try {
const msgStore = await getMessageStore(req); const msgStore = await getMessageStore(req);
@@ -553,7 +579,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
router.get("/agents/mailbox/all", async (req, res) => { router.get("/agents/mailbox/all", async (req, res) => {
try { try {
const msgStore = await getMessageStore(req); const msgStore = await getMessageStore(req);
const messages = await msgStore.getAllAgentToAgentMessages(); const messages = await msgStore.getAllAgentToAgentMessages({ archived: req.query.archived === "true" });
const unreadCount = await msgStore.getUnreadAgentToAgentCount(); const unreadCount = await msgStore.getUnreadAgentToAgentCount();
res.json({ messages, total: messages.length, unreadCount }); res.json({ messages, total: messages.length, unreadCount });
} catch (err: unknown) { } catch (err: unknown) {
@@ -569,8 +595,9 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
const msgStore = await getMessageStore(req); const msgStore = await getMessageStore(req);
const agentId = req.params.id; const agentId = req.params.id;
const mailbox = await msgStore.getMailbox(agentId, "agent"); const mailbox = await msgStore.getMailbox(agentId, "agent");
const inbox = await msgStore.getInbox(agentId, "agent"); const archived = req.query.archived === "true";
const outbox = await msgStore.getOutbox(agentId, "agent"); const inbox = await msgStore.getInbox(agentId, "agent", { archived });
const outbox = await msgStore.getOutbox(agentId, "agent", { archived });
res.json({ ...mailbox, messages: inbox, inbox, outbox }); res.json({ ...mailbox, messages: inbox, inbox, outbox });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {