From 44d19f59a593dfb541bf40dcadbed313ca4a8f97 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 2 Sep 2026 21:51:45 -0400 Subject: [PATCH] fix(desktop): preserve spacing after multi-word mentions (#7128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– ## 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](https://github.com/block/buzz/blob/4fe451d9c251af59c34a0a890d38499912f7e3da/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 Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- AGENTS.md | 7 ++ desktop/playwright.config.ts | 1 + .../lib/mentionHighlightExtension.test.mjs | 58 ++++++++++++ .../messages/lib/mentionHighlightExtension.ts | 92 ++++++++++++++++--- desktop/tests/e2e/mention-spacing.spec.ts | 38 ++++++++ docs/mention-editor.md | 14 +++ 6 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 desktop/tests/e2e/mention-spacing.spec.ts create mode 100644 docs/mention-editor.md diff --git a/AGENTS.md b/AGENTS.md index 7cbe27066e..9d1a6afd42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 14cab5c63b..8636102e16 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 106b036934..24da1152e1 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 2d75bcacc5..e498938771 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -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; } diff --git a/desktop/tests/e2e/mention-spacing.spec.ts b/desktop/tests/e2e/mention-spacing.spec.ts new file mode 100644 index 0000000000..4b61df5396 --- /dev/null +++ b/desktop/tests/e2e/mention-spacing.spec.ts @@ -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`, + }); + }); +} diff --git a/docs/mention-editor.md b/docs/mention-editor.md new file mode 100644 index 0000000000..4bd7d83432 --- /dev/null +++ b/docs/mention-editor.md @@ -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.