From 0b5119648d2f6eae09f3d56d41786632c9ed980f Mon Sep 17 00:00:00 2001 From: Marco Lee Date: Fri, 24 Jul 2026 06:00:09 -0600 Subject: [PATCH 1/2] fix(desktop): snap caret out of agent mention chips Agent mentions in the composer are decorations over plain text, not atom nodes, and the leading @ is width:0. With persistent auto-tag, clicks that look like the start of a chip often land inside the display name; typing then mutates the name and drops the decoration so the agent stops being tagged. Snap an empty caret out of the interior of agent mentions (after the trailing space hydration inserts), and rewrite text input that would land inside a chip so it inserts after the chip instead. Fixes #2707 Signed-off-by: Marco Lee --- .../lib/mentionHighlightExtension.test.mjs | 78 +++++++++++++ .../messages/lib/mentionHighlightExtension.ts | 106 +++++++++++++++++- 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 164225fd80..5255136be9 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -1,11 +1,31 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { getSchema } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; + import { buildHighlightPatterns, + findAgentMentionRanges, findHighlightMatches, + snapCaretOutOfAgentMention, } from "./mentionHighlightExtension.ts"; +// Schema mirrors the composer for document-absolute position tests. +const schema = getSchema([ + StarterKit.configure({ + hardBreak: { keepMarks: true }, + heading: false, + trailingNode: false, + link: false, + }), +]); +const para = (...c) => schema.nodes.paragraph.create(null, c); +const t = (s) => schema.text(s); +function doc(...content) { + return schema.nodes.doc.create(null, content); +} + // ── buildHighlightPatterns ──────────────────────────────────────────── test("returns empty array when no names or channels provided", () => { @@ -164,3 +184,61 @@ test("#general should NOT match inside #generally (trailing word boundary)", () const matches = findHighlightMatches("#generally", patterns); assert.equal(matches.length, 0); }); + +// ── Agent-mention caret snap (#2707) ────────────────────────────────── +// Persistent auto-tag keeps `@Agent ` at the head of the composer. Mentions +// are decorations (not atoms) and the `@` is width:0, so a click that looks +// like the start of the chip often lands *inside* the name. Typing there +// mutates the display name and drops the decoration. + +test("findAgentMentionRanges locates agent mentions in document coords", () => { + // PM: doc=0, para opens at 0, text starts at 1: "@Ada " is positions 1..5 + const d = doc(para(t("@Ada hello"))); + const ranges = findAgentMentionRanges(d, ["Ada"]); + assert.equal(ranges.length, 1); + assert.equal(ranges[0].from, 1); + assert.equal(ranges[0].to, 5); // "@Ada" +}); + +test("snapCaretOutOfAgentMention leaves edge positions alone", () => { + const d = doc(para(t("@Ada "))); + // Positions: 1=before @, 5=after a, 6=after trailing space + assert.equal(snapCaretOutOfAgentMention(d, 1, ["Ada"]), 1); + assert.equal(snapCaretOutOfAgentMention(d, 5, ["Ada"]), 5); + assert.equal(snapCaretOutOfAgentMention(d, 6, ["Ada"]), 6); +}); + +test("snapCaretOutOfAgentMention snaps interior caret past trailing space", () => { + // "@Ada " — interior positions 2..4 (after @, mid-name) must jump to 6 + // (after the trailing space hydration always inserts). + const d = doc(para(t("@Ada "))); + for (const pos of [2, 3, 4]) { + assert.equal( + snapCaretOutOfAgentMention(d, pos, ["Ada"]), + 6, + `interior pos ${pos} should snap after trailing space`, + ); + } +}); + +test("snapCaretOutOfAgentMention snaps to name end when no trailing space", () => { + const d = doc(para(t("@Ada"))); + assert.equal(snapCaretOutOfAgentMention(d, 3, ["Ada"]), 5); +}); + +test("snapCaretOutOfAgentMention ignores non-agent mention names", () => { + // Human-only name list: no snap (agents are the ones with hidden @ + auto-tag). + const d = doc(para(t("@Alice hello"))); + assert.equal(snapCaretOutOfAgentMention(d, 3, []), 3); +}); + +test("snapCaretOutOfAgentMention handles multiple agent chips", () => { + // "@Ada @Bob " — caret inside Bob should land after Bob's trailing space. + const d = doc(para(t("@Ada @Bob more"))); + const ranges = findAgentMentionRanges(d, ["Ada", "Bob"]); + assert.equal(ranges.length, 2); + const bob = ranges[1]; + const interior = bob.from + 2; + const snapped = snapCaretOutOfAgentMention(d, interior, ["Ada", "Bob"]); + assert.equal(snapped, bob.to + 1); // consume trailing space after @Bob +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 1fad414084..0dad8b89a1 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -1,5 +1,11 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { + Plugin, + PluginKey, + TextSelection, + type Transaction, +} from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; export const mentionHighlightKey = new PluginKey("mentionHighlight"); @@ -10,6 +16,13 @@ export const mentionHighlightKey = new PluginKey("mentionHighlight"); * * Accepts `names` (display names) and `channelNames` storage options. * On every doc update the plugin scans text nodes and decorates matches. + * + * Agent mentions are still plain text (decorations, not atom nodes), but the + * composer hides the leading `@` (width:0). Clicks that look like "start of + * the chip" often land *inside* the name; typing then mutates the display + * name and drops the decoration — the persistent auto-tag regression (#2707). + * This extension snaps an empty caret out of the interior of agent mentions + * and rewrites text input so it lands after the chip instead. */ export const MentionHighlightExtension = Extension.create({ name: "mentionHighlight", @@ -80,16 +93,107 @@ export const MentionHighlightExtension = Extension.create({ return oldDecorations.map(tr.mapping, tr.doc); }, }, + appendTransaction(_transactions, _oldState, newState) { + if (!newState.selection.empty) return null; + const agentNames = extension.storage.agentNames as string[]; + if (agentNames.length === 0) return null; + const pos = newState.selection.from; + const snapped = snapCaretOutOfAgentMention( + newState.doc, + pos, + agentNames, + ); + if (snapped === pos) return null; + return newState.tr.setSelection( + TextSelection.create(newState.doc, snapped), + ); + }, props: { decorations(state) { return this.getState(state) ?? DecorationSet.empty; }, + // Typing must not land inside an agent chip: once the name mutates + // the decoration dies and persistent auto-tag appears "broken". + handleTextInput(view, from, to, text) { + const agentNames = extension.storage.agentNames as string[]; + if (agentNames.length === 0) return false; + const snappedFrom = snapCaretOutOfAgentMention( + view.state.doc, + from, + agentNames, + ); + const snappedTo = + from === to + ? snappedFrom + : snapCaretOutOfAgentMention(view.state.doc, to, agentNames); + if (snappedFrom === from && snappedTo === to) return false; + // Empty caret (or a selection wholly interior to the chip): insert + // after the chip instead of corrupting the display name. + const insertAt = Math.max(snappedFrom, snappedTo); + const tr = view.state.tr.insertText(text, insertAt); + tr.setSelection( + TextSelection.create(tr.doc, insertAt + text.length), + ); + view.dispatch(tr); + return true; + }, }, }), ]; }, }); +/** + * Document-absolute ranges of agent `@Name` mentions. + * Exported for testing. + */ +export function findAgentMentionRanges( + doc: PMNode, + agentNames: string[], +): { from: number; to: number }[] { + if (agentNames.length === 0) return []; + const patterns = buildHighlightPatterns(agentNames, []); + if (patterns.length === 0) return []; + const ranges: { from: number; to: number }[] = []; + doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + for (const match of findHighlightMatches(node.text, patterns)) { + ranges.push({ from: pos + match.from, to: pos + match.to }); + } + }); + return ranges; +} + +/** + * If `pos` lies strictly inside an agent mention, snap it just after the + * mention, consuming a single trailing space when present (the space + * autocomplete / persistent hydration always inserts after `@Name`). + * + * Edge positions stay put: + * - `from` (before `@`) so Backspace still works as expected + * - `to` (right after the name) so the user can type after a chip without a + * trailing space yet + */ +export function snapCaretOutOfAgentMention( + doc: PMNode, + pos: number, + agentNames: string[], +): number { + if (agentNames.length === 0) return pos; + for (const { from, to } of findAgentMentionRanges(doc, agentNames)) { + if (pos > from && pos < to) { + let snapped = to; + // Prefer the trailing space that insertResolvedMention always adds. + if (snapped < doc.content.size) { + const next = doc.textBetween(snapped, snapped + 1, "\n", "\0"); + if (next === " ") snapped += 1; + } + return snapped; + } + } + return pos; +} + /** * Build highlight patterns for @Name and #channel-name matching. * Exported for testing — the patterns are the core logic of this extension. From f5ee5b41b737141c884a1ec96711f53d85f1a479 Mon Sep 17 00:00:00 2001 From: Marco Lee Date: Sat, 25 Jul 2026 00:43:52 -0600 Subject: [PATCH 2/2] fix(desktop): treat agent mention chips as atomic on Backspace/Delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backspace/Delete inside (or at the edges of) a persistent agent chip no longer nibbles the display name character-by-character — that killed the decoration and broke auto-tag the same way mid-chip typing did. - Interior caret: either key removes `@Name` + trailing space - Backspace at end of name or just after the trailing space: whole chip - Delete at the start of the chip (before `@`): whole chip Follow-up to the caret-snap work for #2707. Signed-off-by: Marco Lee --- .../lib/mentionHighlightExtension.test.mjs | 81 +++++++++++++++++ .../messages/lib/mentionHighlightExtension.ts | 87 ++++++++++++++++--- 2 files changed, 157 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 5255136be9..3c4c8cc827 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -5,10 +5,12 @@ import { getSchema } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { + agentMentionChipDeleteRange, buildHighlightPatterns, findAgentMentionRanges, findHighlightMatches, snapCaretOutOfAgentMention, + withTrailingSpace, } from "./mentionHighlightExtension.ts"; // Schema mirrors the composer for document-absolute position tests. @@ -242,3 +244,82 @@ test("snapCaretOutOfAgentMention handles multiple agent chips", () => { const snapped = snapCaretOutOfAgentMention(d, interior, ["Ada", "Bob"]); assert.equal(snapped, bob.to + 1); // consume trailing space after @Bob }); + +// ── Atomic Backspace/Delete on agent chips (#2707) ──────────────────── + +test("withTrailingSpace consumes a single trailing space", () => { + const d = doc(para(t("@Ada more"))); + // "@Ada" is 1..5; trailing space at 5 → 6 + assert.equal(withTrailingSpace(d, 5), 6); + assert.equal(withTrailingSpace(d, 6), 6); // already past space +}); + +test("agentMentionChipDeleteRange: interior Backspace/Delete remove whole chip", () => { + const d = doc(para(t("@Ada hello"))); + // "@Ada" 1..5, chip with space 1..6 + for (const key of /** @type {const} */ (["Backspace", "Delete"])) { + for (const pos of [2, 3, 4]) { + assert.deepEqual( + agentMentionChipDeleteRange(d, pos, ["Ada"], key), + { from: 1, to: 6 }, + `${key} at interior ${pos}`, + ); + } + } +}); + +test("agentMentionChipDeleteRange: Backspace at end of name or after space", () => { + const d = doc(para(t("@Ada hello"))); + // end of name (5) and after trailing space (6) + assert.deepEqual(agentMentionChipDeleteRange(d, 5, ["Ada"], "Backspace"), { + from: 1, + to: 6, + }); + assert.deepEqual(agentMentionChipDeleteRange(d, 6, ["Ada"], "Backspace"), { + from: 1, + to: 6, + }); + // Deep in following text: default editing + assert.equal(agentMentionChipDeleteRange(d, 10, ["Ada"], "Backspace"), null); +}); + +test("agentMentionChipDeleteRange: Delete at start of chip only", () => { + const d = doc(para(t("@Ada hello"))); + assert.deepEqual(agentMentionChipDeleteRange(d, 1, ["Ada"], "Delete"), { + from: 1, + to: 6, + }); + // Delete from after the chip leaves following text alone + assert.equal(agentMentionChipDeleteRange(d, 6, ["Ada"], "Delete"), null); +}); + +test("agentMentionChipDeleteRange: Backspace between two agent chips removes the left one", () => { + // "@Ada @Bob" — caret after Ada's trailing space is Bob's `@` + const d = doc(para(t("@Ada @Bob"))); + const ranges = findAgentMentionRanges(d, ["Ada", "Bob"]); + const ada = ranges[0]; + const bob = ranges[1]; + const afterAdaSpace = withTrailingSpace(d, ada.to); + assert.equal(afterAdaSpace, bob.from); + assert.deepEqual( + agentMentionChipDeleteRange(d, afterAdaSpace, ["Ada", "Bob"], "Backspace"), + { from: ada.from, to: afterAdaSpace }, + ); + // Delete at that same spot removes Bob (start of next chip) + assert.deepEqual( + agentMentionChipDeleteRange(d, afterAdaSpace, ["Ada", "Bob"], "Delete"), + { from: bob.from, to: withTrailingSpace(d, bob.to) }, + ); +}); + +test("agentMentionChipDeleteRange: chip without trailing space", () => { + const d = doc(para(t("@Ada"))); + assert.deepEqual(agentMentionChipDeleteRange(d, 3, ["Ada"], "Backspace"), { + from: 1, + to: 5, + }); + assert.deepEqual(agentMentionChipDeleteRange(d, 5, ["Ada"], "Backspace"), { + from: 1, + to: 5, + }); +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 0dad8b89a1..faab5fed36 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -21,8 +21,9 @@ export const mentionHighlightKey = new PluginKey("mentionHighlight"); * composer hides the leading `@` (width:0). Clicks that look like "start of * the chip" often land *inside* the name; typing then mutates the display * name and drops the decoration — the persistent auto-tag regression (#2707). - * This extension snaps an empty caret out of the interior of agent mentions - * and rewrites text input so it lands after the chip instead. + * This extension snaps an empty caret out of the interior of agent mentions, + * rewrites text input so it lands after the chip, and treats Backspace/Delete + * on the chip as an atomic remove (whole `@Name` + trailing space). */ export const MentionHighlightExtension = Extension.create({ name: "mentionHighlight", @@ -137,6 +138,27 @@ export const MentionHighlightExtension = Extension.create({ view.dispatch(tr); return true; }, + // Backspace/Delete must not nibble the display name char-by-char + // (that also kills the decoration). Treat the chip as atomic. + handleKeyDown(view, event) { + if (event.key !== "Backspace" && event.key !== "Delete") { + return false; + } + const agentNames = extension.storage.agentNames as string[]; + if (agentNames.length === 0) return false; + const { empty, from } = view.state.selection; + if (!empty) return false; + const range = agentMentionChipDeleteRange( + view.state.doc, + from, + agentNames, + event.key, + ); + if (!range) return false; + const tr = view.state.tr.delete(range.from, range.to); + view.dispatch(tr); + return true; + }, }, }), ]; @@ -164,10 +186,21 @@ export function findAgentMentionRanges( return ranges; } +/** + * Expand a mention end position by one trailing space when present (the + * space autocomplete / persistent hydration always inserts after `@Name`). + */ +export function withTrailingSpace(doc: PMNode, mentionTo: number): number { + if (mentionTo < doc.content.size) { + const next = doc.textBetween(mentionTo, mentionTo + 1, "\n", "\0"); + if (next === " ") return mentionTo + 1; + } + return mentionTo; +} + /** * If `pos` lies strictly inside an agent mention, snap it just after the - * mention, consuming a single trailing space when present (the space - * autocomplete / persistent hydration always inserts after `@Name`). + * mention, consuming a single trailing space when present. * * Edge positions stay put: * - `from` (before `@`) so Backspace still works as expected @@ -182,18 +215,50 @@ export function snapCaretOutOfAgentMention( if (agentNames.length === 0) return pos; for (const { from, to } of findAgentMentionRanges(doc, agentNames)) { if (pos > from && pos < to) { - let snapped = to; - // Prefer the trailing space that insertResolvedMention always adds. - if (snapped < doc.content.size) { - const next = doc.textBetween(snapped, snapped + 1, "\n", "\0"); - if (next === " ") snapped += 1; - } - return snapped; + return withTrailingSpace(doc, to); } } return pos; } +/** + * Range to delete when Backspace/Delete should treat an agent chip as atomic + * (`@Name` + optional trailing space). Returns null when the key should use + * default text editing. + * + * - Interior of the name → either key removes the whole chip + * - Backspace at the end of the name or just after its trailing space → chip + * - Delete at the start of the chip (before `@`) → chip + */ +export function agentMentionChipDeleteRange( + doc: PMNode, + pos: number, + agentNames: string[], + key: "Backspace" | "Delete", +): { from: number; to: number } | null { + if (agentNames.length === 0) return null; + for (const { from, to } of findAgentMentionRanges(doc, agentNames)) { + const chipTo = withTrailingSpace(doc, to); + + // Strictly inside the `@Name` span — never nibble individual letters. + if (pos > from && pos < to) { + return { from, to: chipTo }; + } + + if (key === "Backspace") { + // Caret at end of name, or right after the trailing space. + if (pos === to || pos === chipTo) { + return { from, to: chipTo }; + } + } + + if (key === "Delete" && pos === from) { + return { from, to: chipTo }; + } + } + return null; +} + /** * Build highlight patterns for @Name and #channel-name matching. * Exported for testing — the patterns are the core logic of this extension.