fix(ui): render board approval payload prose as markdown (#9817)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The approval queue is how agents surface decisions that need a
human, so the approval card is often the only thing an operator reads
before approving or rejecting
> - Agents author those payloads in markdown, because markdown is what
they produce everywhere else in the product
> - `ApprovalPayload.tsx` renders the four prose fields of a board
approval as bare text nodes, while `CommentThread` on the same page
renders through `MarkdownBody` — same authors, two different renderers
> - So the operator sees literal `##`, `**bold**`, backticks and
`[link](url)` in the payload, and correctly formatted text in the
comments directly below it
> - This pull request routes those four fields through the existing
`MarkdownBody` component
> - The benefit is that the highest-stakes text in the product becomes
readable, with no new dependency and no schema change

## Linked Issues or Issue Description

Refs #4911 — prior art, see the note at the bottom of this description.

No open issue covers this, so per (B) here is the bug report:

**What happened:** On a board approval, the `summary`,
`recommendedAction`, `nextActionOnApproval` and `risks` fields display
raw markdown source. Headings appear as literal `##` mid-paragraph,
inline code keeps its backticks, links show as `[text](url)`, and both
levels of a nested bullet list collapse into one run-on paragraph.

**What was expected:** The same rendering the comment thread further
down the same page already gives, since both are agent-authored
markdown.

**Steps to reproduce:** Open any `request_board_approval` whose
`summary` contains markdown — headings, a nested list, code spans or
links.

**Where:** `ui/src/components/ApprovalPayload.tsx`,
`BoardApprovalPayloadContent`.

## What Changed

- `ui/src/components/ApprovalPayload.tsx`: import `MarkdownBody` and
render `summary`, `recommendedAction`, `nextActionOnApproval` and each
`risks` entry through it instead of `<p>` / `<span>` text nodes.
`MarkdownBody` defaults `softBreaks` to `true`, which is the same
behaviour `CommentThread` opts into explicitly, so paragraph handling
matches the comments.
- `stripLeadingListMarker`: risks already render inside a custom bullet
row, so an authored leading `-` / `*` / `•` would nest a second bullet
inside the first. One leading marker is stripped per entry.
- The risk bullet dot gains `shrink-0` so it keeps its shape next to
block-level markdown content.
- `title` stays plain text — it is a one-liner and markdown there is
noise.
- `proposedComment` stays a verbatim `<pre>` block — it is draft text
intended to be posted elsewhere, so it must not be reinterpreted.
- `ui/src/components/ApprovalPayload.test.tsx`: tests for markdown
rendering in all four fields, the leading-list-marker strip, and that
`title` and `proposedComment` remain verbatim.

## Verification

- `npx vitest run ui/src/components/ApprovalPayload.test.tsx` — 5
passed.
- `npx vitest run ui/src/components/ApprovalPayload.test.tsx
ui/src/components/CommentThread.test.tsx` — 12 passed, confirming the
shared `MarkdownBody` path is not disturbed.
- Manual, measured rather than eyeballed: I ran a patched build in a
throwaway container beside an unpatched one and pointed both at the same
real approval payload, then counted nodes in the rendered DOM.

  | | unpatched | patched |
  |---|---|---|
  | `.paperclip-markdown` nodes | 0 | 3 |
  | raw backticks in visible text | yes | no |
  | rendered `h2` | 0 | 5 |
  | rendered `li` | 0 | 16 |

## Risks

Low, and confined to the board approval card.

- Rendering scope widens from text to markdown on four fields. A payload
that contains markdown-significant punctuation but was authored as prose
could render differently than before. This is the intended change, and
it matches how the same author's text is already rendered in comments on
the same page.
- `stripLeadingListMarker` removes one leading list marker per risk
entry. A risk that genuinely begins with a literal hyphen followed by a
space loses that hyphen. Chosen over the alternative of a visible double
bullet on the common case.
- No schema change, no migration, no new dependency. `MarkdownBody` is
already used elsewhere in the same directory.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`), via Claude Code, with extended
thinking and tool use (repository search, file editing, local test
execution, and headless-browser DOM measurement of the before/after
renders).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes — no
docs describe this rendering behaviour, so there was nothing to update
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — pending first CI run on this PR
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
pending first review
- [x] I will address all Greptile and reviewer comments before
requesting merge

---

### On the prior PR

@alxhrzg opened #4911 for this same bug first, and reached the same
conclusion I did: route the four fields through `MarkdownBody`. Credit
for spotting it and for the diagnosis goes there.

That PR has been conflicting against base and untouched since May.
Rather than let the fix sit, this PR reapplies the idea on current
`master` and adds what #4911 was missing: test coverage, the
nested-bullet fix for `risks`, and the `shrink-0` on the bullet dot. I
could not push to #4911 directly as it is on another contributor's fork.

@alxhrzg, if you would rather finish #4911 yourself, I am happy to close
this and hand over the tests and the two risk-row fixes for you to take
across.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dirk 2026-08-13 00:08:22 +01:00 committed by GitHub
parent 5521d768b2
commit 8b1483e601
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 160 additions and 27 deletions

View File

@ -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(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
recommendedAction: "Approve the frog reply.",
nextActionOnApproval: "Post the frog comment on the issue.",
risks: ["The frog might be too powerful."],
proposedComment: "(o)<",
}}
/>,
<ThemeProvider>
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
recommendedAction: "Approve the frog reply.",
nextActionOnApproval: "Post the frog comment on the issue.",
risks: ["The frog might be too powerful."],
proposedComment: "(o)<",
}}
/>
</ThemeProvider>,
);
});
@ -62,19 +65,140 @@ describe("ApprovalPayloadRenderer", () => {
});
});
it("renders markdown in board approval prose fields", () => {
const root = createRoot(container);
act(() => {
root.render(
<ThemeProvider>
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
summary: "**Bold** and `code` and [a link](https://example.com).",
recommendedAction: "Approve the **frog** reply.",
nextActionOnApproval: "Post the `frog` comment.",
risks: ["The **frog** might be too powerful."],
}}
/>
</ThemeProvider>,
);
});
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(
<ThemeProvider>
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
risks: [
"- **Leading dash** risk.",
"* Leading star risk.",
"• Leading dot risk.",
"1. Leading number risk.",
"2) Leading paren risk.",
],
}}
/>
</ThemeProvider>,
);
});
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(
<ThemeProvider>
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
risks: ["- Low probability", "* Low probability"],
}}
/>
</ThemeProvider>,
);
});
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(
<ApprovalPayloadRenderer
type="request_board_approval"
hidePrimaryTitle
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
}}
/>,
<ThemeProvider>
<ApprovalPayloadRenderer
type="request_board_approval"
hidePrimaryTitle
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
}}
/>
</ThemeProvider>,
);
});

View File

@ -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<string, string> = {
@ -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<string, unknown> }) {
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<string, unkn
{summary && (
<div className="space-y-1">
<p className="text-(length:--text-micro) font-medium uppercase tracking-(--tracking-label) text-muted-foreground">Summary</p>
<p className="leading-6 text-foreground/90">{summary}</p>
<MarkdownBody className="leading-6 text-foreground/90">{summary}</MarkdownBody>
</div>
)}
{recommendedAction && (
@ -193,23 +202,23 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
<p className="text-(length:--text-micro) font-medium uppercase tracking-(--tracking-label) text-amber-700 dark:text-amber-300">
Recommended action
</p>
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
<MarkdownBody className="mt-1 leading-6 text-foreground">{recommendedAction}</MarkdownBody>
</div>
)}
{nextActionOnApproval && (
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
<p className="text-(length:--text-micro) font-medium uppercase tracking-(--tracking-label) text-muted-foreground">On approval</p>
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
<MarkdownBody className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</MarkdownBody>
</div>
)}
{risks.length > 0 && (
<div className="space-y-1.5">
<p className="text-(length:--text-micro) font-medium uppercase tracking-(--tracking-label) text-muted-foreground">Risks</p>
<ul className="space-y-1 text-sm text-muted-foreground">
{risks.map((risk) => (
<li key={risk} className="flex items-start gap-2">
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-muted-foreground/60" />
<span className="leading-6">{risk}</span>
{risks.map((risk, index) => (
<li key={`${index}-${risk}`} className="flex items-start gap-2">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60" />
<MarkdownBody className="leading-6">{risk}</MarkdownBody>
</li>
))}
</ul>