FN-7176: add PR body markdown preview toggle

Add a markdown preview mode to the Create PR body editor while preserving raw submission text.

- Add a persisted Preview/Edit toggle for the Create PR body field.
- Render body markdown through the shared sanitized markdown pipeline in preview mode.
- Style the preview region and document the modal behavior.
- Cover edit, preview, regenerate, revert, empty/fallback, and submit payload flows.

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../dashboard/app/components/PrCreateModal.css     |  24 ++++-
 .../dashboard/app/components/PrCreateModal.tsx     |  51 +++++++++-
 .../components/__tests__/PrCreateModal.test.tsx    | 107 +++++++++++++++++++++
 4 files changed, 181 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7176

Fusion-Task-Lineage: 82275fb1-f89a-4fa2-9c47-7a7e7982c6cd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 01:11:03 -07:00
parent b60a743377
commit 3a52c7a7cd
4 changed files with 181 additions and 2 deletions

View File

@@ -1003,6 +1003,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/<task-id-lower>` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit and push real merge changes, or report success without an empty commit when the selected base is already merged; preflight then refreshes so normal PR creation can continue once all checks pass.
- The **Create Pull Request** modal is a floating pop-out like Plan Mission, New Task, and Automations: drag its header or resize from desktop edges/corners, while mobile keeps the full-screen dialog layout. Close it with **X**, **Cancel**, or **Escape**; stray clicks inside or outside the floating shell do not dismiss it.
- The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. The **Diff & commit preview** section starts collapsed and can be expanded on demand.
- The **Body** section includes a **Preview/Edit** toggle so authors can review the rendered markdown description before creating the PR without changing the submitted raw body text.
- AI title/body generation in the dialog is bounded to 15 seconds and is canceled if the request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever.
- Project Settings → Project Models includes optional **PR title prompt guidance** and **PR description prompt guidance** fields. Blank fields preserve the default Create PR metadata prompt; populated fields append guidance for the generated title or body sections.
- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL.

View File

@@ -149,12 +149,34 @@ FN-7170 hosts Create PR in FloatingWindow: desktop geometry belongs to the share
.pr-create-modal__inline-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.pr-create-modal__body-input,
.pr-create-modal__body-preview {
min-height: calc(var(--space-2xl) * 4);
}
.pr-create-modal__body-input {
font-family: var(--font-mono);
min-height: calc(var(--space-2xl) * 4);
}
.pr-create-modal__body-preview {
overflow: auto;
padding: var(--space-sm) var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
}
.pr-create-modal__body-preview > :first-child {
margin-top: 0;
}
.pr-create-modal__body-preview > :last-child {
margin-bottom: 0;
}
.pr-create-template-hint {

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react";
import ReactMarkdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react";
import remarkGfm from "remark-gfm";
import { getErrorMessage, type PrInfo, type StructuredGhError } from "@fusion/core";
import {
createPr,
@@ -16,6 +18,7 @@ import {
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { FloatingWindow } from "./FloatingWindow";
import { sharedRehypePlugins } from "./markdownPipeline";
import "./PrCreateModal.css";
interface PrCreateModalProps {
@@ -39,6 +42,27 @@ type PreflightCheck = {
};
const PR_METADATA_TIMEOUT_MS = 15000;
const PR_CREATE_BODY_PREVIEW_STORAGE_KEY = "fn-pr-create-body-preview";
function readBooleanPref(key: string, defaultValue: boolean): boolean {
if (typeof window === "undefined") return defaultValue;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return defaultValue;
return raw === "true";
} catch {
return defaultValue;
}
}
function writeBooleanPref(key: string, value: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value ? "true" : "false");
} catch {
// ignore storage failures (quota, private mode, etc.)
}
}
/*
FNXC:PrCreateModal 2026-06-27-23:48:
@@ -199,6 +223,7 @@ export function PrCreateModal({
const [aiBody, setAiBody] = useState("");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [showBodyPreview, setShowBodyPreview] = useState<boolean>(() => readBooleanPref(PR_CREATE_BODY_PREVIEW_STORAGE_KEY, false));
const [userEditedTitle, setUserEditedTitle] = useState(false);
const [userEditedBody, setUserEditedBody] = useState(false);
const [templateUsed, setTemplateUsed] = useState(false);
@@ -342,6 +367,10 @@ export function PrCreateModal({
};
}, [loadData, open]);
useEffect(() => {
writeBooleanPref(PR_CREATE_BODY_PREVIEW_STORAGE_KEY, showBodyPreview);
}, [showBodyPreview]);
useEffect(() => {
if (!open) return;
@@ -630,10 +659,30 @@ export function PrCreateModal({
<div className="pr-create-modal__inline-actions">
<button type="button" className="btn btn-sm" onClick={() => void regenerate()} disabled={metadataLoading}><Sparkles size={14} />{t("pr.regenerate", "Regenerate")}</button>
{userEditedBody && <button type="button" className="btn btn-sm" onClick={() => { setBody(aiBody); setUserEditedBody(false); }}>{t("pr.revertToAi", "Revert to AI version")}</button>}
<button
type="button"
className="btn btn-sm"
data-testid="pr-create-body-preview-toggle"
aria-pressed={showBodyPreview}
title={showBodyPreview ? t("pr.editRawMarkdown", "Edit raw markdown") : t("pr.showFormattedMarkdown", "Show formatted markdown")}
onClick={() => setShowBodyPreview((current) => !current)}
>
{showBodyPreview ? t("pr.editBody", "Edit") : t("pr.previewBody", "Preview")}
</button>
</div>
</div>
{metadataLoading ? <div className="pr-create-modal__loading pr-create-modal__section-loading"><span className="status-dot status-dot--pending" aria-hidden="true" />{t("pr.generatingBody", "Generating AI body…")}</div> : null}
<textarea id="pr-create-modal-body" className="input pr-create-modal__body-input" value={body} onChange={(event) => { setBody(event.target.value); setUserEditedBody(true); }} rows={8} />
{/**
* FNXC:PrCreateModal 2026-06-28-00:00:
* PR authors need to preview description markdown before creating the PR. The preview is render-only, uses the shared sanitized markdown pipeline, and submission/regeneration/revert always read and write the raw `body` state.
*/}
{showBodyPreview ? (
<div className="pr-create-modal__body-preview markdown-body" role="region" aria-label={t("pr.bodyPreviewLabel", "Body markdown preview")}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins}>{body}</ReactMarkdown>
</div>
) : (
<textarea id="pr-create-modal-body" className="input pr-create-modal__body-input" value={body} onChange={(event) => { setBody(event.target.value); setUserEditedBody(true); }} rows={8} />
)}
{templateUsed && <p className="pr-create-template-hint">{t("pr.usingTemplate", "Using <code>.github/pull_request_template.md</code>")}</p>}
</section>

View File

@@ -246,6 +246,113 @@ describe("PrCreateModal", () => {
expect(screen.getByText(/using/i)).toBeInTheDocument();
});
it("defaults to raw edit mode and persists the body preview toggle preference", async () => {
localStorage.setItem("fn-pr-create-body-preview", "true");
await renderModalLoaded();
expect(screen.queryByLabelText(/body/i, { selector: "textarea" })).toBeNull();
expect(screen.getByRole("region", { name: "Body markdown preview" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Summary" })).toBeInTheDocument();
const toggle = screen.getByTestId("pr-create-body-preview-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
expect(await screen.findByLabelText(/body/i, { selector: "textarea" })).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-pressed", "false");
await waitFor(() => expect(localStorage.getItem("fn-pr-create-body-preview")).toBe("false"));
});
it("shows editable body by default and previews current markdown without mutating edits", async () => {
await renderModalLoaded();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput).toHaveValue(metadata.body);
expect(screen.queryByRole("region", { name: "Body markdown preview" })).toBeNull();
fireEvent.change(bodyInput, { target: { value: "# Custom title\n\nPlain text body\n\n<details><summary>More</summary>Hidden</details>" } });
const toggle = screen.getByTestId("pr-create-body-preview-toggle");
fireEvent.click(toggle);
expect(screen.queryByLabelText(/body/i, { selector: "textarea" })).toBeNull();
expect(screen.getByRole("region", { name: "Body markdown preview" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "Custom title" })).toBeInTheDocument();
expect(screen.getByText("Plain text body")).toBeInTheDocument();
expect(screen.getByText("More")).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
expect(screen.getByLabelText(/body/i)).toHaveValue("# Custom title\n\nPlain text body\n\n<details><summary>More</summary>Hidden</details>");
});
it("renders empty and fallback body preview states without crashing", async () => {
await renderModalLoaded();
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "" } });
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
const emptyPreview = screen.getByRole("region", { name: "Body markdown preview" });
expect(emptyPreview).toBeInTheDocument();
expect(screen.getByTestId("pr-create-body-preview-toggle")).toHaveAttribute("aria-pressed", "true");
cleanup();
localStorage.clear();
mocks.generatePrMetadata.mockRejectedValueOnce(new Error("metadata blew up"));
renderModal();
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByRole("heading", { level: 2, name: "Summary" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Linked Task" })).toBeInTheDocument();
expect(screen.getByText("Closes FN-4756")).toBeInTheDocument();
});
it("re-renders preview for regenerate and Revert to AI version body changes", async () => {
mocks.generatePrMetadata
.mockResolvedValueOnce(metadata)
.mockResolvedValueOnce({ title: "New title", body: "# Regenerated body\n\nFresh content", templateUsed: false });
await renderModalLoaded();
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByRole("heading", { level: 2, name: "Summary" })).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /^regenerate$/i })[1]);
expect(await screen.findByRole("heading", { level: 1, name: "Regenerated body" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 2, name: "Summary" })).toBeNull();
expect(screen.getByText("Fresh content")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "Plain edited body" } });
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByText("Plain edited body")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /revert to ai version/i }));
expect(screen.getByRole("heading", { level: 1, name: "Regenerated body" })).toBeInTheDocument();
expect(screen.queryByText("Plain edited body")).toBeNull();
});
it("submits the same body payload while markdown preview is active", async () => {
await renderModalLoaded();
const bodyText = "# Submit me\n\nPreview mode should not change payload.";
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: bodyText } });
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByRole("heading", { level: 1, name: "Submit me" })).toBeInTheDocument();
const submitButton = screen.getByRole("button", { name: "Create PR" });
await waitFor(() => expect(submitButton).toBeEnabled());
fireEvent.click(submitButton);
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect(mocks.createPr.mock.calls[0][1]).toMatchObject({
title: "AI title",
body: bodyText,
});
});
it("renders commit preview rows in SHA, subject, author order", async () => {
const commits = [
{