diff --git a/ui/src/components/ApprovalPayload.test.tsx b/ui/src/components/ApprovalPayload.test.tsx
index c11405e92d..9533947473 100644
--- a/ui/src/components/ApprovalPayload.test.tsx
+++ b/ui/src/components/ApprovalPayload.test.tsx
@@ -4,6 +4,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
+import { ThemeProvider } from "../context/ThemeContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -35,17 +36,19 @@ describe("ApprovalPayloadRenderer", () => {
act(() => {
root.render(
- ,
+
+
+ ,
);
});
@@ -62,19 +65,140 @@ describe("ApprovalPayloadRenderer", () => {
});
});
+ it("renders markdown in board approval prose fields", () => {
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ const bodies = container.querySelectorAll(".paperclip-markdown");
+ expect(bodies.length).toBe(4);
+
+ const summary = bodies[0];
+ expect(summary.querySelector("strong")?.textContent).toBe("Bold");
+ expect(summary.querySelector("code")?.textContent).toBe("code");
+ const link = summary.querySelector("a");
+ expect(link?.getAttribute("href")).toBe("https://example.com");
+ expect(link?.textContent).toBe("a link");
+
+ // The raw markdown characters must not survive into the rendered text.
+ expect(container.textContent).not.toContain("**Bold**");
+ expect(container.textContent).not.toContain("[a link](https://example.com)");
+
+ expect(bodies[1].querySelector("strong")?.textContent).toBe("frog");
+ expect(bodies[2].querySelector("code")?.textContent).toBe("frog");
+ expect(bodies[3].querySelector("strong")?.textContent).toBe("frog");
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it("does not nest a second bullet when a risk is authored as a markdown list item", () => {
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ const bodies = container.querySelectorAll(".paperclip-markdown");
+ expect(bodies.length).toBe(5);
+ for (const body of bodies) {
+ expect(body.querySelector("ul")).toBeNull();
+ expect(body.querySelector("ol")).toBeNull();
+ expect(body.querySelector("li")).toBeNull();
+ }
+
+ expect(bodies[0].querySelector("strong")?.textContent).toBe("Leading dash");
+ expect(container.textContent).toContain("Leading star risk.");
+ expect(container.textContent).toContain("Leading dot risk.");
+ expect(container.textContent).toContain("Leading number risk.");
+ expect(container.textContent).toContain("Leading paren risk.");
+ expect(container.textContent).not.toContain("- **Leading dash**");
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it("renders every risk when two entries collapse to the same text after marker stripping", () => {
+ const root = createRoot(container);
+ const errors: unknown[] = [];
+ const originalError = console.error;
+ console.error = (...args: unknown[]) => {
+ errors.push(args);
+ };
+
+ try {
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(container.querySelectorAll(".paperclip-markdown").length).toBe(2);
+ expect(errors).toEqual([]);
+ } finally {
+ console.error = originalError;
+ act(() => {
+ root.unmount();
+ });
+ }
+ });
+
it("can hide the repeated title when the card header already shows it", () => {
const root = createRoot(container);
act(() => {
root.render(
- ,
+
+
+ ,
);
});
diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx
index 165ee8a223..6f8f9752d8 100644
--- a/ui/src/components/ApprovalPayload.tsx
+++ b/ui/src/components/ApprovalPayload.tsx
@@ -1,4 +1,5 @@
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
+import { MarkdownBody } from "./MarkdownBody";
import { formatCents } from "../lib/utils";
export const typeLabel: Record = {
@@ -161,11 +162,19 @@ export function BoardApprovalPayload({
);
}
+/**
+ * Risks render inside a custom bullet row, so a leading markdown list marker
+ * would nest a second bullet inside the first. Strip one leading marker.
+ */
+function stripLeadingListMarker(value: string): string {
+ return value.replace(/^(?:[-*•]|\d+[.)])\s+/, "");
+}
+
function BoardApprovalPayloadContent({ payload }: { payload: Record }) {
const risks = Array.isArray(payload.risks)
? payload.risks
.filter((value): value is string => typeof value === "string")
- .map((value) => value.trim())
+ .map((value) => stripLeadingListMarker(value.trim()))
.filter(Boolean)
: [];
const title = firstNonEmptyString(payload.title);
@@ -185,7 +194,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record
Summary
- {summary}
+ {summary}
)}
{recommendedAction && (
@@ -193,23 +202,23 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record
Recommended action
- {recommendedAction}
+ {recommendedAction}
)}
{nextActionOnApproval && (
On approval
-
{nextActionOnApproval}
+
{nextActionOnApproval}
)}
{risks.length > 0 && (
Risks
- {risks.map((risk) => (
- -
-
- {risk}
+ {risks.map((risk, index) => (
+
-
+
+ {risk}
))}