From 914cd308746a2b5e43a0f93f76be46ecf5cbcb59 Mon Sep 17 00:00:00 2001 From: wpeterr Date: Sun, 26 Jul 2026 09:15:10 +0700 Subject: [PATCH] fix(desktop): keep the space after a mention through the markdown parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer refills itself with `@Name ` — the trailing space is deliberate, and both producers write it. But `setContent` runs its argument through `tiptap-markdown`, and markdown-it discards end-of-line whitespace, so `"@Name "` parses to `

@Name

` and the caret lands flush against the mention. That character is load-bearing. Mention chips are inline decorations over plain text, and both the decoration regex and the extraction regex require a boundary after the name. The next keystroke turns `@Name` into `@Nameabc`: the chip disappears, `extractMentionPubkeys` stops resolving it, and the message goes out with no `p` tag — the agent is silently dropped as a recipient. Hydration cannot repair it. `hydrate()` only inserts mentions that are absent, and this one is present, just unspaced. The broken state is then persisted into the draft, so it survives navigation. Five call sites reload composer content through this parse: post-send refill, draft restore on channel/thread switch, loading a message to edit, cancelling an edit, and restoring after a failed send. Two of them were reported independently. Rather than rewrite each to use the verbatim `replacePlainTextRange` path, repair once at the parse boundary — the whitespace survives serialization, so drafts on disk still carry it and it is recoverable there. In `setContentAndFocusEnd` the repair runs before `focus("end")` so the caret lands after the space. The repair dispatch carries `preventUpdate` so it stays invisible to the user-edit observers, matching `setContent`'s own `emitUpdate: false` intent; without it the insert reads as a keystroke and re-opens mention autocomplete on every channel switch. The existing guard could not see this: `toHaveText` normalizes whitespace, so an assertion written to protect the trailing space passed with or without it. Replaced with an exact `textContent` read, plus two e2e tests reproducing both reports and unit tests over the extraction rule. All three were confirmed to fail without the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: wpeterr --- .../lib/markdownTrailingWhitespace.test.mjs | 45 +++++++++++++ .../lib/markdownTrailingWhitespace.ts | 58 ++++++++++++++++ .../messages/lib/useRichTextEditor.ts | 15 +++-- .../e2e/persistent-agent-audience.spec.ts | 67 ++++++++++++++++++- 4 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs create mode 100644 desktop/src/features/messages/lib/markdownTrailingWhitespace.ts 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, }) => {