diff --git a/desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs b/desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs new file mode 100644 index 0000000000..f5c93b5820 --- /dev/null +++ b/desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { markdownTrailingWhitespace } from "./markdownTrailingWhitespace.ts"; + +// The markdown parse behind `setContent` drops end-of-line whitespace, which +// silently breaks mention chips (`@Name ` → `@Name`, next keystroke → +// `@Nameabc`, recipient dropped). This decides what gets put back. + +test("captures the single trailing space after a mention", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita "), " "); +}); + +test("captures the trailing space after multiple mentions", () => { + assert.equal(markdownTrailingWhitespace("@Vogue @Morgarita "), " "); +}); + +test("returns null when there is no trailing whitespace", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita"), null); + assert.equal(markdownTrailingWhitespace(""), null); +}); + +test("returns null for whitespace-only input", () => { + // Nothing to trail. Restoring here would leave a composer that reads as + // non-empty — Send enabled, placeholder suppressed — on an empty draft. + assert.equal(markdownTrailingWhitespace(" "), null); + assert.equal(markdownTrailingWhitespace(" \t"), null); +}); + +test("captures a mixed run of spaces and tabs", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita \t "), " \t "); +}); + +test("captures whitespace trailing the last line only", () => { + assert.equal(markdownTrailingWhitespace("first line\n@Morgarita "), " "); +}); + +test("returns null when the string ends on a newline", () => { + // The caret lands on the empty final line, so nothing needs restoring. + assert.equal(markdownTrailingWhitespace("@Morgarita \n"), null); +}); + +test("ignores interior whitespace", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita hello"), null); +}); diff --git a/desktop/src/features/messages/lib/markdownTrailingWhitespace.ts b/desktop/src/features/messages/lib/markdownTrailingWhitespace.ts new file mode 100644 index 0000000000..31d7318599 --- /dev/null +++ b/desktop/src/features/messages/lib/markdownTrailingWhitespace.ts @@ -0,0 +1,58 @@ +import type { Editor } from "@tiptap/core"; +import { Selection } from "@tiptap/pm/state"; + +/** + * The run of spaces/tabs at the very end of `markdown`, or `null` when there + * is none. + * + * A whitespace-only string yields `null` on purpose: there is no content for + * the whitespace to trail, and restoring it would leave a "blank" composer + * that reads as non-empty (enabling Send, suppressing the placeholder). + */ +export function markdownTrailingWhitespace(markdown: string): string | null { + return /[^ \t]([ \t]+)$/.exec(markdown)?.[1] ?? null; +} + +/** + * Re-append trailing spaces/tabs that a markdown parse dropped. + * + * `setContent` runs its argument through `tiptap-markdown`, and markdown-it + * discards end-of-line whitespace — `"@Name "` parses to `

@Name

`. That + * lone character is load-bearing: mention chips are inline decorations over + * plain text (see `mentionHighlightExtension`), matched only when a boundary + * follows the name. Restore the caret flush against `@Name` and the next + * keystroke produces `@Nameabc`, which stops matching — the chip disappears + * and `extractMentionPubkeys` no longer resolves the name, so the recipient is + * silently dropped from the outgoing event. + * + * The whitespace survives *serialization* (drafts on disk keep it), so it is + * recoverable here, at the single parse boundary, rather than at each of the + * call sites that reload composer content: post-send refill, draft restore on + * channel/thread switch, loading a message to edit, cancelling an edit, and + * restoring after a failed send. + * + * Uses the `preventUpdate` meta so the repair is invisible to the user-edit + * observers — the same mechanism `setContent`'s `emitUpdate: false` uses. + * Without it the insert looks like a keystroke and re-opens the mention + * autocomplete on every channel switch. + */ +export function restoreMarkdownTrailingWhitespace( + editor: Editor, + markdown: string, +): void { + const trailing = markdownTrailingWhitespace(markdown); + if (!trailing) return; + + const end = Selection.atEnd(editor.state.doc).from; + // Idempotence guard: if the parser ever starts preserving the whitespace, + // this must not double it. + const existing = editor.state.doc.textBetween( + Math.max(0, end - trailing.length), + end, + ); + if (existing === trailing) return; + + editor.view.dispatch( + editor.state.tr.insertText(trailing, end).setMeta("preventUpdate", true), + ); +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4fee2db46f..ac5846977b 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -26,6 +26,7 @@ import { mentionHighlightKey, } from "./mentionHighlightExtension"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; +import { restoreMarkdownTrailingWhitespace } from "./markdownTrailingWhitespace"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; @@ -681,6 +682,7 @@ export function useRichTextEditor({ (markdown: string) => { if (!editor) return; editor.commands.setContent(markdown); + restoreMarkdownTrailingWhitespace(editor, markdown); }, [editor], ); @@ -689,13 +691,12 @@ export function useRichTextEditor({ (markdown: string) => { 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(); + // restoration out of user-edit observers (autocomplete/reconciliation). + editor.commands.setContent(markdown, { emitUpdate: false }); + // Repair before focusing: the caret must land *after* any trailing + // whitespace the parse dropped, not flush against the last character. + restoreMarkdownTrailingWhitespace(editor, markdown); + editor.commands.focus("end"); }, [editor], ); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index ae424b4e5c..6cd0bebc1a 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -66,6 +66,25 @@ async function emitRootMessage( return event; } +/** + * Assert the composer's *exact* text, trailing whitespace included. + * + * `toHaveText` normalizes whitespace, so `"@Morgarita"` and `"@Morgarita "` + * both satisfy it — which is how a dropped trailing space shipped past an + * assertion written to guard that very space. Mention chips are decorations + * over plain text and only match when a boundary follows the name, so the + * space is the difference between the next keystroke starting a message and + * it corrupting the mention. + */ +async function expectExactComposerText( + input: ReturnType, + expected: string, +) { + await expect + .poll(() => input.evaluate((element) => element.textContent)) + .toBe(expected); +} + function channelComposer(page: Page) { return page.getByTestId("channel-composer-overlay"); } @@ -147,7 +166,7 @@ test("persistent agents transition atomically before Enter-send resolves", async // The network send is still pending, so this is the first observable // post-submit editor state rather than the later success hydration pass. - await expect(input).toHaveText("@Morgarita ", { timeout: 500 }); + await expectExactComposerText(input, "@Morgarita "); await expect(input.locator(".agent-mention-highlight")).toHaveCount(1, { timeout: 500, }); @@ -186,6 +205,52 @@ test("persistent agents transition atomically before Enter-send resolves", async .toEqual({ empty: true, atDocumentEnd: true }); }); +test("typing straight after a send extends the message, not 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 input.fill("@Morgarita hello"); + await input.press("Enter"); + + // The post-send refill goes through the markdown parse, which used to eat + // the trailing space and leave the caret flush against the mention. + await expectExactComposerText(input, "@Morgarita "); + await input.pressSequentially("next"); + + await expect(input).toHaveText("@Morgarita next"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); +}); + +test("a mention survives the draft round-trip when a thread is reopened", async ({ + page, +}) => { + await seedAudience(page, [AGENT_A]); + await installAudienceFixtures(page); + await openThread(page); + + const composer = threadComposer(page); + const input = composer.getByTestId("message-input"); + await expectExactComposerText(input, "@Morgarita "); + + // Leaving the thread persists the composer to the draft store; returning + // reloads it through the same markdown parse. Hydration cannot repair the + // result — it only inserts mentions that are *absent*, and this one is + // present, just unspaced. + await openGeneral(page); + await openThread(page); + + await expectExactComposerText(input, "@Morgarita "); + await input.pressSequentially("hi"); + + await expect(input).toHaveText("@Morgarita hi"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); +}); + test("timeline agent send remains one-shot and returns to the placeholder", async ({ page, }) => {