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

View File

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

View File

@@ -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 */

View File

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

View File

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

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, 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}

View File

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

View File

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

View File

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

View File

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

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(), 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` */

View File

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

View File

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

View File

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