1// @vitest-environment jsdom
2
3import { undo, redo } from "@codemirror/commands";
4import { Transaction } from "@codemirror/state";
5import { ComposerContextId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
6import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
9import {
10 COMPOSER_CONTEXT_CLIPBOARD_MIME,
11 encodeComposerContextFragment,
12} from "@t3tools/shared/composerContextClipboard";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15
16import { detectComposerTrigger, composerSubmissionIntentForKey } from "~/composer-logic";
17import { composerEmojiKeyAction } from "./chat/composerMenuHighlight";
18import {
19 composerSuggestionsSuppressed,
20 composerTokens,
21 createComposerMarkdownEditor,
22} from "./composerMarkdownEditor";
23import type { ComposerPromptEditorProps } from "./ComposerPromptEditor";
24
25let editor: ReturnType<typeof createComposerMarkdownEditor>;
26let props: ComposerPromptEditorProps;
27let sends: ReturnType<typeof vi.fn<(intent: string) => void>>;
28
29beforeEach(() => {
30 Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] });
31 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
32 configurable: true,
33 value: () => new DOMRect(),
34 });
35 props = {
36 value: "",
37 cursor: 0,
38 contextRecords: new Map(),
39 skills: [],
40 disabled: false,
41 placeholder: "Message",
42 editorRef: { current: null },
43 onPaste: () => {},
44 onChange: (value, cursor) => {
45 props = { ...props, value, cursor };
46 },
47 };
48 sends = vi.fn<(intent: string) => void>();
49});
50afterEach(() => {
51 editor?.destroy();
52 editor?.dom.remove();
53});
54
55function mount(value = "", overrides: Partial<ComposerPromptEditorProps> = {}) {
56 props = { ...props, ...overrides, value, cursor: value.length };
57 editor = createComposerMarkdownEditor({
58 props: () => props,
59 mountChip: () => {},
60 unmountChip: () => {},
61 });
62 document.body.append(editor.dom);
63 editor.focus();
64 return editor;
65}
66function key(key: string, init: KeyboardEventInit = {}) {
67 const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
68 editor.view.contentDOM.dispatchEvent(event);
69 return event;
70}
71function submission(key: string, event: KeyboardEvent) {
72 const snapshot = editor.snapshot();
73 const trigger = composerSuggestionsSuppressed(editor.view, props.literalText ?? false)
74 ? null
75 : detectComposerTrigger(snapshot.value, snapshot.expandedCursor);
76 if (trigger?.kind === "emoji" && composerEmojiKeyAction(event) === "select") {
77 editor.replaceText("💫", { from: trigger.rangeStart, to: trigger.rangeEnd });
78 return true;
79 }
80 const intent = composerSubmissionIntentForKey({
81 event,
82 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
83 platform: "Linux",
84 isMobileViewport: false,
85 isDraftThread: false,
86 prompt: snapshot.value,
87 });
88 if (intent) {
89 sends(intent);
90 return true;
91 }
92 return false;
93}
94
95describe("CodeMirror composer behavior", () => {
96 it("keeps the preceding paragraph plain while starting a dash list", () => {
97 const intro = "tui up. this is better. my overall points";
98 mount(intro, { richTextEnabled: true });
99 key("Enter", { shiftKey: true });
100 editor.view.dispatch({ ...editor.view.state.replaceSelection("-"), userEvent: "input" });
101
102 const line = editor.dom.querySelector(".cm-line")!;
103 const text = line.firstElementChild ?? line;
104 expect(getComputedStyle(text).textDecoration).not.toContain("underline");
105 expect(["bold", "700"]).not.toContain(getComputedStyle(text).fontWeight);
106 expect(editor.getValue()).toBe(`${intro}\n-`);
107
108 editor.view.dispatch({
109 ...editor.view.state.replaceSelection(" first point"),
110 userEvent: "input",
111 });
112 key("Enter", { shiftKey: true });
113 expect(editor.getValue()).toBe(`${intro}\n- first point\n- `);
114 });
115
116 it("keeps explicit hash headings formatted", () => {
117 const value = "# An explicit heading\n\nPlain text";
118 mount(value, { richTextEnabled: true });
119 expect(editor.dom.querySelector(".cm-lp-h1")?.textContent).toBe("An explicit heading");
120 expect(editor.getValue()).toBe(value);
121 });
122
123 it.each(["-", "="])("formats an underline heading only after the third %s", (marker) => {
124 const intro = "A heading when the underline is deliberate";
125 mount(`${intro}\n`, { richTextEnabled: true });
126
127 for (let count = 1; count <= 4; count++) {
128 editor.view.dispatch({ ...editor.view.state.replaceSelection(marker), userEvent: "input" });
129
130 const line = editor.dom.querySelector(".cm-line")!;
131 const text = line.firstElementChild ?? line;
132 const style = getComputedStyle(text);
133 expect(style.textDecoration.includes("underline")).toBe(count >= 3);
134 expect(["bold", "700"].includes(style.fontWeight)).toBe(count >= 3);
135 expect(editor.getValue()).toBe(`${intro}\n${marker.repeat(count)}`);
136 }
137
138 editor.view.dispatch({ changes: { from: intro.length + 3, to: editor.view.state.doc.length } });
139 const line = editor.dom.querySelector(".cm-line")!;
140 expect(getComputedStyle(line.firstElementChild ?? line).textDecoration).not.toContain(
141 "underline",
142 );
143 expect(editor.getValue()).toBe(`${intro}\n${marker.repeat(2)}`);
144 });
145
146 it("claims emoji Enter and Tab, keeps a Unicode caret, and never submits", () => {
147 mount("before :dizzy", { onCommandKeyDown: submission });
148 expect(key("Enter").defaultPrevented).toBe(true);
149 expect(editor.snapshot()).toMatchObject({
150 value: "before 💫",
151 expandedCursor: "before 💫".length,
152 });
153 expect(sends).not.toHaveBeenCalled();
154 expect(undo(editor.view)).toBe(true);
155 expect(editor.getValue()).toBe("before :dizzy");
156 expect(redo(editor.view)).toBe(true);
157 expect(editor.getValue()).toBe("before 💫");
158 editor.setValue(":dizzy");
159 editor.view.dispatch({ selection: { anchor: 6 } });
160 expect(key("Tab").defaultPrevented).toBe(true);
161 expect(editor.getValue()).toBe("💫");
162 expect(sends).not.toHaveBeenCalled();
163 });
164
165 it("respects submission outside code and inserts newlines inside fences", () => {
166 mount("hello", { onCommandKeyDown: submission });
167 key("Enter");
168 expect(sends).toHaveBeenCalledTimes(1);
169 editor.setValue("```ts\nconst x = 1");
170 editor.view.dispatch({ selection: { anchor: editor.view.state.doc.length } });
171 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(true);
172 key("Enter");
173 expect(editor.getValue()).toContain("const x = 1\n");
174 expect(sends).toHaveBeenCalledTimes(1);
175 });
176
177 it("keeps context source bytes, skips code tokens, and deletes an atomic mention in one key", () => {
178 const mention = serializeComposerFileLink("src/example.ts");
179 mount(`${mention} after`);
180 const token = composerTokens(editor.view.state, false)[0]!;
181 expect(editor.getValue()).toBe(`${mention} after`);
182 expect(editor.dom.querySelectorAll('[data-composer-chip="true"]')).toHaveLength(1);
183 editor.view.dispatch({ selection: { anchor: token.end } });
184 key("Backspace");
185 expect(editor.getValue()).toBe(" after");
186 undo(editor.view);
187 expect(editor.getValue()).toBe(`${mention} after`);
188 editor.setValue(`\` ${mention} \`\n\`\`\`\n${mention}\n\`\`\``);
189 expect(composerTokens(editor.view.state, false)).toEqual([]);
190 });
191
192 it("round-trips citation and skill context as actual atomic source ranges", () => {
193 const citation = serializeAssistantCitation({
194 version: 1,
195 environmentId: EnvironmentId.make("env"),
196 threadId: ThreadId.make("thread"),
197 messageId: MessageId.make("message"),
198 text: "Quoted text",
199 start: 0,
200 end: 11,
201 prefix: "",
202 suffix: "",
203 });
204 mount(`${citation} $browser `);
205 expect(composerTokens(editor.view.state, false).map((token) => token.type)).toEqual([
206 "citation",
207 "skill",
208 ]);
209 expect(editor.getValue()).toBe(`${citation} $browser `);
210 props = { ...props, literalText: true };
211 editor.configure();
212 expect(composerTokens(editor.view.state, true)).toEqual([]);
213 expect(editor.getValue()).toBe(`${citation} $browser `);
214 expect(editor.dom.querySelector('[data-composer-chip="true"]')).toBeNull();
215 });
216
217 it("suppresses suggestions and submission during real composition events", async () => {
218 const onChange = vi.fn(props.onChange);
219 mount(":dizzy", { onCommandKeyDown: submission, onChange });
220 editor.view.contentDOM.dispatchEvent(
221 new CompositionEvent("compositionstart", { bubbles: true }),
222 );
223 expect(onChange.mock.calls.at(-1)?.[3]).toBe(true);
224 key("Enter", { isComposing: true });
225 expect(sends).not.toHaveBeenCalled();
226 expect(editor.getValue()).toBe(":dizzy");
227 editor.view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
228 await Promise.resolve();
229 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
230 });
231
232 it("lets native selection and copy shortcuts pass through readonly input", () => {
233 mount("readonly", { disabled: true });
234 key("a", { ctrlKey: true });
235 expect(editor.view.state.selection.main).toMatchObject({ from: 0, to: 8 });
236 expect(key("c", { ctrlKey: true }).defaultPrevented).toBe(false);
237 });
238
239 it("routes PageDown to the timeline only at the editor's scroll boundary", () => {
240 const onPageScrollKeyDown = vi.fn();
241 mount("line", { onPageScrollKeyDown });
242 Object.defineProperties(editor.view.scrollDOM, {
243 clientHeight: { configurable: true, value: 100 },
244 scrollHeight: { configurable: true, value: 300 },
245 scrollTop: { configurable: true, writable: true, value: 0 },
246 });
247 key("PageDown");
248 expect(onPageScrollKeyDown).not.toHaveBeenCalled();
249 editor.view.scrollDOM.scrollTop = 200;
250 expect(key("PageDown").defaultPrevented).toBe(true);
251 expect(onPageScrollKeyDown).toHaveBeenCalledWith("PageDown");
252 });
253
254 it("keeps readonly drafts copyable and permits only controlled replacement", () => {
255 mount("readonly", { disabled: true, onCommandKeyDown: submission });
256 key("Enter");
257 key("b", { ctrlKey: true });
258 expect(editor.getValue()).toBe("readonly");
259 expect(sends).not.toHaveBeenCalled();
260 editor.view.dispatch({
261 changes: { from: 0, to: 8, insert: "streamed" },
262 annotations: Transaction.userEvent.of("t3.controlled"),
263 });
264 expect(editor.getValue()).toBe("streamed");
265 editor.view.dispatch({ selection: { anchor: 0, head: 8 } });
266 expect(editor.view.state.selection.main.empty).toBe(false);
267 });
268
269 it("continues tasks and keeps the same document and history through preview changes", () => {
270 mount("- [ ] task", { richTextEnabled: true });
271 key("Enter", { shiftKey: true });
272 expect(editor.getValue()).toContain("\n- [ ] ");
273 const draft = editor.getValue();
274 props = { ...props, richTextEnabled: false };
275 editor.configure();
276 expect(editor.getValue()).toBe(draft);
277 undo(editor.view);
278 expect(editor.getValue()).toBe("- [ ] task");
279 });
280});
281
282it("imports actual structured terminal clipboard records and rewrites their atomic references", () => {
283 const oldId = ComposerContextId.make("terminal-old");
284 const newId = ComposerContextId.make("terminal-new");
285 const text = formatComposerContextReference({
286 kind: "terminal",
287 contextId: oldId,
288 label: "Build",
289 });
290 const fragment = {
291 version: 1 as const,
292 source: { environmentId: EnvironmentId.make("env") },
293 records: [
294 {
295 version: 1 as const,
296 contextId: oldId,
297 kind: "terminal" as const,
298 label: "Build",
299 terminalId: "build",
300 terminalLabel: "Build",
301 lineStart: 1,
302 lineEnd: 2,
303 text: "build output",
304 },
305 ],
306 };
307 const records = new Map<string, unknown>();
308 mount("before ", {
309 importContextFragment: (incoming) => {
310 for (const record of incoming.records) records.set(newId, record);
311 return new Map([[oldId, newId]]);
312 },
313 });
314 const event = new Event("paste", { bubbles: true, cancelable: true });
315 Object.defineProperty(event, "clipboardData", {
316 value: {
317 files: [],
318 getData: (format: string) =>
319 format === "text/plain"
320 ? text
321 : format === COMPOSER_CONTEXT_CLIPBOARD_MIME
322 ? encodeComposerContextFragment(fragment)
323 : "",
324 },
325 });
326 editor.view.contentDOM.dispatchEvent(event);
327 expect(event.defaultPrevented).toBe(true);
328 expect(records.get(newId)).toEqual(fragment.records[0]);
329 expect(editor.snapshot().contextIds).toEqual([newId]);
330 expect(editor.getValue()).toBe(
331 `before ${formatComposerContextReference({ kind: "terminal", contextId: newId, label: "Build" })}`,
332 );
333 undo(editor.view);
334 expect(editor.getValue()).toBe("before ");
335});
336
337it("keeps literal answers verbatim through formatting shortcuts, lists, and fenced text", () => {
338 mount("- item", { literalText: true });
339 key("Enter", { shiftKey: true });
340 expect(editor.getValue()).toBe("- item\n");
341 editor.view.dispatch({ selection: { anchor: 0, head: 6 } });
342 key("b", { ctrlKey: true });
343 expect(editor.getValue()).toBe("- item\n");
344});
345
346it.each([false, true])(
347 "continues fence indentation with Shift=%s and restores it with undo",
348 (shiftKey) => {
349 const source = "```ts\n nested()\n```";
350 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
351 const cursor = source.indexOf("\n```");
352 editor.view.dispatch({ selection: { anchor: cursor } });
353 expect(key("Enter", { shiftKey }).defaultPrevented).toBe(true);
354 expect(editor.getValue()).toBe("```ts\n nested()\n \n```");
355 expect(editor.snapshot().expandedCursor).toBe(cursor + 3);
356 expect(sends).not.toHaveBeenCalled();
357 expect(undo(editor.view)).toBe(true);
358 expect(editor.getValue()).toBe(source);
359 expect(editor.view.hasFocus).toBe(true);
360 },
361);
362
363it.each(["```", "~~~~"])(
364 "closes an unfinished %s fence after two blank lines and leaves prose",
365 (fence) => {
366 const source = `${fence}ts\n nested()\n \n `;
367 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
368 expect(key("Enter").defaultPrevented).toBe(true);
369 expect(editor.getValue()).toBe(`${fence}ts\n nested()\n${fence}\n\n`);
370 expect(editor.snapshot().expandedCursor).toBe(editor.getValue().length);
371 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
372 expect(sends).not.toHaveBeenCalled();
373 expect(undo(editor.view)).toBe(true);
374 expect(editor.getValue()).toBe(source);
375 },
376);
377
378it("leaves an already closed fence from its trailing blank body without consuming following prose", () => {
379 const source = "```ts\nx\n\n\n```\nfollowing";
380 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
381 editor.view.dispatch({ selection: { anchor: source.indexOf("\n```") } });
382 key("Enter", { shiftKey: true });
383 expect(editor.getValue()).toBe("```ts\nx\n```\n\n\nfollowing");
384 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
385 expect(sends).not.toHaveBeenCalled();
386 undo(editor.view);
387 expect(editor.getValue()).toBe(source);
388});
389
390it("treats a completed closing fence as the way back to prose, without scaffolding a second fence", () => {
391 const source = "```ts\n nested()\n```";
392 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
393 key("Enter");
394 expect(editor.getValue()).toBe(`${source}\n`);
395 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
396 expect(sends).not.toHaveBeenCalled();
397 undo(editor.view);
398 expect(editor.getValue()).toBe(source);
399});
400
401it("continues two indented code lines before the third Enter returns to prose", () => {
402 mount("```ts\n nested()", { richTextEnabled: true, onCommandKeyDown: submission });
403 key("Enter");
404 expect(editor.getValue()).toBe("```ts\n nested()\n ");
405 key("Enter");
406 expect(editor.getValue()).toBe("```ts\n nested()\n \n ");
407 key("Enter");
408 expect(editor.getValue()).toBe("```ts\n nested()\n```\n\n");
409 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
410 expect(sends).not.toHaveBeenCalled();
411 expect(editor.view.hasFocus).toBe(true);
412});