diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index b996629a..d9a2afb6 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm";
import { parseProjectMentionHref } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
+import { normalizeMarkdownArtifacts } from "../lib/markdown";
interface MarkdownBodyProps {
children: string;
@@ -114,6 +115,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
const { theme } = useTheme();
+ const normalizedMarkdown = normalizeMarkdownArtifacts(children);
return (
- {children}
+ {normalizedMarkdown}
);
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx
index 85b67c32..507308a1 100644
--- a/ui/src/components/MarkdownEditor.tsx
+++ b/ui/src/components/MarkdownEditor.tsx
@@ -29,6 +29,7 @@ import {
} from "@mdxeditor/editor";
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
import { cn } from "../lib/utils";
+import { normalizeMarkdownArtifacts } from "../lib/markdown";
/* ---- Mention types ---- */
@@ -203,7 +204,7 @@ export const MarkdownEditor = forwardRef
}: MarkdownEditorProps, forwardedRef) {
const containerRef = useRef(null);
const ref = useRef(null);
- const latestValueRef = useRef(value);
+ const latestValueRef = useRef(normalizeMarkdownArtifacts(value));
const [uploadError, setUploadError] = useState(null);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
@@ -281,9 +282,10 @@ export const MarkdownEditor = forwardRef
}, [hasImageUpload]);
useEffect(() => {
- if (value !== latestValueRef.current) {
- ref.current?.setMarkdown(value);
- latestValueRef.current = value;
+ const normalizedValue = normalizeMarkdownArtifacts(value);
+ if (normalizedValue !== latestValueRef.current) {
+ ref.current?.setMarkdown(normalizedValue);
+ latestValueRef.current = normalizedValue;
}
}, [value]);
@@ -554,11 +556,15 @@ export const MarkdownEditor = forwardRef
>
{
- latestValueRef.current = next;
- onChange(next);
+ const normalizedNext = normalizeMarkdownArtifacts(next);
+ latestValueRef.current = normalizedNext;
+ if (normalizedNext !== next) {
+ ref.current?.setMarkdown(normalizedNext);
+ }
+ onChange(normalizedNext);
}}
onBlur={() => onBlur?.()}
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
diff --git a/ui/src/lib/markdown.test.ts b/ui/src/lib/markdown.test.ts
new file mode 100644
index 00000000..a455ea1f
--- /dev/null
+++ b/ui/src/lib/markdown.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { normalizeMarkdownArtifacts } from "./markdown";
+
+describe("normalizeMarkdownArtifacts", () => {
+ it("normalizes escaped unordered list markers and space entities", () => {
+ const input = "Here is a list:\n\n\\* foo \n\\- bar ";
+ const output = normalizeMarkdownArtifacts(input);
+ expect(output).toBe("Here is a list:\n\n* foo \n- bar ");
+ });
+
+ it("does not rewrite escaped markers inside fenced code blocks", () => {
+ const input = "```md\n\\* keep literal \n\\- keep literal \n```";
+ expect(normalizeMarkdownArtifacts(input)).toBe(input);
+ });
+
+ it("keeps escaped non-list syntax intact", () => {
+ const input = "\\*not-a-list";
+ expect(normalizeMarkdownArtifacts(input)).toBe(input);
+ });
+});
diff --git a/ui/src/lib/markdown.ts b/ui/src/lib/markdown.ts
new file mode 100644
index 00000000..ab485319
--- /dev/null
+++ b/ui/src/lib/markdown.ts
@@ -0,0 +1,47 @@
+const FENCE_RE = /^\s*(`{3,}|~{3,})/;
+const SPACE_ENTITY_RE = / /gi;
+const ESCAPED_UNORDERED_LIST_RE = /^(\s{0,3})\\([*+-])([ \t]+)/;
+
+/**
+ * Normalize markdown artifacts emitted by rich-text serialization so
+ * plain markdown list syntax remains usable in Paperclip editors.
+ */
+export function normalizeMarkdownArtifacts(markdown: string): string {
+ if (!markdown) return markdown;
+
+ const lines = markdown.split(/\r?\n/);
+ let inFence = false;
+ let fenceMarker: "`" | "~" | null = null;
+ let fenceLength = 0;
+ let changed = false;
+
+ const normalized = lines.map((line) => {
+ const fenceMatch = FENCE_RE.exec(line);
+ if (fenceMatch) {
+ const marker = fenceMatch[1];
+ if (!inFence) {
+ inFence = true;
+ fenceMarker = marker[0] as "`" | "~";
+ fenceLength = marker.length;
+ } else if (marker[0] === fenceMarker && marker.length >= fenceLength) {
+ inFence = false;
+ fenceMarker = null;
+ fenceLength = 0;
+ }
+ return line;
+ }
+
+ if (inFence) return line;
+
+ let next = line;
+ if (next.includes(" ")) {
+ next = next.replace(SPACE_ENTITY_RE, " ");
+ }
+ const unescaped = next.replace(ESCAPED_UNORDERED_LIST_RE, "$1$2$3");
+ if (unescaped !== line) changed = true;
+ return unescaped;
+ });
+
+ if (!changed) return markdown;
+ return normalized.join("\n");
+}