Skip to content

fix(desktop): keep the space after a mention through the markdown parse - #2924

Open
peterw wants to merge 1 commit into
block:mainfrom
peterw:fix/composer-mention-trailing-space
Open

fix(desktop): keep the space after a mention through the markdown parse#2924
peterw wants to merge 1 commit into
block:mainfrom
peterw:fix/composer-mention-trailing-space

Conversation

@peterw

@peterw peterw commented Jul 26, 2026

Copy link
Copy Markdown

The problem

Send a message to an agent, start typing the next one, and the caret is already inside the mention chip. The next keystroke fuses into the mention — @Bumble + asdf becomes @Bumbleasdf — the blue chip vanishes, and the message goes out without the agent as a recipient. The agent never sees it. The broken chip is only the visible tell; the silent recipient drop is the actual cost.

Two independent reports, which turned out to be one bug reached from two directions:

  1. Immediately after sending in a channel.
  2. Opening a thread that already had an agent in the composer.

Root cause

The composer refills itself with "@Name " — that trailing space is deliberate, and both producers write it (useMentions.ts insertResolvedMention, usePersistentAgentMentionHydration.ts resolvePostSendContent).

The string then goes through richText.setContent(), and because tiptap-markdown is registered, setContent(string) is a markdown parse, not a literal insert. markdown-it discards end-of-line whitespace:

input:  "@Name "
output: "<p>@Name</p>"

So the composer really holds @Name with the caret flush against the final character.

That single character is load-bearing. Mention chips are ProseMirror inline decorations over plain text, not atomic nodes, and both the decoration regex and the extraction regex require a boundary after the name:

// mentionHighlightExtension.ts
`(?:^|(?<=[\\s(]))@(${names})(?=\\W|$)`
// mentionPattern.ts
const boundary = "(?=[\\s,;.!?:)\\]}]|$)";

Type a word character and both stop matching — decoration gone, extractMentionPubkeys returns nothing, no p tag on the outgoing event.

Hydration cannot repair it: hydrate() only inserts mentions that are absent (usePersistentAgentMentionHydration.ts:67-71), and the mention is present — just unspaced. The broken state is then persisted back into the draft, so it survives navigation.

Why it looked timing-dependent

It isn't. It is deterministic, but only on the paths that re-parse. hydrate() inserts through replacePlainTextRangetr.insertText — verbatim, space intact — which is why a freshly opened channel behaves correctly and the bug reads as a race.

Why the caret renders mid-word

The caret is at the correct model position (end of document). That position is now inside the chip span, and the chip reserves left padding for its absolutely-positioned agent icon (markdown.css). Measuring the reported screenshot: chip left edge 34px + width of Bumble 50.5px = caret at 84.5px — exactly one padding-left short of the glyph run's real end. With the space present the caret sits outside the chip and none of it shows.

Approaches considered

Rewrite the post-send call site to use replacePlainTextRange (the verbatim path). Correct for that one site, but there are five sites with the same defect — post-send refill, draft restore, loading a message to edit, cancelling an edit, restoring after a failed send — and it leaves two mechanisms that have to keep agreeing about whitespace.

Repair at the parse boundary (this PR). One place, all five sites, and it composes with however the composer is reloaded next.

The repair is only possible because the whitespace survives serialization — drafts on disk keep it. Verified against the real serializer/parser pair rather than assumed:

defaultMarkdownSerializer.serialize(<p>@Fizz␣</p>) → "@Fizz "   ← space survives the write
defaultMarkdownParser.parse("@Fizz ").textContent → "@Fizz"    ← space dies on the read

The solution

restoreMarkdownTrailingWhitespace(editor, markdown) re-appends the run of spaces/tabs the parse dropped, called after the parse in both setContent and setContentAndFocusEnd.

In setContentAndFocusEnd the repair runs before focus("end"), so the caret lands after the space rather than in front of it. That required unrolling the previous single .chain(); the emitUpdate: false intent is preserved.

Edge cases:

  • Whitespace-only input returns null. There is nothing for the whitespace to trail, and restoring it would leave a composer that reads as non-empty — Send enabled, placeholder suppressed — on what should be an empty draft.
  • Input ending on a newline returns null. The caret lands on the empty final line; nothing needs restoring.
  • Idempotence guard. If the parser ever starts preserving trailing whitespace, the existing run is detected and the insert is skipped rather than doubled.
  • preventUpdate meta. The repair dispatch 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. This is the line most worth a reviewer's eye.

Tests

The existing guard could not see this bug:

await expect(input).toHaveText("@Morgarita ", { timeout: 500 });

toHaveText normalizes whitespace, so "@Morgarita" and "@Morgarita " both satisfy it — an assertion written specifically to protect the trailing space, blind to its absence. Replaced with an exact textContent read via a shared expectExactComposerText helper.

Added:

  • markdownTrailingWhitespace.test.mjs — 8 unit tests over the extraction rule.
  • typing straight after a send extends the message, not the mention — reproduces report 1 by actually typing after the refill.
  • a mention survives the draft round-trip when a thread is reopened — reproduces report 2 by leaving and re-entering the thread, exercising the persist → localStorage → parse path.

Every new assertion was confirmed to fail without the fix and pass with it — the tests were run against the unfixed build first, and all three (the two new ones plus the tightened existing one) failed there. A test that cannot fail proves nothing, which is exactly how this bug shipped.

desktop unit          3523 passed, 0 failed
typecheck             clean
biome check + guards  clean
playwright smoke      713 passed, 1 failed, 2 flaky, 1 skipped

The one smoke failure is video-attachment.spec.ts:223 (poster frames). It is pre-existing and unrelated — verified by reverting this change and re-running that spec alone, where it fails identically.

Broader context

Mention chips being decorations over raw text rather than atomic nodes means any caret adjacent to a mention is one keystroke away from silently dropping a recipient, regardless of how the text arrived. This PR closes the five known ways to land there. A handleTextInput guard — insert a space when a word character is typed at the end of a mention decoration — would close the class rather than the instances, but that changes typing behaviour and belongs in its own change.

Worth noting what the failure mode costs: there is no error, no warning, and the message still sends. The only signal is an agent that stays quiet.

Reported by @peterw in the accent-app channel.

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 `<p>@name</p>` 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 <mycodeisbad@gmail.com>
@peterw
peterw requested a review from a team as a code owner July 26, 2026 02:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 914cd30874

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +55 to +56
editor.view.dispatch(
editor.state.tr.insertText(trailing, end).setMeta("preventUpdate", true),

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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant