fix(composer): preserve caret when inserting mentions mid-message (#6531)

**Category:** fix
**User Impact:** Users can insert mentions earlier in a draft and
continue typing without the caret corrupting the rest of the message.

**Problem:** Caret correction ran after every document change, so typing
a mention before existing text repeatedly advanced across the mention
separator and interleaved spaces into the draft. **Solution:** Limit
correction to the autocomplete settlement it was designed for, with
transaction-level and browser-level regression coverage for known and
unregistered mentions.

<details>
<summary>File changes</summary>

**desktop/src/features/messages/lib/mentionHighlightExtension.ts**
Restricts trailing-space caret advancement to an armed autocomplete
settlement instead of every document change.

**desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs**
Exercises the real ProseMirror plugin state and verifies mid-draft
mention typing, unknown tokens, end-of-message typing, and
completed-mention separators.

**desktop/tests/e2e/mentions.spec.ts**
Reproduces the reported composer workflow in Chromium and covers the
same corruption path for an unregistered `@token`.

</details>

## Reproduction steps

1. Open a channel and enter `hello world` in the composer.
2. Move the caret between `hello` and ` world`.
3. Type ` @bo`, select `bob` from autocomplete, and continue typing
`abc`.
4. Confirm the composer reads `hello @bob abc world` with the caret
after `abc`.
5. Repeat with an unregistered token such as ` @zzq` and confirm the
existing text remains intact.

## Before / After

| Before | After |
| --- | --- |
| Typing after a mid-draft mention walks the caret through the existing
message. | Continued typing stays after the inserted mention. |
| ![Before: mention caret corrupts existing draft
text](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6531/mention-caret-before.gif)
| ![After: caret remains after the inserted
mention](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6531/mention-caret-after.gif)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-21 17:48:21 -07:00
committed by GitHub
parent f794216737
commit 074561233e
3 changed files with 130 additions and 15 deletions

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { getSchema } from "@tiptap/core";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import StarterKit from "@tiptap/starter-kit";
import {
@@ -10,6 +11,7 @@ import {
createMentionCaretSettlement,
findHighlightMatches,
insertPosForMentionTextInput,
MentionHighlightExtension,
mentionTextInputInsertPos,
positionAfterArrowLeftThroughMentionSpace,
selectionAfterMentionTrailingSpace,
@@ -213,7 +215,6 @@ test("shouldAdvanceMentionCaret restores a remap while this editor is settling",
from: 7,
next: 8,
settling: true,
docChanged: false,
}),
true,
);
@@ -225,21 +226,18 @@ test("shouldAdvanceMentionCaret does not steal ArrowLeft after settlement is can
from: 7,
next: 8,
settling: false,
docChanged: false,
}),
false,
);
});
test("shouldAdvanceMentionCaret still advances after a document change", () => {
test("shouldAdvanceMentionCaret does not advance on an unsettled document change", () => {
// Regression: `docChanged` used to force an advance. That walked the caret
// across a mention's trailing space on every keystroke, so typing `@name`
// before existing text interleaved spaces into the draft.
assert.equal(
shouldAdvanceMentionCaret({
from: 7,
next: 8,
settling: false,
docChanged: true,
}),
true,
shouldAdvanceMentionCaret({ from: 7, next: 8, settling: false }),
false,
);
});
@@ -312,3 +310,71 @@ test("assignMentionHighlightNames updates when a new mention is added", () => {
);
assert.deepEqual(storage.names, ["bob", "quinn"]);
});
// ── caret regression: typing a mention before existing text ───────────
//
// Drives the real plugin through EditorState so `appendTransaction` runs.
// Before the fix, every keystroke advanced the caret across a mention's
// trailing space, interleaving spaces into the rest of the draft.
function editorStateWithMentionHighlight(initialText, names) {
// The plugin factory only reads `this.storage`, so a minimal stand-in is
// enough to exercise it without constructing a full editor (needs a DOM).
const plugins = MentionHighlightExtension.config.addProseMirrorPlugins.call({
storage: { names, agentNames: [], channelNames: [] },
});
return EditorState.create({
doc: document(paragraph(text(initialText))),
schema,
plugins,
});
}
/** Insert `typed` one character at a time at `startPos`, as typing does. */
function typeAt(state, startPos, typed) {
let next = state.apply(
state.tr.setSelection(TextSelection.create(state.doc, startPos)),
);
for (const char of typed) {
const { from, to } = next.selection;
const tr = next.tr.insertText(char, from, to);
tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(to, 1)));
next = next.apply(tr);
}
return next;
}
test("typing a mention before existing text does not interleave spaces", () => {
// Caret placed just after "hello" in "hello world", then " @qu" is typed.
const state = editorStateWithMentionHighlight("hello world", ["quinn"]);
const typed = typeAt(state, 1 + "hello".length, " @qu");
assert.equal(typed.doc.textContent, "hello @qu world");
});
test("typing a mention before existing text keeps the caret with the text", () => {
const state = editorStateWithMentionHighlight("hello world", ["quinn"]);
const typed = typeAt(state, 1 + "hello".length, " @qu");
assert.equal(typed.selection.from, 1 + "hello @qu".length);
});
test("an unknown @token before existing text is not rewritten either", () => {
// The trailing-space scan is purely textual, so it fired even for names
// that were never registered as mentions.
const state = editorStateWithMentionHighlight("hello world", []);
const typed = typeAt(state, 1 + "hello".length, " @zz");
assert.equal(typed.doc.textContent, "hello @zz world");
});
test("typing a mention at the end of a message still works", () => {
const state = editorStateWithMentionHighlight("hello world", ["quinn"]);
const typed = typeAt(state, 1 + "hello world".length, " @qu");
assert.equal(typed.doc.textContent, "hello world @qu");
});
test("typing after a completed mention keeps the separator intact", () => {
// "@quinn " already exists; typing at the very end must not consume or
// duplicate the trailing space.
const state = editorStateWithMentionHighlight("@quinn world", ["quinn"]);
const typed = typeAt(state, 1 + "@quinn world".length, "!");
assert.equal(typed.doc.textContent, "@quinn world!");
});

View File

@@ -40,19 +40,22 @@ export function createMentionCaretSettlement(): MentionCaretSettlement {
* Whether to move an empty caret from `from` to `next` after a mention
* trailing space. Settlement is per editor: autocomplete arms it, and
* ArrowLeft/click cancel it so we do not steal an intentional caret.
*
* Only an armed settlement may advance the caret. Advancing on any
* document change instead walked the caret across the separator on every
* keystroke, so typing `@name` before existing text interleaved spaces
* into the draft (`hello @q uworld`).
*/
export function shouldAdvanceMentionCaret({
from,
next,
settling,
docChanged,
}: {
from: number;
next: number;
settling: boolean;
docChanged: boolean;
}): boolean {
return next !== from && (settling || docChanged);
return next !== from && settling;
}
/**
@@ -334,7 +337,7 @@ export const MentionHighlightExtension = Extension.create({
return oldDecorations.map(tr.mapping, tr.doc);
},
},
appendTransaction(transactions, _oldState, newState) {
appendTransaction(_transactions, _oldState, newState) {
if (!newState.selection.empty) {
settlement.cancel();
return null;
@@ -346,7 +349,6 @@ export const MentionHighlightExtension = Extension.create({
from,
next,
settling: settlement.peek() !== null,
docChanged: transactions.some((tr) => tr.docChanged),
})
) {
return null;

View File

@@ -885,6 +885,53 @@ test("clicking a person mention chip edge is not treated as after the trailing s
expect(text).not.toMatch(/@bob x/);
});
test("typing a mention before existing text does not interleave spaces", async ({
page,
}) => {
// Regression (the reported repro): with a draft already written, place the
// caret earlier in the message, type a partial mention, pick a suggestion,
// then keep typing. Caret correction used to fire on every document change
// and walk the caret across the mention's trailing space, so each keystroke
// pushed a space further into the rest of the draft.
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("hello world");
// Click between "hello" and " world", then open autocomplete there.
await input.focus();
for (let i = 0; i < " world".length; i++) {
await page.keyboard.press("ArrowLeft");
}
await page.keyboard.type(" @bo");
await autocomplete(page).getByText("bob").click();
await page.keyboard.type("abc");
await expect(input).toHaveText("hello @bob abc world");
});
test("typing an unregistered @token before existing text is left alone", async ({
page,
}) => {
// The trailing-space scan is purely textual, so it also fired for tokens
// that were never registered as mentions.
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("hello world");
await input.focus();
for (let i = 0; i < " world".length; i++) {
await page.keyboard.press("ArrowLeft");
}
await page.keyboard.type(" @zzq");
await expect(input).toHaveText("hello @zzq world");
});
test("channel references keep caret movement through the channel name", async ({
page,
}) => {