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
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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
});
106 changes: 105 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,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",
Expand Down Expand Up @@ -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.
Expand Down