authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-18 13:03:37+02:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-18 13:05:45+02:00
log2318e00270203780b72efbbcffce92e907312027
tree4eef7fd30115699eca8986bdc4672784a1243376
parent4cfec8c128084a0abde67853d10e996f5b2aac05

fix(web): avoid duplicate mention text on paste

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 186 insertions(+), 88 deletions(-)

apps/web/src/components/ComposerPromptEditor.test.ts created+73
......@@ -0,0 +1,73 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 $createParagraphNode,
4 $createTextNode,
5 $getRoot,
6 $getSelection,
7 $isRangeSelection,
8 COMMAND_PRIORITY_EDITOR,
9 createEditor,
10 PASTE_COMMAND,
11} from "lexical";
12
13import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
14
15class TestClipboardEvent extends Event {
16 readonly clipboardData: DataTransfer;
17
18 constructor(text: string) {
19 super("paste", { cancelable: true });
20 this.clipboardData = {
21 files: [],
22 getData: (type: string) => (type === "text/plain" ? text : ""),
23 } as unknown as DataTransfer;
24 }
25}
26
27describe("registerComposerInlineTokenPaste", () => {
28 afterEach(() => {
29 vi.unstubAllGlobals();
30 });
31
32 it("handles a copied mention without also running the plain-text paste fallback", () => {
33 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
34 const editor = createEditor();
35 const mention = "[improve-deploy-error-logging.md](.changeset/improve-deploy-error-logging.md)";
36 const plainTextFallback = vi.fn(() => {
37 const selection = $getSelection();
38 if (!$isRangeSelection(selection)) return false;
39 selection.insertText(mention);
40 return true;
41 });
42
43 editor.update(
44 () => {
45 const paragraph = $createParagraphNode();
46 $getRoot().append(paragraph);
47 paragraph.selectEnd();
48 },
49 { discrete: true },
50 );
51 registerComposerInlineTokenPaste(editor, {
52 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
53 getExpandedAbsoluteOffsetForPoint: () => 0,
54 });
55 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
56
57 const event = new TestClipboardEvent(mention);
58 let handled = false;
59 editor.update(
60 () => {
61 handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
62 },
63 { discrete: true },
64 );
65
66 expect(handled).toBe(true);
67 expect(plainTextFallback).not.toHaveBeenCalled();
68 expect(event.defaultPrevented).toBe(true);
69 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
70 "<mention:.changeset/improve-deploy-error-logging.md> ",
71 );
72 });
73});
apps/web/src/components/ComposerPromptEditor.tsx+9-88
......@@ -27,7 +27,6 @@ import {
2727 KEY_TAB_COMMAND,
2828 COMMAND_PRIORITY_HIGH,
2929 KEY_BACKSPACE_COMMAND,
30 PASTE_COMMAND,
3130 $getRoot,
3231 HISTORY_MERGE_TAG,
3332 DecoratorNode,
......@@ -56,7 +55,6 @@ import {
5655 expandCollapsedComposerCursor,
5756 isCollapsedCursorAdjacentToInlineToken,
5857} from "~/composer-logic";
59import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
6058import {
6159 selectionTouchesMentionBoundary,
6260 splitPromptIntoComposerSegments,
......@@ -77,6 +75,7 @@ import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip
7775import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
7876import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
7977import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
78import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
8079
8180const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
8281const SURROUND_SYMBOLS: [string, string][] = [
......@@ -1122,92 +1121,14 @@ function ComposerInlineTokenBackspacePlugin() {
11221121function ComposerInlineTokenPastePlugin() {
11231122 const [editor] = useLexicalComposerContext();
11241123
1125 useEffect(() => {
1126 return editor.registerCommand(
1127 PASTE_COMMAND,
1128 (event) => {
1129 if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
1130 return false;
1131 }
1132 if (event.clipboardData.files.length > 0) {
1133 return false;
1134 }
1135 const text = event.clipboardData.getData("text/plain");
1136 if (text.length === 0) {
1137 return false;
1138 }
1139 // Token grammar requires trailing whitespace; a virtual newline lets a
1140 // mention at the very end of the pasted text still parse.
1141 const mentions = collectComposerInlineTokens(`${text}\n`).filter(
1142 (token) => token.type === "mention" && token.end <= text.length,
1143 );
1144 if (mentions.length === 0) {
1145 return false;
1146 }
1147 let inserted = false;
1148 editor.update(() => {
1149 const selection = $getSelection();
1150 if (!$isRangeSelection(selection)) {
1151 return;
1152 }
1153 const nodes: LexicalNode[] = [];
1154 const appendText = (value: string) => {
1155 const lines = value.split("\n");
1156 for (let index = 0; index < lines.length; index += 1) {
1157 const line = lines[index] ?? "";
1158 if (line.length > 0) {
1159 nodes.push($createTextNode(line));
1160 }
1161 if (index < lines.length - 1) {
1162 nodes.push($createLineBreakNode());
1163 }
1164 }
1165 };
1166 const firstMention = mentions[0];
1167 if (firstMention && firstMention.start === 0) {
1168 const startPoint = selection.isBackward() ? selection.focus : selection.anchor;
1169 const insertionOffset = getExpandedAbsoluteOffsetForPoint(
1170 startPoint.getNode(),
1171 startPoint.offset,
1172 );
1173 const precedingChar = $getRoot()
1174 .getTextContent()
1175 .slice(insertionOffset - 1, insertionOffset);
1176 if (precedingChar.length > 0 && !/\s/.test(precedingChar)) {
1177 nodes.push($createTextNode(" "));
1178 }
1179 }
1180 let cursor = 0;
1181 for (const mention of mentions) {
1182 if (mention.start < cursor) {
1183 continue;
1184 }
1185 if (mention.start > cursor) {
1186 appendText(text.slice(cursor, mention.start));
1187 }
1188 nodes.push($createComposerMentionNode(mention.value));
1189 cursor = mention.end;
1190 }
1191 if (cursor < text.length) {
1192 appendText(text.slice(cursor));
1193 } else {
1194 // Keep the serialized prompt valid: mention tokens need trailing
1195 // whitespace, so a paste ending in a mention gets the same
1196 // trailing space the autocomplete inserts.
1197 nodes.push($createTextNode(" "));
1198 }
1199 selection.insertNodes(nodes);
1200 inserted = true;
1201 });
1202 if (!inserted) {
1203 return false;
1204 }
1205 event.preventDefault();
1206 return true;
1207 },
1208 COMMAND_PRIORITY_HIGH,
1209 );
1210 }, [editor]);
1124 useEffect(
1125 () =>
1126 registerComposerInlineTokenPaste(editor, {
1127 createMentionNode: $createComposerMentionNode,
1128 getExpandedAbsoluteOffsetForPoint,
1129 }),
1130 [editor],
1131 );
12111132
12121133 return null;
12131134}
apps/web/src/components/composerInlineTokenPaste.ts created+104
......@@ -0,0 +1,104 @@
1import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
2import {
3 $createLineBreakNode,
4 $createTextNode,
5 $getRoot,
6 $getSelection,
7 $isRangeSelection,
8 COMMAND_PRIORITY_HIGH,
9 PASTE_COMMAND,
10 type LexicalEditor,
11 type LexicalNode,
12} from "lexical";
13
14interface ComposerInlineTokenPasteOptions {
15 createMentionNode: (path: string) => LexicalNode;
16 getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number;
17}
18
19export function registerComposerInlineTokenPaste(
20 editor: LexicalEditor,
21 options: ComposerInlineTokenPasteOptions,
22): () => void {
23 return editor.registerCommand(
24 PASTE_COMMAND,
25 (event) => {
26 if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
27 return false;
28 }
29 if (event.clipboardData.files.length > 0) {
30 return false;
31 }
32 const text = event.clipboardData.getData("text/plain");
33 if (text.length === 0) {
34 return false;
35 }
36 // Token grammar requires trailing whitespace; a virtual newline lets a
37 // mention at the very end of the pasted text still parse.
38 const mentions = collectComposerInlineTokens(`${text}\n`).filter(
39 (token) => token.type === "mention" && token.end <= text.length,
40 );
41 if (mentions.length === 0) {
42 return false;
43 }
44
45 // Lexical command listeners already run inside an editor update. Starting
46 // a nested update here queues the mention insertion until after this
47 // listener returns, which lets the plain-text paste handler run as well.
48 const selection = $getSelection();
49 if (!$isRangeSelection(selection)) {
50 return false;
51 }
52 const nodes: LexicalNode[] = [];
53 const appendText = (value: string) => {
54 const lines = value.split("\n");
55 for (let index = 0; index < lines.length; index += 1) {
56 const line = lines[index] ?? "";
57 if (line.length > 0) {
58 nodes.push($createTextNode(line));
59 }
60 if (index < lines.length - 1) {
61 nodes.push($createLineBreakNode());
62 }
63 }
64 };
65 const firstMention = mentions[0];
66 if (firstMention && firstMention.start === 0) {
67 const startPoint = selection.isBackward() ? selection.focus : selection.anchor;
68 const insertionOffset = options.getExpandedAbsoluteOffsetForPoint(
69 startPoint.getNode(),
70 startPoint.offset,
71 );
72 const precedingChar = $getRoot()
73 .getTextContent()
74 .slice(insertionOffset - 1, insertionOffset);
75 if (precedingChar.length > 0 && !/\s/.test(precedingChar)) {
76 nodes.push($createTextNode(" "));
77 }
78 }
79 let cursor = 0;
80 for (const mention of mentions) {
81 if (mention.start < cursor) {
82 continue;
83 }
84 if (mention.start > cursor) {
85 appendText(text.slice(cursor, mention.start));
86 }
87 nodes.push(options.createMentionNode(mention.value));
88 cursor = mention.end;
89 }
90 if (cursor < text.length) {
91 appendText(text.slice(cursor));
92 } else {
93 // Keep the serialized prompt valid: mention tokens need trailing
94 // whitespace, so a paste ending in a mention gets the same
95 // trailing space the autocomplete inserts.
96 nodes.push($createTextNode(" "));
97 }
98 selection.insertNodes(nodes);
99 event.preventDefault();
100 return true;
101 },
102 COMMAND_PRIORITY_HIGH,
103 );
104}