From e40f76fef99a11a4a9676b48bc851051d170fcd3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 02:07:43 -0700 Subject: [PATCH] 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) --- .../fn-8057-planning-question-markdown.md | 7 ++++ .../app/components/ConversationHistory.css | 18 ++++++++ .../app/components/ConversationHistory.tsx | 6 ++- .../MilestoneSliceInterviewModal.tsx | 12 +++++- .../app/components/MissionInterviewModal.tsx | 12 +++++- .../app/components/PlanningModeModal.css | 27 +++++++++++- .../app/components/PlanningModeModal.tsx | 18 +++++++- .../__tests__/ConversationHistory.test.tsx | 20 +++++++++ .../MilestoneSliceInterviewModal.test.tsx | 39 ++++++++++++++++++ .../__tests__/MissionInterviewModal.test.tsx | 31 ++++++++++++++ ...PlanningModeModal.ui-interactions.test.tsx | 41 ++++++++++++++++++- 11 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-8057-planning-question-markdown.md diff --git a/.changeset/fn-8057-planning-question-markdown.md b/.changeset/fn-8057-planning-question-markdown.md new file mode 100644 index 0000000000..6872224864 --- /dev/null +++ b/.changeset/fn-8057-planning-question-markdown.md @@ -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. diff --git a/packages/dashboard/app/components/ConversationHistory.css b/packages/dashboard/app/components/ConversationHistory.css index 0fbaafa3e2..8c8b297d4c 100644 --- a/packages/dashboard/app/components/ConversationHistory.css +++ b/packages/dashboard/app/components/ConversationHistory.css @@ -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; diff --git a/packages/dashboard/app/components/ConversationHistory.tsx b/packages/dashboard/app/components/ConversationHistory.tsx index 6e6b8d9c4b..7f62a7c434 100644 --- a/packages/dashboard/app/components/ConversationHistory.tsx +++ b/packages/dashboard/app/components/ConversationHistory.tsx @@ -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 ? (
Q{entry.questionNumber} -

{entry.question?.question}

+
) : (
diff --git a/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx b/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx index 3066db0ada..66a529f74e 100644 --- a/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx +++ b/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx @@ -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 }:
-

{question.question}

+ {question.description && ( -

{question.description}

+ )}
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");