fix(ui): recover blockquotes the markdown editor escaped as backslash-gt (#10466)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The web UI uses one shared markdown editor for comments, issue
descriptions, and documents.
> - Users type `>` at the start of a line to insert a blockquote.
> - The live editor shortcut does not always run in every browser and
input method.
> - The markdown exporter then changes the leading `>` to `\>` and saves
literal text.
> - The saved text does not render as a blockquote.
> - This pull request restores the blockquote marker when markdown
enters or leaves the editor.
> - The benefit is reliable blockquote insertion on every surface that
uses the shared editor.

## Linked Issues or Issue Description

No public GitHub issue exists.

Related prior attempt: #10465.

**What happened?**

The shared markdown editor sometimes saved a blockquote as literal text.
This happened when the live shortcut did not run. The exporter saved `\>
text`, which rendered as literal `> text`.

**Expected behavior**

A line that starts with `>` must render as a blockquote in comments,
issue descriptions, and documents.

**Steps to reproduce**

1. Open a task comment composer, description editor, or document editor.
2. Add `> ` to an existing line, or use an input method that does not
run the live shortcut.
3. Save the content.
4. Observe that the saved line renders as literal text instead of a
blockquote.

**Paperclip version or commit**

`master` at `78f8c6c3d4`.

**Deployment mode**

Self-hosted server.

## What Changed

- Add `unescapeBlockquoteMarkers()` to restore block-level `\>` markers.
- Keep indented code, list content, nested content, and fenced code
unchanged.
- Apply the helper when markdown enters and leaves `MarkdownEditor`.
- Add focused tests for line position, indentation, container prefixes,
and CommonMark fence rules.

## Verification

- `pnpm exec vitest run ui/src/lib/blockquote-markdown.test.ts` passes
with 22 tests.
- `pnpm exec vitest run ui/src/components/MarkdownEditor.test.tsx`
passes with 37 tests.
- `pnpm --filter @paperclipai/ui typecheck` passes.
- `pnpm check:token-gates` passes.
- `git diff --check origin/master...HEAD` passes.
- A browser harness used the real `MarkdownEditor` and `IssueChatThread`
composer. It confirmed that `> text` renders as a blockquote and exports
as `> text`.
- The [Cutter
preview](https://github.com/paperclipai/paperclip/pull/10466#issuecomment-5140558263)
supplies a task-page screenshot and an editor interaction video.

## Risks

- Low risk. The helper returns the input unchanged when it contains no
`\>`.
- A paragraph that deliberately starts with literal `\>` now becomes a
blockquote. The editor has no literal-marker control, so this matches
the available input behavior.
- There are no database, API, or migration changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended
thinking, with tool use and code execution.
- OpenAI Codex with GPT-5 (`gpt-5`; runtime build and context-window
metadata were not exposed), with reasoning, tool use, code execution,
and GitHub review tools.

## 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
- [x] I have updated relevant documentation to reflect my changes (none
needed)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-31 18:52:42 -07:00 committed by GitHub
parent fc5a30805e
commit b1ac92f305
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 237 additions and 2 deletions

View File

@ -48,6 +48,7 @@ import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/me
import { mentionDeletionPlugin } from "../lib/mention-deletion";
import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
import { normalizeMarkdown } from "../lib/normalize-markdown";
import { unescapeBlockquoteMarkers } from "../lib/blockquote-markdown";
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils";
import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext";
@ -141,7 +142,10 @@ function convertHtmlImagesToMarkdown(text: string): string {
function prepareMarkdownForEditor(value: string): string {
const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
return convertHtmlImagesToMarkdown(normalizedLineEndings);
// Recover escaped blockquotes (`\>`) so `>`-prefixed content renders as a real
// blockquote in the editor as well as on display (keeps import/export in sync).
const withBlockquotes = unescapeBlockquoteMarkers(normalizedLineEndings);
return convertHtmlImagesToMarkdown(withBlockquotes);
}
function escapeRegExp(value: string): string {
@ -1324,8 +1328,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
suppressHtmlProcessing
placeholder={placeholder}
readOnly={readOnly}
onChange={(next) => {
onChange={(rawNext) => {
if (readOnly) return;
// Recover blockquotes the exporter escaped as `\>` (see
// unescapeBlockquoteMarkers) so a `>`-prefixed line the user typed
// always survives as a real blockquote, even when the WYSIWYG
// shortcut didn't fire.
const next = unescapeBlockquoteMarkers(rawNext);
const echo = echoIgnoreMarkdownRef.current;
if (echo !== null && next === echo) {
echoIgnoreMarkdownRef.current = null;

View File

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { unescapeBlockquoteMarkers } from "./blockquote-markdown";
describe("unescapeBlockquoteMarkers", () => {
it("leaves markdown without escaped markers untouched", () => {
expect(unescapeBlockquoteMarkers("> quoted")).toBe("> quoted");
expect(unescapeBlockquoteMarkers("plain text")).toBe("plain text");
expect(unescapeBlockquoteMarkers("")).toBe("");
});
it("unescapes a single escaped blockquote line", () => {
expect(unescapeBlockquoteMarkers("\\> see")).toBe("> see");
});
it("unescapes multiple escaped blockquote lines", () => {
expect(unescapeBlockquoteMarkers("\\> line one\n\\> line two")).toBe("> line one\n> line two");
});
it("unescapes escaped blockquotes interleaved with normal text", () => {
expect(unescapeBlockquoteMarkers("hello\n\n\\> quoted\n\nbye")).toBe("hello\n\n> quoted\n\nbye");
});
it("preserves up to 3 spaces of block indent before the marker", () => {
expect(unescapeBlockquoteMarkers(" \\> indented")).toBe(" > indented");
expect(unescapeBlockquoteMarkers(" \\> three")).toBe(" > three");
});
it("does not touch an escaped marker in an indented code block (4+ spaces)", () => {
expect(unescapeBlockquoteMarkers(" \\> literal")).toBe(" \\> literal");
expect(unescapeBlockquoteMarkers("\tcode \\> x")).toBe("\tcode \\> x");
});
it("does not touch an escaped marker carrying a blockquote container prefix", () => {
// A code fence nested inside a blockquote has a `> ` prefix on each line, so
// its `\>` content must stay escaped.
expect(unescapeBlockquoteMarkers("> \\> nested")).toBe("> \\> nested");
});
it("does not touch an escaped marker inside a list item", () => {
expect(unescapeBlockquoteMarkers("- \\> item")).toBe("- \\> item");
});
it("does not touch escaped markers inside fenced code blocks", () => {
const input = "```\n\\> not a quote\n```\n\\> real quote";
expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> not a quote\n```\n> real quote");
});
it("does not touch escaped markers inside a list-nested fenced code block", () => {
const input = "- ```\n \\> literal\n ```\n\\> real quote";
expect(unescapeBlockquoteMarkers(input)).toBe("- ```\n \\> literal\n ```\n> real quote");
});
it("does not close a list-nested fence on a list-marker content line", () => {
const input = "- ```\n - ```\n \\> literal\n ```\n\\> real quote";
expect(unescapeBlockquoteMarkers(input)).toBe("- ```\n - ```\n \\> literal\n ```\n> real quote");
});
it("tracks the continuation indent of an ordered-list fence", () => {
const input = "10. ```\n \\> literal\n ```\n\\> real quote";
expect(unescapeBlockquoteMarkers(input)).toBe("10. ```\n \\> literal\n ```\n> real quote");
});
it("tracks fenced code blocks through blockquote container prefixes", () => {
const input = "> ```\n> \\> literal\n> ```\n\\> real quote";
expect(unescapeBlockquoteMarkers(input)).toBe("> ```\n> \\> literal\n> ```\n> real quote");
});
it("handles tilde fences", () => {
const input = "~~~\n\\> literal\n~~~";
expect(unescapeBlockquoteMarkers(input)).toBe("~~~\n\\> literal\n~~~");
});
it("only rewrites the leading marker, not later text", () => {
expect(unescapeBlockquoteMarkers("\\> a \\> b")).toBe("> a \\> b");
});
it("leaves a mid-line escaped marker alone", () => {
expect(unescapeBlockquoteMarkers("text \\> not a quote")).toBe("text \\> not a quote");
});
it("does not close a fence on a shorter same-char run (nested fence content stays code)", () => {
// A 3-backtick line inside a 4-backtick fence is code content, not a close,
// so the escaped marker after it must remain escaped.
const input = "````\n\\> a\n```\n\\> b\n````\n\\> real";
expect(unescapeBlockquoteMarkers(input)).toBe("````\n\\> a\n```\n\\> b\n````\n> real");
});
it("does not treat a fence-like line with trailing content as a closing fence", () => {
const input = "```\n\\> code\n``` not a close\n\\> still code\n```\n\\> real";
expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> code\n``` not a close\n\\> still code\n```\n> real");
});
it("does not close a backtick fence with a tilde fence", () => {
const input = "```\n\\> code\n~~~\n\\> still code\n```\n\\> real";
expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> code\n~~~\n\\> still code\n```\n> real");
});
it("closes on a longer run than the opening fence", () => {
const input = "```\n\\> code\n`````\n\\> real";
expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> code\n`````\n> real");
});
it("treats a backtick fence with a backtick in the info string as non-opening", () => {
// `` ```` `` with `x` after is not a valid opening fence, so following
// escaped markers are ordinary paragraph text and get recovered.
const input = "``` `x`\n\\> real";
expect(unescapeBlockquoteMarkers(input)).toBe("``` `x`\n> real");
});
it("keeps an info string on the opening fence and still protects contents", () => {
const input = "```ts\n\\> code\n```\n\\> real";
expect(unescapeBlockquoteMarkers(input)).toBe("```ts\n\\> code\n```\n> real");
});
});

View File

@ -0,0 +1,112 @@
/**
* When the WYSIWYG blockquote shortcut does not fire (e.g. the `> ` prefix is
* assembled by an edit that Lexical's markdown-shortcut transform doesn't catch,
* which happens on some browsers/IMEs), MDXEditor exports the paragraph as an
* *escaped* blockquote `\> text`. `mdast-util-to-markdown` escapes a leading
* `>` so a literal paragraph round-trips as text rather than a blockquote.
*
* In this product `>` at the start of a line always means "blockquote" (there is
* no separate literal-`>` affordance and the composer has no blockquote toolbar
* button), so an escaped `\>` is never the user's intent it is a silently
* dropped blockquote. This helper rewrites a leading `\>` back to `>` so the
* stored markdown renders as the blockquote the user typed.
*
* Only a marker at the block-level line start is rewritten. The exporter escapes
* a `>` exactly where CommonMark would otherwise start a blockquote the first
* column of a block, allowing the 03 spaces of insignificant indent. Restricting
* to `^ {0,3}\>` deliberately skips:
* - indented code blocks (4+ spaces of indent), whose `\>` is literal content;
* - content nested in a blockquote or list, whose lines carry a `>`/`-`/`digit.`
* container prefix rather than plain indent.
*
* Fenced code blocks are also skipped, including fences nested in blockquote or
* list containers: their contents are never `\`-escaped by the exporter, and a
* `\>` inside a code fence is meaningful literal text. Fence tracking follows
* CommonMark: a closing fence must use the same character as the opening fence,
* be at least as long, and carry no trailing content (an info string is only
* allowed on the opening fence).
*/
// An opening fence may follow blockquote/list container markers. Capture the
// whole prefix so the closing scan can preserve that container context.
const FENCE_OPEN_RE =
/^( {0,3}(?:(?:> ?|(?:[-+*]|\d{1,9}[.)]) +))*)(`{3,}|~{3,})(.*)$/;
const LIST_MARKER_RE = /(?:[-+*]|\d{1,9}[.)]) +/g;
const BLOCKQUOTE_MARKER_RE = />/g;
const FENCE_CLOSE_RE = /^( *)(`{3,}|~{3,})[ \t]*$/;
// A block-level escaped blockquote marker: `\>` at column 0, allowing only the
// 03 spaces of insignificant leading indent CommonMark permits before a block.
const ESCAPED_BLOCKQUOTE_RE = /^( {0,3})\\>/;
function stripBlockquotePrefix(line: string, depth: number): string | null {
let rest = line;
for (let i = 0; i < depth; i += 1) {
const marker = /^ {0,3}> ?/.exec(rest);
if (!marker) return null;
rest = rest.slice(marker[0].length);
}
return rest;
}
export function unescapeBlockquoteMarkers(markdown: string): string {
if (!markdown.includes("\\>")) return markdown;
const lines = markdown.split("\n");
let fenceChar = ""; // "" when not inside a fenced code block
let fenceLen = 0;
let fenceBlockquoteDepth = 0;
let fenceCloseIndentMax = 3;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (fenceChar) {
const closeCandidate = stripBlockquotePrefix(line, fenceBlockquoteDepth);
const closeMatch = closeCandidate ? FENCE_CLOSE_RE.exec(closeCandidate) : null;
if (closeMatch) {
const indent = closeMatch[1].length;
const run = closeMatch[2];
if (indent <= fenceCloseIndentMax && run[0] === fenceChar && run.length >= fenceLen) {
fenceChar = "";
fenceLen = 0;
fenceBlockquoteDepth = 0;
fenceCloseIndentMax = 3;
}
}
continue;
}
const fenceMatch = FENCE_OPEN_RE.exec(line);
if (fenceMatch) {
const prefix = fenceMatch[1];
const run = fenceMatch[2];
const char = run[0];
const rest = fenceMatch[3];
// A backtick info string may not itself contain a backtick (CommonMark);
// such a line is not a valid opening fence.
if (!(char === "`" && rest.includes("`"))) {
const listMarkers = prefix.match(LIST_MARKER_RE) ?? [];
fenceChar = char;
fenceLen = run.length;
fenceBlockquoteDepth = (prefix.match(BLOCKQUOTE_MARKER_RE) ?? []).length;
// A list's continuation indent includes its marker and following spaces.
// A closing fence may add CommonMark's normal 03 spaces after that.
fenceCloseIndentMax =
(listMarkers.length > 0 ? listMarkers.reduce((sum, marker) => sum + marker.length, 0) : 0) + 3;
continue;
}
}
if (ESCAPED_BLOCKQUOTE_RE.test(line)) {
lines[i] = line.replace(ESCAPED_BLOCKQUOTE_RE, "$1>");
}
}
return lines.join("\n");
}