diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx
index 6be52cd9a0..4bdab3de8e 100644
--- a/packages/dashboard/app/components/MissionInterviewModal.tsx
+++ b/packages/dashboard/app/components/MissionInterviewModal.tsx
@@ -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 }:
-
{question.question}
+
{question.description && (
-
{question.description}
+
)}
diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css
index 21ee20b030..2f846a918c 100644
--- a/packages/dashboard/app/components/PlanningModeModal.css
+++ b/packages/dashboard/app/components/PlanningModeModal.css
@@ -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;
diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx
index a7bb9965d8..6e86c7fba7 100644
--- a/packages/dashboard/app/components/PlanningModeModal.tsx
+++ b/packages/dashboard/app/components/PlanningModeModal.tsx
@@ -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
-
{question.question}
+ {/*
+ 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.
+ */}
+
{question.description && (
-
{question.description}
+
)}
diff --git a/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx b/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx
index a640b1846d..2651cc1815 100644
--- a/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ConversationHistory.test.tsx
@@ -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(
+
,
+ );
+
+ 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(
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(
+ ,
+ );
+
+ 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" });
diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx
index adcf8c2a35..1e2c5e0dcc 100644
--- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx
@@ -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();
diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx
index 10300589a0..6e07d8e02e 100644
--- a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx
+++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(".planning-loading svg.spin");