FN-5682: render mission markdown across manager views

Render mission, milestone, slice, and feature text as Markdown in MissionManager.

- add a shared markdown renderer using react-markdown with GFM support
- render mission descriptions, milestone acceptance criteria, slice verification, and feature description/acceptance fields as markdown
- render feature-derived completion criteria and mission list descriptions as markdown
- add MissionManager markdown styling and update mission docs/tests for markdown behavior

Files changed:
 docs/missions.md                                   |  2 +
 .../dashboard/app/components/MissionManager.css    | 45 ++++++++++++
 .../dashboard/app/components/MissionManager.tsx    | 46 ++++++++----
 .../components/__tests__/MissionManager.test.tsx   | 85 ++++++++++++++++++----
 4 files changed, 146 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-5682

Fusion-Task-Lineage: 521193fb-7150-44ab-981f-045c094f32df
This commit is contained in:
gsxdsm
2026-05-29 10:11:49 -07:00
parent 86c98d3593
commit 32c877a16a
4 changed files with 145 additions and 31 deletions

View File

@@ -42,6 +42,8 @@ On mobile, Mission Manager surfaces the primary **Plan New Mission** CTA at the
Mission detail refreshes now preserve expanded milestone/slice state and keep the selected milestone expanded, so persisted milestone acceptance criteria remain visible across live updates.
Mission, milestone, slice, and feature read-only text surfaces in Mission Manager render Markdown (GFM) for descriptions, verification, and acceptance criteria; edit forms continue to use raw plain-text `<textarea>` inputs.
### CLI
```bash

View File

@@ -837,6 +837,19 @@
white-space: nowrap;
}
.mission-list__item-description .markdown-body {
color: inherit;
font-size: inherit;
}
.mission-list__item-description .markdown-body > :first-child {
margin-top: 0;
}
.mission-list__item-description .markdown-body > :last-child {
margin-bottom: 0;
}
/* Desktop split-only clamp prevents description overflow in the narrow sidebar column. */
.mission-manager__sidebar .mission-list__item-description {
display: -webkit-box;
@@ -979,6 +992,19 @@
margin: 0;
}
.mission-detail__description .markdown-body {
color: inherit;
font-size: inherit;
}
.mission-detail__description .markdown-body > :first-child {
margin-top: 0;
}
.mission-detail__description .markdown-body > :last-child {
margin-bottom: 0;
}
.mission-detail__meta {
display: flex;
align-items: center;
@@ -1871,6 +1897,25 @@
line-height: 1.4;
}
.mission-feature__description .markdown-body,
.mission-feature__criteria .markdown-body,
.mission-assertion__text .markdown-body {
color: inherit;
font-size: inherit;
}
.mission-feature__description .markdown-body > :first-child,
.mission-feature__criteria .markdown-body > :first-child,
.mission-assertion__text .markdown-body > :first-child {
margin-top: 0;
}
.mission-feature__description .markdown-body > :last-child,
.mission-feature__criteria .markdown-body > :last-child,
.mission-assertion__text .markdown-body > :last-child {
margin-bottom: 0;
}
.mission-feature__criteria {
font-size: 12px;
color: var(--text-dim);

View File

@@ -1,5 +1,7 @@
import "./MissionManager.css";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { getErrorMessage } from "@fusion/core";
import {
X,
@@ -462,6 +464,14 @@ function getActivityQueryEventType(
return undefined;
}
function renderMarkdownText(text: string): ReactNode {
return (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
</div>
);
}
function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: string): string | null {
if (!lastActivityAt) {
return null;
@@ -2328,7 +2338,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</span>
</div>
{selectedMission.description && (
<p className="mission-detail__description">{selectedMission.description}</p>
<div className="mission-detail__description">{renderMarkdownText(selectedMission.description)}</div>
)}
<div className="mission-detail__meta">
<span className="mission-detail__meta-info">
@@ -2610,9 +2620,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{expandedMilestones.has(milestone.id) && (
<div className="mission-milestone__body">
{milestone.acceptanceCriteria && (
<p className="mission-feature__criteria">
<strong>Acceptance:</strong> {milestone.acceptanceCriteria}
</p>
<div className="mission-feature__criteria">
<strong>Acceptance:</strong>
{renderMarkdownText(milestone.acceptanceCriteria)}
</div>
)}
{/* Create milestone form (inline edit) */}
@@ -2889,9 +2900,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{expandedSlices.has(slice.id) && (
<div className="mission-slice__body">
{slice.verification?.trim() && (
<p className="mission-feature__criteria">
<strong>Verification:</strong> {slice.verification}
</p>
<div className="mission-feature__criteria">
<strong>Verification:</strong>
{renderMarkdownText(slice.verification)}
</div>
)}
{/* Create slice form */}
@@ -3104,12 +3116,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</div>
{feature.description && (
<p className="mission-feature__description">{feature.description}</p>
<div className="mission-feature__description">{renderMarkdownText(feature.description)}</div>
)}
{feature.acceptanceCriteria && (
<p className="mission-feature__criteria">
<strong>Acceptance:</strong> {feature.acceptanceCriteria}
</p>
<div className="mission-feature__criteria">
<strong>Acceptance:</strong>
{renderMarkdownText(feature.acceptanceCriteria)}
</div>
)}
{/* Triage preview panel */}
@@ -3587,9 +3600,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{featuresWithAcceptanceCriteria.map((feature) => (
<div key={feature.id} className="mission-assertion">
<span className="mission-assertion__title">{feature.title}</span>
<p className="mission-assertion__text">
<strong>Acceptance:</strong> {feature.acceptanceCriteria}
</p>
<div className="mission-assertion__text">
<strong>Acceptance:</strong>
{renderMarkdownText(feature.acceptanceCriteria ?? "")}
</div>
</div>
))}
</div>
@@ -3924,7 +3938,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{isInterviewStyle ? (
<p className="mission-list__item-description">Mission interview is still in progress. Open this mission to continue planning.</p>
) : m.description ? (
<p className="mission-list__item-description">{m.description}</p>
<div className="mission-list__item-description">{renderMarkdownText(m.description)}</div>
) : null}
{showSummaryBlock && (
<div className="mission-list__item-summary">

View File

@@ -4625,28 +4625,81 @@ describe("MissionManager", () => {
});
describe("mission acceptance and verification visibility", () => {
it("renders milestone acceptance criteria, slice verification, and feature acceptance criteria", async () => {
it("renders markdown for mission hierarchy display surfaces while preserving labels and raw textarea editing", async () => {
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as any;
missionDetail.milestones[0].acceptanceCriteria = "MILESTONE_AC_MARKER";
missionDetail.milestones[0].slices[0].verification = "SLICE_VERIFY_MARKER";
missionDetail.milestones[0].slices[0].features[0].acceptanceCriteria = "FEATURE_AC_MARKER";
missionDetail.description = "Mission detail **DETAIL_BOLD**";
missionDetail.milestones[0].acceptanceCriteria = "Milestone acceptance **MILESTONE_BOLD**";
missionDetail.milestones[0].slices[0].verification = "- VERIFY_BULLET";
missionDetail.milestones[0].slices[0].features[0].description = "Feature description **FEATURE_DESC_BOLD**";
missionDetail.milestones[0].slices[0].features[0].acceptanceCriteria = "Feature acceptance **FEATURE_AC_BOLD**";
const missionsWithMarkdown = [
{ ...mockMissions[0], description: "Mission list **LIST_BOLD**" },
mockMissions[1],
];
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, mockMissionEvents)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(missionDetail));
}
return Promise.resolve(mockApiResponse(missionsWithMarkdown));
});
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
fireEvent.click(await screen.findByText("Build Auth System"));
const sidebar = await screen.findByTestId("mission-sidebar");
const missionItem = within(sidebar).getByText("Build Auth System").closest(".mission-list__item");
expect(missionItem).toBeTruthy();
const missionDescription = within(missionItem as HTMLElement).getByText("LIST_BOLD");
expect(missionDescription.tagName).toBe("STRONG");
fireEvent.click(within(sidebar).getByText("Build Auth System"));
await waitForDetailLoaded();
if (!screen.queryByText("MILESTONE_AC_MARKER")) {
fireEvent.click(screen.getByText("Database Schema"));
}
if (!screen.queryByText("SLICE_VERIFY_MARKER")) {
fireEvent.click(screen.getByText("User Tables"));
}
const detailDescription = document.querySelector(".mission-detail__description .markdown-body strong");
expect(detailDescription).toBeTruthy();
expect(detailDescription?.textContent).toBe("DETAIL_BOLD");
expect(await screen.findByText(/MILESTONE_AC_MARKER/)).toBeInTheDocument();
expect(await screen.findByText(/SLICE_VERIFY_MARKER/)).toBeInTheDocument();
expect((await screen.findAllByText(/FEATURE_AC_MARKER/)).length).toBeGreaterThan(0);
const milestone = screen.getByText("Database Schema").closest(".mission-milestone");
expect(milestone).toBeTruthy();
const acceptanceLabel = within(milestone as HTMLElement).getByText("Acceptance:");
expect(acceptanceLabel.tagName).toBe("STRONG");
expect(within(milestone as HTMLElement).getByText("MILESTONE_BOLD").tagName).toBe("STRONG");
const slice = screen.getByText("User Tables").closest(".mission-slice");
expect(slice).toBeTruthy();
expect(within(slice as HTMLElement).getByText("Verification:")).toBeInTheDocument();
expect((slice as HTMLElement).querySelectorAll("li")).toHaveLength(1);
expect(within(slice as HTMLElement).getByText("VERIFY_BULLET")).toBeInTheDocument();
const feature = screen.getByText("User model").closest(".mission-feature");
expect(feature).toBeTruthy();
expect(within(feature as HTMLElement).getByText("FEATURE_DESC_BOLD").tagName).toBe("STRONG");
expect(within(feature as HTMLElement).getByText("FEATURE_AC_BOLD").tagName).toBe("STRONG");
fireEvent.click(within(feature as HTMLElement).getByTitle("Edit feature"));
expect(screen.getByDisplayValue("Feature description **FEATURE_DESC_BOLD**")).toBeInTheDocument();
expect(screen.getByDisplayValue("Feature acceptance **FEATURE_AC_BOLD**")).toBeInTheDocument();
});
});