FN-8292: embed native structures in mail messages
Embed selected native project structures directly in mailbox messages and drafts. - Persist native structure embeds in message metadata and expose their shared types. - Add composer selection, mailbox rendering, previews, and dashboard navigation for linked structures. - Cover metadata persistence and mailbox embed behavior with core and dashboard tests. Files changed: .changeset/fn-8292-mail-native-structure-embeds.md | 7 ++ docs/dashboard-guide.md | 1 + .../message-metadata-native-structures.test.ts | 25 +++++++ .../__tests__/postgres/message-store.pg.test.ts | 21 ++++++ packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 32 +++++++++ packages/core/src/types/messages.ts | 14 ++++ packages/dashboard/app/components/MailboxModal.css | 29 ++++++++ packages/dashboard/app/components/MailboxModal.tsx | 13 +++- .../components/MailboxNativeStructureEmbeds.tsx | 37 ++++++++++ packages/dashboard/app/components/MailboxView.tsx | 14 +++- .../dashboard/app/components/MessageComposer.tsx | 65 ++++++++++++++++-- .../app/components/NativeStructurePreview.tsx | 6 +- .../__tests__/MailboxModal.cache.test.tsx | 8 +-- .../app/components/__tests__/MailboxModal.test.tsx | 9 +++ .../MailboxNativeStructureEmbeds.test.tsx | 42 ++++++++++++ .../app/components/__tests__/MailboxView.test.tsx | 9 +++ .../components/__tests__/MessageComposer.test.tsx | 25 +++++++ .../app/components/dashboard/MainContent.tsx | 78 +++++++++++++++++++++- .../MainContent.mailbox-view-task.test.tsx | 46 ++++++++++++- 20 files changed, 459 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8292 Fusion-Task-Lineage: ae6cfb9c-a764-407c-bd45-cfd879ccdd3e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8292-mail-native-structure-embeds.md
Normal file
7
.changeset/fn-8292-mail-native-structure-embeds.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Attach reviewable native structures to mailbox messages.
|
||||||
|
category: feature
|
||||||
|
dev: Message metadata now carries validated native structure references with lazy previews.
|
||||||
@@ -718,6 +718,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an
|
|||||||
- when a real pending mailbox approval request is created, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; task plan-approval states (`awaiting-approval`) remain visible on the triage board and do not create a mailbox banner
|
- when a real pending mailbox approval request is created, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; task plan-approval states (`awaiting-approval`) remain visible on the triage board and do not create a mailbox banner
|
||||||
- when a task first transitions into `done`, the dashboard shows a one-time **Enjoying Fusion?** GitHub star prompt in the project view after first-run setup is closed; clicking **Star on GitHub** or dismissing the card marks it shown in browser `localStorage`, so it does not reappear on reload or later task completions. The setup wizard does not add a second star prompt.
|
- when a task first transitions into `done`, the dashboard shows a one-time **Enjoying Fusion?** GitHub star prompt in the project view after first-run setup is closed; clicking **Star on GitHub** or dismissing the card marks it shown in browser `localStorage`, so it does not reappear on reload or later task completions. The setup wizard does not add a second star prompt.
|
||||||
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links
|
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links
|
||||||
|
- Compose can attach native missions, milestones, goals, research findings, and eval results as structural cards. Recipients can open the live structure from the message detail or conversation thread; a captured label keeps unavailable or soft-deleted attachments identifiable.
|
||||||
- Separate top-level messages from the same sender remain independent in the inbox and detail pane
|
- Separate top-level messages from the same sender remain independent in the inbox and detail pane
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateMessageMetadata } from "../types.js";
|
||||||
|
|
||||||
|
describe("validateMessageMetadata nativeStructures", () => {
|
||||||
|
it("accepts absent, single, and multiple supported structural embeds", () => {
|
||||||
|
expect(() => validateMessageMetadata(undefined)).not.toThrow();
|
||||||
|
expect(() => validateMessageMetadata({ nativeStructures: [{ kind: "mission", id: "M-1" }] })).not.toThrow();
|
||||||
|
expect(() => validateMessageMetadata({
|
||||||
|
nativeStructures: [
|
||||||
|
{ kind: "goal", id: "G-1", label: "Ship mail embeds" },
|
||||||
|
{ kind: "eval-result", id: "E-1", projectId: "project-1" },
|
||||||
|
],
|
||||||
|
})).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ nativeStructures: "not-an-array" }, "must be an array"],
|
||||||
|
[{ nativeStructures: [{ kind: "mission" }] }, "id must be a non-empty string"],
|
||||||
|
[{ nativeStructures: [{ kind: "roadmap-item", id: "R-1" }] }, "kind is invalid"],
|
||||||
|
[{ nativeStructures: [{ kind: "unknown", id: "X-1" }] }, "kind is invalid"],
|
||||||
|
[{ nativeStructures: [{ kind: "goal", id: "G-1", label: 1 }] }, "label must be a string"],
|
||||||
|
])("rejects invalid native structures %#", (metadata, message) => {
|
||||||
|
expect(() => validateMessageMetadata(metadata as never)).toThrow(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,27 @@ 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("round-trips native structure embeds through mailbox metadata", async () => {
|
||||||
|
const { MessageStore } = await import("../../message-store.js");
|
||||||
|
const store = new MessageStore(null, { asyncLayer: h.layer() });
|
||||||
|
const nativeStructures = [
|
||||||
|
{ kind: "mission" as const, id: "M-1", label: "Launch roadmap" },
|
||||||
|
{ kind: "goal" as const, id: "G-1", projectId: "project-1" },
|
||||||
|
];
|
||||||
|
const sent = await store.sendMessage({
|
||||||
|
fromId: "agent-a",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "user-x",
|
||||||
|
toType: "user",
|
||||||
|
content: "Review these structures",
|
||||||
|
type: "agent-to-user",
|
||||||
|
metadata: { nativeStructures },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sent.metadata?.nativeStructures).toEqual(nativeStructures);
|
||||||
|
expect((await store.getMessage(sent.id))?.metadata?.nativeStructures).toEqual(nativeStructures);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PostgresMigrationInbox 2026-07-14-12:10:
|
FNXC:PostgresMigrationInbox 2026-07-14-12:10:
|
||||||
Once-only inbox delivery must use PostgreSQL's primary-key conflict handling as the concurrency authority; parallel callers may share the resulting message, but only one may report inserting it.
|
Once-only inbox delivery must use PostgreSQL's primary-key conflict handling as the concurrency authority; parallel callers may share the resulting message, but only one may report inserting it.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7051,6 +7051,7 @@ import type {
|
|||||||
MessageReplyReference,
|
MessageReplyReference,
|
||||||
EphemeralTaskCreationPolicy,
|
EphemeralTaskCreationPolicy,
|
||||||
ProposedTaskMetadata,
|
ProposedTaskMetadata,
|
||||||
|
NativeStructureEmbed,
|
||||||
MessageMetadata,
|
MessageMetadata,
|
||||||
Message,
|
Message,
|
||||||
MessageCreateInput,
|
MessageCreateInput,
|
||||||
@@ -7062,6 +7063,7 @@ export type {
|
|||||||
MessageReplyReference,
|
MessageReplyReference,
|
||||||
EphemeralTaskCreationPolicy,
|
EphemeralTaskCreationPolicy,
|
||||||
ProposedTaskMetadata,
|
ProposedTaskMetadata,
|
||||||
|
NativeStructureEmbed,
|
||||||
MessageMetadata,
|
MessageMetadata,
|
||||||
Message,
|
Message,
|
||||||
MessageCreateInput,
|
MessageCreateInput,
|
||||||
@@ -7088,6 +7090,36 @@ export function validateMessageMetadata(metadata: MessageMetadata | undefined):
|
|||||||
throw new Error("metadata.wakeRecipient must be a boolean");
|
throw new Error("metadata.wakeRecipient must be a boolean");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||||
|
Mail accepts only the shared five-kind NativeStructureRef union. Reject unsupported future
|
||||||
|
kinds at the persistence boundary so every stored attachment remains renderable by the shared
|
||||||
|
preview component; labels are optional attach-time fallbacks, not serialized preview snapshots.
|
||||||
|
*/
|
||||||
|
if (metadata.nativeStructures !== undefined) {
|
||||||
|
if (!Array.isArray(metadata.nativeStructures)) {
|
||||||
|
throw new Error("metadata.nativeStructures must be an array");
|
||||||
|
}
|
||||||
|
const supportedKinds: NativeStructureRef["kind"][] = ["mission", "milestone", "research-finding", "eval-result", "goal"];
|
||||||
|
for (const embed of metadata.nativeStructures) {
|
||||||
|
if (typeof embed !== "object" || embed === null || Array.isArray(embed)) {
|
||||||
|
throw new Error("metadata.nativeStructures entries must be objects");
|
||||||
|
}
|
||||||
|
if (!supportedKinds.includes(embed.kind)) {
|
||||||
|
throw new Error("metadata.nativeStructures.kind is invalid");
|
||||||
|
}
|
||||||
|
if (typeof embed.id !== "string" || embed.id.trim().length === 0) {
|
||||||
|
throw new Error("metadata.nativeStructures.id must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (embed.projectId !== undefined && (typeof embed.projectId !== "string" || embed.projectId.trim().length === 0)) {
|
||||||
|
throw new Error("metadata.nativeStructures.projectId must be a non-empty string");
|
||||||
|
}
|
||||||
|
if (embed.label !== undefined && typeof embed.label !== "string") {
|
||||||
|
throw new Error("metadata.nativeStructures.label must be a string");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const proposalFieldsPresent = metadata.proposalStatus !== undefined || metadata.createdTaskId !== undefined || metadata.proposalIdempotencyKey !== undefined || metadata.claimOwnerToken !== undefined || metadata.claimStartedAt !== undefined;
|
const proposalFieldsPresent = metadata.proposalStatus !== undefined || metadata.createdTaskId !== undefined || metadata.proposalIdempotencyKey !== undefined || metadata.claimOwnerToken !== undefined || metadata.claimStartedAt !== undefined;
|
||||||
if (metadata.kind === "task-proposal" || proposalFieldsPresent || metadata.proposedTask !== undefined) {
|
if (metadata.kind === "task-proposal" || proposalFieldsPresent || metadata.proposedTask !== undefined) {
|
||||||
if (metadata.kind !== "task-proposal" || !metadata.proposedTask) throw new Error("task proposal metadata requires kind and proposedTask");
|
if (metadata.kind !== "task-proposal" || !metadata.proposedTask) throw new Error("task proposal metadata requires kind and proposedTask");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TaskPriority } from "./board.js";
|
import type { TaskPriority } from "./board.js";
|
||||||
|
import type { NativeStructureRef } from "../types.js";
|
||||||
|
|
||||||
export type ParticipantType = "agent" | "user" | "system";
|
export type ParticipantType = "agent" | "user" | "system";
|
||||||
|
|
||||||
@@ -64,6 +65,13 @@ export interface ProposedTaskMetadata {
|
|||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||||
|
* Mail persists a compact native-structure reference with an optional attach-time label. The
|
||||||
|
* shared dashboard preview resolves current content lazily so metadata never stores stale cards.
|
||||||
|
*/
|
||||||
|
export type NativeStructureEmbed = NativeStructureRef & { label?: string };
|
||||||
|
|
||||||
export interface MessageMetadata extends Record<string, unknown> {
|
export interface MessageMetadata extends Record<string, unknown> {
|
||||||
/** Optional link to the original message when this message is a reply. */
|
/** Optional link to the original message when this message is a reply. */
|
||||||
replyTo?: MessageReplyReference;
|
replyTo?: MessageReplyReference;
|
||||||
@@ -84,6 +92,12 @@ export interface MessageMetadata extends Record<string, unknown> {
|
|||||||
claimOwnerToken?: string;
|
claimOwnerToken?: string;
|
||||||
/** Durable ISO timestamp used to reclaim a creator that died before task persistence. */
|
/** Durable ISO timestamp used to reclaim a creator that died before task persistence. */
|
||||||
claimStartedAt?: string;
|
claimStartedAt?: string;
|
||||||
|
/**
|
||||||
|
* FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||||
|
* First-class report/approval attachments. Each reference stays small and the label gives an
|
||||||
|
* unavailable target a human-readable fallback after lazy preview resolution.
|
||||||
|
*/
|
||||||
|
nativeStructures?: NativeStructureEmbed[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Message record stored in the system */
|
/** Message record stored in the system */
|
||||||
|
|||||||
@@ -1340,6 +1340,35 @@ separate approvals Back button outside this header or non-mobile mailbox layouts
|
|||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-composer-field--structures {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer-structure-controls {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer-structure-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer-structure-list li {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.message-composer-field--wake {
|
.message-composer-field--wake {
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
import type { Message, MessageType, NativeStructurePreviewResult, NativeStructureRef, ParticipantType } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
fetchInbox,
|
fetchInbox,
|
||||||
fetchOutbox,
|
fetchOutbox,
|
||||||
@@ -34,9 +34,10 @@ import {
|
|||||||
type AgentMailboxResponse,
|
type AgentMailboxResponse,
|
||||||
type AllAgentsMailboxResponse,
|
type AllAgentsMailboxResponse,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { MessageComposer } from "./MessageComposer";
|
import { MessageComposer, type NativeStructureCandidate } from "./MessageComposer";
|
||||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||||
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
|
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
|
||||||
|
import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds";
|
||||||
import { MailboxTaskProposal } from "./MailboxTaskProposal";
|
import { MailboxTaskProposal } from "./MailboxTaskProposal";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
@@ -58,6 +59,9 @@ interface MailboxModalProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
addToast?: (msg: string, type?: "success" | "error") => void;
|
addToast?: (msg: string, type?: "success" | "error") => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
/** Opens a persisted structure from the shared preview card. */
|
||||||
|
onOpenNativeStructure: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
||||||
|
nativeStructureCandidates: NativeStructureCandidate[];
|
||||||
agents?: Agent[];
|
agents?: Agent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +176,8 @@ export function MailboxModal({
|
|||||||
projectId,
|
projectId,
|
||||||
addToast,
|
addToast,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
onOpenNativeStructure,
|
||||||
|
nativeStructureCandidates,
|
||||||
agents = [],
|
agents = [],
|
||||||
}: MailboxModalProps) {
|
}: MailboxModalProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
@@ -902,6 +908,7 @@ export function MailboxModal({
|
|||||||
taskId={msg.metadata?.taskId}
|
taskId={msg.metadata?.taskId}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
|
<MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} />
|
||||||
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -934,6 +941,7 @@ export function MailboxModal({
|
|||||||
taskId={selectedMessage.metadata?.taskId}
|
taskId={selectedMessage.metadata?.taskId}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
|
<MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} />
|
||||||
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -947,6 +955,7 @@ export function MailboxModal({
|
|||||||
replyContext={composeReplyContext}
|
replyContext={composeReplyContext}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
nativeStructureCandidates={nativeStructureCandidates}
|
||||||
onSend={handleMessageSent}
|
onSend={handleMessageSent}
|
||||||
onCancel={handleComposeCancel}
|
onCancel={handleComposeCancel}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import type { Message, NativeStructurePreviewResult, NativeStructureRef } from "@fusion/core";
|
||||||
|
import { NativeStructurePreview } from "./NativeStructurePreview";
|
||||||
|
|
||||||
|
export interface MailboxNativeStructureEmbedsProps {
|
||||||
|
message: Pick<Message, "metadata">;
|
||||||
|
projectId?: string;
|
||||||
|
onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||||
|
* Mail message metadata is the durable home for first-class structure embeds. This thin wrapper
|
||||||
|
* deliberately owns no preview behavior: the shared lazy resolver handles live data and missing
|
||||||
|
* targets, while this returns no shell for ordinary mail with no structural attachment.
|
||||||
|
*/
|
||||||
|
export const MailboxNativeStructureEmbeds = memo(function MailboxNativeStructureEmbeds({
|
||||||
|
message,
|
||||||
|
projectId,
|
||||||
|
onOpen,
|
||||||
|
}: MailboxNativeStructureEmbedsProps) {
|
||||||
|
const embeds = message.metadata?.nativeStructures;
|
||||||
|
if (!embeds?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mailbox-native-structure-embeds" data-testid="mailbox-native-structure-embeds">
|
||||||
|
{embeds.map((embed, index) => (
|
||||||
|
<NativeStructurePreview
|
||||||
|
key={`${embed.kind}:${embed.id}:${index}`}
|
||||||
|
ref={{ kind: embed.kind, id: embed.id, projectId: embed.projectId ?? projectId }}
|
||||||
|
capturedLabel={embed.label}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Message, MessageType, ParticipantType } from "@fusion/core";
|
import type { Message, MessageType, NativeStructurePreviewResult, NativeStructureRef, ParticipantType } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
fetchInbox,
|
fetchInbox,
|
||||||
fetchOutbox,
|
fetchOutbox,
|
||||||
@@ -39,8 +39,9 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||||
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
|
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
|
||||||
|
import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds";
|
||||||
import { MailboxTaskProposal } from "./MailboxTaskProposal";
|
import { MailboxTaskProposal } from "./MailboxTaskProposal";
|
||||||
import { MessageComposer } from "./MessageComposer";
|
import { MessageComposer, type NativeStructureCandidate } from "./MessageComposer";
|
||||||
import { ViewHeader } from "./ViewHeader";
|
import { ViewHeader } from "./ViewHeader";
|
||||||
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
|
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
|
||||||
import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails";
|
import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails";
|
||||||
@@ -59,6 +60,9 @@ interface MailboxViewProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
addToast?: (msg: string, type?: "success" | "error") => void;
|
addToast?: (msg: string, type?: "success" | "error") => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
/** Opens a persisted structure from the shared preview card. */
|
||||||
|
onOpenNativeStructure: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
||||||
|
nativeStructureCandidates: NativeStructureCandidate[];
|
||||||
/** Callback when unread count changes (for header badge updates) */
|
/** Callback when unread count changes (for header badge updates) */
|
||||||
onUnreadCountChange?: (count: number) => void;
|
onUnreadCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
@@ -217,6 +221,8 @@ export function MailboxView({
|
|||||||
projectId,
|
projectId,
|
||||||
addToast,
|
addToast,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
onOpenNativeStructure,
|
||||||
|
nativeStructureCandidates,
|
||||||
onUnreadCountChange,
|
onUnreadCountChange,
|
||||||
}: MailboxViewProps) {
|
}: MailboxViewProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
@@ -952,6 +958,7 @@ export function MailboxView({
|
|||||||
taskId={msg.metadata?.taskId}
|
taskId={msg.metadata?.taskId}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
|
<MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} />
|
||||||
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -979,6 +986,7 @@ export function MailboxView({
|
|||||||
taskId={selectedMessage.metadata?.taskId}
|
taskId={selectedMessage.metadata?.taskId}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
/>
|
/>
|
||||||
|
<MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} />
|
||||||
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1267,6 +1275,7 @@ export function MailboxView({
|
|||||||
replyContext={composeReplyContext}
|
replyContext={composeReplyContext}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
nativeStructureCandidates={nativeStructureCandidates}
|
||||||
onSend={handleMessageSent}
|
onSend={handleMessageSent}
|
||||||
onCancel={handleComposeCancel}
|
onCancel={handleComposeCancel}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
@@ -1492,6 +1501,7 @@ export function MailboxView({
|
|||||||
replyContext={composeReplyContext}
|
replyContext={composeReplyContext}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
nativeStructureCandidates={nativeStructureCandidates}
|
||||||
onSend={handleMessageSent}
|
onSend={handleMessageSent}
|
||||||
onCancel={handleComposeCancel}
|
onCancel={handleComposeCancel}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
|
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
|
||||||
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
|
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
|
||||||
import type { ParticipantType, MessageType } from "@fusion/core";
|
import type { NativeStructureEmbed, NativeStructureRef, ParticipantType, MessageType } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import { sendMessage } from "../api";
|
import { sendMessage } from "../api";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NativeStructureCandidate {
|
||||||
|
ref: NativeStructureRef;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageComposerProps {
|
interface MessageComposerProps {
|
||||||
/** Pre-fill recipient (e.g. when replying) */
|
/** Pre-fill recipient (e.g. when replying) */
|
||||||
recipient?: { id: string; type: ParticipantType } | null;
|
recipient?: { id: string; type: ParticipantType } | null;
|
||||||
@@ -26,6 +31,8 @@ interface MessageComposerProps {
|
|||||||
addToast?: (msg: string, type?: "success" | "error") => void;
|
addToast?: (msg: string, type?: "success" | "error") => void;
|
||||||
/** Loading state for agents (shows placeholder) */
|
/** Loading state for agents (shows placeholder) */
|
||||||
isLoadingAgents?: boolean;
|
isLoadingAgents?: boolean;
|
||||||
|
/** Project-scoped structures the mail parent makes available for attachment. */
|
||||||
|
nativeStructureCandidates?: NativeStructureCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 2000;
|
const MAX_CONTENT_LENGTH = 2000;
|
||||||
@@ -41,12 +48,14 @@ export function MessageComposer({
|
|||||||
onCancel,
|
onCancel,
|
||||||
addToast,
|
addToast,
|
||||||
isLoadingAgents = false,
|
isLoadingAgents = false,
|
||||||
|
nativeStructureCandidates = [],
|
||||||
}: MessageComposerProps) {
|
}: MessageComposerProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [toId, setToId] = useState(recipient?.id ?? "");
|
const [toId, setToId] = useState(recipient?.id ?? "");
|
||||||
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
|
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [wakeRecipient, setWakeRecipient] = useState(false);
|
const [wakeRecipient, setWakeRecipient] = useState(false);
|
||||||
|
const [nativeStructures, setNativeStructures] = useState<NativeStructureEmbed[]>([]);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
@@ -82,10 +91,11 @@ export function MessageComposer({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
|
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
|
||||||
const metadata =
|
const metadata = {
|
||||||
replyContext
|
...(replyContext ? { replyTo: { messageId: replyContext.messageId } } : {}),
|
||||||
? { replyTo: { messageId: replyContext.messageId } }
|
...(nativeStructures.length > 0 ? { nativeStructures } : {}),
|
||||||
: undefined;
|
};
|
||||||
|
const hasMetadata = Object.keys(metadata).length > 0;
|
||||||
const sendWakeImmediately = wakeImmediately;
|
const sendWakeImmediately = wakeImmediately;
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
{
|
{
|
||||||
@@ -93,7 +103,7 @@ export function MessageComposer({
|
|||||||
toType,
|
toType,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
type: messageType,
|
type: messageType,
|
||||||
...(metadata ? { metadata } : {}),
|
...(hasMetadata ? { metadata } : {}),
|
||||||
...(sendWakeImmediately ? { wakeImmediately: true } : {}),
|
...(sendWakeImmediately ? { wakeImmediately: true } : {}),
|
||||||
},
|
},
|
||||||
projectId,
|
projectId,
|
||||||
@@ -106,13 +116,19 @@ export function MessageComposer({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
}, [isValid, isSending, toId, toType, content, wakeImmediately, replyContext, projectId, onSend, addToast]);
|
}, [isValid, isSending, toId, toType, content, wakeImmediately, replyContext, nativeStructures, projectId, onSend, addToast]);
|
||||||
|
|
||||||
const handleAgentSelect = useCallback((agentId: string) => {
|
const handleAgentSelect = useCallback((agentId: string) => {
|
||||||
setToId(agentId);
|
setToId(agentId);
|
||||||
setToType("agent");
|
setToType("agent");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const attachNativeStructure = useCallback((candidateIndex: string) => {
|
||||||
|
const candidate = nativeStructureCandidates[Number(candidateIndex)];
|
||||||
|
if (!candidate) return;
|
||||||
|
setNativeStructures((current) => [...current, { ...candidate.ref, label: candidate.label }]);
|
||||||
|
}, [nativeStructureCandidates]);
|
||||||
|
|
||||||
const scrollTextareaIntoView = useCallback(() => {
|
const scrollTextareaIntoView = useCallback(() => {
|
||||||
if (typeof textareaRef.current?.scrollIntoView !== "function") {
|
if (typeof textareaRef.current?.scrollIntoView !== "function") {
|
||||||
return;
|
return;
|
||||||
@@ -223,6 +239,41 @@ export function MessageComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||||
|
The composer receives project-scoped candidates from its mailbox parent and persists only
|
||||||
|
each reference plus label. Selection appends to the draft so reports can carry multiple
|
||||||
|
independently reviewable structures without serializing preview payloads.
|
||||||
|
*/}
|
||||||
|
<div className="message-composer-field message-composer-field--structures">
|
||||||
|
<label className="message-composer-label" htmlFor="message-native-structure">Attach structure</label>
|
||||||
|
<div className="message-composer-structure-controls">
|
||||||
|
<select
|
||||||
|
id="message-native-structure"
|
||||||
|
className="message-composer-select"
|
||||||
|
value=""
|
||||||
|
disabled={nativeStructureCandidates.length === 0}
|
||||||
|
onChange={(event) => attachNativeStructure(event.target.value)}
|
||||||
|
data-testid="message-composer-attach-structure"
|
||||||
|
>
|
||||||
|
<option value="">{nativeStructureCandidates.length === 0 ? "No structures available" : "Select structure…"}</option>
|
||||||
|
{nativeStructureCandidates.map((candidate, index) => (
|
||||||
|
<option key={`${candidate.ref.kind}:${candidate.ref.id}`} value={index}>{candidate.ref.kind}: {candidate.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nativeStructures.length > 0 && (
|
||||||
|
<ul className="message-composer-structure-list" data-testid="message-composer-attached-structures">
|
||||||
|
{nativeStructures.map((embed, index) => (
|
||||||
|
<li key={`${embed.kind}:${embed.id}:${index}`}>
|
||||||
|
<span>{embed.kind}: {embed.label ?? embed.id}</span>
|
||||||
|
<button className="btn btn-sm btn-secondary" type="button" onClick={() => setNativeStructures((current) => current.filter((_, currentIndex) => currentIndex !== index))} aria-label={`Remove ${embed.label ?? embed.id}`}>Remove</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Wake recipient toggle (agents only) */}
|
{/* Wake recipient toggle (agents only) */}
|
||||||
{recipientIsAgent && (
|
{recipientIsAgent && (
|
||||||
<div className="message-composer-field message-composer-field--wake">
|
<div className="message-composer-field message-composer-field--wake">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import "./NativeStructurePreview.css";
|
|||||||
export interface NativeStructurePreviewProps {
|
export interface NativeStructurePreviewProps {
|
||||||
ref: NativeStructureRef;
|
ref: NativeStructureRef;
|
||||||
payload?: NativeStructurePreviewResult;
|
payload?: NativeStructurePreviewResult;
|
||||||
|
/** Attach-time label used only when a persisted target is no longer available. */
|
||||||
|
capturedLabel?: string;
|
||||||
onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ function unavailableLabel(kind: string): string {
|
|||||||
* owned by each consumer through `onOpen` because dashboard views use callback/view state rather
|
* owned by each consumer through `onOpen` because dashboard views use callback/view state rather
|
||||||
* than URL routes; rendering an anchor here would create dead destinations.
|
* than URL routes; rendering an anchor here would create dead destinations.
|
||||||
*/
|
*/
|
||||||
export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, onOpen }: NativeStructurePreviewProps) {
|
export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, capturedLabel, onOpen }: NativeStructurePreviewProps) {
|
||||||
const supportedKind = isSupportedKind(ref.kind);
|
const supportedKind = isSupportedKind(ref.kind);
|
||||||
const refKey = `${ref.kind}\u0000${ref.id}\u0000${ref.projectId ?? ""}`;
|
const refKey = `${ref.kind}\u0000${ref.id}\u0000${ref.projectId ?? ""}`;
|
||||||
const [fetchedPayload, setFetchedPayload] = useState<{ refKey: string; result: NativeStructurePreviewResult } | undefined>();
|
const [fetchedPayload, setFetchedPayload] = useState<{ refKey: string; result: NativeStructurePreviewResult } | undefined>();
|
||||||
@@ -94,7 +96,7 @@ export const NativeStructurePreview = memo(function NativeStructurePreview({ ref
|
|||||||
return (
|
return (
|
||||||
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason={result.reason}>
|
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason={result.reason}>
|
||||||
<Icon aria-hidden="true" />
|
<Icon aria-hidden="true" />
|
||||||
<div className="native-structure-preview__content"><span className="native-structure-preview__label">{unavailableLabel(result.kind)}</span><p>This structure is unavailable.</p></div>
|
<div className="native-structure-preview__content"><span className="native-structure-preview__label">{unavailableLabel(result.kind)}</span><strong className="native-structure-preview__title">{capturedLabel?.trim() || "Preview unavailable"}</strong><p>This structure is unavailable.</p></div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe("MailboxModal cache hydration", () => {
|
|||||||
);
|
);
|
||||||
mockFetchInbox.mockImplementation(() => new Promise(() => {}));
|
mockFetchInbox.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} />);
|
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("mailbox-item-msg-cache")).toBeInTheDocument();
|
expect(screen.getByTestId("mailbox-item-msg-cache")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -62,7 +62,7 @@ describe("MailboxModal cache hydration", () => {
|
|||||||
unreadCount: 1,
|
unreadCount: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} />);
|
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const cachedRaw = localStorage.getItem(`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`);
|
const cachedRaw = localStorage.getItem(`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`);
|
||||||
@@ -90,7 +90,7 @@ describe("MailboxModal cache hydration", () => {
|
|||||||
}));
|
}));
|
||||||
mockFetchInbox.mockResolvedValueOnce({ messages: oversized, total: oversized.length, unreadCount: oversized.length });
|
mockFetchInbox.mockResolvedValueOnce({ messages: oversized, total: oversized.length, unreadCount: oversized.length });
|
||||||
|
|
||||||
const { rerender } = render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} />);
|
const { rerender } = render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const envelope = JSON.parse(localStorage.getItem(`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`) ?? "{}");
|
const envelope = JSON.parse(localStorage.getItem(`${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}p1`) ?? "{}");
|
||||||
@@ -111,7 +111,7 @@ describe("MailboxModal cache hydration", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
mockFetchInbox.mockImplementation(() => new Promise(() => {}));
|
mockFetchInbox.mockImplementation(() => new Promise(() => {}));
|
||||||
rerender(<MailboxModal isOpen onClose={() => {}} projectId="p2" agents={[]} />);
|
rerender(<MailboxModal isOpen onClose={() => {}} projectId="p2" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("mailbox-item-msg-p2")).toBeInTheDocument();
|
expect(screen.getByTestId("mailbox-item-msg-p2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock("../../api", () => ({
|
|||||||
fetchConversation: vi.fn(),
|
fetchConversation: vi.fn(),
|
||||||
fetchMessage: vi.fn(),
|
fetchMessage: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
|
fetchNativeStructurePreview: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||||
@@ -49,6 +50,12 @@ vi.mock("lucide-react", () => ({
|
|||||||
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
|
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
|
||||||
ChevronDown: () => <span data-testid="icon-chevron-down">ChevronDown</span>,
|
ChevronDown: () => <span data-testid="icon-chevron-down">ChevronDown</span>,
|
||||||
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
||||||
|
Map: () => <span data-testid="icon-map">Map</span>,
|
||||||
|
Flag: () => <span data-testid="icon-flag">Flag</span>,
|
||||||
|
Lightbulb: () => <span data-testid="icon-lightbulb">Lightbulb</span>,
|
||||||
|
BarChart3: () => <span data-testid="icon-chart">Chart</span>,
|
||||||
|
Target: () => <span data-testid="icon-target">Target</span>,
|
||||||
|
CircleAlert: () => <span data-testid="icon-circle-alert">CircleAlert</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
|
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
|
||||||
@@ -123,6 +130,8 @@ const defaultProps = {
|
|||||||
isOpen: true,
|
isOpen: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
|
onOpenNativeStructure: vi.fn(),
|
||||||
|
nativeStructureCandidates: [],
|
||||||
agents: mockAgents,
|
agents: mockAgents,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Message, NativeStructureEmbed } from "@fusion/core";
|
||||||
|
import { fetchNativeStructurePreview } from "../../api";
|
||||||
|
import { MailboxNativeStructureEmbeds } from "../MailboxNativeStructureEmbeds";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({ fetchNativeStructurePreview: vi.fn() }));
|
||||||
|
const fetchPreview = vi.mocked(fetchNativeStructurePreview);
|
||||||
|
|
||||||
|
function message(nativeStructures?: NativeStructureEmbed[]): Pick<Message, "metadata"> {
|
||||||
|
return { metadata: nativeStructures ? { nativeStructures } : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MailboxNativeStructureEmbeds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create an attachment shell without embeds", () => {
|
||||||
|
const { container } = render(<MailboxNativeStructureEmbeds message={message()} onOpen={vi.fn()} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders every persisted embed and forwards preview navigation", async () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
fetchPreview.mockResolvedValue({ available: true, kind: "mission", kindLabel: "Mission", title: "Launch mail", excerpt: "Review", openTarget: { view: "missions", id: "M-1" } });
|
||||||
|
render(<MailboxNativeStructureEmbeds message={message([
|
||||||
|
{ kind: "mission", id: "M-1", label: "Launch mail" },
|
||||||
|
{ kind: "goal", id: "G-1", label: "Ship" },
|
||||||
|
])} onOpen={onOpen} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId("native-structure-preview")).toHaveLength(2));
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: /Open Mission/ })[0]);
|
||||||
|
expect(onOpen).toHaveBeenCalledWith({ kind: "mission", id: "M-1", projectId: undefined }, expect.objectContaining({ available: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the captured label for unavailable targets", async () => {
|
||||||
|
fetchPreview.mockResolvedValue({ available: false, kind: "mission", id: "M-1", reason: "soft-deleted" });
|
||||||
|
render(<MailboxNativeStructureEmbeds message={message([{ kind: "mission", id: "M-1", label: "Launch mail" }])} onOpen={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveTextContent("Launch mail"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ vi.mock("../../api", () => ({
|
|||||||
fetchApprovalDetail: vi.fn(),
|
fetchApprovalDetail: vi.fn(),
|
||||||
decideApproval: vi.fn(),
|
decideApproval: vi.fn(),
|
||||||
artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
|
artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/api/artifacts/${id}/media${projectId ? `?projectId=${projectId}&` : "?"}fn_token=daemon-token`),
|
||||||
|
fetchNativeStructurePreview: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useViewportMode", () => {
|
vi.mock("../../hooks/useViewportMode", () => {
|
||||||
@@ -71,6 +72,12 @@ vi.mock("lucide-react", () => ({
|
|||||||
MessageSquare: () => <span data-testid="icon-message">Message</span>,
|
MessageSquare: () => <span data-testid="icon-message">Message</span>,
|
||||||
User: () => <span data-testid="icon-user">User</span>,
|
User: () => <span data-testid="icon-user">User</span>,
|
||||||
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
AlertCircle: () => <span data-testid="icon-alert">Alert</span>,
|
||||||
|
Map: () => <span data-testid="icon-map">Map</span>,
|
||||||
|
Flag: () => <span data-testid="icon-flag">Flag</span>,
|
||||||
|
Lightbulb: () => <span data-testid="icon-lightbulb">Lightbulb</span>,
|
||||||
|
BarChart3: () => <span data-testid="icon-chart">Chart</span>,
|
||||||
|
Target: () => <span data-testid="icon-target">Target</span>,
|
||||||
|
CircleAlert: () => <span data-testid="icon-circle-alert">CircleAlert</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
|
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
|
||||||
@@ -167,6 +174,8 @@ const mockUnknownAgentMessage: Message = {
|
|||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
|
onOpenNativeStructure: vi.fn(),
|
||||||
|
nativeStructureCandidates: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build a valid InboxResponse shape — `total` defaults to `messages.length` */
|
/** Build a valid InboxResponse shape — `total` defaults to `messages.length` */
|
||||||
|
|||||||
@@ -97,6 +97,31 @@ describe("MessageComposer", () => {
|
|||||||
expect(select.textContent).toContain("Loading agents…");
|
expect(select.textContent).toContain("Loading agents…");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds structural attachments to sent metadata and removes them from the draft", async () => {
|
||||||
|
render(<MessageComposer {...defaultProps} agents={mockAgents} nativeStructureCandidates={[
|
||||||
|
{ ref: { kind: "mission", id: "M-1" }, label: "Launch" },
|
||||||
|
{ ref: { kind: "goal", id: "G-1" }, label: "Ship" },
|
||||||
|
]} />);
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-recipient"), { target: { value: "agent-001" } });
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-content"), { target: { value: "Review" } });
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-attach-structure"), { target: { value: "0" } });
|
||||||
|
expect(screen.getByTestId("message-composer-attached-structures")).toHaveTextContent("Launch");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove Launch" }));
|
||||||
|
expect(screen.queryByTestId("message-composer-attached-structures")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-attach-structure"), { target: { value: "1" } });
|
||||||
|
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||||
|
await waitFor(() => expect(mockSendMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
metadata: { nativeStructures: [{ kind: "goal", id: "G-1", label: "Ship" }] },
|
||||||
|
}), undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables structural attachment selection when no candidates are available", () => {
|
||||||
|
render(<MessageComposer {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId("message-composer-attach-structure")).toBeDisabled();
|
||||||
|
expect(screen.getByText("No structures available")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("disables send button when content is empty", () => {
|
it("disables send button when content is empty", () => {
|
||||||
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||||
const sendBtn = screen.getByTestId("message-composer-send");
|
const sendBtn = screen.getByTestId("message-composer-send");
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
FNXC:MainContent 2026-06-24-00:00:
|
FNXC:MainContent 2026-06-24-00:00:
|
||||||
MainContent is the presentational switch for the dashboard's main content area, extracted verbatim from AppInner's renderMainContent(). It is a pure switch on taskView/viewMode returning the existing <PageErrorBoundary>/<Suspense> subtrees unchanged. The lazy view chunks (and their leading-underscore inventory convention) stay declared in App.tsx per the docs guard and are threaded in as props; the eager ChatView.css import remains in App.tsx so the styles bundle into the main CSS file.
|
MainContent is the presentational switch for the dashboard's main content area, extracted verbatim from AppInner's renderMainContent(). It is a pure switch on taskView/viewMode returning the existing <PageErrorBoundary>/<Suspense> subtrees unchanged. The lazy view chunks (and their leading-underscore inventory convention) stay declared in App.tsx per the docs guard and are threaded in as props; the eager ChatView.css import remains in App.tsx so the styles bundle into the main CSS file.
|
||||||
*/
|
*/
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
import type { Task, TaskDetail } from "@fusion/core";
|
import type { NativeStructurePreviewResult, NativeStructureRef, Task, TaskDetail } from "@fusion/core";
|
||||||
import { Board } from "../Board";
|
import { Board } from "../Board";
|
||||||
import { TaskCard } from "../TaskCard";
|
import { TaskCard } from "../TaskCard";
|
||||||
import { ListView } from "../ListView";
|
import { ListView } from "../ListView";
|
||||||
@@ -12,6 +12,7 @@ import { ProjectOverview } from "../ProjectOverview";
|
|||||||
import { MissionManager } from "../MissionManager";
|
import { MissionManager } from "../MissionManager";
|
||||||
import { MailboxView } from "../MailboxView";
|
import { MailboxView } from "../MailboxView";
|
||||||
import { IdeationPanel } from "../command-center/IdeationPanel";
|
import { IdeationPanel } from "../command-center/IdeationPanel";
|
||||||
|
import type { NativeStructureCandidate } from "../MessageComposer";
|
||||||
import { PageErrorBoundary } from "../ErrorBoundary";
|
import { PageErrorBoundary } from "../ErrorBoundary";
|
||||||
import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
|
import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
|
||||||
import { CapacityRiskBanner } from "../CapacityRiskBanner";
|
import { CapacityRiskBanner } from "../CapacityRiskBanner";
|
||||||
@@ -22,7 +23,7 @@ import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "
|
|||||||
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
|
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
|
||||||
import { isPluginViewId } from "../../plugins/pluginViewRegistry";
|
import { isPluginViewId } from "../../plugins/pluginViewRegistry";
|
||||||
import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical";
|
import { isNearDuplicateCanonicalInactive } from "../../../../core/src/near-duplicate-canonical";
|
||||||
import { fetchTaskDetail } from "../../api";
|
import { fetchMission, fetchMissions, fetchInsights, fetchTaskDetail, listEvals } from "../../api";
|
||||||
import type { DetailTaskTab } from "../../hooks/useModalManager";
|
import type { DetailTaskTab } from "../../hooks/useModalManager";
|
||||||
import type { SectionId } from "../SettingsModal";
|
import type { SectionId } from "../SettingsModal";
|
||||||
import type { MainContentProps } from "./types";
|
import type { MainContentProps } from "./types";
|
||||||
@@ -189,6 +190,75 @@ export function MainContent({
|
|||||||
}: MainContentProps) {
|
}: MainContentProps) {
|
||||||
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
|
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
|
||||||
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(null);
|
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(null);
|
||||||
|
const [nativeStructureCandidates, setNativeStructureCandidates] = useState<NativeStructureCandidate[]>([]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:NativeStructureEmbed 2026-07-20-14:30:
|
||||||
|
The mailbox owns no structure data, so MainContent assembles its picker candidates from the
|
||||||
|
existing project-scoped mission, insight, evaluation, and goal sources. Clear the prior project
|
||||||
|
before loading to prevent attaching cross-project refs. Persist only refs and labels;
|
||||||
|
NativeStructurePreview resolves current details lazily after the message is sent.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const projectId = currentProject?.id;
|
||||||
|
setNativeStructureCandidates([]);
|
||||||
|
const ref = (kind: NativeStructureRef["kind"], id: string): NativeStructureRef => ({ kind, id, ...(projectId ? { projectId } : {}) });
|
||||||
|
|
||||||
|
void Promise.all([
|
||||||
|
fetchMissions(projectId).catch(() => []),
|
||||||
|
fetchInsights({ limit: 100 }, projectId).catch(() => ({ insights: [], count: 0 })),
|
||||||
|
listEvals({ limit: 100 }, projectId).catch(() => ({ results: [], count: 0 })),
|
||||||
|
fetch(projectId ? `/api/goals?projectId=${encodeURIComponent(projectId)}` : "/api/goals")
|
||||||
|
.then(async (response) => response.ok ? response.json() as Promise<{ goals?: Array<{ id: string; title: string }> }> : { goals: [] })
|
||||||
|
.catch(() => ({ goals: [] })),
|
||||||
|
]).then(async ([missions, insights, evals, goalsResponse]) => {
|
||||||
|
const missionHierarchies = await Promise.all(missions.map(async (mission) => {
|
||||||
|
try {
|
||||||
|
return await fetchMission(mission.id, projectId);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const candidates: NativeStructureCandidate[] = [
|
||||||
|
...missions.map((mission) => ({ ref: ref("mission", mission.id), label: mission.title })),
|
||||||
|
...missionHierarchies.flatMap((mission) => mission?.milestones.map((milestone) => ({ ref: ref("milestone", milestone.id), label: milestone.title })) ?? []),
|
||||||
|
...insights.insights.map((insight) => ({ ref: ref("research-finding", insight.id), label: insight.title })),
|
||||||
|
...evals.results.map((result) => ({ ref: ref("eval-result", result.id), label: result.taskSnapshot.title || result.taskId })),
|
||||||
|
...(Array.isArray(goalsResponse.goals) ? goalsResponse.goals : []).map((goal) => ({ ref: ref("goal", goal.id), label: goal.title })),
|
||||||
|
];
|
||||||
|
setNativeStructureCandidates(candidates);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [currentProject?.id]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||||
|
Mail previews navigate through the dashboard's existing stateful destinations instead of URLs.
|
||||||
|
Milestones retain their parent mission anchor when the lazy preview resolver supplies it.
|
||||||
|
*/
|
||||||
|
const onOpenNativeStructure = useCallback((ref: NativeStructureRef, payload: NativeStructurePreviewResult) => {
|
||||||
|
switch (ref.kind) {
|
||||||
|
case "mission":
|
||||||
|
case "milestone":
|
||||||
|
setMissionTargetId(payload.available ? payload.openTarget.missionId ?? payload.openTarget.id : ref.id);
|
||||||
|
handleChangeTaskView("missions");
|
||||||
|
break;
|
||||||
|
case "goal":
|
||||||
|
setGoalAnchorId(ref.id);
|
||||||
|
handleChangeTaskView("goalsView");
|
||||||
|
break;
|
||||||
|
case "research-finding":
|
||||||
|
handleChangeTaskView("research");
|
||||||
|
break;
|
||||||
|
case "eval-result":
|
||||||
|
handleChangeTaskView("evals");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [handleChangeTaskView, setGoalAnchorId, setMissionTargetId]);
|
||||||
|
|
||||||
if (showBackendConnectionErrorPage) {
|
if (showBackendConnectionErrorPage) {
|
||||||
return (
|
return (
|
||||||
@@ -379,6 +449,8 @@ export function MainContent({
|
|||||||
.catch(() => addToast?.("Failed to open task", "error"));
|
.catch(() => addToast?.("Failed to open task", "error"));
|
||||||
}}
|
}}
|
||||||
onUnreadCountChange={setMailboxUnreadCount}
|
onUnreadCountChange={setMailboxUnreadCount}
|
||||||
|
onOpenNativeStructure={onOpenNativeStructure}
|
||||||
|
nativeStructureCandidates={nativeStructureCandidates}
|
||||||
/>
|
/>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,45 @@ import type { TaskDetail } from "@fusion/core";
|
|||||||
import { MainContent } from "../MainContent";
|
import { MainContent } from "../MainContent";
|
||||||
import type { MainContentProps } from "../types";
|
import type { MainContentProps } from "../types";
|
||||||
|
|
||||||
const { fetchTaskDetailMock } = vi.hoisted(() => ({
|
const { fetchTaskDetailMock, fetchMissionMock, fetchMissionsMock, fetchInsightsMock, listEvalsMock } = vi.hoisted(() => ({
|
||||||
fetchTaskDetailMock: vi.fn(),
|
fetchTaskDetailMock: vi.fn(),
|
||||||
|
fetchMissionMock: vi.fn(async () => ({
|
||||||
|
id: "mission-1",
|
||||||
|
milestones: [{ id: "milestone-1", title: "Milestone candidate" }],
|
||||||
|
})),
|
||||||
|
fetchMissionsMock: vi.fn(async () => [{ id: "mission-1", title: "Mission candidate" }]),
|
||||||
|
fetchInsightsMock: vi.fn(async () => ({
|
||||||
|
insights: [{ id: "insight-1", title: "Research candidate" }],
|
||||||
|
count: 1,
|
||||||
|
})),
|
||||||
|
listEvalsMock: vi.fn(async () => ({
|
||||||
|
results: [{ id: "eval-1", taskId: "FN-1", taskSnapshot: { title: "Evaluation candidate" } }],
|
||||||
|
count: 1,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../api", () => ({
|
vi.mock("../../../api", () => ({
|
||||||
fetchTaskDetail: fetchTaskDetailMock,
|
fetchTaskDetail: fetchTaskDetailMock,
|
||||||
|
fetchMission: fetchMissionMock,
|
||||||
|
fetchMissions: fetchMissionsMock,
|
||||||
|
fetchInsights: fetchInsightsMock,
|
||||||
|
listEvals: listEvalsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../MailboxView", () => ({
|
vi.mock("../../MailboxView", () => ({
|
||||||
MailboxView: ({ onOpenTask }: { onOpenTask?: (taskId: string) => void }) => (
|
MailboxView: ({
|
||||||
<button type="button" onClick={() => onOpenTask?.("FN-7935")}>Open mailbox artifact task</button>
|
onOpenTask,
|
||||||
|
nativeStructureCandidates = [],
|
||||||
|
}: {
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
nativeStructureCandidates?: Array<{ label: string }>;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => onOpenTask?.("FN-7935")}>Open mailbox artifact task</button>
|
||||||
|
<output aria-label="Native structure candidate labels">
|
||||||
|
{nativeStructureCandidates.map((candidate) => candidate.label).join(", ")}
|
||||||
|
</output>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -80,4 +108,16 @@ describe("MainContent mailbox artifact View task routing", () => {
|
|||||||
expect(fetchTaskDetailMock).toHaveBeenCalledWith("FN-7935", "project-1");
|
expect(fetchTaskDetailMock).toHaveBeenCalledWith("FN-7935", "project-1");
|
||||||
expect(openDetailTask).not.toHaveBeenCalled();
|
expect(openDetailTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supplies project-scoped native structure candidates to the mailbox picker", async () => {
|
||||||
|
render(<MainContent {...mainContentProps()} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("Native structure candidate labels")).toHaveTextContent(
|
||||||
|
"Mission candidate, Milestone candidate, Research candidate, Evaluation candidate",
|
||||||
|
));
|
||||||
|
expect(fetchMissionsMock).toHaveBeenCalledWith("project-1");
|
||||||
|
expect(fetchMissionMock).toHaveBeenCalledWith("mission-1", "project-1");
|
||||||
|
expect(fetchInsightsMock).toHaveBeenCalledWith({ limit: 100 }, "project-1");
|
||||||
|
expect(listEvalsMock).toHaveBeenCalledWith({ limit: 100 }, "project-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user