diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 82f87566e86..9831b3a61f4 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -980,6 +980,34 @@ describe("MarkdownEditor", () => { }); }); + it.each([ + ["Steer the current turn ", "Steer the current turn "], + ["Steer the current turn \n", "Steer the current turn \n"], + ["Use ` ` ", "Use ` ` "], + ["First line \nsecond \n", "First line \nsecond \n"], + ["**bold ** \n", "**bold ** \n"], + ["First line \nsecond", "First line \nsecond"], + ["**bold **\n", "**bold **\n"], + ["Use \\ ", "Use \\ "], + ["```text\n ", "```text\n "], + [" ", " "], + ])("stores trailing editor spaces without changing literal entities: %s", async (exported, stored) => { + mdxEditorMockState.emitMountChange = exported; + const handleChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + await flush(); + + expect(handleChange).toHaveBeenCalledWith(stored); + + await act(async () => { + root.unmount(); + }); + }); + it("stores intraword underscores as typed instead of the exporter's escaped form", async () => { const value = "Your final answer must be exactly E2E_ASK_12_journey_1793913565974_507b01b8."; // The real exporter escapes every literal underscore, including inside words. diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index fe991f82ead..fc69ef6ff85 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -59,6 +59,7 @@ import { } from "../lib/angle-bracket-markdown"; import { unescapeBlockquoteMarkers } from "../lib/blockquote-markdown"; import { unescapeIntrawordUnderscores } from "../lib/underscore-markdown"; +import { restoreTrailingMarkdownSpaces } from "../lib/trailing-markdown-spaces"; import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { cn } from "../lib/utils"; import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext"; @@ -189,9 +190,10 @@ function prepareMarkdownForEditor(value: string): string { * feed agent prompts and must stay in the clean, human-authored form. */ function toStoredMarkdown(markdown: string): string { + const withTrailingSpaces = restoreTrailingMarkdownSpaces(markdown); // Unescape underscores first, on the exporter's exact text: removing `\<` or // `\>` can turn following lines into HTML or blockquotes and move code ranges. - return unescapeAngleBracketEscapes(unescapeBlockquoteMarkers(unescapeIntrawordUnderscores(markdown))); + return unescapeAngleBracketEscapes(unescapeBlockquoteMarkers(unescapeIntrawordUnderscores(withTrailingSpaces))); } function escapeRegExp(value: string): string { diff --git a/ui/src/lib/trailing-markdown-spaces.test.ts b/ui/src/lib/trailing-markdown-spaces.test.ts new file mode 100644 index 00000000000..cb388d356e4 --- /dev/null +++ b/ui/src/lib/trailing-markdown-spaces.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { restoreTrailingMarkdownSpaces } from "./trailing-markdown-spaces"; + +describe("restoreTrailingMarkdownSpaces", () => { + it.each([ + ["Send this ", "Send this "], + ["Send this \n", "Send this \n"], + ["Send this \n\n", "Send this \n\n"], + ["> - Send this ", "> - Send this "], + ["Keep this ", "Keep this "], + ["First line \nsecond \n", "First line \nsecond \n"], + ["**bold ** \n", "**bold ** \n"], + ["Use ` ` ", "Use ` ` "], + [String.raw`Use \\ `, String.raw`Use \\` + " "], + ])("restores trailing prose spaces: %s", (input, expected) => { + expect(restoreTrailingMarkdownSpaces(input)).toBe(expected); + }); + + it.each([ + "", + "Keep two spaces \nfor a hard break", + "Keep this", + "First line \nsecond", + "First line \nsecond", + "**bold **\n", + "*italic *\n", + String.raw`Keep \ `, + String.raw`Keep \\\ `, + "Use ` `", + "```text\n ", + "> ```text\n> ", + " ", + "
\n ", + "[link](https://example.com/ )", + ])("preserves literal entities and other Markdown: %s", (input) => { + expect(restoreTrailingMarkdownSpaces(input)).toBe(input); + }); +}); diff --git a/ui/src/lib/trailing-markdown-spaces.ts b/ui/src/lib/trailing-markdown-spaces.ts new file mode 100644 index 00000000000..e86b44ec821 --- /dev/null +++ b/ui/src/lib/trailing-markdown-spaces.ts @@ -0,0 +1,32 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +interface PositionedNode { + type: string; + position?: { start: { offset?: number }; end: { offset?: number } }; + children?: PositionedNode[]; +} + +/** + * The rich editor exports a final prose space as ` `. Restore that space + * before storing the draft so message submission can trim it normally. + * Only touch a document-final prose suffix. Entities before internal newlines or + * closing emphasis markers stay encoded: literal spaces there can create a + * hard break or invalidate emphasis. Code, HTML, and escaped literal entities + * must keep their source. Run on the exact export, before other unescaping. + */ +export function restoreTrailingMarkdownSpaces(markdown: string): string { + const suffix = /(?: [ \t\r\n]*)+$/.exec(markdown); + if (!suffix) return markdown; + + let node: PositionedNode = fromMarkdown(markdown); + while (node.children?.length) node = node.children[node.children.length - 1]; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (node.type !== "text" || start === undefined || end === undefined || end <= suffix.index) { + return markdown; + } + + const text = markdown.slice(start, end).replace(/\\[\s\S]| /g, (value, offset: number) => + value === " " && start + offset >= suffix.index ? " " : value); + return markdown.slice(0, start) + text + markdown.slice(end); +}