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 {...@@ -27,7 +27,6 @@ import {
27 KEY_TAB_COMMAND,27 KEY_TAB_COMMAND,
28 COMMAND_PRIORITY_HIGH,28 COMMAND_PRIORITY_HIGH,
29 KEY_BACKSPACE_COMMAND,29 KEY_BACKSPACE_COMMAND,
30 PASTE_COMMAND,
31 $getRoot,30 $getRoot,
32 HISTORY_MERGE_TAG,31 HISTORY_MERGE_TAG,
33 DecoratorNode,32 DecoratorNode,
...@@ -56,7 +55,6 @@ import {...@@ -56,7 +55,6 @@ import {
56 expandCollapsedComposerCursor,55 expandCollapsedComposerCursor,
57 isCollapsedCursorAdjacentToInlineToken,56 isCollapsedCursorAdjacentToInlineToken,
58} from "~/composer-logic";57} from "~/composer-logic";
59import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
60import {58import {
61 selectionTouchesMentionBoundary,59 selectionTouchesMentionBoundary,
62 splitPromptIntoComposerSegments,60 splitPromptIntoComposerSegments,
...@@ -77,6 +75,7 @@ import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip...@@ -77,6 +75,7 @@ import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip
77import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";75import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
78import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";76import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
79import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";77import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
78import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
8079
81const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;80const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
82const SURROUND_SYMBOLS: [string, string][] = [81const SURROUND_SYMBOLS: [string, string][] = [
...@@ -1122,92 +1121,14 @@ function ComposerInlineTokenBackspacePlugin() {...@@ -1122,92 +1121,14 @@ function ComposerInlineTokenBackspacePlugin() {
1122function ComposerInlineTokenPastePlugin() {1121function ComposerInlineTokenPastePlugin() {
1123 const [editor] = useLexicalComposerContext();1122 const [editor] = useLexicalComposerContext();
11241123
1125 useEffect(() => {1124 useEffect(
1126 return editor.registerCommand(1125 () =>
1127 PASTE_COMMAND,1126 registerComposerInlineTokenPaste(editor, {
1128 (event) => {1127 createMentionNode: $createComposerMentionNode,
1129 if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {1128 getExpandedAbsoluteOffsetForPoint,
1130 return false;1129 }),
1131 }1130 [editor],
1132 if (event.clipboardData.files.length > 0) {1131 );
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]);
12111132
1212 return null;1133 return null;
1213}1134}
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}