Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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,
});
});
171 changes: 170 additions & 1 deletion desktop/src/features/messages/lib/mentionHighlightExtension.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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",
Expand Down Expand Up @@ -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.
Expand Down