fix(desktop): preserve spacing after multi-word mentions (#7128)

🤖

## Summary

In Buzz Desktop, choosing a multi-word name and immediately continuing a
sentence could swallow the space after the mention: `Hey @Alice
Chenhello`. This keeps the separator, so the same action produces `Hey
@Alice Chen hello` without moving the caret or repairing the name by
hand.

The editor recognizes the complete selected label, including its
internal spaces, and settles the autocomplete caret after the trailing
separator. Deliberately moving left or clicking inside the label still
lets you edit there; this is not a rule that forces every caret to the
end of a mention.

### Related issue

Independent base: `main`. Child:
[#7133](https://github.com/block/buzz/pull/7133), whose disambiguated
labels also contain spaces. Extracted from
[#7114](https://github.com/block/buzz/pull/7114), retained as historical
source (`98fe33ec`).

[Behavior
contract](4fe451d9c2/docs/mention-editor.md).
Originating [Buzz
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848)
· channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`.

### Testing

Select an existing member named Alice Chen, then type `hello`
immediately. Repeat after ArrowLeft or clicking inside the mention:
typing should follow your chosen caret position.

Mock-browser captures, not live remote-agent evidence:

#### Immediate typing preserves the separator
Choosing the complete label then typing produces `Hey @Alice Chen
hello`.


![separator](https://raw.githubusercontent.com/block/buzz/7258fe2d9f276b93f4d304ac2ac47c450f104157/pr-7128--separator.png)

#### Deliberate caret movement is respected
After ArrowLeft, typing edits at the chosen caret rather than forcing
the caret back beyond the separator.


![intentional-caret](https://raw.githubusercontent.com/block/buzz/7258fe2d9f276b93f4d304ac2ac47c450f104157/pr-7128--intentional-caret.png)

[Original screenshot
publication](https://github.com/block/buzz/pull/7128#issuecomment-5482715543);
immutable image URLs and captions retained here.

#### Evidence and limitations

**5,801 desktop tests**, **45 focused editor tests**, both new browser
regressions, the browser-test build and static/type/size checks passed.
[Applicable CI
passed](https://github.com/block/buzz/actions/runs/33421534320).

The broader browser run had **132 passes / 6 failures**: two
clipboard-origin setup failures and four generic caret-formatting
failures also reproduced on unchanged main. Full local `just ci` stopped
at three native timing/probe failures; a same-head native rerun passed
**3,005 tests** with 18 existing ignores. This is not a full local-CI
pass. The change fixes insertion and caret behavior, not duplicate-name
recipient selection, discovery or invitation.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-02 21:51:45 -04:00
committed by GitHub
parent c328202cb0
commit 44d19f59a5
6 changed files with 199 additions and 11 deletions

View File

@@ -733,3 +733,10 @@ usage.
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds
- [README.md](README.md) — project overview and quick start
### Mention editor contract
Autocomplete inserts a literal full label and a separator, including multi-word
names. Only autocomplete settlement may move the caret past that separator;
internal label spaces and deliberate ArrowLeft/click movement must be respected.
See `docs/mention-editor.md` and `desktop/tests/e2e/mention-spacing.spec.ts`.

View File

@@ -77,6 +77,7 @@ export default defineConfig({
"**/composer-selection-formatting.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/mention-spacing.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",

View File

@@ -17,6 +17,7 @@ import {
positionAfterArrowLeftThroughMentionSpace,
selectionAfterMentionTrailingSpace,
shouldAdvanceMentionCaret,
settleAutocompleteMentionInsert,
} from "./mentionHighlightExtension.ts";
// ── buildHighlightPatterns ────────────────────────────────────────────
@@ -505,3 +506,60 @@ test("a whitespace-run rewrite after a mention pick keeps the draft space", () =
assert.equal(typed.doc.textContent, "hello @bob a world", shape.name);
}
});
// Multi-word display names must use the same settlement as single-word names.
// Simulate the browser remapping the DOM caret to the chip edge before typing.
test("multi-word autocomplete keeps its separator after a chip-edge remap", () => {
const storage = { names: ["Remote Scout"], agentNames: [], channelNames: [] };
const plugins = MentionHighlightExtension.config.addProseMirrorPlugins.call({
storage,
});
const state = EditorState.create({
doc: document(paragraph(text("@Remote"))),
schema,
plugins,
});
const tr = state.tr.insertText("@Remote Scout ", 1, 8);
tr.setSelection(TextSelection.create(tr.doc, 15));
settleAutocompleteMentionInsert(
{ storage: { mentionHighlight: storage } },
tr,
"@Remote Scout ",
);
const picked = state.apply(tr);
const spacePos = 1 + "@Remote Scout".length;
const typed = textInput(picked, spacePos, spacePos, "hello");
assert.equal(typed.doc.textContent, "@Remote Scout hello");
});
test("full-name boundaries preserve internal spaces and intentional caret moves", () => {
const names = ["Remote Scout", "Scout (ed12)"];
const doc = document(paragraph(text("@Remote Scout world")));
const edge = 1 + "@Remote Scout".length;
assert.equal(
selectionAfterMentionTrailingSpace(doc, 1 + "@Remote".length, names),
1 + "@Remote".length,
);
assert.equal(selectionAfterMentionTrailingSpace(doc, edge, names), edge + 1);
assert.equal(
positionAfterArrowLeftThroughMentionSpace(doc, edge + 1, names),
edge,
);
assert.equal(
mentionTextInputInsertion(doc, edge, edge, "x", false, names),
null,
);
assert.deepEqual(
insertionForMentionTextInput(doc, edge, edge + 2, "\u00a0x", names),
{ insertAt: edge + 1, text: "x" },
);
const disambiguated = document(paragraph(text("@Scout (ed12) ")));
assert.equal(
selectionAfterMentionTrailingSpace(
disambiguated,
1 + "@Scout (ed12)".length,
names,
),
1 + "@Scout (ed12) ".length,
);
});

View File

@@ -77,10 +77,14 @@ const OUTER_SPACES = /^[ \u00A0]+|[ \u00A0]+$/g;
function mentionTrailingSpaceBoundary(
doc: ProseMirrorNode,
pos: number,
names: readonly string[] = [],
): number | null {
const afterSpace = selectionAfterMentionTrailingSpace(doc, pos);
const afterSpace = selectionAfterMentionTrailingSpace(doc, pos, names);
if (afterSpace !== pos) return afterSpace;
if (pos > 0 && selectionAfterMentionTrailingSpace(doc, pos - 1) === pos) {
if (
pos > 0 &&
selectionAfterMentionTrailingSpace(doc, pos - 1, names) === pos
) {
return pos;
}
return null;
@@ -114,12 +118,13 @@ export function insertionForMentionTextInput(
from: number,
to: number,
text: string,
names: readonly string[] = [],
): MentionTextInsertion | null {
if (from === to) {
const next = selectionAfterMentionTrailingSpace(doc, from);
const next = selectionAfterMentionTrailingSpace(doc, from, names);
return next === from ? null : { insertAt: next, text };
}
const boundary = mentionTrailingSpaceBoundary(doc, from);
const boundary = mentionTrailingSpaceBoundary(doc, from, names);
if (boundary === null) return null;
if (!SPACE_RUN.test(doc.textBetween(from, to, "\n", "\0"))) return null;
return { insertAt: boundary, text: text.replace(OUTER_SPACES, "") };
@@ -136,19 +141,21 @@ export function mentionTextInputInsertion(
to: number,
text: string,
settling: boolean,
names: readonly string[] = [],
): MentionTextInsertion | null {
if (!settling) return null;
return insertionForMentionTextInput(doc, from, to, text);
return insertionForMentionTextInput(doc, from, to, text, names);
}
/** Caret just after a mention trailing space: ArrowLeft lands on the token end. */
export function positionAfterArrowLeftThroughMentionSpace(
doc: ProseMirrorNode,
from: number,
names: readonly string[] = [],
): number | null {
if (from <= 0) return null;
const chipEnd = from - 1;
if (selectionAfterMentionTrailingSpace(doc, chipEnd) === from) {
if (selectionAfterMentionTrailingSpace(doc, chipEnd, names) === from) {
return chipEnd;
}
return null;
@@ -252,7 +259,8 @@ export function settleAutocompleteMentionInsert(
settleCaret = true,
): void {
const storage = mentionHighlightStorage(editor);
const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text);
// Autocomplete provides a literal label, including spaces and disambiguators.
const mentionInsert = /(?:^|[\s(])([@#])([^@#\r\n]+) $/.exec(text);
if (!mentionInsert) return;
const prefix = mentionInsert[1];
const label = mentionInsert[2];
@@ -319,6 +327,11 @@ export const MentionHighlightExtension = Extension.create({
addProseMirrorPlugins() {
const extension = this;
const settlement = createMentionCaretSettlement();
const knownNames = () => [
...extension.storage.names,
...extension.storage.agentNames,
...extension.storage.channelNames,
];
return [
new Plugin({
@@ -339,7 +352,11 @@ export const MentionHighlightExtension = Extension.create({
(tr.docChanged || settlement.peek() !== null)
) {
settlement.arm(
selectionAfterMentionTrailingSpace(tr.doc, tr.selection.from),
selectionAfterMentionTrailingSpace(
tr.doc,
tr.selection.from,
knownNames(),
),
);
}
@@ -391,7 +408,11 @@ export const MentionHighlightExtension = Extension.create({
return null;
}
const from = newState.selection.from;
const next = selectionAfterMentionTrailingSpace(newState.doc, from);
const next = selectionAfterMentionTrailingSpace(
newState.doc,
from,
knownNames(),
);
if (
!shouldAdvanceMentionCaret({
from,
@@ -418,6 +439,7 @@ export const MentionHighlightExtension = Extension.create({
const next = selectionAfterMentionTrailingSpace(
view.state.doc,
from,
knownNames(),
);
if (next !== from) {
applying = true;
@@ -454,6 +476,7 @@ export const MentionHighlightExtension = Extension.create({
to,
text,
settlement.peek() !== null,
knownNames(),
);
if (insertion == null) {
settlement.cancel();
@@ -488,6 +511,7 @@ export const MentionHighlightExtension = Extension.create({
const chipEnd = positionAfterArrowLeftThroughMentionSpace(
view.state.doc,
view.state.selection.from,
knownNames(),
);
if (chipEnd == null) return false;
view.dispatch(
@@ -508,6 +532,7 @@ export const MentionHighlightExtension = Extension.create({
const chipEnd = positionAfterArrowLeftThroughMentionSpace(
view.state.doc,
pos,
knownNames(),
);
if (chipEnd == null) return false;
view.dispatch(
@@ -577,13 +602,58 @@ export function buildHighlightPatterns(
export function selectionAfterMentionTrailingSpace(
doc: ProseMirrorNode,
pos: number,
names: readonly string[] = [],
): number {
if (pos < 0 || pos >= doc.content.size) return pos;
const nextChar = doc.textBetween(pos, pos + 1, "\n", "\0");
if (nextChar !== " ") return pos;
const lookbehind = Math.min(pos, 80);
// Use registered full labels as well as the legacy single-token fallback.
// A multi-word name's internal spaces are not trailing separators.
const lookbehind = Math.min(
pos,
names.reduce((max, name) => Math.max(max, name.length + 2), 80),
);
const before = doc.textBetween(pos - lookbehind, pos, "\n", "\0");
if (!/(?:^|[\s(])[@#][^\s]+$/.test(before)) return pos;
const lower = before.toLowerCase();
// Don't treat the space inside a registered multi-word label as the end of
// a single-word token, even when the browser maps the caret there.
const internalSpace = names.some((name) => {
const token = name.toLowerCase();
for (
let space = token.indexOf(" ");
space >= 0;
space = token.indexOf(" ", space + 1)
) {
const start = before.length - space - 1;
if (start < 0 || (start > 0 && !/[\s(]/.test(before[start - 1])))
continue;
if (before[start] !== "@" && before[start] !== "#") continue;
if (lower.slice(start + 1) !== token.slice(0, space)) continue;
if (
doc
.textBetween(
pos,
Math.min(doc.content.size, pos + name.length - space),
"\n",
"\0",
)
.toLowerCase() === token.slice(space)
)
return true;
}
return false;
});
if (internalSpace) return pos;
const knownBoundary = names.some((name) => {
const start = before.length - name.length - 1;
return (
start >= 0 &&
(start === 0 || /[\s(]/.test(before[start - 1])) &&
(before[start] === "@" || before[start] === "#") &&
lower.slice(start + 1) === name.toLowerCase()
);
});
if (!knownBoundary && !/(?:^|[\s(])[@#][^\s]+$/.test(before)) return pos;
return pos + 1;
}

View File

@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
// An ordinary existing channel member: no discovery, invitation or runtime needed.
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
searchProfiles: [
{ pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "Alice Chen" },
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
for (const deliberateMove of [false, true]) {
test(`multi-word mention ${deliberateMove ? "respects intentional ArrowLeft" : "preserves separator when typing immediately"}`, async ({
page,
}) => {
const input = page.getByTestId("message-input");
await input.fill("Hey @Ali");
await page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete")
.getByText("Alice Chen", { exact: true })
.click();
if (deliberateMove) await page.keyboard.press("ArrowLeft");
await page.keyboard.type("hello");
expect((await input.innerText()).trimEnd()).toBe(
deliberateMove ? "Hey @Alice Chenhello" : "Hey @Alice Chen hello",
);
await waitForAnimations(page);
await page.getByTestId("message-composer").screenshot({
path: `test-results/mention-spacing/${deliberateMove ? "intentional-caret" : "separator"}.png`,
});
});
}

14
docs/mention-editor.md Normal file
View File

@@ -0,0 +1,14 @@
# Mention editor contract
Autocomplete inserts a full literal label followed by a separator. Full labels
may contain spaces; internal spaces must not be mistaken for the final boundary.
The immediate next typed character goes after the separator even if the browser
remaps the DOM caret to the highlight edge. Explicit ArrowLeft or click cancels
that settlement so users can intentionally edit a mention. Plain typed tokens
retain their existing behavior; this does not change recipient resolution.
Regression coverage: `mentionHighlightExtension.test.mjs` simulates chip-edge and
whitespace-run rewrites, internal spaces, and deliberate motion. The ordinary
member browser cases in `mention-spacing.spec.ts` test immediate typing and
ArrowLeft without requiring remote discovery or invitation; `mentions.spec.ts`
also covers clicking chip edges and insertion before existing text.