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,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);
});
58 changes: 58 additions & 0 deletions desktop/src/features/messages/lib/markdownTrailingWhitespace.ts
Original file line number Diff line number Diff line change
@@ -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 `<p>@Name</p>`. 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),
Comment on lines +55 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Insert restored whitespace without inheriting the preceding mark

When a restored draft or edited message ends with formatted text followed by whitespace, such as **bold** or `command`, tr.insertText(trailing, end) inherits the active marks at that ProseMirror position. The restored space therefore becomes bold/code text, and after focus("end") subsequent typing can continue inside that mark even though the original Markdown placed the space outside it. Insert an explicitly unmarked text node (or otherwise clear the inherited marks) so this repair preserves the formatting boundary as well as the whitespace.

Useful? React with 👍 / 👎.

);
}
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 @@ -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";
Expand Down Expand Up @@ -681,6 +682,7 @@ export function useRichTextEditor({
(markdown: string) => {
if (!editor) return;
editor.commands.setContent(markdown);
restoreMarkdownTrailingWhitespace(editor, markdown);
},
[editor],
);
Expand All @@ -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],
);
Expand Down
67 changes: 66 additions & 1 deletion desktop/tests/e2e/persistent-agent-audience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page["getByTestId"]>,
expected: string,
) {
await expect
.poll(() => input.evaluate((element) => element.textContent))
.toBe(expected);
}

function channelComposer(page: Page) {
return page.getByTestId("channel-composer-overlay");
}
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
}) => {
Expand Down