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:
7
.changeset/fn-9014-message-archive.md
Normal file
7
.changeset/fn-9014-message-archive.md
Normal 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.
|
||||
@@ -681,7 +681,12 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
|
||||
|
||||
## Chat View
|
||||
|
||||
Chat view provides project-scoped conversations with agents.
|
||||
Chat view provides project-scoped conversations with agents. The default conversation list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action.
|
||||
|
||||
## Mailbox archive
|
||||
|
||||
Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unread badges ignore it. Select **Archived** to review archived messages and restore them; Archive is the default removal action and Delete remains available as an explicit destructive action.
|
||||
|
||||
|
||||
- Direct Chat, Chat Room responders, and task-detail Planner Chat have coding workspace tools at the interactive project checkout: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. They can make user-directed edits and run shell investigation; a bound durable agent remains subject to its permanent-agent file-write and command-execution permission policy. These Chat sessions keep the checkout branch sticky unless you explicitly ask to switch it. Planning/mission interviews and WhatsApp plugin chat remain readonly.
|
||||
|
||||
|
||||
@@ -535,7 +535,7 @@ The `tasks.cumulativeActiveMs` and `tasks.executionCompletedAt` columns are the
|
||||
| `todo_lists` | Project-scoped todo list metadata (`projectId`, title, created/updated timestamps). |
|
||||
| `todo_items` | Todo list items (`listId` FK) with completion state, completion timestamp, and deterministic `sortOrder`. |
|
||||
| `ai_sessions` *(migration-created)* | Persisted AI interactive sessions (planning/interview/subtask) with status and conversation history. Deletion is final within a bounded tombstone window (FN-7949) — see below. |
|
||||
| `messages` *(migration-created)* | Inter-agent/user message mailbox storage. |
|
||||
| `messages` *(migration-created)* | Inter-agent/user message mailbox storage with an `archived` flag; archived mail is retained for restore but excluded from default mailbox reads and unread counts. |
|
||||
| `agentRatings` *(migration-created)* | Agent performance ratings (1-5), optional reviewer metadata, and run/task attribution. |
|
||||
| `chat_sessions` *(migration-created)* | Chat session metadata (agent/project/model/status/title timestamps). |
|
||||
| `chat_messages` *(migration-created)* | Chat message history per session (`role`, `content`, thinking output, metadata). |
|
||||
|
||||
@@ -74,6 +74,40 @@ pgTest("MessageStore send (PostgreSQL backend mode)", () => {
|
||||
expect((await store.getMessage(msg.id))?.content).toBe("hi user");
|
||||
});
|
||||
|
||||
it("archives correspondence by default and restores it on unarchive", async () => {
|
||||
const { MessageStore } = await import("../../stores/message-store.js");
|
||||
const store = new MessageStore(null, { asyncLayer: h.layer() });
|
||||
const message = await store.sendMessage({
|
||||
fromId: "agent-a",
|
||||
fromType: "agent",
|
||||
toId: "agent-b",
|
||||
toType: "agent",
|
||||
content: "retain this correspondence",
|
||||
type: "agent-to-agent",
|
||||
});
|
||||
|
||||
await expect(store.archiveMessage(message.id)).resolves.toMatchObject({ id: message.id, archived: true });
|
||||
expect((await store.getInbox("agent-b", "agent")).map(({ id }) => id)).not.toContain(message.id);
|
||||
expect((await store.getOutbox("agent-a", "agent")).map(({ id }) => id)).not.toContain(message.id);
|
||||
expect((await store.getConversation({ id: "agent-a", type: "agent" }, { id: "agent-b", type: "agent" })).map(({ id }) => id)).not.toContain(message.id);
|
||||
expect((await store.getAllAgentToAgentMessages()).map(({ id }) => id)).not.toContain(message.id);
|
||||
expect((await store.getMailbox("agent-b", "agent")).unreadCount).toBe(0);
|
||||
expect((await store.getInbox("agent-b", "agent", { archived: true })).map(({ id }) => id)).toContain(message.id);
|
||||
|
||||
await expect(store.unarchiveMessage(message.id)).resolves.toMatchObject({ id: message.id, archived: false });
|
||||
expect((await store.getInbox("agent-b", "agent")).map(({ id }) => id)).toContain(message.id);
|
||||
await expect(store.archiveMessage("missing-message")).rejects.toThrow("Message missing-message not found");
|
||||
});
|
||||
|
||||
it("treats legacy NULL archive values as active correspondence", async () => {
|
||||
const { MessageStore } = await import("../../stores/message-store.js");
|
||||
const store = new MessageStore(null, { asyncLayer: h.layer() });
|
||||
const id = "legacy-null-archive";
|
||||
await h.adminSql()`INSERT INTO project.messages (project_id, id, from_id, from_type, to_id, to_type, content, type, read, archived, created_at, updated_at)
|
||||
VALUES ('', ${id}, 'agent-a', 'agent', 'agent-b', 'agent', 'legacy', 'agent-to-agent', 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`;
|
||||
expect((await store.getInbox("agent-b", "agent")).map(({ id: messageId }) => messageId)).toContain(id);
|
||||
});
|
||||
|
||||
it("round-trips native structure embeds through mailbox metadata", async () => {
|
||||
const { MessageStore } = await import("../../stores/message-store.js");
|
||||
const store = new MessageStore(null, { asyncLayer: h.layer() });
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
|
||||
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
|
||||
MESSAGE_ARCHIVE_SCHEMA_VERSION,
|
||||
} from "../../postgres/schema-applier.js";
|
||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
||||
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
||||
@@ -133,7 +134,8 @@ describe("schema-applier: immutable migration identities", () => {
|
||||
expect(AGENT_RATINGS_PROJECT_PARTITION_VERSION).toBe("0055");
|
||||
expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056");
|
||||
expect(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION).toBe("0057");
|
||||
expect(SCHEMA_BASELINE_VERSION).toBe("0057");
|
||||
expect(MESSAGE_ARCHIVE_SCHEMA_VERSION).toBe("0058");
|
||||
expect(SCHEMA_BASELINE_VERSION).toBe("0058");
|
||||
});
|
||||
|
||||
it("keeps monitor and approval isolation assigned to version 0003", () => {
|
||||
@@ -1806,6 +1808,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
|
||||
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
|
||||
MESSAGE_ARCHIVE_SCHEMA_VERSION,
|
||||
]);
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||
});
|
||||
@@ -1889,6 +1892,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
|
||||
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
|
||||
MESSAGE_ARCHIVE_SCHEMA_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2105,6 +2109,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
|
||||
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
|
||||
MESSAGE_ARCHIVE_SCHEMA_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2202,6 +2207,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
|
||||
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
|
||||
MESSAGE_ARCHIVE_SCHEMA_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2299,6 +2305,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
|
||||
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
|
||||
MESSAGE_ARCHIVE_SCHEMA_VERSION,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ interface MessageRow {
|
||||
content: string;
|
||||
type: string;
|
||||
read: number | null;
|
||||
archived: number | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -61,6 +62,7 @@ const messageColumns = {
|
||||
content: schema.project.messages.content,
|
||||
type: schema.project.messages.type,
|
||||
read: schema.project.messages.read,
|
||||
archived: schema.project.messages.archived,
|
||||
metadata: schema.project.messages.metadata,
|
||||
createdAt: schema.project.messages.createdAt,
|
||||
updatedAt: schema.project.messages.updatedAt,
|
||||
@@ -76,6 +78,7 @@ function rowToMessage(row: MessageRow): Message {
|
||||
content: row.content,
|
||||
type: row.type as MessageType,
|
||||
read: (row.read ?? 0) === 1,
|
||||
archived: (row.archived ?? 0) === 1,
|
||||
metadata: row.metadata ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -89,6 +92,13 @@ function participantIdsForLookup(ownerId: string, ownerType: ParticipantType): s
|
||||
return [ownerId];
|
||||
}
|
||||
|
||||
/** Archived correspondence stays out of default reads; NULL preserves pre-migration rows. */
|
||||
function archivedCondition(archived?: boolean) {
|
||||
if (archived === true) return eq(schema.project.messages.archived, 1);
|
||||
if (archived === false) return or(eq(schema.project.messages.archived, 0), sql`${schema.project.messages.archived} IS NULL`)!;
|
||||
return or(eq(schema.project.messages.archived, 0), sql`${schema.project.messages.archived} IS NULL`)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:MessageStore 2026-06-24-07:00:
|
||||
* Create (send) a message. Non-destructive INSERT.
|
||||
@@ -115,6 +125,7 @@ export async function sendMessage(
|
||||
content: sanitizedContent,
|
||||
type: message.type,
|
||||
read: message.read ? 1 : 0,
|
||||
archived: message.archived ? 1 : 0,
|
||||
metadata: sanitizedMetadata,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.updatedAt,
|
||||
@@ -124,6 +135,7 @@ export async function sendMessage(
|
||||
content: sanitizedContent,
|
||||
metadata: sanitizedMetadata,
|
||||
read: message.read ? 1 : 0,
|
||||
archived: message.archived ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +158,7 @@ export async function sendMessageOnce(
|
||||
content: message.content,
|
||||
type: message.type,
|
||||
read: message.read ? 1 : 0,
|
||||
archived: message.archived ? 1 : 0,
|
||||
metadata: message.metadata,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.updatedAt,
|
||||
@@ -219,7 +232,7 @@ export async function queryMessagesByParticipant(
|
||||
const idCol = direction === "to" ? schema.project.messages.toId : schema.project.messages.fromId;
|
||||
const typeCol = direction === "to" ? schema.project.messages.toType : schema.project.messages.fromType;
|
||||
const participantIds = participantIdsForLookup(ownerId, ownerType);
|
||||
const conditions: ReturnType<typeof eq>[] = [
|
||||
const conditions = [
|
||||
inArray(idCol, participantIds),
|
||||
eq(typeCol, ownerType),
|
||||
];
|
||||
@@ -229,6 +242,7 @@ export async function queryMessagesByParticipant(
|
||||
if (filter?.read !== undefined) {
|
||||
conditions.push(eq(schema.project.messages.read, filter.read ? 1 : 0));
|
||||
}
|
||||
conditions.push(archivedCondition(filter?.archived));
|
||||
const limit = filter?.limit ?? 100;
|
||||
const offset = filter?.offset ?? 0;
|
||||
const rows = await handle
|
||||
@@ -264,6 +278,22 @@ export async function markMessageAsRead(
|
||||
* Mark all inbox messages as read for a participant. Returns the count of
|
||||
* messages that were unread before the update.
|
||||
*/
|
||||
export async function setMessageArchived(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
archived: boolean,
|
||||
): Promise<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(
|
||||
handle: QueryHandle,
|
||||
ownerId: string,
|
||||
@@ -279,6 +309,7 @@ export async function markAllMessagesAsRead(
|
||||
inArray(schema.project.messages.toId, participantIds),
|
||||
eq(schema.project.messages.toType, ownerType),
|
||||
eq(schema.project.messages.read, 0),
|
||||
archivedCondition(),
|
||||
),
|
||||
);
|
||||
const count = countRows[0]?.count ?? 0;
|
||||
@@ -290,6 +321,7 @@ export async function markAllMessagesAsRead(
|
||||
inArray(schema.project.messages.toId, participantIds),
|
||||
eq(schema.project.messages.toType, ownerType),
|
||||
eq(schema.project.messages.read, 0),
|
||||
archivedCondition(),
|
||||
),
|
||||
);
|
||||
return count;
|
||||
@@ -343,7 +375,7 @@ export async function getConversation(
|
||||
handle: QueryHandle,
|
||||
participantA: { id: string; type: ParticipantType },
|
||||
participantB: { id: string; type: ParticipantType },
|
||||
options?: { limit?: number },
|
||||
options?: Pick<MessageFilter, "limit" | "archived">,
|
||||
): Promise<Message[]> {
|
||||
const limit = Math.max(1, options?.limit ?? DEFAULT_CONVERSATION_LIMIT);
|
||||
const aIds = participantIdsForLookup(participantA.id, participantA.type);
|
||||
@@ -352,19 +384,22 @@ export async function getConversation(
|
||||
.select(messageColumns)
|
||||
.from(schema.project.messages)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
inArray(schema.project.messages.fromId, aIds),
|
||||
eq(schema.project.messages.fromType, participantA.type),
|
||||
inArray(schema.project.messages.toId, bIds),
|
||||
eq(schema.project.messages.toType, participantB.type),
|
||||
),
|
||||
and(
|
||||
inArray(schema.project.messages.fromId, bIds),
|
||||
eq(schema.project.messages.fromType, participantB.type),
|
||||
inArray(schema.project.messages.toId, aIds),
|
||||
eq(schema.project.messages.toType, participantA.type),
|
||||
and(
|
||||
or(
|
||||
and(
|
||||
inArray(schema.project.messages.fromId, aIds),
|
||||
eq(schema.project.messages.fromType, participantA.type),
|
||||
inArray(schema.project.messages.toId, bIds),
|
||||
eq(schema.project.messages.toType, participantB.type),
|
||||
),
|
||||
and(
|
||||
inArray(schema.project.messages.fromId, bIds),
|
||||
eq(schema.project.messages.fromType, participantB.type),
|
||||
inArray(schema.project.messages.toId, aIds),
|
||||
eq(schema.project.messages.toType, participantA.type),
|
||||
),
|
||||
),
|
||||
archivedCondition(options?.archived),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.project.messages.createdAt))
|
||||
@@ -390,6 +425,7 @@ export async function getMailbox(
|
||||
inArray(schema.project.messages.toId, participantIds),
|
||||
eq(schema.project.messages.toType, ownerType),
|
||||
eq(schema.project.messages.read, 0),
|
||||
archivedCondition(),
|
||||
),
|
||||
);
|
||||
const unreadCount = unreadRows[0]?.count ?? 0;
|
||||
@@ -400,6 +436,7 @@ export async function getMailbox(
|
||||
and(
|
||||
inArray(schema.project.messages.toId, participantIds),
|
||||
eq(schema.project.messages.toType, ownerType),
|
||||
archivedCondition(),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.project.messages.createdAt), desc(schema.project.messages.id))
|
||||
@@ -415,11 +452,14 @@ export async function getMailbox(
|
||||
/**
|
||||
* Get all agent-to-agent messages (newest first).
|
||||
*/
|
||||
export async function getAllAgentToAgentMessages(handle: QueryHandle): Promise<Message[]> {
|
||||
export async function getAllAgentToAgentMessages(
|
||||
handle: QueryHandle,
|
||||
filter?: Pick<MessageFilter, "archived">,
|
||||
): Promise<Message[]> {
|
||||
const rows = await handle
|
||||
.select(messageColumns)
|
||||
.from(schema.project.messages)
|
||||
.where(eq(schema.project.messages.type, "agent-to-agent"))
|
||||
.where(and(eq(schema.project.messages.type, "agent-to-agent"), archivedCondition(filter?.archived)))
|
||||
.orderBy(desc(schema.project.messages.createdAt), desc(schema.project.messages.id));
|
||||
return rows.map((row) => rowToMessage(row as MessageRow));
|
||||
}
|
||||
@@ -435,6 +475,7 @@ export async function getUnreadAgentToAgentCount(handle: QueryHandle): Promise<n
|
||||
and(
|
||||
eq(schema.project.messages.type, "agent-to-agent"),
|
||||
eq(schema.project.messages.read, 0),
|
||||
archivedCondition(),
|
||||
),
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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: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-12-15:43: 0057 reconciles the post-0006 GitHub check-state ownership default on upgrades. */
|
||||
export const SCHEMA_BASELINE_VERSION = "0057";
|
||||
/* FNXC:MessageArchive 2026-08-12-22:14: 0058 persists non-destructive mailbox archival on upgrades. */
|
||||
export const SCHEMA_BASELINE_VERSION = "0058";
|
||||
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||
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";
|
||||
/** FNXC:MultiProjectIsolation 2026-08-12-15:43: register the 0048 default reconciliation explicitly for upgrades. */
|
||||
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. */
|
||||
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 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 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
|
||||
@@ -578,6 +581,7 @@ export async function applySchemaBaseline(
|
||||
const agentRatingsProjectPartitionAlreadyApplied = applied.includes(AGENT_RATINGS_PROJECT_PARTITION_VERSION);
|
||||
const projectOwnershipDeclarationDriftAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION);
|
||||
const projectOwnershipDefaultReconciliationAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION);
|
||||
const messageArchiveSchemaAlreadyApplied = applied.includes(MESSAGE_ARCHIVE_SCHEMA_VERSION);
|
||||
assertBinaryNotOlderThanDatabase(applied);
|
||||
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`);
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2098,12 +2098,14 @@ export const messages = projectSchema.table("messages", {
|
||||
content: text("content").notNull(),
|
||||
type: text("type").notNull(),
|
||||
read: integer("read").default(0),
|
||||
archived: integer("archived").default(0),
|
||||
metadata: jsonb("metadata"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxMessagesTo").on(t.toId, t.toType, t.read),
|
||||
index("idxMessagesToArchived").on(t.toId, t.toType, t.archived),
|
||||
index("idxMessagesFrom").on(t.fromId, t.fromType),
|
||||
index("idxMessagesCreatedAt").on(t.createdAt),
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface MessageStoreEvents {
|
||||
"message:received": [message: Message];
|
||||
/** Emitted when a message is marked as read */
|
||||
"message:read": [message: Message];
|
||||
/** Emitted when a message is archived */
|
||||
"message:archived": [message: Message];
|
||||
/** Emitted when a message is restored from archive */
|
||||
"message:unarchived": [message: Message];
|
||||
/** Emitted when a message is deleted */
|
||||
"message:deleted": [messageId: string];
|
||||
/** Emitted when proposal metadata changes without creating a new message. */
|
||||
@@ -51,6 +55,7 @@ interface MessageRow {
|
||||
content: string;
|
||||
type: string;
|
||||
read: number;
|
||||
archived: number | null;
|
||||
metadata: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -109,12 +114,12 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
// Prepare frequently-run statements (SQLite path)
|
||||
const sqliteDb = this.db!;
|
||||
this.stmtInsert = sqliteDb.prepare(`
|
||||
INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO messages (id, fromId, fromType, toId, toType, content, type, read, archived, metadata, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtInsertOnce = sqliteDb.prepare(`
|
||||
INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, metadata, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT OR IGNORE INTO messages (id, fromId, fromType, toId, toType, content, type, read, archived, metadata, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.stmtGetById = sqliteDb.prepare(`
|
||||
@@ -150,6 +155,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
content: row.content,
|
||||
type: row.type as MessageType,
|
||||
read: row.read === 1,
|
||||
archived: (row.archived ?? 0) === 1,
|
||||
metadata: fromJson<Message["metadata"]>(row.metadata),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -188,6 +194,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
content: sanitizeTextValue(input.content),
|
||||
type: input.type,
|
||||
read: false,
|
||||
archived: false,
|
||||
metadata: sanitizeJsonbValue(input.metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -204,6 +211,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
content: message.content,
|
||||
type: message.type,
|
||||
read: message.read,
|
||||
archived: message.archived,
|
||||
metadata: message.metadata ?? null,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.updatedAt,
|
||||
@@ -218,6 +226,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
message.content,
|
||||
message.type,
|
||||
message.read ? 1 : 0,
|
||||
message.archived ? 1 : 0,
|
||||
toJsonNullable(message.metadata),
|
||||
message.createdAt,
|
||||
message.updatedAt,
|
||||
@@ -295,6 +304,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
content: sanitizeTextValue(input.content),
|
||||
type: input.type,
|
||||
read: false,
|
||||
archived: false,
|
||||
metadata: sanitizeJsonbValue(input.metadata),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -316,6 +326,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
message.content,
|
||||
message.type,
|
||||
0,
|
||||
0,
|
||||
toJsonNullable(message.metadata),
|
||||
message.createdAt,
|
||||
message.updatedAt,
|
||||
@@ -467,6 +478,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
whereClauses.push("read = ?");
|
||||
params.push(filter.read ? 1 : 0);
|
||||
}
|
||||
whereClauses.push(filter?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)");
|
||||
|
||||
const whereSql = whereClauses.join(" AND ");
|
||||
const limit = filter?.limit ?? 100;
|
||||
@@ -533,19 +545,47 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
|
||||
// Get count of unread messages before updating
|
||||
const unreadRow = this.db!.prepare(`
|
||||
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0
|
||||
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0 AND (archived = 0 OR archived IS NULL)
|
||||
`).get(...participantIds, ownerType) as { count: number } | undefined;
|
||||
const count = unreadRow?.count ?? 0;
|
||||
|
||||
// Mark all as read
|
||||
this.db!.prepare(`
|
||||
UPDATE messages SET read = 1, updatedAt = ? WHERE ${toIdPredicate} AND toType = ? AND read = 0
|
||||
UPDATE messages SET read = 1, updatedAt = ? WHERE ${toIdPredicate} AND toType = ? AND read = 0 AND (archived = 0 OR archived IS NULL)
|
||||
`).run(now, ...participantIds, ownerType);
|
||||
|
||||
this.db!.bumpLastModified();
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Archive a message without destroying its correspondence history. */
|
||||
async archiveMessage(id: string): Promise<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.
|
||||
* @param id - The message ID
|
||||
@@ -632,7 +672,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
async getConversation(
|
||||
participantA: { id: string; type: ParticipantType },
|
||||
participantB: { id: string; type: ParticipantType },
|
||||
options?: { limit?: number },
|
||||
options?: Pick<MessageFilter, "limit" | "archived">,
|
||||
): Promise<Message[]> {
|
||||
if (this.asyncLayer) {
|
||||
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 = ?)
|
||||
OR
|
||||
(${participantBFromPredicate} AND fromType = ? AND ${participantAToPredicate} AND toType = ?)
|
||||
)
|
||||
) AND ${options?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)"}
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT ?
|
||||
`).all(
|
||||
@@ -702,12 +742,12 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
: `toId IN (${participantIds.map(() => "?").join(", ")})`;
|
||||
|
||||
const unreadRow = this.db!.prepare(`
|
||||
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0
|
||||
SELECT COUNT(*) as count FROM messages WHERE ${toIdPredicate} AND toType = ? AND read = 0 AND (archived = 0 OR archived IS NULL)
|
||||
`).get(...participantIds, ownerType) as { count: number } | undefined;
|
||||
const unreadCount = unreadRow?.count ?? 0;
|
||||
|
||||
const lastRow = this.db!.prepare(`
|
||||
SELECT * FROM messages WHERE ${toIdPredicate} AND toType = ? ORDER BY createdAt DESC, rowid DESC LIMIT 1
|
||||
SELECT * FROM messages WHERE ${toIdPredicate} AND toType = ? AND (archived = 0 OR archived IS NULL) ORDER BY createdAt DESC, rowid DESC LIMIT 1
|
||||
`).get(...participantIds, ownerType) as unknown as MessageRow | undefined;
|
||||
const lastMessage = lastRow ? this.rowToMessage(lastRow) : undefined;
|
||||
|
||||
@@ -723,13 +763,13 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
* Get all agent-to-agent messages across all agents.
|
||||
* @returns Array of messages (newest first)
|
||||
*/
|
||||
async getAllAgentToAgentMessages(): Promise<Message[]> {
|
||||
async getAllAgentToAgentMessages(filter?: Pick<MessageFilter, "archived">): Promise<Message[]> {
|
||||
if (this.asyncLayer) {
|
||||
return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db);
|
||||
return asyncMessageStore.getAllAgentToAgentMessages(this.asyncLayer.db, filter);
|
||||
}
|
||||
const rows = this.db!.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE type = ?
|
||||
WHERE type = ? AND ${filter?.archived === true ? "archived = 1" : "(archived = 0 OR archived IS NULL)"}
|
||||
ORDER BY createdAt DESC, rowid DESC
|
||||
`).all("agent-to-agent");
|
||||
|
||||
@@ -745,7 +785,7 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
}
|
||||
const row = this.db!.prepare(`
|
||||
SELECT COUNT(*) as count FROM messages
|
||||
WHERE type = ? AND read = 0
|
||||
WHERE type = ? AND read = 0 AND (archived = 0 OR archived IS NULL)
|
||||
`).get("agent-to-agent") as { count: number } | undefined;
|
||||
|
||||
return row?.count ?? 0;
|
||||
|
||||
@@ -151,6 +151,13 @@ export interface Message {
|
||||
type: MessageType;
|
||||
/** Whether the recipient has read this message */
|
||||
read: boolean;
|
||||
/*
|
||||
FNXC:MessageArchive 2026-08-12-22:14:
|
||||
Archive is the default non-destructive removal action; delete remains an explicit
|
||||
operator choice. Default message reads exclude archived correspondence.
|
||||
*/
|
||||
/** Whether the message is archived */
|
||||
archived: boolean;
|
||||
/** Optional extra data */
|
||||
metadata?: MessageMetadata;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
@@ -183,6 +190,8 @@ export interface MessageFilter {
|
||||
type?: MessageType;
|
||||
/** Filter by read status */
|
||||
read?: boolean;
|
||||
/** Filter by archived status; omitted excludes archived messages. */
|
||||
archived?: boolean;
|
||||
/** Maximum number of messages to return */
|
||||
limit?: number;
|
||||
/** Number of messages to skip (for pagination) */
|
||||
|
||||
@@ -123,13 +123,14 @@ export interface ApprovalListResponse {
|
||||
|
||||
/** Fetch inbox messages for the current user. */
|
||||
export function fetchInbox(
|
||||
options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType },
|
||||
options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType; archived?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<InboxResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
||||
if (options?.unreadOnly) params.set("unreadOnly", "true");
|
||||
if (options?.archived) params.set("archived", "true");
|
||||
if (options?.type) params.set("type", options.type);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
@@ -138,12 +139,13 @@ export function fetchInbox(
|
||||
|
||||
/** Fetch sent messages for the current user. */
|
||||
export function fetchOutbox(
|
||||
options?: { limit?: number; offset?: number; type?: MessageType },
|
||||
options?: { limit?: number; offset?: number; type?: MessageType; archived?: boolean },
|
||||
projectId?: string,
|
||||
): Promise<OutboxResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
||||
if (options?.archived) params.set("archived", "true");
|
||||
if (options?.type) params.set("type", options.type);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
@@ -187,6 +189,16 @@ export function markAllMessagesRead(projectId?: string): Promise<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. */
|
||||
export function deleteMessage(id: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), {
|
||||
@@ -199,19 +211,30 @@ export function fetchConversation(
|
||||
participantId: string,
|
||||
participantType: ParticipantType,
|
||||
projectId?: string,
|
||||
options?: { archived?: boolean },
|
||||
): Promise<Message[]> {
|
||||
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). */
|
||||
export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<AgentMailboxResponse> {
|
||||
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId));
|
||||
export function fetchAgentMailbox(agentId: string, projectId?: string, options?: { archived?: boolean }): Promise<AgentMailboxResponse> {
|
||||
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). */
|
||||
export function fetchAllAgentMailbox(projectId?: string): Promise<AllAgentsMailboxResponse> {
|
||||
return api<AllAgentsMailboxResponse>(withProjectId("/agents/mailbox/all", projectId));
|
||||
export function fetchAllAgentMailbox(projectId?: string, options?: { archived?: boolean }): Promise<AllAgentsMailboxResponse> {
|
||||
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(
|
||||
|
||||
@@ -1287,6 +1287,7 @@ export type {
|
||||
*/
|
||||
export {
|
||||
addAgentRating,
|
||||
archiveMessage,
|
||||
createProposedTask,
|
||||
decideApproval,
|
||||
deleteAgentRating,
|
||||
@@ -1311,6 +1312,7 @@ export {
|
||||
resetAgentBudget,
|
||||
sendMessage,
|
||||
triggerAgentReflection,
|
||||
unarchiveMessage,
|
||||
} from "./chat/messaging.js";
|
||||
export type {
|
||||
AgentMailboxResponse,
|
||||
|
||||
@@ -626,6 +626,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
selectSession,
|
||||
createSession,
|
||||
archiveSession,
|
||||
archivedSessions,
|
||||
refreshArchivedSessions,
|
||||
unarchiveSession,
|
||||
renameSession,
|
||||
pinSession,
|
||||
pinnedCount,
|
||||
@@ -679,6 +682,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
return getPersistedChatDraft(initialDraftKey);
|
||||
});
|
||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; anchorX: number; anchorY: number; anchorRight: boolean; x: number; y: number } | null>(null);
|
||||
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
/*
|
||||
FNXC:ChatSidebar 2026-07-17-00:12:
|
||||
@@ -2491,6 +2495,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
[archiveSession, addToast],
|
||||
);
|
||||
|
||||
const handleRestoreArchived = useCallback(async (id: string) => {
|
||||
try { await unarchiveSession(id); addToast(t("chat.conversationRestored", "Conversation restored"), "success"); }
|
||||
catch { addToast(t("chat.failedToRestoreConversation", "Failed to restore conversation"), "error"); }
|
||||
}, [unarchiveSession, addToast, t]);
|
||||
|
||||
const openRenameDialog = useCallback(
|
||||
(id: string) => {
|
||||
const session = filteredSessions.find((item) => item.id === id) ?? (activeSession?.id === id ? activeSession : null);
|
||||
@@ -3211,8 +3220,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
FNXC:ChatHeader 2026-06-22-18:44:
|
||||
Very narrow chat headers collapse Direct/Rooms to icons while retaining aria-selected tabs and text labels for wider headers. The segmented control must stay height-aligned with the ViewHeader action row, so icon+label markup is stable and CSS hides only the label.
|
||||
*/
|
||||
const pinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt != null);
|
||||
const unpinnedFilteredSessions = filteredSessions.filter((session) => session.pinnedAt == null);
|
||||
const visibleSidebarSessions = showArchivedSessions ? archivedSessions : filteredSessions;
|
||||
const pinnedFilteredSessions = visibleSidebarSessions.filter((session) => session.pinnedAt != null);
|
||||
const unpinnedFilteredSessions = visibleSidebarSessions.filter((session) => session.pinnedAt == null);
|
||||
const contextMenuSession = contextMenu
|
||||
? filteredSessions.find((session) => session.id === contextMenu.sessionId) ?? (activeSession?.id === contextMenu.sessionId ? activeSession : undefined)
|
||||
: undefined;
|
||||
@@ -3301,6 +3311,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
>
|
||||
{session.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
</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
|
||||
type="button"
|
||||
className="btn-icon chat-mobile-session-rename"
|
||||
@@ -3495,10 +3506,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
</label>
|
||||
</div>
|
||||
{/* 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">
|
||||
{sessionsLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
@@ -3534,7 +3546,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
e.preventDefault();
|
||||
openSessionMenu(session.id, e.clientX, e.clientY);
|
||||
}}
|
||||
data-testid={`chat-session-${session.id}`}
|
||||
data-testid={showArchivedSessions ? `chat-archived-session-${session.id}` : `chat-session-${session.id}`}
|
||||
>
|
||||
{/*
|
||||
FNXC:ChatSidebar 2026-07-16-00:00:
|
||||
@@ -3579,6 +3591,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
{t("chat.matchedInMessage", "Matched: \"{{preview}}\"", { preview: session.matchedMessagePreview })}
|
||||
</div>
|
||||
) : 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">
|
||||
<span className="chat-session-meta-model">
|
||||
{sessionResolvedModel?.provider ? <ProviderIcon provider={sessionResolvedModel.provider} size="sm" /> : null}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Inbox as InboxIcon,
|
||||
Bot,
|
||||
Trash2,
|
||||
Archive,
|
||||
CheckCheck,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
fetchAllAgentMailbox,
|
||||
markMessageRead,
|
||||
markAllMessagesRead,
|
||||
archiveMessage,
|
||||
unarchiveMessage,
|
||||
deleteMessage,
|
||||
fetchConversation,
|
||||
fetchMessage,
|
||||
@@ -52,7 +55,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MailboxTab = "inbox" | "outbox" | "agents";
|
||||
type MailboxTab = "inbox" | "outbox" | "archived" | "agents";
|
||||
|
||||
const ALL_AGENTS_MAILBOX_ID = "__all_agents__";
|
||||
|
||||
@@ -302,9 +305,11 @@ export function MailboxModal({
|
||||
const [inbox, setInbox] = useState<InboxResponse | null>(() => initialInbox ?? null);
|
||||
const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all");
|
||||
const [outbox, setOutbox] = useState<OutboxResponse | null>(() => initialOutbox ?? null);
|
||||
const [archivedInbox, setArchivedInbox] = useState<InboxResponse | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(initialUnreadCount ?? 0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState<string | null>(null);
|
||||
const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
|
||||
@@ -385,6 +390,25 @@ export function MailboxModal({
|
||||
}
|
||||
}, [inboxCacheKey, projectId, unreadCountCacheKey]);
|
||||
|
||||
const loadArchivedInbox = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
/*
|
||||
FNXC:MessageArchive 2026-08-12-22:38:
|
||||
Archived mail must remain restorable regardless of whether it originated in the inbox, outbox, or an agent mailbox.
|
||||
Deduplicate the combined source results because aggregate agent queries can overlap a participant-specific response.
|
||||
*/
|
||||
const [inbox, outbox, agentMailbox] = await Promise.all([
|
||||
fetchInbox({ limit: 50, archived: true }, projectId),
|
||||
fetchOutbox({ limit: 50, archived: true }, projectId),
|
||||
fetchAllAgentMailbox(projectId, { archived: true }),
|
||||
]);
|
||||
const messages = [...inbox.messages, ...outbox.messages, ...agentMailbox.messages]
|
||||
.filter((message, index, all) => all.findIndex(({ id }) => id === message.id) === index);
|
||||
setArchivedInbox({ messages, total: messages.length, unreadCount: 0 });
|
||||
} finally { setIsLoading(false); }
|
||||
}, [projectId]);
|
||||
|
||||
const loadOutbox = useCallback(async () => {
|
||||
const shouldSkipOpenSpinner = skipOpenSpinnerOutboxRef.current;
|
||||
if (!shouldSkipOpenSpinner) {
|
||||
@@ -464,7 +488,8 @@ export function MailboxModal({
|
||||
if (!isOpen) return;
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
}, [isOpen, activeTab, loadInbox, loadOutbox]);
|
||||
else if (activeTab === "archived") loadArchivedInbox();
|
||||
}, [isOpen, activeTab, loadInbox, loadOutbox, loadArchivedInbox]);
|
||||
|
||||
// Load agent mailbox when selected
|
||||
useEffect(() => {
|
||||
@@ -669,8 +694,28 @@ export function MailboxModal({
|
||||
}
|
||||
}, [addToast, inboxCacheKey, projectId, unreadCountCacheKey, t]);
|
||||
|
||||
/* FNXC:MessageArchive 2026-08-12-22:14: Archive is the default removal action; deletion is explicit and destructive. */
|
||||
const handleArchiveMessage = useCallback(async (id: string) => {
|
||||
try {
|
||||
await archiveMessage(id, projectId);
|
||||
handleCloseMessage();
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "archived") loadArchivedInbox();
|
||||
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
void refreshUnreadCount();
|
||||
addToast?.("Message archived", "success");
|
||||
} catch { addToast?.("Failed to archive message", "error"); }
|
||||
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadArchivedInbox, loadAgentMailbox, loadAllAgentsMailbox, refreshUnreadCount, addToast, handleCloseMessage]);
|
||||
const handleUnarchiveMessage = useCallback(async (id: string) => {
|
||||
try { await unarchiveMessage(id, projectId); handleCloseMessage(); loadArchivedInbox(); void refreshUnreadCount(); addToast?.("Message restored", "success"); }
|
||||
catch { addToast?.("Failed to restore message", "error"); }
|
||||
}, [projectId, loadArchivedInbox, refreshUnreadCount, addToast, handleCloseMessage]);
|
||||
|
||||
const handleDeleteMessage = useCallback(async (id: string) => {
|
||||
consumeCurrentDeepLink();
|
||||
setPendingDeleteMessageId(null);
|
||||
try {
|
||||
await deleteMessage(id, projectId);
|
||||
setSelectedMessage(null);
|
||||
@@ -860,6 +905,7 @@ export function MailboxModal({
|
||||
<Send size={14} />
|
||||
<span>{t("mailbox.outboxTab", "Outbox")}</span>
|
||||
</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
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
onClick={() => { consumeCurrentDeepLink(); setActiveTab("agents"); setSelectedMessage(null); }}
|
||||
@@ -899,14 +945,16 @@ export function MailboxModal({
|
||||
<span>{t("mailbox.replyButton", "Reply")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleDeleteMessage(selectedMessage.id)}
|
||||
data-testid="mailbox-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>{t("mailbox.deleteButton", "Delete")}</span>
|
||||
</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>}
|
||||
{pendingDeleteMessageId === selectedMessage.id ? (
|
||||
<>
|
||||
{/* 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>
|
||||
<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.deleteButton", "Delete")}</span></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-message-participants">
|
||||
@@ -1044,6 +1092,12 @@ export function MailboxModal({
|
||||
{!selectedMessage && !showComposer && (
|
||||
<>
|
||||
{/* 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" && (
|
||||
<div className="mailbox-list" data-testid="mailbox-inbox-list">
|
||||
<div className="mailbox-structural-filter" role="group" aria-label="Inbox filter">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Inbox as InboxIcon,
|
||||
Bot,
|
||||
Trash2,
|
||||
Archive,
|
||||
CheckCheck,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
fetchAllAgentMailbox,
|
||||
markMessageRead,
|
||||
markAllMessagesRead,
|
||||
archiveMessage,
|
||||
unarchiveMessage,
|
||||
deleteMessage,
|
||||
fetchConversation,
|
||||
fetchAgents,
|
||||
@@ -57,7 +60,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MailboxTab = "inbox" | "outbox" | "agents" | "approvals";
|
||||
type MailboxTab = "inbox" | "outbox" | "archived" | "agents" | "approvals";
|
||||
|
||||
interface MailboxViewProps {
|
||||
projectId?: string;
|
||||
@@ -240,9 +243,11 @@ export function MailboxView({
|
||||
const consumedComposePrefillNonceRef = useRef<number | null>(null);
|
||||
const [structuralFilter, setStructuralFilter] = useState<"all" | "structural">("all");
|
||||
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
|
||||
const [archivedInbox, setArchivedInbox] = useState<InboxResponse | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState<string | null>(null);
|
||||
const [conversationMessages, setConversationMessages] = useState<Message[]>([]);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null);
|
||||
@@ -456,6 +461,28 @@ export function MailboxView({
|
||||
}
|
||||
}, [projectId, onUnreadCountChange, captureMailboxScroll]);
|
||||
|
||||
const loadArchivedInbox = useCallback(async () => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
/*
|
||||
FNXC:MessageArchive 2026-08-12-22:38:
|
||||
The archive is a restore surface for every mailbox source, including sent and agent mail.
|
||||
Combine the source-specific archive queries and deduplicate IDs so archiving never strands a message outside its restore view.
|
||||
*/
|
||||
const [inbox, outbox, agentMailbox] = await Promise.all([
|
||||
fetchInbox({ limit: 50, archived: true }, projectId),
|
||||
fetchOutbox({ limit: 50, archived: true }, projectId),
|
||||
fetchAllAgentMailbox(projectId, { archived: true }),
|
||||
]);
|
||||
const messages = [...inbox.messages, ...outbox.messages, ...agentMailbox.messages]
|
||||
.filter((message, index, all) => all.findIndex(({ id }) => id === message.id) === index);
|
||||
setArchivedInbox({ messages, total: messages.length, unreadCount: 0 });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId, captureMailboxScroll]);
|
||||
|
||||
const loadOutbox = useCallback(async () => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
@@ -546,11 +573,12 @@ export function MailboxView({
|
||||
useEffect(() => {
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "archived") loadArchivedInbox();
|
||||
else if (activeTab === "agents") loadAgents();
|
||||
else if (activeTab === "approvals") {
|
||||
void loadApprovals(approvalSubTab);
|
||||
}
|
||||
}, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]);
|
||||
}, [activeTab, loadInbox, loadOutbox, loadArchivedInbox, loadAgents, loadApprovals, approvalSubTab]);
|
||||
|
||||
// Load agent mailbox when selected
|
||||
useEffect(() => {
|
||||
@@ -746,14 +774,45 @@ export function MailboxView({
|
||||
}
|
||||
}, [projectId, addToast, onUnreadCountChange]);
|
||||
|
||||
/*
|
||||
FNXC:MessageArchive 2026-08-12-22:14:
|
||||
Archive is the default mailbox removal action. Delete remains an explicit destructive choice.
|
||||
*/
|
||||
const handleArchiveMessage = useCallback(async (id: string) => {
|
||||
consumeCurrentDeepLink();
|
||||
try {
|
||||
await archiveMessage(id, projectId);
|
||||
dismissMessage();
|
||||
if (activeTab === "archived") loadArchivedInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "inbox") loadInbox();
|
||||
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
refreshUnreadCount();
|
||||
addToast?.("Message archived", "success");
|
||||
} catch { addToast?.("Failed to archive message", "error"); }
|
||||
}, [projectId, activeTab, selectedAgentId, loadArchivedInbox, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, refreshUnreadCount, addToast, consumeCurrentDeepLink, dismissMessage]);
|
||||
|
||||
const handleUnarchiveMessage = useCallback(async (id: string) => {
|
||||
try {
|
||||
await unarchiveMessage(id, projectId);
|
||||
dismissMessage();
|
||||
loadArchivedInbox();
|
||||
refreshUnreadCount();
|
||||
addToast?.("Message restored", "success");
|
||||
} catch { addToast?.("Failed to restore message", "error"); }
|
||||
}, [projectId, loadArchivedInbox, refreshUnreadCount, addToast, dismissMessage]);
|
||||
|
||||
const handleDeleteMessage = useCallback(async (id: string) => {
|
||||
consumeCurrentDeepLink();
|
||||
setPendingDeleteMessageId(null);
|
||||
try {
|
||||
await deleteMessage(id, projectId);
|
||||
dismissMessage();
|
||||
// Refresh current tab
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "archived") loadArchivedInbox();
|
||||
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
addToast?.("Message deleted", "success");
|
||||
@@ -942,14 +1001,30 @@ export function MailboxView({
|
||||
<span>{t("mailbox.reply", "Reply")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleDeleteMessage(selectedMessage.id)}
|
||||
data-testid="mailbox-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>{t("mailbox.delete", "Delete")}</span>
|
||||
</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>
|
||||
)}
|
||||
{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 className="mailbox-message-participants">
|
||||
@@ -1059,6 +1134,17 @@ export function MailboxView({
|
||||
|
||||
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" && (
|
||||
<div className="mailbox-list" data-testid="mailbox-inbox-list">
|
||||
<div className="mailbox-structural-filter" role="group" aria-label="Inbox filter">
|
||||
@@ -1490,6 +1576,7 @@ export function MailboxView({
|
||||
onClick={() => {
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "archived") loadArchivedInbox();
|
||||
else if (activeTab === "approvals") loadApprovals(approvalSubTab);
|
||||
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
@@ -1523,6 +1610,7 @@ export function MailboxView({
|
||||
<Send size={14} />
|
||||
<span>{t("mailbox.outbox", "Outbox")}</span>
|
||||
</button>
|
||||
<button className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "archived" ? "active" : ""}`} onClick={() => handleSelectTab("archived")} data-testid="mailbox-tab-archived">Archived</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
onClick={() => handleSelectTab("agents")}
|
||||
|
||||
@@ -340,6 +340,30 @@ describe("Chat Session Action Menu", () => {
|
||||
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 () => {
|
||||
const selectSession = vi.fn();
|
||||
setupMockChat({
|
||||
|
||||
@@ -63,6 +63,9 @@ export const defaultChatState: UseChatReturn = {
|
||||
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),
|
||||
archiveSession: vi.fn(),
|
||||
archivedSessions: [],
|
||||
refreshArchivedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
unarchiveSession: vi.fn().mockResolvedValue(undefined),
|
||||
renameSession: vi.fn(),
|
||||
setSessionThinkingLevel: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("lucide-react", () => ({
|
||||
Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
|
||||
Bot: () => <span data-testid="icon-bot">Bot</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>,
|
||||
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
|
||||
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} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
@@ -693,6 +694,9 @@ describe("MailboxModal", () => {
|
||||
expect(deleteButton).toHaveClass("btn", "btn-sm", "btn-secondary");
|
||||
|
||||
fireEvent.click(deleteButton);
|
||||
expect(mockDeleteMessage).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("lucide-react", () => ({
|
||||
Inbox: () => <span data-testid="icon-inbox">Inbox</span>,
|
||||
Bot: () => <span data-testid="icon-bot">Bot</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>,
|
||||
CheckCheck: () => <span data-testid="icon-checkcheck">CheckCheck</span>,
|
||||
Loader2: ({ className }: { className?: string }) => (
|
||||
@@ -1219,7 +1220,10 @@ describe("MailboxView", () => {
|
||||
|
||||
it.each([
|
||||
["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"))],
|
||||
])("consumes the message entry before %s", async (_label, close) => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
@@ -1575,7 +1579,11 @@ describe("MailboxView", () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
expect(mockDeleteMessage).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-delete-confirm"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("useChat", () => {
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123");
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -210,7 +210,7 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith(projectId);
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith(projectId, "active");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,13 +390,13 @@ describe("useChat", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("p1");
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("p1", "active");
|
||||
});
|
||||
|
||||
rerender({ projectId: "p2" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("p2");
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("p2", "active");
|
||||
});
|
||||
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 () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Old title" });
|
||||
const renamedSession = makeSession({
|
||||
@@ -3245,7 +3270,7 @@ describe("useChat", () => {
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123");
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123", "active");
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
|
||||
@@ -136,6 +136,9 @@ export interface UseChatReturn {
|
||||
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string },
|
||||
) => Promise<ChatSessionInfo>;
|
||||
archiveSession: (id: string) => Promise<void>;
|
||||
archivedSessions: ChatSessionInfo[];
|
||||
refreshArchivedSessions: () => Promise<void>;
|
||||
unarchiveSession: (id: string) => Promise<void>;
|
||||
renameSession: (id: string, title: string) => Promise<void>;
|
||||
pinSession: (id: string, pinned: boolean) => Promise<void>;
|
||||
pinnedCount: number;
|
||||
@@ -434,14 +437,18 @@ export function useChat(
|
||||
/*
|
||||
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.
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// Session state
|
||||
const [sessions, setSessions] = useState<ChatSessionInfo[]>(() => readCachedSessions(projectId));
|
||||
const [archivedSessions, setArchivedSessions] = useState<ChatSessionInfo[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(() => readCachedSessions(projectId).length === 0);
|
||||
const [tags, setTags] = useState<ChatTag[]>([]);
|
||||
@@ -536,8 +543,12 @@ export function useChat(
|
||||
setSessionsLoading(true);
|
||||
}
|
||||
try {
|
||||
const data: ChatSessionListResponse = await fetchChatSessions(projectId);
|
||||
const sorted = sortChatSessions(data.sessions);
|
||||
const data: ChatSessionListResponse = await fetchChatSessions(projectId, "active");
|
||||
/*
|
||||
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);
|
||||
const cacheKey = getChatSessionsCacheKey(projectId);
|
||||
if (cacheKey) {
|
||||
@@ -1132,6 +1143,21 @@ export function useChat(
|
||||
[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
|
||||
const archiveSession = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -1713,6 +1739,7 @@ export function useChat(
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchChatSessions(projectId, undefined, {
|
||||
status: "active",
|
||||
q: trimmedSearchQuery,
|
||||
titleOnly: false,
|
||||
});
|
||||
@@ -1824,7 +1851,7 @@ export function useChat(
|
||||
}
|
||||
|
||||
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);
|
||||
if (!session?.isGenerating) {
|
||||
clearInterval(interval);
|
||||
@@ -2140,6 +2167,9 @@ export function useChat(
|
||||
selectSession,
|
||||
createSession,
|
||||
archiveSession,
|
||||
archivedSessions,
|
||||
refreshArchivedSessions,
|
||||
unarchiveSession,
|
||||
renameSession,
|
||||
pinSession,
|
||||
pinnedCount,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -236,6 +236,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
read: req.query.unreadOnly === "true" ? false : undefined,
|
||||
archived: req.query.archived === "true",
|
||||
type: req.query.type as MessageType | undefined,
|
||||
};
|
||||
const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter);
|
||||
@@ -255,6 +256,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
|
||||
const filter = {
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
offset: parseInt(req.query.offset as string) || 0,
|
||||
archived: req.query.archived === "true",
|
||||
type: req.query.type as MessageType | undefined,
|
||||
};
|
||||
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(
|
||||
{ id: DASHBOARD_USER_ID, type: "user" },
|
||||
{ id: participantId, type: participantType as ParticipantType },
|
||||
{ archived: req.query.archived === "true" },
|
||||
);
|
||||
res.json(messages);
|
||||
} 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) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
@@ -553,7 +579,7 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/agents/mailbox/all", async (req, res) => {
|
||||
try {
|
||||
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();
|
||||
res.json({ messages, total: messages.length, unreadCount });
|
||||
} catch (err: unknown) {
|
||||
@@ -569,8 +595,9 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const agentId = req.params.id;
|
||||
const mailbox = await msgStore.getMailbox(agentId, "agent");
|
||||
const inbox = await msgStore.getInbox(agentId, "agent");
|
||||
const outbox = await msgStore.getOutbox(agentId, "agent");
|
||||
const archived = req.query.archived === "true";
|
||||
const inbox = await msgStore.getInbox(agentId, "agent", { archived });
|
||||
const outbox = await msgStore.getOutbox(agentId, "agent", { archived });
|
||||
res.json({ ...mailbox, messages: inbox, inbox, outbox });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user