Skip to content

Commit 761d4ba

Browse files
fix(web): preserve original mention text in the composer (#10100)
1 parent c1d27e5 commit 761d4ba

2 files changed

Lines changed: 197 additions & 7 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2+
import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical";
3+
import { act, createRef } from "react";
4+
import { create, type ReactTestRenderer } from "react-test-renderer";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6+
7+
import { collapseExpandedComposerCursor } from "../composer-logic";
8+
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
9+
10+
vi.mock("./chat/FileTagChip", () => ({
11+
FILE_TAG_CHIP_CLASS_NAME: "",
12+
FileTagChipContent: () => null,
13+
}));
14+
vi.mock("./chat/ComposerPendingTerminalContexts", () => ({
15+
ComposerPendingTerminalContextChip: () => null,
16+
}));
17+
vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null }));
18+
19+
let 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.
22+
vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({
23+
PlainTextPlugin: function HeadlessEditor() {
24+
[lexicalEditor] = useLexicalComposerContext();
25+
return null;
26+
},
27+
}));
28+
29+
let renderer: ReactTestRenderer | undefined;
30+
const editorRef = createRef<ComposerPromptEditorHandle>();
31+
32+
function 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+
49+
async function renderPrompt(value: string) {
50+
await act(() => {
51+
if (renderer) renderer.update(composer(value));
52+
else renderer = create(composer(value));
53+
});
54+
}
55+
56+
function $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+
64+
class 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+
76+
beforeEach(() => {
77+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
78+
vi.stubGlobal("document", { activeElement: null });
79+
});
80+
81+
afterEach(async () => {
82+
await act(() => renderer?.unmount());
83+
renderer = undefined;
84+
vi.unstubAllGlobals();
85+
});
86+
87+
describe("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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null;
117117
type 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

175176
class 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

227-
function $createComposerMentionNode(path: string): ComposerMentionNode {
228-
return $applyNodeReplacement(new ComposerMentionNode(path));
233+
function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode {
234+
return $applyNodeReplacement(new ComposerMentionNode(path, source));
229235
}
230236

231237
function 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") {

0 commit comments

Comments
 (0)