From e2b5532290075fb7ab1e0ae2e1435caedb06377e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 18:11:02 -0700 Subject: [PATCH] FN-8288: add native structure preview foundation Provide reusable, typed previews for native dashboard structures. - Define five-kind native structure references and preview payloads. - Add project-scoped preview resolution API with unavailable states and capped excerpts. - Add a callback-driven inline preview card, tests, documentation, and release note. Files changed: .changeset/fn-8288-native-structure-preview.md | 7 ++ docs/dashboard-guide.md | 4 + packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/types.ts | 54 ++++++++++ packages/dashboard/app/api/legacy.ts | 1 + packages/dashboard/app/api/task-content.ts | 13 ++- .../app/components/NativeStructurePreview.css | 61 +++++++++++ .../app/components/NativeStructurePreview.tsx | 115 +++++++++++++++++++++ .../__tests__/NativeStructurePreview.test.tsx | 102 ++++++++++++++++++ packages/dashboard/src/native-structure-preview.ts | 88 ++++++++++++++++ .../native-structure-preview-routes.test.ts | 102 ++++++++++++++++++ .../src/routes/register-task-workflow-routes.ts | 23 +++++ 13 files changed, 571 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8288 Fusion-Task-Lineage: 1a4ce369-9f9e-4a45-b533-2d53b5f1020c Co-authored-by: Fusion (runfusion.ai) --- .../fn-8288-native-structure-preview.md | 7 ++ docs/dashboard-guide.md | 4 + packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/types.ts | 54 ++++++++ packages/dashboard/app/api/legacy.ts | 1 + packages/dashboard/app/api/task-content.ts | 13 +- .../app/components/NativeStructurePreview.css | 61 ++++++++++ .../app/components/NativeStructurePreview.tsx | 115 ++++++++++++++++++ .../__tests__/NativeStructurePreview.test.tsx | 102 ++++++++++++++++ .../dashboard/src/native-structure-preview.ts | 88 ++++++++++++++ .../native-structure-preview-routes.test.ts | 102 ++++++++++++++++ .../routes/register-task-workflow-routes.ts | 23 ++++ 13 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-8288-native-structure-preview.md create mode 100644 packages/dashboard/app/components/NativeStructurePreview.css create mode 100644 packages/dashboard/app/components/NativeStructurePreview.tsx create mode 100644 packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx create mode 100644 packages/dashboard/src/native-structure-preview.ts create mode 100644 packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts diff --git a/.changeset/fn-8288-native-structure-preview.md b/.changeset/fn-8288-native-structure-preview.md new file mode 100644 index 0000000000..7045e2b360 --- /dev/null +++ b/.changeset/fn-8288-native-structure-preview.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add reusable native structure preview payloads and dashboard cards. +category: feature +dev: Exposes five-kind NativeStructureRef contracts and a callback-based open target for chat and mail consumers. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 376279e5f1..07d837101d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1880,6 +1880,10 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s **Rule:** New CSS for a component goes in `app/components/ComponentName.css`, NOT `styles.css`. Only design tokens, primitives (`.btn`, `.card`, `.modal`, `.form-input`), and cross-component `@media` overrides belong in the global file. +### Native structure previews + +`NativeStructurePreview` is the shared compact card for mission, milestone, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. `roadmap-item` is intentionally deferred until its plugin provides a backend-safe reader and dashboard destination. + PR tab note: `PrPanel` cards use tokenized `.pr-card` grid spacing (`padding` + `gap`) and boxed token-based hint callouts for empty/loading states. Manual PR merges now show in-progress feedback (`Merging…` button state + status hint) until the merge call resolves. The `index.html` shell is templated server-side: the server injects a per-user `` for the last-used `taskView` chunk, sourced from Vite's `dist/client/.vite/manifest.json` and `kb::kb-dashboard-task-view` in localStorage. diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 3d1860cf04..c3f56d76f6 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -43,7 +43,7 @@ etc. pulled in by production modules, not test files). */ 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, 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, 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, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } 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, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, 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, 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, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a2b344a008..3554df1525 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +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 { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8d0dc23f4e..3247851cde 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -866,6 +866,60 @@ export function isReviewArtifactGenerationEligible( return mode === "on" || (mode === "user-facing" && classification === "user-facing"); } +/** + * FNXC:NativeStructureEmbed 2026-07-16-12:00: + * Chat and mail share this compact reference contract so their consumers never invent + * incompatible structure identifiers. `roadmap-item` remains a deferred future kind until + * its plugin exposes a PostgreSQL-safe read adapter and a restored dashboard destination. + */ +export interface NativeStructureRef { + kind: "mission" | "milestone" | "research-finding" | "eval-result" | "goal"; + id: string; + projectId?: string; +} + +/** + * FNXC:NativeStructureEmbed 2026-07-16-12:00: + * Dashboard destinations are callback/view-state based rather than HTML routes. Consumers use + * this stable descriptor with their navigation callback; it is intentionally not a URL. + */ +export interface NativeStructureOpenTarget { + view: "missions" | "insights" | "evals" | "goals"; + id: string; + missionId?: string; +} + +/** + * FNXC:NativeStructureEmbed 2026-07-18-18:15: + * A previewable native structure projected by the dashboard read layer. + */ +export interface NativeStructurePreviewPayload { + available: true; + kind: NativeStructureRef["kind"]; + kindLabel: string; + title: string; + excerpt: string; + openTarget: NativeStructureOpenTarget; +} + +/** + * FNXC:NativeStructureEmbed 2026-07-18-18:15: + * A native structure whose existing lifecycle state makes it unavailable for preview. + */ +export interface NativeStructureUnavailablePayload { + available: false; + kind: NativeStructureRef["kind"]; + id: string; + reason: "missing" | "soft-deleted"; +} + +/** + * FNXC:NativeStructureEmbed 2026-07-16-12:00: + * Unavailability is a typed result so shared consumers show a safe placeholder instead of + * crashing. Eval results have no archive lifecycle and therefore only return `missing`. + */ +export type NativeStructurePreviewResult = NativeStructurePreviewPayload | NativeStructureUnavailablePayload; + /** * Goal-citation Slice 2 success-signal surfaces where goal IDs are extracted. */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 3bf52a4122..d73722b69a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -254,6 +254,7 @@ export { artifactMediaUrl, artifactMediaUrlWithToken, fetchArtifact, + fetchNativeStructurePreview, updateArtifact, fetchAllDocuments, fetchProjectMarkdownFiles, diff --git a/packages/dashboard/app/api/task-content.ts b/packages/dashboard/app/api/task-content.ts index 0dd2f9ed08..becf99e137 100644 --- a/packages/dashboard/app/api/task-content.ts +++ b/packages/dashboard/app/api/task-content.ts @@ -12,6 +12,8 @@ import type { Artifact, ArtifactType, ArtifactWithTask, + NativeStructureRef, + NativeStructurePreviewResult, AgentLogEntry, } from "@fusion/core"; import { appendTokenQuery, withTokenHeader } from "../auth"; @@ -206,6 +208,16 @@ export async function fetchArtifact(id: string, projectId?: string): Promise(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId)); } +/** + * FNXC:NativeStructureEmbed 2026-07-18-18:15: + * Fetch the shared compact projection for an in-app native structure reference. + */ +export async function fetchNativeStructurePreview(ref: NativeStructureRef): Promise { + return api( + withProjectId(`/native-structures/${encodeURIComponent(ref.kind)}/${encodeURIComponent(ref.id)}/preview`, ref.projectId), + ); +} + export interface UpdateArtifactInput { title?: string; description?: string; @@ -274,4 +286,3 @@ export function deleteTaskDocument(taskId: string, key: string, projectId?: stri method: "DELETE", }); } - diff --git a/packages/dashboard/app/components/NativeStructurePreview.css b/packages/dashboard/app/components/NativeStructurePreview.css new file mode 100644 index 0000000000..ca5ca6c2ab --- /dev/null +++ b/packages/dashboard/app/components/NativeStructurePreview.css @@ -0,0 +1,61 @@ +/* FNXC:NativeStructureEmbed 2026-07-16-12:00: Keep the shared chat/mail card compact and token-driven while preserving a reachable consumer-owned open control on narrow screens. */ +.native-structure-preview { + align-items: start; + background: var(--bg-secondary); + border: solid var(--border); + border-radius: var(--radius-md); + display: grid; + gap: var(--space-sm); + grid-template-columns: auto minmax(0, 1fr) auto; + padding: var(--space-md); +} + +.native-structure-preview > svg { + color: var(--accent); +} + +.native-structure-preview__content { + min-width: 0; +} + +.native-structure-preview__label { + color: var(--text-muted); + font-size: var(--font-size-xs); + text-transform: uppercase; +} + +.native-structure-preview__title, +.native-structure-preview__excerpt { + display: block; + overflow-wrap: anywhere; +} + +.native-structure-preview__excerpt { + color: var(--text-muted); + font-size: var(--font-size-sm); + margin: var(--space-xs) 0 0; +} + +.native-structure-preview__open { + align-self: center; + white-space: nowrap; +} + +.native-structure-preview--unavailable { + background: color-mix(in srgb, var(--color-error) 10%, var(--bg-secondary)); +} + +.native-structure-preview--unavailable > svg { + color: var(--color-error); +} + +@media (max-width: 768px) { + .native-structure-preview { + grid-template-columns: auto minmax(0, 1fr); + } + + .native-structure-preview__open { + grid-column: 2; + justify-self: start; + } +} diff --git a/packages/dashboard/app/components/NativeStructurePreview.tsx b/packages/dashboard/app/components/NativeStructurePreview.tsx new file mode 100644 index 0000000000..a774469a6b --- /dev/null +++ b/packages/dashboard/app/components/NativeStructurePreview.tsx @@ -0,0 +1,115 @@ +import { memo, useEffect, useState } from "react"; +import { BarChart3, CircleAlert, Flag, Lightbulb, Map, Target } from "lucide-react"; +import type { NativeStructurePreviewResult, NativeStructureRef } from "@fusion/core"; +import { fetchNativeStructurePreview } from "../api"; +import "./NativeStructurePreview.css"; + +export interface NativeStructurePreviewProps { + ref: NativeStructureRef; + payload?: NativeStructurePreviewResult; + onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void; +} + +const icons = { + mission: Map, + milestone: Flag, + "research-finding": Lightbulb, + "eval-result": BarChart3, + goal: Target, +} satisfies Record; + +function isSupportedKind(kind: string): kind is NativeStructureRef["kind"] { + return Object.prototype.hasOwnProperty.call(icons, kind); +} + +function unavailableLabel(kind: string): string { + return kind.replace(/-/g, " "); +} + +/** + * FNXC:NativeStructureEmbed 2026-07-16-12:00: + * Chat and mail use this one memoized renderer for compact structure cards. Navigation remains + * 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) { + const supportedKind = isSupportedKind(ref.kind); + const refKey = `${ref.kind}\u0000${ref.id}\u0000${ref.projectId ?? ""}`; + const [fetchedPayload, setFetchedPayload] = useState<{ refKey: string; result: NativeStructurePreviewResult } | undefined>(); + const [error, setError] = useState(false); + // FNXC:NativeStructureEmbed 2026-07-16-12:00: A ref update must not briefly render a prior fetch result; consumers can replace cards while messages or drafts rehydrate. + const result = payload ?? (fetchedPayload?.refKey === refKey ? fetchedPayload.result : undefined); + const Icon = supportedKind ? icons[ref.kind] : CircleAlert; + + useEffect(() => { + /* + FNXC:NativeStructureEmbed 2026-07-19-18:00: + Refs can arrive from persisted chat/mail content, so reject a future or malformed kind before + fetching. The five-kind route is the sole resolver contract; roadmap-item must not trigger a + plugin read or turn an invalid icon lookup into a render crash. + */ + if (payload || !supportedKind) return; + let active = true; + setFetchedPayload(undefined); + setError(false); + void fetchNativeStructurePreview(ref) + .then((nextPayload) => { + if (active) setFetchedPayload({ refKey, result: nextPayload }); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { active = false; }; + }, [payload, ref.kind, ref.id, ref.projectId, refKey]); + + if (!supportedKind) { + return ( +
+
+ ); + } + + // FNXC:NativeStructureEmbed 2026-07-16-14:05: A caller-supplied projection is authoritative after a transient fetch failure, so it must replace the error placeholder for the same ref. + if (error && !payload) { + return ( +
+
+ ); + } + + if (!result) { + return ( +
+
+ ); + } + + if (!result.available) { + return ( +
+
+ ); + } + + return ( +
+
+ ); +}); diff --git a/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx b/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx new file mode 100644 index 0000000000..067f577141 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { NativeStructurePreviewPayload, NativeStructureRef } from "@fusion/core"; +import { fetchNativeStructurePreview } from "../../api"; +import { NativeStructurePreview } from "../NativeStructurePreview"; +import { loadAllAppCss } from "../../test/cssFixture"; + +vi.mock("../../api", () => ({ fetchNativeStructurePreview: vi.fn() })); +const fetchPreview = vi.mocked(fetchNativeStructurePreview); + +const refs: NativeStructureRef[] = [ + { kind: "mission", id: "M-1" }, + { kind: "milestone", id: "MS-1" }, + { kind: "research-finding", id: "INS-1" }, + { kind: "eval-result", id: "EV-1" }, + { kind: "goal", id: "G-1" }, +]; + +function payload(ref: NativeStructureRef): NativeStructurePreviewPayload { + const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals" } as const; + return { available: true, kind: ref.kind, kindLabel: ref.kind, title: `${ref.kind} title`, excerpt: "Compact excerpt", openTarget: { view: views[ref.kind], id: ref.id } }; +} + +describe("NativeStructurePreview", () => { + it.each(refs)("renders pre-resolved %s cards without fetching", (ref) => { + const onOpen = vi.fn(); + render(); + expect(screen.getByTestId("native-structure-preview")).toHaveAttribute("data-kind", ref.kind); + expect(screen.getByText(`${ref.kind} title`)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: new RegExp("Open") })).toBeInTheDocument(); + expect(fetchPreview).not.toHaveBeenCalled(); + }); + + it.each(refs)("fetches and renders a ref-only %s card", async (ref) => { + const result = payload(ref); + fetchPreview.mockResolvedValueOnce(result); + render(); + await waitFor(() => expect(screen.getByTestId("native-structure-preview")).toHaveAttribute("data-kind", ref.kind)); + expect(fetchPreview).toHaveBeenCalledWith(ref); + expect(screen.getByText(`${ref.kind} title`)).toBeInTheDocument(); + }); + + it("dispatches consumer navigation without a dead anchor", () => { + const ref = refs[0]; + const result = payload(ref); + const onOpen = vi.fn(); + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open mission: mission title" })); + expect(onOpen).toHaveBeenCalledWith(ref, result); + expect(container.querySelector('a[href*="/missions/"]')).toBeNull(); + }); + + it.each(["missing", "soft-deleted"] as const)("renders a graceful %s placeholder", (reason) => { + render(); + expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", reason); + expect(screen.getByText("This structure is unavailable.")).toBeInTheDocument(); + }); + + it("does not render a previous ref's fetched payload after a ref change", async () => { + fetchPreview.mockResolvedValueOnce(payload(refs[0])); + const { rerender } = render(); + await waitFor(() => expect(screen.getByText("mission title")).toBeInTheDocument()); + + fetchPreview.mockImplementationOnce(() => new Promise(() => {})); + rerender(); + + expect(screen.getByTestId("native-structure-preview-loading")).toBeInTheDocument(); + expect(screen.queryByText("mission title")).toBeNull(); + }); + + it("renders a deliberate error card when fetching fails", async () => { + fetchPreview.mockRejectedValueOnce(new Error("offline")); + render(); + await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument()); + }); + + it("does not fetch or crash for a deferred runtime kind", () => { + fetchPreview.mockClear(); + const deferredRef = { kind: "roadmap-item", id: "R-1" } as unknown as NativeStructureRef; + render(); + expect(screen.getByTestId("native-structure-preview-unavailable")).toBeInTheDocument(); + expect(fetchPreview).not.toHaveBeenCalled(); + }); + + it("lets a later caller-supplied payload replace a failed fetch", async () => { + fetchPreview.mockRejectedValueOnce(new Error("offline")); + const { rerender } = render(); + await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument()); + + rerender(); + + expect(screen.getByTestId("native-structure-preview")).toBeInTheDocument(); + expect(screen.queryByTestId("native-structure-preview-error")).toBeNull(); + }); + + it("keeps the open affordance inside the mobile layout contract", () => { + const { container } = render(); + expect(container.querySelector(".native-structure-preview__open")).toBeInTheDocument(); + const css = loadAllAppCss(); + expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.native-structure-preview__open/); + }); +}); diff --git a/packages/dashboard/src/native-structure-preview.ts b/packages/dashboard/src/native-structure-preview.ts new file mode 100644 index 0000000000..4ecade7739 --- /dev/null +++ b/packages/dashboard/src/native-structure-preview.ts @@ -0,0 +1,88 @@ +import type { + NativeStructurePreviewPayload, + NativeStructurePreviewResult, + NativeStructureRef, + TaskStore, +} from "@fusion/core"; + +const KIND_LABELS: Record = { + mission: "Mission", + milestone: "Milestone", + "research-finding": "Research finding", + "eval-result": "Evaluation result", + goal: "Goal", +}; +const MAX_EXCERPT_LENGTH = 180; + +function unavailable(ref: NativeStructureRef, reason: "missing" | "soft-deleted"): NativeStructurePreviewResult { + return { available: false, kind: ref.kind, id: ref.id, reason }; +} + +function preview( + ref: NativeStructureRef, + title: string, + excerpt: string, + openTarget: NativeStructurePreviewPayload["openTarget"], +): NativeStructurePreviewPayload { + return { available: true, kind: ref.kind, kindLabel: KIND_LABELS[ref.kind], title, excerpt, openTarget }; +} + +function text(value: string | null | undefined, fallback: string): string { + const normalized = value?.replace(/\s+/g, " ").trim(); + const resolved = normalized || fallback; + return resolved.length <= MAX_EXCERPT_LENGTH + ? resolved + : `${resolved.slice(0, MAX_EXCERPT_LENGTH - 1).trimEnd()}…`; +} + +/** + * FNXC:NativeStructureEmbed 2026-07-16-12:00: + * This is a read-only projection over the task-scoped stores; it never duplicates structure + * persistence. Existing archived/dismissed lifecycle status supplies `soft-deleted` because no + * target has a tombstone column. Missing and unavailable structures are returned, never thrown; + * eval results have no archive lifecycle and can only be missing. + */ +export async function resolveNativeStructurePreview( + store: TaskStore, + ref: NativeStructureRef, +): Promise { + switch (ref.kind) { + case "mission": { + const mission = await store.getMissionStore().getMission(ref.id); + if (!mission) return unavailable(ref, "missing"); + if (mission.status === "archived") return unavailable(ref, "soft-deleted"); + return preview(ref, mission.title, text(mission.description, `Status: ${mission.status}`), { view: "missions", id: mission.id }); + } + case "milestone": { + const missionStore = store.getMissionStore(); + const milestone = await missionStore.getMilestone(ref.id); + if (!milestone) return unavailable(ref, "missing"); + const mission = await missionStore.getMission(milestone.missionId); + if (!mission) return unavailable(ref, "missing"); + if (mission.status === "archived") return unavailable(ref, "soft-deleted"); + return preview(ref, milestone.title, text(milestone.description, `Status: ${milestone.status}`), { + view: "missions", + id: milestone.id, + missionId: mission.id, + }); + } + case "research-finding": { + const insight = await store.getInsightStore().getInsight(ref.id); + if (!insight) return unavailable(ref, "missing"); + if (insight.status === "dismissed" || insight.status === "archived") return unavailable(ref, "soft-deleted"); + return preview(ref, insight.title, text(insight.content, `Status: ${insight.status}`), { view: "insights", id: insight.id }); + } + case "eval-result": { + const result = await store.getEvalStore().getTaskResult(ref.id); + if (!result) return unavailable(ref, "missing"); + const score = result.overallScore === undefined ? "Score unavailable" : `Score: ${result.overallScore}${result.maxScore === undefined ? "" : `/${result.maxScore}`}`; + return preview(ref, result.taskSnapshot.title || result.taskId, text(result.summary ?? result.rationale, score), { view: "evals", id: result.id }); + } + case "goal": { + const goal = await store.getGoalStore().getGoal(ref.id); + if (!goal) return unavailable(ref, "missing"); + if (goal.status === "archived") return unavailable(ref, "soft-deleted"); + return preview(ref, goal.title, text(goal.description, `Status: ${goal.status}`), { view: "goals", id: goal.id }); + } + } +} diff --git a/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts b/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts new file mode 100644 index 0000000000..77efbd5d85 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import express from "express"; +import type { TaskStore } from "@fusion/core"; +import { resolveNativeStructurePreview } from "../../native-structure-preview.js"; +import { createApiRoutes } from "../../routes.js"; +import { request as REQUEST } from "../../test-request.js"; + +const mission = { id: "M-1", title: "Mission", description: "Mission description", status: "active" }; +const milestone = { id: "MS-1", missionId: "M-1", title: "Milestone", description: "Milestone description", status: "planning" }; +const insight = { id: "INS-1", title: "Finding", content: "Finding content", status: "stale" }; +const evaluation = { id: "EV-1", taskId: "FN-1", taskSnapshot: { title: "Evaluated task" }, overallScore: 8, maxScore: 10 }; +const goal = { id: "G-1", title: "Goal", description: "Goal description", status: "active" }; + +function store(overrides: Record = {}): TaskStore { + return { + getMissionStore: vi.fn(() => ({ getMission: vi.fn(async (id) => id === mission.id ? mission : undefined), getMilestone: vi.fn(async (id) => id === milestone.id ? milestone : undefined) })), + getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async (id) => id === insight.id ? insight : undefined) })), + getEvalStore: vi.fn(() => ({ getTaskResult: vi.fn(async (id) => id === evaluation.id ? evaluation : undefined) })), + getGoalStore: vi.fn(() => ({ getGoal: vi.fn(async (id) => id === goal.id ? goal : null) })), + getRootDir: vi.fn(() => process.cwd()), + ...overrides, + } as unknown as TaskStore; +} + +describe("resolveNativeStructurePreview", () => { + it.each([ + ["mission", mission.id, { view: "missions", id: mission.id }], + ["milestone", milestone.id, { view: "missions", id: milestone.id, missionId: mission.id }], + ["research-finding", insight.id, { view: "insights", id: insight.id }], + ["eval-result", evaluation.id, { view: "evals", id: evaluation.id }], + ["goal", goal.id, { view: "goals", id: goal.id }], + ] as const)("projects %s into its owning view", async (kind, id, openTarget) => { + const result = await resolveNativeStructurePreview(store(), { kind, id }); + expect(result).toMatchObject({ available: true, kind, openTarget }); + }); + + it.each(["mission", "milestone", "research-finding", "eval-result", "goal"] as const)("returns missing for absent %s", async (kind) => { + const result = await resolveNativeStructurePreview(store(), { kind, id: "absent" }); + expect(result).toEqual({ available: false, kind, id: "absent", reason: "missing" }); + }); + + it.each([ + ["mission", { getMissionStore: vi.fn(() => ({ getMission: vi.fn(async () => ({ ...mission, status: "archived" })) })) }], + ["milestone", { getMissionStore: vi.fn(() => ({ getMilestone: vi.fn(async () => milestone), getMission: vi.fn(async () => ({ ...mission, status: "archived" })) })) }], + ["research-finding", { getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async () => ({ ...insight, status: "dismissed" })) })) }], + ["goal", { getGoalStore: vi.fn(() => ({ getGoal: vi.fn(async () => ({ ...goal, status: "archived" })) })) }], + ] as const)("maps archived %s to soft-deleted", async (kind, overrides) => { + const result = await resolveNativeStructurePreview(store(overrides), { kind, id: "target" }); + expect(result).toMatchObject({ available: false, reason: "soft-deleted" }); + }); + + it("never maps a missing eval to soft-deleted", async () => { + await expect(resolveNativeStructurePreview(store(), { kind: "eval-result", id: "absent" })).resolves.toMatchObject({ reason: "missing" }); + }); + + it("normalizes and bounds long excerpts for compact cards", async () => { + const longContent = ` ${"finding detail ".repeat(30)} `; + const result = await resolveNativeStructurePreview(store({ + getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async () => ({ ...insight, content: longContent })) })), + }), { kind: "research-finding", id: insight.id }); + + expect(result).toMatchObject({ available: true }); + if (result.available) { + expect(result.excerpt.length).toBeLessThanOrEqual(180); + expect(result.excerpt).not.toMatch(/\s{2,}/); + expect(result.excerpt.endsWith("…")).toBe(true); + } + }); +}); + +describe("native structure preview route", () => { + it.each([ + ["mission", mission.id, { view: "missions", id: mission.id }], + ["milestone", milestone.id, { view: "missions", id: milestone.id, missionId: mission.id }], + ["research-finding", insight.id, { view: "insights", id: insight.id }], + ["eval-result", evaluation.id, { view: "evals", id: evaluation.id }], + ["goal", goal.id, { view: "goals", id: goal.id }], + ] as const)("returns the %s preview projection", async (kind, id, openTarget) => { + const app = express(); + app.use("/api", createApiRoutes(store())); + const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/${id}/preview`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ available: true, kind, openTarget }); + }); + + it("returns the typed unavailable payload with 200", async () => { + const app = express(); + app.use("/api", createApiRoutes(store())); + const res = await REQUEST(app, "GET", "/api/native-structures/mission/absent/preview"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ available: false, reason: "missing" }); + }); + + it.each(["roadmap-item", "unknown"])("rejects unsupported %s", async (kind) => { + const app = express(); + app.use("/api", createApiRoutes(store())); + const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/id/preview`); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 52fc484e47..81f5a9ff80 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -75,6 +75,7 @@ import { type WorkspaceRepoRevertPrBranch, } from "@fusion/engine"; import { buildBoardWorkflowsPayload } from "./board-workflows.js"; +import { resolveNativeStructurePreview } from "../native-structure-preview.js"; import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js"; import { computePlanApprovalFingerprint, isWorkspaceTask, type RunAuditEventInput } from "@fusion/core"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; @@ -3889,6 +3890,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + /** + * FNXC:NativeStructureEmbed 2026-07-16-12:00: + * Native-structure consumers need a single project-scoped read endpoint. Unavailable targets + * deliberately return HTTP 200 with a typed payload so chat and mail render a placeholder; + * unsupported kinds are malformed requests and remain HTTP 400. + */ + router.get("/native-structures/:kind/:id/preview", async (req, res) => { + try { + const { kind, id } = req.params; + if (kind !== "mission" && kind !== "milestone" && kind !== "research-finding" && kind !== "eval-result" && kind !== "goal") { + throw badRequest("kind must be one of: mission, milestone, research-finding, eval-result, goal"); + } + if (!id.trim()) throw badRequest("id must be non-empty"); + const { store: scopedStore } = await getProjectContext(req); + const preview = await resolveNativeStructurePreview(scopedStore, { kind, id }); + res.json(preview); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + throw new ApiError(500, err instanceof Error ? err.message : String(err)); + } + }); + /** * FNXC:ArtifactRegistry 2026-06-21-04:46: * Documents view needs a cross-agent registry read surface for all artifact media classes. Keep query validation aligned with `/documents` so dashboard tabs share bounded pagination behavior while rejecting unknown artifact types before store access.