Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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("<!doctype html><html><body></body></html>", {
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);
}
});
Original file line number Diff line number Diff line change
@@ -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");
}
}
15 changes: 8 additions & 7 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
insertNewlineInCodeBlock,
} from "./codeBlockExtensions";
import { SpoilerMark } from "./spoilerMark";
import { setEditorMarkdownPreservingTrailingWhitespace } from "./setEditorMarkdownPreservingTrailingWhitespace";

function hardBreakLineBounds($from: ResolvedPos) {
const parentStart = $from.start();
Expand Down Expand Up @@ -714,7 +715,7 @@ export function useRichTextEditor({
const setContent = React.useCallback(
(markdown: string) => {
if (!editor) return;
editor.commands.setContent(markdown);
setEditorMarkdownPreservingTrailingWhitespace(editor, markdown);
},
[editor],
);
Expand All @@ -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],
);
Expand Down
26 changes: 26 additions & 0 deletions desktop/tests/e2e/persistent-agent-audience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down