From 83ccb9f5e0f489e9caccd97f2d4e1ffb6e43e9fb Mon Sep 17 00:00:00 2001 From: Vaibhav Jain Date: Thu, 6 Aug 2026 12:55:57 +0530 Subject: [PATCH] fix(desktop): keep trailing space after persistent agent mentions TipTap markdown setContent strips trailing whitespace, so post-send "Keep addressed agents active" restores left the caret glued to the mention chip and the next keystroke collapsed it. Re-attach the space via insertText after markdown parse. Signed-off-by: Vaibhav Jain Co-authored-by: Cursor --- ...kdownPreservingTrailingWhitespace.test.mjs | 150 ++++++++++++++++++ ...torMarkdownPreservingTrailingWhitespace.ts | 44 +++++ .../messages/lib/useRichTextEditor.ts | 15 +- .../e2e/persistent-agent-audience.spec.ts | 26 +++ 4 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.test.mjs create mode 100644 desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.ts diff --git a/desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.test.mjs b/desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.test.mjs new file mode 100644 index 0000000000..2e793734da --- /dev/null +++ b/desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { JSDOM } from "jsdom"; +import { Markdown } from "tiptap-markdown"; + +import { setEditorMarkdownPreservingTrailingWhitespace } from "./setEditorMarkdownPreservingTrailingWhitespace.ts"; + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost", +}); + +/** @type {Editor | null} */ +let editor = null; + +function plainText() { + assert.ok(editor); + return editor.state.doc.textBetween( + 0, + editor.state.doc.content.size, + "\n", + "\n", + ); +} + +before(() => { + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + DOMParser: dom.window.DOMParser, + Node: dom.window.Node, + DocumentFragment: dom.window.DocumentFragment, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + MutationObserver: dom.window.MutationObserver, + getSelection: dom.window.getSelection.bind(dom.window), + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + }); + + editor = new Editor({ + element: document.createElement("div"), + extensions: [ + StarterKit.configure({ + trailingNode: false, + heading: false, + link: false, + }), + Markdown.configure({ + html: false, + transformPastedText: true, + transformCopiedText: true, + }), + ], + content: "", + }); +}); + +after(() => { + editor?.destroy(); + editor = null; +}); + +test("markdown setContent alone strips the trailing space (#4979 repro)", () => { + assert.ok(editor); + editor.commands.setContent("@Pearl "); + assert.equal(plainText(), "@Pearl"); +}); + +test("helper keeps a trailing space after a mention restore", () => { + assert.ok(editor); + setEditorMarkdownPreservingTrailingWhitespace(editor, "@Pearl ", { + emitUpdate: false, + focusEnd: true, + }); + assert.equal(plainText(), "@Pearl "); + assert.equal(editor.state.selection.from, editor.state.doc.content.size - 1); +}); + +test("helper keeps trailing space after multiple mentions", () => { + assert.ok(editor); + setEditorMarkdownPreservingTrailingWhitespace(editor, "@Vogue @Morgarita ", { + emitUpdate: false, + focusEnd: true, + }); + assert.equal(plainText(), "@Vogue @Morgarita "); +}); + +test("helper still parses markdown marks in the body", () => { + assert.ok(editor); + setEditorMarkdownPreservingTrailingWhitespace(editor, "**bold** ", { + emitUpdate: false, + focusEnd: true, + }); + assert.equal(plainText(), "bold "); + let sawBold = false; + editor.state.doc.descendants((node) => { + if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) { + sawBold = true; + } + }); + assert.equal(sawBold, true); +}); + +test("helper is a no-op for content without trailing whitespace", () => { + assert.ok(editor); + setEditorMarkdownPreservingTrailingWhitespace(editor, "@Pearl", { + emitUpdate: false, + focusEnd: true, + }); + assert.equal(plainText(), "@Pearl"); +}); + +test("emitUpdate:false suppresses onUpdate for the re-attached space", () => { + assert.ok(editor); + let updateCount = 0; + const onUpdate = () => { + updateCount += 1; + }; + editor.on("update", onUpdate); + try { + setEditorMarkdownPreservingTrailingWhitespace(editor, "@Pearl ", { + emitUpdate: false, + focusEnd: true, + }); + assert.equal(plainText(), "@Pearl "); + assert.equal(updateCount, 0); + } finally { + editor.off("update", onUpdate); + } +}); + +test("emitUpdate:true still notifies observers after restore", () => { + assert.ok(editor); + let updateCount = 0; + const onUpdate = () => { + updateCount += 1; + }; + editor.on("update", onUpdate); + try { + setEditorMarkdownPreservingTrailingWhitespace(editor, "@Pearl "); + assert.equal(plainText(), "@Pearl "); + assert.ok(updateCount >= 1); + } finally { + editor.off("update", onUpdate); + } +}); diff --git a/desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.ts b/desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.ts new file mode 100644 index 0000000000..067467b624 --- /dev/null +++ b/desktop/src/features/messages/lib/setEditorMarkdownPreservingTrailingWhitespace.ts @@ -0,0 +1,44 @@ +import type { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; + +/** + * TipTap's markdown `setContent` strips trailing whitespace. Persistent agent + * mentions (and autocomplete) rely on a trailing space so the next keystroke + * does not extend the `@Name` token and collapse the mention chip. + * + * Parse the markdown body without its trailing run of spaces/tabs, then + * re-attach that run with a raw `insertText` transaction (which preserves it). + */ +export function setEditorMarkdownPreservingTrailingWhitespace( + editor: Editor, + markdown: string, + options?: { emitUpdate?: boolean; focusEnd?: boolean }, +): void { + const emitUpdate = options?.emitUpdate ?? true; + const focusEnd = options?.focusEnd ?? false; + const trailingWhitespace = markdown.match(/[ \t]+$/)?.[0] ?? ""; + const body = trailingWhitespace + ? markdown.slice(0, -trailingWhitespace.length) + : markdown; + + editor.commands.setContent(body, { emitUpdate }); + + if (trailingWhitespace) { + const insertAt = editor.state.doc.content.size - 1; + let tr = editor.state.tr.insertText(trailingWhitespace, insertAt); + tr = tr.setSelection( + TextSelection.create(tr.doc, insertAt + trailingWhitespace.length), + ); + // Mirror TipTap setContent({ emitUpdate: false }): suppress onUpdate and + // keep programmatic restores out of undo history. + if (!emitUpdate) { + tr.setMeta("addToHistory", false); + tr.setMeta("preventUpdate", true); + } + editor.view.dispatch(tr); + } + + if (focusEnd) { + editor.commands.focus("end"); + } +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index c761081cc3..16e2a9a9d4 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -36,6 +36,7 @@ import { insertNewlineInCodeBlock, } from "./codeBlockExtensions"; import { SpoilerMark } from "./spoilerMark"; +import { setEditorMarkdownPreservingTrailingWhitespace } from "./setEditorMarkdownPreservingTrailingWhitespace"; function hardBreakLineBounds($from: ResolvedPos) { const parentStart = $from.start(); @@ -714,7 +715,7 @@ export function useRichTextEditor({ const setContent = React.useCallback( (markdown: string) => { if (!editor) return; - editor.commands.setContent(markdown); + setEditorMarkdownPreservingTrailingWhitespace(editor, markdown); }, [editor], ); @@ -724,12 +725,12 @@ export function useRichTextEditor({ if (!editor) return; // The caller already synchronizes composer state. Keep this programmatic // restoration out of user-edit observers (autocomplete/reconciliation), - // then move selection in the same command chain. - editor - .chain() - .setContent(markdown, { emitUpdate: false }) - .focus("end") - .run(); + // then move selection to the end — including any trailing space that + // markdown parse would otherwise strip (see #4979). + setEditorMarkdownPreservingTrailingWhitespace(editor, markdown, { + emitUpdate: false, + focusEnd: true, + }); }, [editor], ); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index ae424b4e5c..315b8d92f0 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -260,6 +260,32 @@ test("persistent agents restore through the native inline mention UI", async ({ await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); }); +test("post-send restore keeps a trailing space so typing does not collapse the mention", async ({ + page, +}) => { + await seedAudience(page, [AGENT_A]); + await installAudienceFixtures(page); + await openThread(page); + + const composer = threadComposer(page); + const input = composer.getByTestId("message-input"); + await expect(input).toHaveText("@Morgarita "); + + await input.pressSequentially("hello"); + await expect(input).toHaveText("@Morgarita hello"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + + await composer.getByTestId("send-message").click(); + await expect(input).toHaveText("@Morgarita "); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + + // Regression for #4979: without the trailing space, the next keystroke + // extends `@Morgarita` into `@Morgaritah` and drops the mention chip. + await input.pressSequentially("again"); + await expect(input).toHaveText("@Morgarita again"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); +}); + for (const theme of ["buzz", "buzz-dark"]) { test(`captures native persistent mentions in ${theme}`, async ({ page }) => { await seedAudience(page, [AGENT_A, AGENT_B], theme);