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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user