diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 164225fd80..3c4c8cc827 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -1,11 +1,33 @@ import assert from "node:assert/strict"; import test from "node:test"; +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. +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 +186,140 @@ 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 +}); + +// ── 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 1fad414084..faab5fed36 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,14 @@ 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, + * 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", @@ -80,16 +94,171 @@ 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; + }, + // 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; + }, }, }), ]; }, }); +/** + * 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; +} + +/** + * 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. + * + * 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) { + 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.