FN-8292: embed native structures in mail messages
Embed selected native project structures directly in mailbox messages and drafts. - Persist native structure embeds in message metadata and expose their shared types. - Add composer selection, mailbox rendering, previews, and dashboard navigation for linked structures. - Cover metadata persistence and mailbox embed behavior with core and dashboard tests. Files changed: .changeset/fn-8292-mail-native-structure-embeds.md | 7 ++ docs/dashboard-guide.md | 1 + .../message-metadata-native-structures.test.ts | 25 +++++++ .../__tests__/postgres/message-store.pg.test.ts | 21 ++++++ packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 32 +++++++++ packages/core/src/types/messages.ts | 14 ++++ packages/dashboard/app/components/MailboxModal.css | 29 ++++++++ packages/dashboard/app/components/MailboxModal.tsx | 13 +++- .../components/MailboxNativeStructureEmbeds.tsx | 37 ++++++++++ packages/dashboard/app/components/MailboxView.tsx | 14 +++- .../dashboard/app/components/MessageComposer.tsx | 65 ++++++++++++++++-- .../app/components/NativeStructurePreview.tsx | 6 +- .../__tests__/MailboxModal.cache.test.tsx | 8 +-- .../app/components/__tests__/MailboxModal.test.tsx | 9 +++ .../MailboxNativeStructureEmbeds.test.tsx | 42 ++++++++++++ .../app/components/__tests__/MailboxView.test.tsx | 9 +++ .../components/__tests__/MessageComposer.test.tsx | 25 +++++++ .../app/components/dashboard/MainContent.tsx | 78 +++++++++++++++++++++- .../MainContent.mailbox-view-task.test.tsx | 46 ++++++++++++- 20 files changed, 459 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8292 Fusion-Task-Lineage: ae6cfb9c-a764-407c-bd45-cfd879ccdd3e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8292-mail-native-structure-embeds.md
Normal file
7
.changeset/fn-8292-mail-native-structure-embeds.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Attach reviewable native structures to mailbox messages.
|
||||
category: feature
|
||||
dev: Message metadata now carries validated native structure references with lazy previews.
|
||||
@@ -718,6 +718,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an
|
||||
- when a real pending mailbox approval request is created, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; task plan-approval states (`awaiting-approval`) remain visible on the triage board and do not create a mailbox banner
|
||||
- when a 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
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateMessageMetadata } from "../types.js";
|
||||
|
||||
describe("validateMessageMetadata nativeStructures", () => {
|
||||
it("accepts absent, single, and multiple supported structural embeds", () => {
|
||||
expect(() => validateMessageMetadata(undefined)).not.toThrow();
|
||||
expect(() => validateMessageMetadata({ nativeStructures: [{ kind: "mission", id: "M-1" }] })).not.toThrow();
|
||||
expect(() => validateMessageMetadata({
|
||||
nativeStructures: [
|
||||
{ kind: "goal", id: "G-1", label: "Ship mail embeds" },
|
||||
{ kind: "eval-result", id: "E-1", projectId: "project-1" },
|
||||
],
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ nativeStructures: "not-an-array" }, "must be an array"],
|
||||
[{ nativeStructures: [{ kind: "mission" }] }, "id must be a non-empty string"],
|
||||
[{ nativeStructures: [{ kind: "roadmap-item", id: "R-1" }] }, "kind is invalid"],
|
||||
[{ nativeStructures: [{ kind: "unknown", id: "X-1" }] }, "kind is invalid"],
|
||||
[{ nativeStructures: [{ kind: "goal", id: "G-1", label: 1 }] }, "label must be a string"],
|
||||
])("rejects invalid native structures %#", (metadata, message) => {
|
||||
expect(() => validateMessageMetadata(metadata as never)).toThrow(message);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,27 @@ pgTest("MessageStore send (PostgreSQL backend mode)", () => {
|
||||
expect((await store.getMessage(msg.id))?.content).toBe("hi user");
|
||||
});
|
||||
|
||||
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
@@ -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");
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { memo } from "react";
|
||||
import type { Message, NativeStructurePreviewResult, NativeStructureRef } from "@fusion/core";
|
||||
import { NativeStructurePreview } from "./NativeStructurePreview";
|
||||
|
||||
export interface MailboxNativeStructureEmbedsProps {
|
||||
message: Pick<Message, "metadata">;
|
||||
projectId?: string;
|
||||
onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:NativeStructureEmbed 2026-07-20-12:00:
|
||||
* Mail message metadata is the durable home for first-class structure embeds. This thin wrapper
|
||||
* deliberately owns no preview behavior: the shared lazy resolver handles live data and missing
|
||||
* targets, while this returns no shell for ordinary mail with no structural attachment.
|
||||
*/
|
||||
export const MailboxNativeStructureEmbeds = memo(function MailboxNativeStructureEmbeds({
|
||||
message,
|
||||
projectId,
|
||||
onOpen,
|
||||
}: MailboxNativeStructureEmbedsProps) {
|
||||
const embeds = message.metadata?.nativeStructures;
|
||||
if (!embeds?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mailbox-native-structure-embeds" data-testid="mailbox-native-structure-embeds">
|
||||
{embeds.map((embed, index) => (
|
||||
<NativeStructurePreview
|
||||
key={`${embed.kind}:${embed.id}:${index}`}
|
||||
ref={{ kind: embed.kind, id: embed.id, projectId: embed.projectId ?? projectId }}
|
||||
capturedLabel={embed.label}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
MessageSquare,
|
||||
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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Message, NativeStructureEmbed } from "@fusion/core";
|
||||
import { fetchNativeStructurePreview } from "../../api";
|
||||
import { MailboxNativeStructureEmbeds } from "../MailboxNativeStructureEmbeds";
|
||||
|
||||
vi.mock("../../api", () => ({ fetchNativeStructurePreview: vi.fn() }));
|
||||
const fetchPreview = vi.mocked(fetchNativeStructurePreview);
|
||||
|
||||
function message(nativeStructures?: NativeStructureEmbed[]): Pick<Message, "metadata"> {
|
||||
return { metadata: nativeStructures ? { nativeStructures } : undefined };
|
||||
}
|
||||
|
||||
describe("MailboxNativeStructureEmbeds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not create an attachment shell without embeds", () => {
|
||||
const { container } = render(<MailboxNativeStructureEmbeds message={message()} onOpen={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders every persisted embed and forwards preview navigation", async () => {
|
||||
const onOpen = vi.fn();
|
||||
fetchPreview.mockResolvedValue({ available: true, kind: "mission", kindLabel: "Mission", title: "Launch mail", excerpt: "Review", openTarget: { view: "missions", id: "M-1" } });
|
||||
render(<MailboxNativeStructureEmbeds message={message([
|
||||
{ kind: "mission", id: "M-1", label: "Launch mail" },
|
||||
{ kind: "goal", id: "G-1", label: "Ship" },
|
||||
])} onOpen={onOpen} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId("native-structure-preview")).toHaveLength(2));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /Open Mission/ })[0]);
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: "mission", id: "M-1", projectId: undefined }, expect.objectContaining({ available: true }));
|
||||
});
|
||||
|
||||
it("uses the captured label for unavailable targets", async () => {
|
||||
fetchPreview.mockResolvedValue({ available: false, kind: "mission", id: "M-1", reason: "soft-deleted" });
|
||||
render(<MailboxNativeStructureEmbeds message={message([{ kind: "mission", id: "M-1", label: "Launch mail" }])} onOpen={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveTextContent("Launch mail"));
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../api", () => ({
|
||||
fetchApprovalDetail: vi.fn(),
|
||||
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` */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user