From 790f27cf6cb533d929e602437dd2d608029ea706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:01:44 +0200 Subject: [PATCH 1/3] fix(desktop): guard typing after agent mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> Signed-off-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> --- .../lib/mentionBoundaryBeforeInput.test.mjs | 144 ++++++++++++++++++ .../lib/mentionBoundaryBeforeInput.ts | 64 ++++++++ .../messages/lib/useRichTextEditor.ts | 12 ++ 3 files changed, 220 insertions(+) create mode 100644 desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs create mode 100644 desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs new file mode 100644 index 0000000000..7f8ded273b --- /dev/null +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Schema } from "@tiptap/pm/model"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; + +import { handleMentionBoundaryBeforeInput } from "./mentionBoundaryBeforeInput.ts"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*" }, + text: { group: "inline" }, + }, +}); + +function createView(text, options = {}) { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, text ? [schema.text(text)] : []), + ]); + const from = options.from ?? 1 + text.length; + const to = options.to ?? from; + let state = EditorState.create({ + doc, + selection: TextSelection.create(doc, from, to), + }); + let dispatchCount = 0; + + return { + get state() { + return state; + }, + composing: options.composing ?? false, + dispatch(transaction) { + dispatchCount += 1; + state = state.apply(transaction); + }, + get dispatchCount() { + return dispatchCount; + }, + }; +} + +function createBeforeInput(overrides = {}) { + let prevented = false; + return { + inputType: "insertText", + data: "t", + isComposing: false, + preventDefault() { + prevented = true; + }, + get prevented() { + return prevented; + }, + ...overrides, + }; +} + +test("inserts the first character after a highlighted agent mention through ProseMirror", () => { + const view = createView("@Reinhold "); + const event = createBeforeInput(); + + assert.equal( + handleMentionBoundaryBeforeInput(view, event, ["Reinhold"]), + true, + ); + assert.equal(event.prevented, true); + assert.equal(view.dispatchCount, 1); + assert.equal(view.state.doc.textContent, "@Reinhold t"); + assert.equal( + view.state.doc.textContent.codePointAt("@Reinhold".length), + 0x20, + ); + assert.equal(view.state.selection.from, 1 + "@Reinhold t".length); +}); + +test("leaves ordinary typing outside an agent-mention boundary to the browser", () => { + const view = createView("ordinary "); + const event = createBeforeInput(); + + assert.equal( + handleMentionBoundaryBeforeInput(view, event, ["Reinhold"]), + false, + ); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("requires a collapsed selection after a U+0020 separator", () => { + const selectedView = createView("@Reinhold ", { from: 1, to: 2 }); + const selectedEvent = createBeforeInput(); + assert.equal( + handleMentionBoundaryBeforeInput(selectedView, selectedEvent, ["Reinhold"]), + false, + ); + + const noSpaceView = createView("@Reinhold"); + const noSpaceEvent = createBeforeInput(); + assert.equal( + handleMentionBoundaryBeforeInput(noSpaceView, noSpaceEvent, ["Reinhold"]), + false, + ); +}); + +test("does not intercept an unhighlighted mention", () => { + const view = createView("@Reinhold "); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event, []), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("does not intercept paste or replacement input", () => { + for (const inputType of ["insertFromPaste", "insertReplacementText"]) { + const view = createView("@Reinhold "); + const event = createBeforeInput({ inputType }); + assert.equal( + handleMentionBoundaryBeforeInput(view, event, ["Reinhold"]), + false, + ); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); + } +}); + +test("does not intercept composition or IME input", () => { + const composingEventView = createView("@Reinhold "); + const composingEvent = createBeforeInput({ isComposing: true }); + assert.equal( + handleMentionBoundaryBeforeInput(composingEventView, composingEvent, [ + "Reinhold", + ]), + false, + ); + + const composingView = createView("@Reinhold ", { composing: true }); + const commitEvent = createBeforeInput(); + assert.equal( + handleMentionBoundaryBeforeInput(composingView, commitEvent, ["Reinhold"]), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts new file mode 100644 index 0000000000..8b7e49264b --- /dev/null +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts @@ -0,0 +1,64 @@ +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { + buildHighlightPatterns, + findHighlightMatches, +} from "./mentionHighlightExtension"; + +/** + * Bypass native contenteditable mutation for the first ordinary character + * typed after the separator following a highlighted agent mention. + * + * Packaged WebKit can move that character across the decorated mention + * boundary and replace the separator. Dispatching the verified insertion as + * a ProseMirror transaction keeps the document and DOM mutation in agreement. + * Every other beforeinput shape falls through to ProseMirror's normal path. + */ +export function handleMentionBoundaryBeforeInput( + view: EditorView, + event: InputEvent, + agentMentionNames: readonly string[], +): boolean { + const insertedText = event.data; + if ( + event.inputType !== "insertText" || + event.isComposing || + view.composing || + !insertedText || + Array.from(insertedText).length !== 1 || + /[\r\n]/.test(insertedText) + ) { + return false; + } + + const { selection } = view.state; + if (!selection.empty || !selection.$from.parent.inlineContent) return false; + + const { $from, from } = selection; + if ($from.parentOffset < 2) return false; + + const textBeforeCaret = $from.parent.textBetween( + 0, + $from.parentOffset, + "\n", + "\n", + ); + if (!textBeforeCaret.endsWith(" ")) return false; + + const textBeforeSeparator = textBeforeCaret.slice(0, -1); + const patterns = buildHighlightPatterns([...agentMentionNames], []); + const immediatelyAfterAgentMention = findHighlightMatches( + textBeforeSeparator, + patterns, + ).some((match) => match.to === textBeforeSeparator.length); + if (!immediatelyAfterAgentMention) return false; + + event.preventDefault(); + const transaction = view.state.tr.insertText(insertedText, from, from); + transaction.setSelection( + TextSelection.create(transaction.doc, from + insertedText.length), + ); + view.dispatch(transaction.scrollIntoView()); + return true; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index c761081cc3..e542de66ac 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -30,6 +30,7 @@ import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; +import { handleMentionBoundaryBeforeInput } from "./mentionBoundaryBeforeInput"; import { CodeBlockAfterHardBreak, handleCodeFenceEnter, @@ -228,6 +229,9 @@ export function useRichTextEditor({ const placeholderRef = React.useRef(placeholder); placeholderRef.current = placeholder; + const agentMentionNamesRef = React.useRef(agentMentionNames); + agentMentionNamesRef.current = agentMentionNames; + // Custom-emoji atom node wiring (config + src re-resolve). Kept in a sibling // hook so this file stays focused on generic editor setup. const customEmojiWiring = useComposerCustomEmoji(customEmoji); @@ -494,6 +498,14 @@ export function useRichTextEditor({ "data-testid": "message-input", spellcheck: "true", }, + handleDOMEvents: { + beforeinput: (view, event) => + handleMentionBoundaryBeforeInput( + view, + event as InputEvent, + agentMentionNamesRef.current ?? [], + ), + }, // ArrowUp in an empty composer → edit your last message (Slack // parity). Handled here in ProseMirror's own DOM `keydown` hook — // NOT via `addKeyboardShortcuts` (the keymap plugin) and NOT via a From 61096595e95f4e6e853a6ca6d0093e5397ec7bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:36:39 +0200 Subject: [PATCH 2/3] fix(desktop): require decorated mention boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> Signed-off-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> --- .../lib/mentionBoundaryBeforeInput.test.mjs | 185 ++++++++++++++---- .../lib/mentionBoundaryBeforeInput.ts | 17 +- .../messages/lib/mentionHighlightExtension.ts | 84 +++++++- ...useRichTextEditor.mentionBoundary.test.mjs | 142 ++++++++++++++ .../messages/lib/useRichTextEditor.ts | 9 +- 5 files changed, 365 insertions(+), 72 deletions(-) create mode 100644 desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs index 7f8ded273b..c7df7e6bbe 100644 --- a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs @@ -1,39 +1,101 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { after, afterEach, before, test } from "node:test"; -import { Schema } from "@tiptap/pm/model"; -import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { JSDOM } from "jsdom"; import { handleMentionBoundaryBeforeInput } from "./mentionBoundaryBeforeInput.ts"; +import { + MentionHighlightExtension, + mentionHighlightKey, +} from "./mentionHighlightExtension.ts"; + +const dom = new JSDOM("
", { + pretendToBeVisual: true, + url: "http://localhost", +}); +const editors = new Set(); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + DocumentFragment: dom.window.DocumentFragment, + DOMParser: dom.window.DOMParser, + Element: dom.window.Element, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + getSelection: dom.window.getSelection.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + window: dom.window, + }); +}); -const schema = new Schema({ - nodes: { - doc: { content: "block+" }, - paragraph: { group: "block", content: "inline*" }, - text: { group: "inline" }, - }, +afterEach(() => { + for (const editor of editors) editor.destroy(); + editors.clear(); }); +after(() => dom.window.close()); + +function paragraphContent(text) { + return text ? [{ type: "text", text }] : []; +} + function createView(text, options = {}) { - const doc = schema.node("doc", null, [ - schema.node("paragraph", null, text ? [schema.text(text)] : []), - ]); - const from = options.from ?? 1 + text.length; - const to = options.to ?? from; - let state = EditorState.create({ - doc, - selection: TextSelection.create(doc, from, to), + const editor = new Editor({ + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: options.content ?? paragraphContent(text), + }, + ], + }, + element: document.createElement("div"), + extensions: [ + StarterKit.configure({ + heading: false, + link: false, + trailingNode: false, + }), + MentionHighlightExtension, + ], }); + editors.add(editor); + + const storage = editor.storage.mentionHighlight; + storage.names = []; + storage.agentNames = options.decorated ? ["Reinhold"] : []; + storage.channelNames = []; + editor.view.dispatch(editor.state.tr.setMeta(mentionHighlightKey, true)); + + const doc = editor.state.doc; + const textLength = doc.textContent.length; + const from = options.from ?? 1 + textLength; + const to = options.to ?? from; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(doc, from, to)), + ); + let dispatchCount = 0; + const originalDispatch = editor.view.dispatch.bind(editor.view); + editor.view.dispatch = (transaction) => { + dispatchCount += 1; + originalDispatch(transaction); + }; return { get state() { - return state; + return editor.state; }, composing: options.composing ?? false, dispatch(transaction) { - dispatchCount += 1; - state = state.apply(transaction); + editor.view.dispatch(transaction); }, get dispatchCount() { return dispatchCount; @@ -58,13 +120,10 @@ function createBeforeInput(overrides = {}) { } test("inserts the first character after a highlighted agent mention through ProseMirror", () => { - const view = createView("@Reinhold "); + const view = createView("@Reinhold ", { decorated: true }); const event = createBeforeInput(); - assert.equal( - handleMentionBoundaryBeforeInput(view, event, ["Reinhold"]), - true, - ); + assert.equal(handleMentionBoundaryBeforeInput(view, event), true); assert.equal(event.prevented, true); assert.equal(view.dispatchCount, 1); assert.equal(view.state.doc.textContent, "@Reinhold t"); @@ -79,10 +138,7 @@ test("leaves ordinary typing outside an agent-mention boundary to the browser", const view = createView("ordinary "); const event = createBeforeInput(); - assert.equal( - handleMentionBoundaryBeforeInput(view, event, ["Reinhold"]), - false, - ); + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); assert.equal(event.prevented, false); assert.equal(view.dispatchCount, 0); }); @@ -91,14 +147,14 @@ test("requires a collapsed selection after a U+0020 separator", () => { const selectedView = createView("@Reinhold ", { from: 1, to: 2 }); const selectedEvent = createBeforeInput(); assert.equal( - handleMentionBoundaryBeforeInput(selectedView, selectedEvent, ["Reinhold"]), + handleMentionBoundaryBeforeInput(selectedView, selectedEvent), false, ); const noSpaceView = createView("@Reinhold"); const noSpaceEvent = createBeforeInput(); assert.equal( - handleMentionBoundaryBeforeInput(noSpaceView, noSpaceEvent, ["Reinhold"]), + handleMentionBoundaryBeforeInput(noSpaceView, noSpaceEvent), false, ); }); @@ -107,38 +163,81 @@ test("does not intercept an unhighlighted mention", () => { const view = createView("@Reinhold "); const event = createBeforeInput(); - assert.equal(handleMentionBoundaryBeforeInput(view, event, []), false); + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("does not infer an agent mention from marked text without a decoration", () => { + const view = createView("@Reinhold ", { + content: [ + { type: "text", marks: [{ type: "bold" }], text: "@Reinhold" }, + { type: "text", text: " " }, + ], + }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); + assert.equal(event.prevented, false); + assert.equal(view.dispatchCount, 0); +}); + +test("accepts marked text when the exact mention range is decorated", () => { + const view = createView("@Reinhold ", { + decorated: true, + content: [ + { type: "text", marks: [{ type: "bold" }], text: "@Reinhold" }, + { type: "text", text: " " }, + ], + }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), true); + assert.equal(event.prevented, true); + assert.equal(view.dispatchCount, 1); + assert.equal(view.state.doc.textContent, "@Reinhold t"); +}); + +test("does not infer an agent mention across split text nodes", () => { + const view = createView("@Reinhold ", { + decorated: true, + content: [ + { type: "text", marks: [{ type: "bold" }], text: "@Rein" }, + { type: "text", text: "hold " }, + ], + }); + const event = createBeforeInput(); + + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); assert.equal(event.prevented, false); assert.equal(view.dispatchCount, 0); }); test("does not intercept paste or replacement input", () => { for (const inputType of ["insertFromPaste", "insertReplacementText"]) { - const view = createView("@Reinhold "); + const view = createView("@Reinhold ", { decorated: true }); const event = createBeforeInput({ inputType }); - assert.equal( - handleMentionBoundaryBeforeInput(view, event, ["Reinhold"]), - false, - ); + assert.equal(handleMentionBoundaryBeforeInput(view, event), false); assert.equal(event.prevented, false); assert.equal(view.dispatchCount, 0); } }); test("does not intercept composition or IME input", () => { - const composingEventView = createView("@Reinhold "); + const composingEventView = createView("@Reinhold ", { decorated: true }); const composingEvent = createBeforeInput({ isComposing: true }); assert.equal( - handleMentionBoundaryBeforeInput(composingEventView, composingEvent, [ - "Reinhold", - ]), + handleMentionBoundaryBeforeInput(composingEventView, composingEvent), false, ); - const composingView = createView("@Reinhold ", { composing: true }); + const composingView = createView("@Reinhold ", { + composing: true, + decorated: true, + }); const commitEvent = createBeforeInput(); assert.equal( - handleMentionBoundaryBeforeInput(composingView, commitEvent, ["Reinhold"]), + handleMentionBoundaryBeforeInput(composingView, commitEvent), false, ); }); diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts index 8b7e49264b..182fb63220 100644 --- a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.ts @@ -1,10 +1,7 @@ import { TextSelection } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; -import { - buildHighlightPatterns, - findHighlightMatches, -} from "./mentionHighlightExtension"; +import { findAgentMentionDecorationEndingAt } from "./mentionHighlightExtension"; /** * Bypass native contenteditable mutation for the first ordinary character @@ -18,7 +15,6 @@ import { export function handleMentionBoundaryBeforeInput( view: EditorView, event: InputEvent, - agentMentionNames: readonly string[], ): boolean { const insertedText = event.data; if ( @@ -46,13 +42,10 @@ export function handleMentionBoundaryBeforeInput( ); if (!textBeforeCaret.endsWith(" ")) return false; - const textBeforeSeparator = textBeforeCaret.slice(0, -1); - const patterns = buildHighlightPatterns([...agentMentionNames], []); - const immediatelyAfterAgentMention = findHighlightMatches( - textBeforeSeparator, - patterns, - ).some((match) => match.to === textBeforeSeparator.length); - if (!immediatelyAfterAgentMention) return false; + const separatorPosition = from - 1; + if (!findAgentMentionDecorationEndingAt(view.state, separatorPosition)) { + return false; + } event.preventDefault(); const transaction = view.state.tr.insertText(insertedText, from, from); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 1fad414084..c514f7713d 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -1,9 +1,59 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; export const mentionHighlightKey = new PluginKey("mentionHighlight"); +type AgentMentionDecorationSpec = { + mentionKind: "agent"; + segment: "prefix" | "label"; +}; + +/** + * Return the authoritative decorated agent-mention range whose final + * character is immediately before `to`, or null when no exact range exists. + * + * Agent mention rendering uses two adjacent decorations so WebKit can hide + * the `@` prefix independently from the visible label. Both segments must be + * present at their exact mapped positions; plain matching text is never + * sufficient. + */ +export function findAgentMentionDecorationEndingAt( + state: EditorState, + to: number, +): { from: number; to: number } | null { + const decorations = mentionHighlightKey.getState(state); + if (!decorations || to <= 1) return null; + + const agentSegments = decorations.find( + 0, + state.doc.content.size, + (spec: AgentMentionDecorationSpec) => spec.mentionKind === "agent", + ); + const label = agentSegments.find( + (decoration: Decoration) => + decoration.to === to && decoration.spec.segment === "label", + ); + if (!label || label.from <= 1) return null; + + const prefixFrom = label.from - 1; + const hasExactPrefix = agentSegments.some( + (decoration: Decoration) => + decoration.from === prefixFrom && + decoration.to === label.from && + decoration.spec.segment === "prefix", + ); + if (!hasExactPrefix) return null; + + if (state.doc.textBetween(prefixFrom, label.from) !== "@") return null; + return { from: prefixFrom, to }; +} + /** * TipTap extension that applies inline `mention-chip` decorations * to `@Name` and `#channel-name` patterns in the document. @@ -305,16 +355,32 @@ function addMatchesForPatterns( const to = from + match[0].length; if (options?.hideMentionPrefix && match[0].startsWith("@")) { decorations.push( - Decoration.inline(from, from + 1, { - class: "agent-mention-at-hidden", - spellcheck: "false", - }), + Decoration.inline( + from, + from + 1, + { + class: "agent-mention-at-hidden", + spellcheck: "false", + }, + { + mentionKind: "agent", + segment: "prefix", + } satisfies AgentMentionDecorationSpec, + ), ); decorations.push( - Decoration.inline(from + 1, to, { - class: className, - spellcheck: "false", - }), + Decoration.inline( + from + 1, + to, + { + class: className, + spellcheck: "false", + }, + { + mentionKind: "agent", + segment: "label", + } satisfies AgentMentionDecorationSpec, + ), ); } else { decorations.push( diff --git a/desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs b/desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs new file mode 100644 index 0000000000..fb6cb35a48 --- /dev/null +++ b/desktop/src/features/messages/lib/useRichTextEditor.mentionBoundary.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { TextSelection } from "@tiptap/pm/state"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost", +}); + +let act; +let cleanup; +let renderHook; +let useRichTextEditor; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + DocumentFragment: dom.window.DocumentFragment, + DOMParser: dom.window.DOMParser, + Element: dom.window.Element, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + getSelection: dom.window.getSelection.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + InputEvent: dom.window.InputEvent, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + window: dom.window, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + ({ act, cleanup, renderHook } = await import("@testing-library/react")); + ({ useRichTextEditor } = await import("./useRichTextEditor.ts")); +}); + +afterEach(() => cleanup?.()); +after(() => dom.window.close()); + +function createBeforeInput() { + let prevented = false; + return { + data: "t", + inputType: "insertText", + isComposing: false, + preventDefault() { + prevented = true; + }, + get prevented() { + return prevented; + }, + }; +} + +function setMentionContent(editor, name = "Reinhold") { + editor.commands.setContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: `@${name} ` }], + }, + ], + }); + const end = 1 + editor.state.doc.textContent.length; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, end)), + ); +} + +function invokeProductionBeforeInput(editor, event) { + const handler = editor.view.props.handleDOMEvents?.beforeinput; + assert.equal(typeof handler, "function"); + return handler(editor.view, event); +} + +test("the mounted production beforeinput callback dispatches only at the decorated boundary and uses current agent names", async () => { + const hook = renderHook( + ({ agentMentionNames }) => useRichTextEditor({ agentMentionNames }), + { initialProps: { agentMentionNames: ["Reinhold"] } }, + ); + + assert.ok(hook.result.current.editor); + const editor = hook.result.current.editor; + act(() => setMentionContent(editor)); + + let dispatchCount = 0; + const originalDispatch = editor.view.dispatch.bind(editor.view); + editor.view.dispatch = (transaction) => { + dispatchCount += 1; + originalDispatch(transaction); + }; + + const decoratedEvent = createBeforeInput(); + let handled; + act(() => { + handled = invokeProductionBeforeInput(editor, decoratedEvent); + }); + + assert.equal(handled, true); + assert.equal(decoratedEvent.prevented, true); + assert.equal(dispatchCount, 1); + assert.equal(editor.state.doc.textContent, "@Reinhold t"); + assert.equal( + editor.state.doc.textContent.codePointAt("@Reinhold".length), + 0x20, + ); + assert.equal(editor.state.selection.from, 1 + "@Reinhold t".length); + + act(() => { + hook.rerender({ agentMentionNames: ["Fizz"] }); + }); + act(() => setMentionContent(editor)); + dispatchCount = 0; + + const undecoratedEvent = createBeforeInput(); + act(() => { + handled = invokeProductionBeforeInput(editor, undecoratedEvent); + }); + + assert.equal(handled, false); + assert.equal(undecoratedEvent.prevented, false); + assert.equal(dispatchCount, 0); + assert.equal(editor.state.doc.textContent, "@Reinhold "); + + act(() => setMentionContent(editor, "Fizz")); + dispatchCount = 0; + const refreshedEvent = createBeforeInput(); + act(() => { + handled = invokeProductionBeforeInput(editor, refreshedEvent); + }); + + assert.equal(handled, true); + assert.equal(refreshedEvent.prevented, true); + assert.equal(dispatchCount, 1); + assert.equal(editor.state.doc.textContent, "@Fizz t"); +}); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index e542de66ac..1aded84c21 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -229,9 +229,6 @@ export function useRichTextEditor({ const placeholderRef = React.useRef(placeholder); placeholderRef.current = placeholder; - const agentMentionNamesRef = React.useRef(agentMentionNames); - agentMentionNamesRef.current = agentMentionNames; - // Custom-emoji atom node wiring (config + src re-resolve). Kept in a sibling // hook so this file stays focused on generic editor setup. const customEmojiWiring = useComposerCustomEmoji(customEmoji); @@ -500,11 +497,7 @@ export function useRichTextEditor({ }, handleDOMEvents: { beforeinput: (view, event) => - handleMentionBoundaryBeforeInput( - view, - event as InputEvent, - agentMentionNamesRef.current ?? [], - ), + handleMentionBoundaryBeforeInput(view, event as InputEvent), }, // ArrowUp in an empty composer → edit your last message (Slack // parity). Handled here in ProseMirror's own DOM `keydown` hook — From 51cafdb0d55168aaffafc016bdf0acc585d780d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:25:41 +0200 Subject: [PATCH 3/3] test(desktop): cover mention separator geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> Signed-off-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> --- desktop/playwright.config.ts | 1 + .../lib/mentionBoundaryBeforeInput.test.mjs | 27 ++++ .../tests/e2e/mention-caret-geometry.spec.ts | 152 ++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 desktop/tests/e2e/mention-caret-geometry.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..85b7f5fa0f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -65,6 +65,7 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/mention-caret-geometry.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs index c7df7e6bbe..0c68f062f2 100644 --- a/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs +++ b/desktop/src/features/messages/lib/mentionBoundaryBeforeInput.test.mjs @@ -90,6 +90,10 @@ function createView(text, options = {}) { }; return { + dom: editor.view.dom, + domAtPos(position) { + return editor.view.domAtPos(position); + }, get state() { return editor.state; }, @@ -134,6 +138,29 @@ test("inserts the first character after a highlighted agent mention through Pros assert.equal(view.state.selection.from, 1 + "@Reinhold t".length); }); +test("maps the caret after the separator outside the agent chip DOM", () => { + const view = createView("@Reinhold ", { decorated: true }); + const label = view.dom.querySelector(".agent-mention-highlight"); + assert.ok(label); + + const separator = label.nextSibling; + assert.equal(separator?.nodeType, Node.TEXT_NODE); + assert.equal(separator?.textContent, " "); + assert.equal(view.state.selection.from, 1 + "@Reinhold ".length); + + const mappedCaret = view.domAtPos(view.state.selection.from); + assert.ok( + (mappedCaret.node === separator && mappedCaret.offset === 1) || + (mappedCaret.node === separator?.parentNode && + mappedCaret.offset === + Array.prototype.indexOf.call( + separator.parentNode.childNodes, + separator, + ) + + 1), + ); +}); + test("leaves ordinary typing outside an agent-mention boundary to the browser", () => { const view = createView("ordinary "); const event = createBeforeInput(); diff --git a/desktop/tests/e2e/mention-caret-geometry.spec.ts b/desktop/tests/e2e/mention-caret-geometry.spec.ts new file mode 100644 index 0000000000..3aa951629c --- /dev/null +++ b/desktop/tests/e2e/mention-caret-geometry.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +type CaretGeometry = { + caretLeft: number; + caretMeasurement: "collapsed-range" | "marker"; + caretRight: number; + caretWidth: number; + chipRight: number; + selectionAfterSeparator: boolean; + selectionInsideChip: boolean; + spaceLeft: number; + spaceRight: number; + spaceWidth: number; + whiteSpace: string; +}; + +async function measureMentionCaret( + input: import("@playwright/test").Locator, +): Promise