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);
+}