diff --git a/.changeset/fn-8292-mail-native-structure-embeds.md b/.changeset/fn-8292-mail-native-structure-embeds.md new file mode 100644 index 0000000000..38b7f5b099 --- /dev/null +++ b/.changeset/fn-8292-mail-native-structure-embeds.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index aa06767c2c..d98560d1d2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -718,6 +718,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an - when a real pending mailbox approval request is created, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; task plan-approval states (`awaiting-approval`) remain visible on the triage board and do not create a mailbox banner - when a task first transitions into `done`, the dashboard shows a one-time **Enjoying Fusion?** GitHub star prompt in the project view after first-run setup is closed; clicking **Star on GitHub** or dismissing the card marks it shown in browser `localStorage`, so it does not reappear on reload or later task completions. The setup wizard does not add a second star prompt. - Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links +- Compose can attach native missions, milestones, goals, research findings, and eval results as structural cards. Recipients can open the live structure from the message detail or conversation thread; a captured label keeps unavailable or soft-deleted attachments identifiable. - Separate top-level messages from the same sender remain independent in the inbox and detail pane ![Mailbox view](./screenshots/mailbox-view.png) diff --git a/packages/core/src/__tests__/message-metadata-native-structures.test.ts b/packages/core/src/__tests__/message-metadata-native-structures.test.ts new file mode 100644 index 0000000000..53317c2342 --- /dev/null +++ b/packages/core/src/__tests__/message-metadata-native-structures.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/__tests__/postgres/message-store.pg.test.ts b/packages/core/src/__tests__/postgres/message-store.pg.test.ts index 4ab79b0014..3a53ce0ab6 100644 --- a/packages/core/src/__tests__/postgres/message-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/message-store.pg.test.ts @@ -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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f2c71b3d9..16c3ea5ccc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; -export type { NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult } from "./types.js"; +export type { NativeStructureRef, NativeStructureEmbed, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult } from "./types.js"; export type { SymbolLockStatus, SymbolLockIdentity, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 68ed610d7f..1b5767871c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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"); diff --git a/packages/core/src/types/messages.ts b/packages/core/src/types/messages.ts index 118ab012f8..db588ea2bf 100644 --- a/packages/core/src/types/messages.ts +++ b/packages/core/src/types/messages.ts @@ -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 { /** Optional link to the original message when this message is a reply. */ replyTo?: MessageReplyReference; @@ -84,6 +92,12 @@ export interface MessageMetadata extends Record { 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 */ diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index c19e55805e..61cccc1522 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -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); } diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index 6901a414bc..7b454a6f84 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -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} /> + ); @@ -934,6 +941,7 @@ export function MailboxModal({ taskId={selectedMessage.metadata?.taskId} onOpenTask={onOpenTask} /> + )} @@ -947,6 +955,7 @@ export function MailboxModal({ replyContext={composeReplyContext} agents={agents} projectId={projectId} + nativeStructureCandidates={nativeStructureCandidates} onSend={handleMessageSent} onCancel={handleComposeCancel} addToast={addToast} diff --git a/packages/dashboard/app/components/MailboxNativeStructureEmbeds.tsx b/packages/dashboard/app/components/MailboxNativeStructureEmbeds.tsx new file mode 100644 index 0000000000..139fe9ee2b --- /dev/null +++ b/packages/dashboard/app/components/MailboxNativeStructureEmbeds.tsx @@ -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; + 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 ( +
+ {embeds.map((embed, index) => ( + + ))} +
+ ); +}); diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 11593df99d..aef4f22b4c 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -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} /> + ); @@ -979,6 +986,7 @@ export function MailboxView({ taskId={selectedMessage.metadata?.taskId} 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} diff --git a/packages/dashboard/app/components/MessageComposer.tsx b/packages/dashboard/app/components/MessageComposer.tsx index 82d03aa0ed..55891b06ed 100644 --- a/packages/dashboard/app/components/MessageComposer.tsx +++ b/packages/dashboard/app/components/MessageComposer.tsx @@ -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(recipient?.type ?? "agent"); const [content, setContent] = useState(""); const [wakeRecipient, setWakeRecipient] = useState(false); + const [nativeStructures, setNativeStructures] = useState([]); const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); const textareaRef = useRef(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({ + {/* + 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. + */} +
+ +
+ + {nativeStructures.length > 0 && ( +
    + {nativeStructures.map((embed, index) => ( +
  • + {embed.kind}: {embed.label ?? embed.id} + +
  • + ))} +
+ )} +
+
+ {/* Wake recipient toggle (agents only) */} {recipientIsAgent && (
diff --git a/packages/dashboard/app/components/NativeStructurePreview.tsx b/packages/dashboard/app/components/NativeStructurePreview.tsx index a774469a6b..cc6d87300b 100644 --- a/packages/dashboard/app/components/NativeStructurePreview.tsx +++ b/packages/dashboard/app/components/NativeStructurePreview.tsx @@ -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 (
); } diff --git a/packages/dashboard/app/components/__tests__/MailboxModal.cache.test.tsx b/packages/dashboard/app/components/__tests__/MailboxModal.cache.test.tsx index 2736032e10..d5ec541715 100644 --- a/packages/dashboard/app/components/__tests__/MailboxModal.cache.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxModal.cache.test.tsx @@ -50,7 +50,7 @@ describe("MailboxModal cache hydration", () => { ); mockFetchInbox.mockImplementation(() => new Promise(() => {})); - render( {}} projectId="p1" agents={[]} />); + render( {}} projectId="p1" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />); expect(screen.getByTestId("mailbox-item-msg-cache")).toBeInTheDocument(); }); @@ -62,7 +62,7 @@ describe("MailboxModal cache hydration", () => { unreadCount: 1, }); - render( {}} projectId="p1" agents={[]} />); + render( {}} 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( {}} projectId="p1" agents={[]} />); + const { rerender } = render( {}} 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( {}} projectId="p2" agents={[]} />); + rerender( {}} projectId="p2" agents={[]} nativeStructureCandidates={[]} onOpenNativeStructure={() => {}} />); expect(screen.getByTestId("mailbox-item-msg-p2")).toBeInTheDocument(); }); diff --git a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx index 3d86f6cd95..9564747e55 100644 --- a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx @@ -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: () => ChevronRight, ChevronDown: () => ChevronDown, AlertCircle: () => Alert, + Map: () => Map, + Flag: () => Flag, + Lightbulb: () => Lightbulb, + BarChart3: () => Chart, + Target: () => Target, + CircleAlert: () => CircleAlert, })); 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, }; diff --git a/packages/dashboard/app/components/__tests__/MailboxNativeStructureEmbeds.test.tsx b/packages/dashboard/app/components/__tests__/MailboxNativeStructureEmbeds.test.tsx new file mode 100644 index 0000000000..890ae5001c --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MailboxNativeStructureEmbeds.test.tsx @@ -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 { + return { metadata: nativeStructures ? { nativeStructures } : undefined }; +} + +describe("MailboxNativeStructureEmbeds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not create an attachment shell without embeds", () => { + const { container } = render(); + 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(); + + 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(); + await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveTextContent("Launch mail")); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index 322d82d900..e203843f46 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -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: () => Message, User: () => User, AlertCircle: () => Alert, + Map: () => Map, + Flag: () => Flag, + Lightbulb: () => Lightbulb, + BarChart3: () => Chart, + Target: () => Target, + CircleAlert: () => CircleAlert, })); 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` */ diff --git a/packages/dashboard/app/components/__tests__/MessageComposer.test.tsx b/packages/dashboard/app/components/__tests__/MessageComposer.test.tsx index c65f9a340a..c856f53a39 100644 --- a/packages/dashboard/app/components/__tests__/MessageComposer.test.tsx +++ b/packages/dashboard/app/components/__tests__/MessageComposer.test.tsx @@ -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(); + 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(); + expect(screen.getByTestId("message-composer-attach-structure")).toBeDisabled(); + expect(screen.getByText("No structures available")).toBeInTheDocument(); + }); + it("disables send button when content is empty", () => { render(); const sendBtn = screen.getByTestId("message-composer-send"); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index b5f02a1f8c..50fc7ff247 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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 / 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(null); const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState(null); + const [nativeStructureCandidates, setNativeStructureCandidates] = useState([]); + + /* + 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} /> ); diff --git a/packages/dashboard/app/components/dashboard/__tests__/MainContent.mailbox-view-task.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/MainContent.mailbox-view-task.test.tsx index c2ebc3746e..52e8b47a71 100644 --- a/packages/dashboard/app/components/dashboard/__tests__/MainContent.mailbox-view-task.test.tsx +++ b/packages/dashboard/app/components/dashboard/__tests__/MainContent.mailbox-view-task.test.tsx @@ -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 }) => ( - + MailboxView: ({ + onOpenTask, + nativeStructureCandidates = [], + }: { + onOpenTask?: (taskId: string) => void; + nativeStructureCandidates?: Array<{ label: string }>; + }) => ( + <> + + + {nativeStructureCandidates.map((candidate) => candidate.label).join(", ")} + + ), })); @@ -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(); + + 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"); + }); });