fix(desktop): keep the space after a mention through the markdown parse - #2924
fix(desktop): keep the space after a mention through the markdown parse#2924peterw wants to merge 1 commit into
Conversation
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>
There was a problem hiding this comment.
💡 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".
| editor.view.dispatch( | ||
| editor.state.tr.insertText(trailing, end).setMeta("preventUpdate", true), |
There was a problem hiding this comment.
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 👍 / 👎.
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+asdfbecomes@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:
Root cause
The composer refills itself with
"@Name "— that trailing space is deliberate, and both producers write it (useMentions.tsinsertResolvedMention,usePersistentAgentMentionHydration.tsresolvePostSendContent).The string then goes through
richText.setContent(), and becausetiptap-markdownis registered,setContent(string)is a markdown parse, not a literal insert. markdown-it discards end-of-line whitespace:So the composer really holds
@Namewith 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:
Type a word character and both stop matching — decoration gone,
extractMentionPubkeysreturns nothing, noptag 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 throughreplacePlainTextRange→tr.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 ofBumble50.5px = caret at 84.5px — exactly onepadding-leftshort 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:
The solution
restoreMarkdownTrailingWhitespace(editor, markdown)re-appends the run of spaces/tabs the parse dropped, called after the parse in bothsetContentandsetContentAndFocusEnd.In
setContentAndFocusEndthe repair runs beforefocus("end"), so the caret lands after the space rather than in front of it. That required unrolling the previous single.chain(); theemitUpdate: falseintent is preserved.Edge cases:
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.null. The caret lands on the empty final line; nothing needs restoring.preventUpdatemeta. The repair dispatch is invisible to the user-edit observers — the same mechanismsetContent'semitUpdate: falseuses. 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:
toHaveTextnormalizes whitespace, so"@Morgarita"and"@Morgarita "both satisfy it — an assertion written specifically to protect the trailing space, blind to its absence. Replaced with an exacttextContentread via a sharedexpectExactComposerTexthelper.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.
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
handleTextInputguard — 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-appchannel.