FN-8057: render planning questions as sanitized Markdown
Render AI-authored planning and interview questions with readable, sanitized Markdown. - Reuse MailboxMessageContent for active and historical planning questions. - Preserve question typography while spacing Markdown blocks and lists consistently. - Cover Markdown formatting across planning, mission, milestone, and slice interviews. Files changed: .changeset/fn-8057-planning-question-markdown.md | 7 ++++ .../app/components/ConversationHistory.css | 18 ++++++++++ .../app/components/ConversationHistory.tsx | 6 +++- .../components/MilestoneSliceInterviewModal.tsx | 12 +++++-- .../app/components/MissionInterviewModal.tsx | 12 +++++-- .../dashboard/app/components/PlanningModeModal.css | 27 +++++++++++-- .../dashboard/app/components/PlanningModeModal.tsx | 18 +++++++-- .../__tests__/ConversationHistory.test.tsx | 20 +++++++++++ .../MilestoneSliceInterviewModal.test.tsx | 39 ++++++++++++++++++++ .../MissionInterviewModal.test.tsx | 31 ++++++++++++++++ .../PlanningModeModal.ui-interactions.test.tsx | 41 +++++++++++++++++++++- 11 files changed, 221 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-8057 Fusion-Task-Lineage: cf04fc77-c15c-4ca5-a99a-80a9631a1c8c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8057-planning-question-markdown.md
Normal file
7
.changeset/fn-8057-planning-question-markdown.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Planning Mode and interview questions now render markdown formatting correctly.
|
||||
category: fix
|
||||
dev: Reuse MailboxMessageContent for AI-authored question titles and descriptions.
|
||||
@@ -41,6 +41,24 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningInterview 2026-07-16-00:00:
|
||||
History uses the same sanitized markdown renderer as live interview questions.
|
||||
Keep generated blocks compact within the existing timeline row.
|
||||
*/
|
||||
.conversation-entry-question-text > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conversation-entry-question-text > * + * {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.conversation-entry-question-text ul,
|
||||
.conversation-entry-question-text ol {
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.conversation-entry-response {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from "i18next";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { useState } from "react";
|
||||
import type { ConversationHistoryEntry } from "../api";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
|
||||
const COMMENT_ICON = "💬";
|
||||
const PLANNING_OTHER_RESPONSE_KEY = "_other";
|
||||
@@ -171,7 +172,10 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
|
||||
{hasQuestion ? (
|
||||
<div className="conversation-entry-question">
|
||||
<span className="conversation-entry-question-label">Q{entry.questionNumber}</span>
|
||||
<p>{entry.question?.question}</p>
|
||||
<MailboxMessageContent
|
||||
className="conversation-entry-question-text"
|
||||
content={entry.question?.question ?? ""}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversation-entry-question">
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
@@ -685,9 +686,16 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
</div>
|
||||
|
||||
<div className="planning-question-content">
|
||||
<h4 className="planning-question-text">{question.question}</h4>
|
||||
<MailboxMessageContent
|
||||
className="planning-question-text markdown-body"
|
||||
content={question.question}
|
||||
testId="planning-question-text"
|
||||
/>
|
||||
{question.description && (
|
||||
<p className="planning-question-desc">{question.description}</p>
|
||||
<MailboxMessageContent
|
||||
className="planning-question-desc markdown-body"
|
||||
content={question.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="planning-options">
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
@@ -1141,9 +1142,16 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
</div>
|
||||
|
||||
<div className="planning-question-content">
|
||||
<h4 className="planning-question-text">{question.question}</h4>
|
||||
<MailboxMessageContent
|
||||
className="planning-question-text markdown-body"
|
||||
content={question.question}
|
||||
testId="planning-question-text"
|
||||
/>
|
||||
{question.description && (
|
||||
<p className="planning-question-desc">{question.description}</p>
|
||||
<MailboxMessageContent
|
||||
className="planning-question-desc markdown-body"
|
||||
content={question.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="planning-options">
|
||||
|
||||
@@ -997,17 +997,40 @@ FN-8003 keeps Copy prompt in the question progress header so an interview can be
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningInterview 2026-07-16-00:00:
|
||||
Interview questions now use MailboxMessageContent for sanitized GFM. Keep the
|
||||
existing title and muted-description hierarchy while normalizing markdown block
|
||||
margins so paragraphs and lists do not create unintended gaps.
|
||||
*/
|
||||
.planning-question-text {
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.planning-question-desc {
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.planning-question-text > *,
|
||||
.planning-question-desc > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.planning-question-text > * + *,
|
||||
.planning-question-desc > * + * {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-question-text ul,
|
||||
.planning-question-text ol,
|
||||
.planning-question-desc ul,
|
||||
.planning-question-desc ol {
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.planning-options {
|
||||
display: flex;
|
||||
|
||||
@@ -54,6 +54,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore, Copy } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { OnboardingDisclosure } from "./OnboardingDisclosure";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
@@ -2741,9 +2742,22 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
</div>
|
||||
|
||||
<div className="planning-question-content">
|
||||
<h4 className="planning-question-text">{question.question}</h4>
|
||||
{/*
|
||||
FNXC:PlanningInterview 2026-07-16-00:00:
|
||||
GitHub #2152 requires AI-authored interview questions and descriptions to use the
|
||||
sanitized GFM renderer, so bold text, hard line breaks, and lists remain readable
|
||||
without trusting generated HTML.
|
||||
*/}
|
||||
<MailboxMessageContent
|
||||
className="planning-question-text markdown-body"
|
||||
content={question.question}
|
||||
testId="planning-question-text"
|
||||
/>
|
||||
{question.description && (
|
||||
<p className="planning-question-desc">{question.description}</p>
|
||||
<MailboxMessageContent
|
||||
className="planning-question-desc markdown-body"
|
||||
content={question.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="planning-options">
|
||||
|
||||
@@ -29,6 +29,12 @@ const confirmQuestion: PlanningQuestion = {
|
||||
question: "Should Fusion continue?",
|
||||
};
|
||||
|
||||
const markdownQuestion: PlanningQuestion = {
|
||||
id: "q-markdown",
|
||||
type: "text",
|
||||
question: "Do you want **fast** mode?\n\nfirst line \nsecond line\n\n- Option A\n- Option B",
|
||||
};
|
||||
|
||||
function expectNoObjectObject() {
|
||||
expect(screen.queryByText(/\[object Object\]/)).toBeNull();
|
||||
}
|
||||
@@ -51,6 +57,20 @@ describe("ConversationHistory", () => {
|
||||
expect(screen.getByText("Medium")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders historical AI questions as sanitized markdown", () => {
|
||||
const { container } = render(
|
||||
<ConversationHistory
|
||||
entries={[{ question: markdownQuestion, response: { "q-markdown": "Yes" } }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const question = container.querySelector(".conversation-entry-question-text");
|
||||
expect(question?.querySelector("strong")).toHaveTextContent("fast");
|
||||
expect([...question!.querySelectorAll("p")].find((paragraph) => paragraph.textContent?.includes("first line"))?.querySelector("br")).not.toBeNull();
|
||||
expect([...question!.querySelectorAll("li")].map((item) => item.textContent)).toEqual(["Option A", "Option B"]);
|
||||
expect(question).not.toHaveTextContent("**fast**");
|
||||
});
|
||||
|
||||
it("renders single-select Other responses as the user's own answer", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
|
||||
@@ -76,6 +76,21 @@ const SAMPLE_QUESTION = {
|
||||
],
|
||||
};
|
||||
|
||||
const MARKDOWN_QUESTION = {
|
||||
id: "markdown-question",
|
||||
type: "text" as const,
|
||||
question: "Do you want **fast** mode?\n\nfirst line \nsecond line\n\n- Option A\n- Option B",
|
||||
description: "Choose the mode before continuing.",
|
||||
};
|
||||
|
||||
function expectMarkdownQuestionFormatting() {
|
||||
const question = screen.getByTestId("planning-question-text");
|
||||
expect(question.querySelector("strong")).toHaveTextContent("fast");
|
||||
expect([...question.querySelectorAll("p")].find((paragraph) => paragraph.textContent?.includes("first line"))?.querySelector("br")).not.toBeNull();
|
||||
expect([...question.querySelectorAll("li")].map((item) => item.textContent)).toEqual(["Option A", "Option B"]);
|
||||
expect(question).not.toHaveTextContent("**fast**");
|
||||
}
|
||||
|
||||
describe("MilestoneSliceInterviewModal", () => {
|
||||
let streamHandlers: any;
|
||||
|
||||
@@ -394,6 +409,30 @@ describe("MilestoneSliceInterviewModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders markdown formatting in AI milestone and slice interview questions", async () => {
|
||||
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
|
||||
|
||||
render(
|
||||
<MilestoneSliceInterviewModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onApplied={vi.fn()}
|
||||
targetType="milestone"
|
||||
targetId="MS-001"
|
||||
targetTitle="Test Milestone"
|
||||
projectId="test-project"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
act(() => {
|
||||
streamHandlers.onQuestion(MARKDOWN_QUESTION);
|
||||
});
|
||||
|
||||
await waitFor(expectMarkdownQuestionFormatting);
|
||||
});
|
||||
|
||||
it("shows reconnecting only during active generation, not on persisted questions", async () => {
|
||||
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
|
||||
|
||||
|
||||
@@ -61,6 +61,21 @@ const SECOND_QUESTION = {
|
||||
description: "List the product surfaces that need support.",
|
||||
};
|
||||
|
||||
const MARKDOWN_QUESTION = {
|
||||
id: "markdown-question",
|
||||
type: "text" as const,
|
||||
question: "Do you want **fast** mode?\n\nfirst line \nsecond line\n\n- Option A\n- Option B",
|
||||
description: "Choose the mode before continuing.",
|
||||
};
|
||||
|
||||
function expectMarkdownQuestionFormatting() {
|
||||
const question = screen.getByTestId("planning-question-text");
|
||||
expect(question.querySelector("strong")).toHaveTextContent("fast");
|
||||
expect([...question.querySelectorAll("p")].find((paragraph) => paragraph.textContent?.includes("first line"))?.querySelector("br")).not.toBeNull();
|
||||
expect([...question.querySelectorAll("li")].map((item) => item.textContent)).toEqual(["Option A", "Option B"]);
|
||||
expect(question).not.toHaveTextContent("**fast**");
|
||||
}
|
||||
|
||||
const SAMPLE_SUMMARY = {
|
||||
missionTitle: "Resilient mission planning",
|
||||
missionDescription: "Recover mission AI planning after transient stream interruptions.",
|
||||
@@ -291,6 +306,22 @@ describe("MissionInterviewModal", () => {
|
||||
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown formatting in AI mission interview questions", async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Format a mission interview question" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(MARKDOWN_QUESTION);
|
||||
});
|
||||
|
||||
await waitFor(expectMarkdownQuestionFormatting);
|
||||
});
|
||||
|
||||
it("preserves streaming thinking output while reconnecting", async () => {
|
||||
renderModal();
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import type { MergeResult, PlanningQuestion } from "@fusion/core";
|
||||
import {
|
||||
mockStartPlanning,
|
||||
mockStartPlanningStreaming,
|
||||
@@ -130,6 +130,21 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
const MARKDOWN_QUESTION: PlanningQuestion = {
|
||||
id: "markdown-question",
|
||||
type: "text",
|
||||
question: "Do you want **fast** mode?\n\nfirst line \nsecond line\n\n- Option A\n- Option B",
|
||||
description: "Choose the mode before continuing.",
|
||||
};
|
||||
|
||||
function expectMarkdownQuestionFormatting() {
|
||||
const question = screen.getByTestId("planning-question-text");
|
||||
expect(question.querySelector("strong")).toHaveTextContent("fast");
|
||||
expect([...question.querySelectorAll("p")].find((paragraph) => paragraph.textContent?.includes("first line"))?.querySelector("br")).not.toBeNull();
|
||||
expect([...question.querySelectorAll("li")].map((item) => item.textContent)).toEqual(["Option A", "Option B"]);
|
||||
expect(question).not.toHaveTextContent("**fast**");
|
||||
}
|
||||
|
||||
describe("PlanningModeModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnTaskCreated = vi.fn();
|
||||
@@ -219,6 +234,30 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders markdown formatting in AI planning questions", async () => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => handlers.onQuestion?.(MARKDOWN_QUESTION), 0);
|
||||
return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) };
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Format the planning question" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(expectMarkdownQuestionFormatting);
|
||||
});
|
||||
|
||||
describe("Loading state", () => {
|
||||
function getPlanningLoadingSpinner(container: HTMLElement): SVGSVGElement {
|
||||
const spinner = container.querySelector<SVGSVGElement>(".planning-loading svg.spin");
|
||||
|
||||
Reference in New Issue
Block a user