authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 05:37:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 05:37:45-07:00
log761d4bac1c238ea7af4dd36b56719ad5e30771c3
tree535d2f3df14b7468dc6d219bdf655a841dac7245
parentc1d27e593b278e7d997c05efdbcc043bae653e83
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve original mention text in the composer (#10100)


2 files changed, 197 insertions(+), 7 deletions(-)

apps/web/src/components/ComposerPromptEditor.serialization.test.tsx created+184
......@@ -0,0 +1,184 @@
1import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical";
3import { act, createRef } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { collapseExpandedComposerCursor } from "../composer-logic";
8import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
9
10vi.mock("./chat/FileTagChip", () => ({
11 FILE_TAG_CHIP_CLASS_NAME: "",
12 FileTagChipContent: () => null,
13}));
14vi.mock("./chat/ComposerPendingTerminalContexts", () => ({
15 ComposerPendingTerminalContextChip: () => null,
16}));
17vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null }));
18
19let lexicalEditor: LexicalEditor;
20// Keep the real composer, registered nodes, updates, and snapshot API. Only the
21// DOM view is omitted so Lexical runs headlessly in this component test.
22vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({
23 PlainTextPlugin: function HeadlessEditor() {
24 [lexicalEditor] = useLexicalComposerContext();
25 return null;
26 },
27}));
28
29let renderer: ReactTestRenderer | undefined;
30const editorRef = createRef<ComposerPromptEditorHandle>();
31
32function composer(value: string) {
33 return (
34 <ComposerPromptEditor
35 value={value}
36 cursor={collapseExpandedComposerCursor(value, value.length)}
37 terminalContexts={[]}
38 skills={[]}
39 disabled={false}
40 placeholder="Write a prompt"
41 onRemoveTerminalContext={() => {}}
42 onChange={() => {}}
43 onPaste={() => {}}
44 editorRef={editorRef}
45 />
46 );
47}
48
49async function renderPrompt(value: string) {
50 await act(() => {
51 if (renderer) renderer.update(composer(value));
52 else renderer = create(composer(value));
53 });
54}
55
56function $firstMention() {
57 const paragraph = $getRoot().getFirstChildOrThrow();
58 if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph");
59 const mention = paragraph.getFirstChildOrThrow();
60 if (mention.getType() !== "composer-mention") throw new Error("Expected a mention");
61 return mention;
62}
63
64class TestClipboardEvent extends Event {
65 readonly clipboardData: DataTransfer;
66
67 constructor(text: string) {
68 super("paste", { cancelable: true });
69 this.clipboardData = {
70 files: [],
71 getData: (type: string) => (type === "text/plain" ? text : ""),
72 } as unknown as DataTransfer;
73 }
74}
75
76beforeEach(() => {
77 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
78 vi.stubGlobal("document", { activeElement: null });
79});
80
81afterEach(async () => {
82 await act(() => renderer?.unmount());
83 renderer = undefined;
84 vi.unstubAllGlobals();
85});
86
87describe("composer mention serialization", () => {
88 it.each([
89 "@README.md control",
90 "@terminal-1:3 Explain this output\n\n<terminal_context>\n- Terminal 1 line 3:\n 3 | output\n</terminal_context>",
91 '@"docs/My \\"File\\".md" please',
92 '@"docs/雪 👋.md" please',
93 "[README.md](README.md) control",
94 "[config#draft?.json](config%23draft%3f.json) control",
95 "Plain text\n Keep indentation 👋",
96 ])("preserves the initial prompt %s", async (prompt) => {
97 await renderPrompt(prompt);
98 expect(editorRef.current?.readSnapshot().value).toBe(prompt);
99 });
100
101 it("preserves original source when replacing the controlled prompt", async () => {
102 for (const prompt of ["", "@README.md control", "Older plain control", "@README.md control"]) {
103 await renderPrompt(prompt);
104 expect(editorRef.current?.readSnapshot()).toMatchObject({
105 value: prompt,
106 expandedCursor: prompt.length,
107 });
108 if (prompt === "@README.md control") {
109 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
110 }
111 }
112 });
113
114 it("preserves source when Lexical clones the mention and reloads exported state", async () => {
115 const prompt = '@"docs/雪 👋.md" remains a chip';
116 await renderPrompt(prompt);
117 const originalKey = lexicalEditor.getEditorState().read(() => $firstMention().getKey());
118
119 await act(() => {
120 lexicalEditor.update(
121 () => {
122 const mention = $firstMention();
123 mention.replace($copyNode(mention));
124 },
125 { discrete: true },
126 );
127 });
128 expect(lexicalEditor.getEditorState().read(() => $firstMention().getKey())).not.toBe(
129 originalKey,
130 );
131 expect(editorRef.current?.readSnapshot().value).toBe(prompt);
132 const exportedState = lexicalEditor.getEditorState().toJSON();
133
134 await renderPrompt("");
135 await act(() => {
136 lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exportedState));
137 });
138 expect(editorRef.current?.readSnapshot().value).toBe(prompt);
139 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
140 });
141
142 it("keeps canonical serialization when importing legacy mention JSON without source", async () => {
143 await renderPrompt("");
144 await act(() => {
145 lexicalEditor.setEditorState(
146 lexicalEditor.parseEditorState(
147 JSON.stringify({
148 root: {
149 type: "root",
150 version: 1,
151 children: [
152 {
153 type: "paragraph",
154 version: 1,
155 children: [{ type: "composer-mention", version: 1, path: "README.md" }],
156 },
157 ],
158 },
159 }),
160 ),
161 );
162 });
163 expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md)");
164 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
165 });
166
167 it("still serializes a newly inserted mention canonically", async () => {
168 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
169 await renderPrompt("");
170 const event = new TestClipboardEvent("@README.md ");
171 await act(() => {
172 lexicalEditor.update(
173 () => {
174 $getRoot().selectEnd();
175 lexicalEditor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
176 },
177 { discrete: true },
178 );
179 });
180 expect(event.defaultPrevented).toBe(true);
181 expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md) ");
182 expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true);
183 });
184});
apps/web/src/components/ComposerPromptEditor.tsx+13-7
......@@ -117,6 +117,7 @@ const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null;
117117type SerializedComposerMentionNode = Spread<
118118 {
119119 path: string;
120 source?: string;
120121 type: "composer-mention";
121122 version: 1;
122123 },
......@@ -174,28 +175,33 @@ function ComposerMentionDecorator(props: { path: string }) {
174175
175176class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
176177 __path: string;
178 __source: string;
177179
178180 static override getType(): string {
179181 return "composer-mention";
180182 }
181183
182184 static override clone(node: ComposerMentionNode): ComposerMentionNode {
183 return new ComposerMentionNode(node.__path, node.__key);
185 return new ComposerMentionNode(node.__path, node.__source, node.__key);
184186 }
185187
186188 static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode {
187 return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode);
189 return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON(
190 serializedNode,
191 );
188192 }
189193
190 constructor(path: string, key?: NodeKey) {
194 constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) {
191195 super(key);
192196 this.__path = path;
197 this.__source = source;
193198 }
194199
195200 override exportJSON(): SerializedComposerMentionNode {
196201 return {
197202 ...super.exportJSON(),
198203 path: this.__path,
204 source: this.__source,
199205 type: "composer-mention",
200206 version: 1,
201207 };
......@@ -212,7 +218,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
212218 }
213219
214220 override getTextContent(): string {
215 return serializeComposerFileLink(this.__path);
221 return this.__source;
216222 }
217223
218224 override isInline(): true {
......@@ -224,8 +230,8 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
224230 }
225231}
226232
227function $createComposerMentionNode(path: string): ComposerMentionNode {
228 return $applyNodeReplacement(new ComposerMentionNode(path));
233function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode {
234 return $applyNodeReplacement(new ComposerMentionNode(path, source));
229235}
230236
231237function resolveSkillDescription(
......@@ -851,7 +857,7 @@ function $setComposerEditorPrompt(
851857 continue;
852858 }
853859 if (segment.type === "mention") {
854 paragraph.append($createComposerMentionNode(segment.path));
860 paragraph.append($createComposerMentionNode(segment.path, segment.source));
855861 continue;
856862 }
857863 if (segment.type === "skill") {