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:
gsxdsm
2026-07-18 19:45:08 -07:00
parent bc4b679598
commit cf3f5e9bf0
20 changed files with 459 additions and 24 deletions

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

View File

@@ -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 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
- 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
![Mailbox view](./screenshots/mailbox-view.png)

View File

@@ -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);
});
});

View File

@@ -74,6 +74,27 @@ pgTest("MessageStore send (PostgreSQL backend mode)", () => {
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:
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

View File

@@ -7051,6 +7051,7 @@ import type {
MessageReplyReference,
EphemeralTaskCreationPolicy,
ProposedTaskMetadata,
NativeStructureEmbed,
MessageMetadata,
Message,
MessageCreateInput,
@@ -7062,6 +7063,7 @@ export type {
MessageReplyReference,
EphemeralTaskCreationPolicy,
ProposedTaskMetadata,
NativeStructureEmbed,
MessageMetadata,
Message,
MessageCreateInput,
@@ -7088,6 +7090,36 @@ export function validateMessageMetadata(metadata: MessageMetadata | undefined):
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;
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");

View File

@@ -8,6 +8,7 @@
*/
import type { TaskPriority } from "./board.js";
import type { NativeStructureRef } from "../types.js";
export type ParticipantType = "agent" | "user" | "system";
@@ -64,6 +65,13 @@ export interface ProposedTaskMetadata {
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> {
/** Optional link to the original message when this message is a reply. */
replyTo?: MessageReplyReference;
@@ -84,6 +92,12 @@ export interface MessageMetadata extends Record<string, unknown> {
claimOwnerToken?: string;
/** Durable ISO timestamp used to reclaim a creator that died before task persistence. */
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 */

View File

@@ -1340,6 +1340,35 @@ separate approvals Back button outside this header or non-mobile mailbox layouts
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 {
margin-top: var(--space-xs);
}

View File

@@ -17,7 +17,7 @@ import {
ChevronRight,
ChevronDown,
} from "lucide-react";
import type { Message, MessageType, ParticipantType } from "@fusion/core";
import type { Message, MessageType, NativeStructurePreviewResult, NativeStructureRef, ParticipantType } from "@fusion/core";
import {
fetchInbox,
fetchOutbox,
@@ -34,9 +34,10 @@ import {
type AgentMailboxResponse,
type AllAgentsMailboxResponse,
} from "../api";
import { MessageComposer } from "./MessageComposer";
import { MessageComposer, type NativeStructureCandidate } from "./MessageComposer";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds";
import { MailboxTaskProposal } from "./MailboxTaskProposal";
import type { Agent } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -58,6 +59,9 @@ interface MailboxModalProps {
projectId?: string;
addToast?: (msg: string, type?: "success" | "error") => void;
onOpenTask?: (taskId: string) => void;
/** Opens a persisted structure from the shared preview card. */
onOpenNativeStructure: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
nativeStructureCandidates: NativeStructureCandidate[];
agents?: Agent[];
}
@@ -172,6 +176,8 @@ export function MailboxModal({
projectId,
addToast,
onOpenTask,
onOpenNativeStructure,
nativeStructureCandidates,
agents = [],
}: MailboxModalProps) {
const { t } = useTranslation("app");
@@ -902,6 +908,7 @@ export function MailboxModal({
taskId={msg.metadata?.taskId}
onOpenTask={onOpenTask}
/>
<MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</div>
);
@@ -934,6 +941,7 @@ export function MailboxModal({
taskId={selectedMessage.metadata?.taskId}
onOpenTask={onOpenTask}
/>
<MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</>
)}
@@ -947,6 +955,7 @@ export function MailboxModal({
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
nativeStructureCandidates={nativeStructureCandidates}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}

View File

@@ -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>
);
});

View File

@@ -14,7 +14,7 @@ import {
MessageSquare,
User,
} from "lucide-react";
import type { Message, MessageType, ParticipantType } from "@fusion/core";
import type { Message, MessageType, NativeStructurePreviewResult, NativeStructureRef, ParticipantType } from "@fusion/core";
import {
fetchInbox,
fetchOutbox,
@@ -39,8 +39,9 @@ import {
} from "../api";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds";
import { MailboxTaskProposal } from "./MailboxTaskProposal";
import { MessageComposer } from "./MessageComposer";
import { MessageComposer, type NativeStructureCandidate } from "./MessageComposer";
import { ViewHeader } from "./ViewHeader";
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails";
@@ -59,6 +60,9 @@ interface MailboxViewProps {
projectId?: string;
addToast?: (msg: string, type?: "success" | "error") => 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) */
onUnreadCountChange?: (count: number) => void;
}
@@ -217,6 +221,8 @@ export function MailboxView({
projectId,
addToast,
onOpenTask,
onOpenNativeStructure,
nativeStructureCandidates,
onUnreadCountChange,
}: MailboxViewProps) {
const { t } = useTranslation("app");
@@ -952,6 +958,7 @@ export function MailboxView({
taskId={msg.metadata?.taskId}
onOpenTask={onOpenTask}
/>
<MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</div>
);
@@ -979,6 +986,7 @@ export function MailboxView({
taskId={selectedMessage.metadata?.taskId}
onOpenTask={onOpenTask}
/>
<MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</>
)}
@@ -1267,6 +1275,7 @@ export function MailboxView({
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
nativeStructureCandidates={nativeStructureCandidates}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}
@@ -1492,6 +1501,7 @@ export function MailboxView({
replyContext={composeReplyContext}
agents={agents}
projectId={projectId}
nativeStructureCandidates={nativeStructureCandidates}
onSend={handleMessageSent}
onCancel={handleComposeCancel}
addToast={addToast}

View File

@@ -2,13 +2,18 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
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 { sendMessage } from "../api";
import type { Agent } from "../api";
// ── Types ─────────────────────────────────────────────────────────────────
export interface NativeStructureCandidate {
ref: NativeStructureRef;
label: string;
}
interface MessageComposerProps {
/** Pre-fill recipient (e.g. when replying) */
recipient?: { id: string; type: ParticipantType } | null;
@@ -26,6 +31,8 @@ interface MessageComposerProps {
addToast?: (msg: string, type?: "success" | "error") => void;
/** Loading state for agents (shows placeholder) */
isLoadingAgents?: boolean;
/** Project-scoped structures the mail parent makes available for attachment. */
nativeStructureCandidates?: NativeStructureCandidate[];
}
const MAX_CONTENT_LENGTH = 2000;
@@ -41,12 +48,14 @@ export function MessageComposer({
onCancel,
addToast,
isLoadingAgents = false,
nativeStructureCandidates = [],
}: MessageComposerProps) {
const { t } = useTranslation("app");
const [toId, setToId] = useState(recipient?.id ?? "");
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
const [content, setContent] = useState("");
const [wakeRecipient, setWakeRecipient] = useState(false);
const [nativeStructures, setNativeStructures] = useState<NativeStructureEmbed[]>([]);
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -82,10 +91,11 @@ export function MessageComposer({
try {
const messageType: MessageType = toType === "agent" ? "user-to-agent" : "system";
const metadata =
replyContext
? { replyTo: { messageId: replyContext.messageId } }
: undefined;
const metadata = {
...(replyContext ? { replyTo: { messageId: replyContext.messageId } } : {}),
...(nativeStructures.length > 0 ? { nativeStructures } : {}),
};
const hasMetadata = Object.keys(metadata).length > 0;
const sendWakeImmediately = wakeImmediately;
await sendMessage(
{
@@ -93,7 +103,7 @@ export function MessageComposer({
toType,
content: content.trim(),
type: messageType,
...(metadata ? { metadata } : {}),
...(hasMetadata ? { metadata } : {}),
...(sendWakeImmediately ? { wakeImmediately: true } : {}),
},
projectId,
@@ -106,13 +116,19 @@ export function MessageComposer({
} finally {
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) => {
setToId(agentId);
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(() => {
if (typeof textareaRef.current?.scrollIntoView !== "function") {
return;
@@ -223,6 +239,41 @@ export function MessageComposer({
</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) */}
{recipientIsAgent && (
<div className="message-composer-field message-composer-field--wake">

View File

@@ -7,6 +7,8 @@ import "./NativeStructurePreview.css";
export interface NativeStructurePreviewProps {
ref: NativeStructureRef;
payload?: NativeStructurePreviewResult;
/** Attach-time label used only when a persisted target is no longer available. */
capturedLabel?: string;
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
* 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 refKey = `${ref.kind}\u0000${ref.id}\u0000${ref.projectId ?? ""}`;
const [fetchedPayload, setFetchedPayload] = useState<{ refKey: string; result: NativeStructurePreviewResult } | undefined>();
@@ -94,7 +96,7 @@ export const NativeStructurePreview = memo(function NativeStructurePreview({ ref
return (
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason={result.reason}>
<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>
);
}

View File

@@ -50,7 +50,7 @@ describe("MailboxModal cache hydration", () => {
);
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();
});
@@ -62,7 +62,7 @@ describe("MailboxModal cache hydration", () => {
unreadCount: 1,
});
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} />);
render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
await waitFor(() => {
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 });
const { rerender } = render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} />);
const { rerender } = render(<MailboxModal isOpen onClose={() => {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
await waitFor(() => {
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(() => {}));
rerender(<MailboxModal isOpen onClose={() => {}} projectId="p2" agents={[]} />);
rerender(<MailboxModal isOpen onClose={() => {}} projectId="p2" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />);
expect(screen.getByTestId("mailbox-item-msg-p2")).toBeInTheDocument();
});

View File

@@ -20,6 +20,7 @@ vi.mock("../../api", () => ({
fetchConversation: vi.fn(),
fetchMessage: vi.fn(),
sendMessage: vi.fn(),
fetchNativeStructurePreview: vi.fn(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
@@ -49,6 +50,12 @@ vi.mock("lucide-react", () => ({
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
ChevronDown: () => <span data-testid="icon-chevron-down">ChevronDown</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);
@@ -123,6 +130,8 @@ const defaultProps = {
isOpen: true,
onClose: vi.fn(),
addToast: vi.fn(),
onOpenNativeStructure: vi.fn(),
nativeStructureCandidates: [],
agents: mockAgents,
};

View File

@@ -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"));
});
});

View File

@@ -28,6 +28,7 @@ vi.mock("../../api", () => ({
fetchApprovalDetail: vi.fn(),
decideApproval: vi.fn(),
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", () => {
@@ -71,6 +72,12 @@ vi.mock("lucide-react", () => ({
MessageSquare: () => <span data-testid="icon-message">Message</span>,
User: () => <span data-testid="icon-user">User</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);
@@ -167,6 +174,8 @@ const mockUnknownAgentMessage: Message = {
const defaultProps = {
addToast: vi.fn(),
onOpenNativeStructure: vi.fn(),
nativeStructureCandidates: [],
};
/** Build a valid InboxResponse shape — `total` defaults to `messages.length` */

View File

@@ -97,6 +97,31 @@ describe("MessageComposer", () => {
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", () => {
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
const sendBtn = screen.getByTestId("message-composer-send");

View File

@@ -2,8 +2,8 @@
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.
*/
import { Suspense, useState } from "react";
import type { Task, TaskDetail } from "@fusion/core";
import { Suspense, useCallback, useEffect, useState } from "react";
import type { NativeStructurePreviewResult, NativeStructureRef, Task, TaskDetail } from "@fusion/core";
import { Board } from "../Board";
import { TaskCard } from "../TaskCard";
import { ListView } from "../ListView";
@@ -12,6 +12,7 @@ import { ProjectOverview } from "../ProjectOverview";
import { MissionManager } from "../MissionManager";
import { MailboxView } from "../MailboxView";
import { IdeationPanel } from "../command-center/IdeationPanel";
import type { NativeStructureCandidate } from "../MessageComposer";
import { PageErrorBoundary } from "../ErrorBoundary";
import { BackendConnectionErrorPage } from "../BackendConnectionErrorPage";
import { CapacityRiskBanner } from "../CapacityRiskBanner";
@@ -22,7 +23,7 @@ import { GraphWorkflowSwitcherSlot, filterTasksByGraphWorkflowSelection } from "
import { PluginDashboardViewHost } from "../../plugins/PluginDashboardViewHost";
import { isPluginViewId } from "../../plugins/pluginViewRegistry";
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 { SectionId } from "../SettingsModal";
import type { MainContentProps } from "./types";
@@ -189,6 +190,75 @@ export function MainContent({
}: MainContentProps) {
const [missionWorkflowId, setMissionWorkflowId] = 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) {
return (
@@ -379,6 +449,8 @@ export function MainContent({
.catch(() => addToast?.("Failed to open task", "error"));
}}
onUnreadCountChange={setMailboxUnreadCount}
onOpenNativeStructure={onOpenNativeStructure}
nativeStructureCandidates={nativeStructureCandidates}
/>
</PageErrorBoundary>
);

View File

@@ -4,17 +4,45 @@ import type { TaskDetail } from "@fusion/core";
import { MainContent } from "../MainContent";
import type { MainContentProps } from "../types";
const { fetchTaskDetailMock } = vi.hoisted(() => ({
const { fetchTaskDetailMock, fetchMissionMock, fetchMissionsMock, fetchInsightsMock, listEvalsMock } = vi.hoisted(() => ({
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", () => ({
fetchTaskDetail: fetchTaskDetailMock,
fetchMission: fetchMissionMock,
fetchMissions: fetchMissionsMock,
fetchInsights: fetchInsightsMock,
listEvals: listEvalsMock,
}));
vi.mock("../../MailboxView", () => ({
MailboxView: ({ onOpenTask }: { onOpenTask?: (taskId: string) => void }) => (
<button type="button" onClick={() => onOpenTask?.("FN-7935")}>Open mailbox artifact task</button>
MailboxView: ({
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(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");
});
});