char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Refactor data-attrs constants, enum for bool strings (#27002)bdffafa5d

char/aliceai-t5-moe
22.8 KiB804 linesraw
1// Guards the clipboard contract of the ChatFormInputRich:
2// copy/cut expose the markdown SOURCE of the selection (each badge
3// contributes its full `[name](file://...)` link) and pasting such
4// markdown re-renders the badges.
5
6import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
7import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
8import { tick } from 'svelte';
9import { describe, expect, it, vi } from 'vitest';
10import { userEvent } from 'vitest/browser';
11import { render } from 'vitest-browser-svelte';
12
13const SOURCE = 'hello [docs](file:///a/b) world';
14const BADGE_SELECTOR = '[data-mention-badge="true"]';
15
16function editableIn(container: HTMLElement): HTMLElement {
17	const el = container.querySelector('[role="textbox"]');
18
19	if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
20
21	return el;
22}
23
24function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
25	const range = document.createRange();
26
27	place(range, root);
28	const selection = window.getSelection();
29
30	if (!selection) throw new Error('no selection');
31
32	selection.removeAllRanges();
33	selection.addRange(range);
34}
35
36function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
37	const data = new DataTransfer();
38
39	if (text) data.setData('text/plain', text);
40
41	const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
42
43	return { data, event };
44}
45
46describe('ChatFormInputRich clipboard', () => {
47	it('copy exposes the markdown source of the selection', async () => {
48		const { container } = render(ChatFormInputRich, { value: SOURCE });
49
50		await tick();
51
52		const root = editableIn(container);
53
54		setSelection(root, (range) => range.selectNodeContents(root));
55
56		const { data, event } = clipboardEvent('copy');
57
58		root.dispatchEvent(event);
59
60		expect(event.defaultPrevented).toBe(true);
61		expect(data.getData('text/plain')).toBe(SOURCE);
62	});
63
64	it('cut exposes the markdown source and removes the slice', async () => {
65		const { container } = render(ChatFormInputRich, { value: SOURCE });
66
67		await tick();
68
69		const root = editableIn(container);
70
71		setSelection(root, (range) => {
72			const badge = root.querySelector(BADGE_SELECTOR);
73
74			if (!badge) throw new Error('badge not rendered');
75
76			range.setStartBefore(badge);
77			range.setEndAfter(badge);
78		});
79
80		const { data, event } = clipboardEvent('cut');
81
82		root.dispatchEvent(event);
83
84		expect(event.defaultPrevented).toBe(true);
85		expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
86		expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
87		expect(root.textContent).toBe('hello  world');
88	});
89
90	it('paste of markdown mention links re-renders badges', async () => {
91		const { container } = render(ChatFormInputRich, { value: 'hello ' });
92
93		await tick();
94
95		const root = editableIn(container);
96
97		root.focus();
98		setSelection(root, (range) => {
99			range.selectNodeContents(root);
100			range.collapse(false);
101		});
102
103		const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
104
105		root.dispatchEvent(event);
106		await tick();
107
108		expect(event.defaultPrevented).toBe(true);
109		const badge = root.querySelector(BADGE_SELECTOR);
110
111		expect(badge).not.toBeNull();
112		expect(badge!.getAttribute('data-mention-name')).toBe('docs');
113		expect(root.textContent).toContain('world');
114	});
115
116	it('paste without mention links keeps the DOM untouched', async () => {
117		const { container } = render(ChatFormInputRich, { value: 'hello ' });
118
119		await tick();
120
121		const root = editableIn(container);
122
123		root.focus();
124		setSelection(root, (range) => {
125			range.selectNodeContents(root);
126			range.collapse(false);
127		});
128		const firstChild = root.firstChild;
129		const { event } = clipboardEvent('paste', 'plain text');
130
131		root.dispatchEvent(event);
132		await tick();
133
134		expect(event.defaultPrevented).toBe(true);
135		expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
136		// no rebuild: the live text node is the same instance
137		expect(root.firstChild).toBe(firstChild);
138	});
139});
140
141describe('ChatFormInputRich code spans', () => {
142	it('renders inline code from the initial value', async () => {
143		const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' });
144
145		await tick();
146
147		const root = editableIn(container);
148		const code = root.querySelector('code[data-code-token="code_inline"]');
149
150		expect(code).not.toBeNull();
151		expect(code!.textContent).toBe('`npm test`');
152	});
153
154	it('renders a fenced code block with a language', async () => {
155		const source = 'before\n```js\nconst a = 1;\n```\nafter';
156		const { container } = render(ChatFormInputRich, { value: source });
157
158		await tick();
159
160		const root = editableIn(container);
161		const code = root.querySelector('code[data-code-token="code_block"]');
162
163		expect(code).not.toBeNull();
164		expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
165	});
166
167	it('copy exposes the markdown source of a selection spanning code', async () => {
168		const source = 'run `npm test` now';
169		const { container } = render(ChatFormInputRich, { value: source });
170
171		await tick();
172
173		const root = editableIn(container);
174
175		setSelection(root, (range) => range.selectNodeContents(root));
176
177		const { data, event } = clipboardEvent('copy');
178
179		root.dispatchEvent(event);
180
181		expect(event.defaultPrevented).toBe(true);
182		expect(data.getData('text/plain')).toBe(source);
183	});
184
185	it('paste of a code span renders the styled element', async () => {
186		const { container } = render(ChatFormInputRich, { value: 'run ' });
187
188		await tick();
189
190		const root = editableIn(container);
191
192		root.focus();
193		setSelection(root, (range) => {
194			range.selectNodeContents(root);
195			range.collapse(false);
196		});
197
198		const { event } = clipboardEvent('paste', '`npm test` now');
199
200		root.dispatchEvent(event);
201		await tick();
202
203		expect(event.defaultPrevented).toBe(true);
204		const code = root.querySelector('code[data-code-token="code_inline"]');
205
206		expect(code).not.toBeNull();
207		expect(code!.textContent).toBe('`npm test`');
208		expect(root.textContent).toContain('now');
209	});
210
211	it('highlights a fenced block content and stays byte-exact', async () => {
212		const source = '```js\nconst a = 1;\n```';
213		const { container } = render(ChatFormInputRich, { value: source });
214
215		await tick();
216
217		const root = editableIn(container);
218		const code = root.querySelector('code[data-code-token="code_block"]');
219
220		expect(code).not.toBeNull();
221		expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
222		expect(code!.textContent).toBe(source);
223	});
224
225	it('does not highlight inline code', async () => {
226		const { container } = render(ChatFormInputRich, { value: 'run `const` now' });
227
228		await tick();
229
230		const root = editableIn(container);
231
232		expect(root.querySelector('[class*="hljs-"]')).toBeNull();
233	});
234});
235
236describe('ChatFormInputRich code block escape hatches', () => {
237	const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
238	const BLOCK_SELECTOR = 'code[data-code-token="code_block"]';
239
240	function blockIn(root: HTMLElement): HTMLElement {
241		const el = root.querySelector(BLOCK_SELECTOR);
242
243		if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
244
245		return el;
246	}
247
248	// Caret at the very start/end of the block's text (across highlight spans)
249	function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
250		const code = blockIn(root);
251		const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
252
253		let target: Node | null = null;
254
255		for (let n = walker.nextNode(); n; n = walker.nextNode()) {
256			target = where === 'start' ? (target ?? n) : n;
257		}
258
259		if (!target) throw new Error('no text inside code block');
260
261		setSelection(root, (range) => {
262			range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
263			range.collapse(true);
264		});
265	}
266
267	function caretContainer(): Node {
268		const selection = window.getSelection();
269
270		if (!selection || selection.rangeCount === 0) throw new Error('no selection');
271
272		return selection.getRangeAt(0).startContainer;
273	}
274
275	it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
276		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
277
278		await tick();
279
280		const root = editableIn(container);
281
282		// no permanent empty line above a leading block
283		expect(root.firstChild).toBe(blockIn(root));
284		expect(root.lastChild?.nodeName).toBe('BR');
285
286		setSelection(root, (range) => range.selectNodeContents(root));
287		const { data, event } = clipboardEvent('copy');
288
289		root.dispatchEvent(event);
290
291		expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
292	});
293
294	it('escapes a trailing code block with ArrowDown and types after it', async () => {
295		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
296
297		await tick();
298
299		const root = editableIn(container);
300
301		root.focus();
302		placeCaretInBlock(root, 'end');
303
304		await userEvent.keyboard('{ArrowDown}');
305		expect(blockIn(root).contains(caretContainer())).toBe(false);
306
307		await userEvent.keyboard('x');
308		await tick();
309
310		expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
311		// the DOM holds no separator newline (it would render as a
312		// phantom empty line); serialization synthesizes it so the
313		// markdown source keeps the text below the block
314		expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
315		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
316		// the stale trailing hatch is removed once real text follows the block
317		expect(root.lastChild?.nodeName).not.toBe('BR');
318	});
319
320	it('escapes a leading code block with ArrowUp and types before it', async () => {
321		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
322
323		await tick();
324
325		const root = editableIn(container);
326
327		root.focus();
328		placeCaretInBlock(root, 'start');
329
330		await userEvent.keyboard('{ArrowUp}');
331		expect(blockIn(root).contains(caretContainer())).toBe(false);
332		// the transient hatch line exists while the caret sits on it
333		expect(root.firstChild?.nodeName).toBe('BR');
334
335		await userEvent.keyboard('y');
336		await tick();
337
338		expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
339		expect(root.textContent).toBe('y' + BLOCK_SOURCE);
340		expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
341		// the typed text consumed the hatch
342		expect(root.firstChild?.nodeName).not.toBe('BR');
343	});
344
345	it('escapes a leading code block with ArrowLeft from its first character', async () => {
346		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
347
348		await tick();
349
350		const root = editableIn(container);
351
352		root.focus();
353		placeCaretInBlock(root, 'start');
354
355		await userEvent.keyboard('{ArrowLeft}');
356		expect(blockIn(root).contains(caretContainer())).toBe(false);
357		expect(root.firstChild?.nodeName).toBe('BR');
358	});
359
360	it('removes the transient leading hatch when the caret moves back into the block', async () => {
361		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
362
363		await tick();
364
365		const root = editableIn(container);
366
367		root.focus();
368		placeCaretInBlock(root, 'start');
369
370		await userEvent.keyboard('{ArrowUp}');
371		expect(root.firstChild?.nodeName).toBe('BR');
372
373		await userEvent.keyboard('{ArrowDown}');
374		await tick();
375
376		expect(blockIn(root).contains(caretContainer())).toBe(true);
377		expect(root.firstChild).toBe(blockIn(root));
378	});
379
380	it('extends the selection out of the block with Shift+ArrowDown', async () => {
381		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
382
383		await tick();
384
385		const root = editableIn(container);
386
387		root.focus();
388		placeCaretInBlock(root, 'end');
389
390		await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
391
392		const selection = window.getSelection();
393
394		expect(selection).not.toBeNull();
395		expect(selection!.isCollapsed).toBe(false);
396		expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
397	});
398
399	it('line-separates text typed right after the closing fence', async () => {
400		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
401
402		await tick();
403
404		const root = editableIn(container);
405
406		root.focus();
407		placeCaretInBlock(root, 'end');
408
409		// no arrow keys: the caret sits at the block's end edge, where the
410		// post-rebuild restore lands it, and the typed text renders on the
411		// line below the block
412		await userEvent.keyboard('x');
413		await tick();
414
415		// the text stays on the caret's line in the DOM (no phantom empty
416		// line); the source gets the separator newline
417		expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
418		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
419	});
420
421	it('does not double the newline when Shift+Enter already added one', async () => {
422		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
423
424		await tick();
425
426		const root = editableIn(container);
427
428		root.focus();
429		placeCaretInBlock(root, 'end');
430
431		await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
432		await userEvent.keyboard('x');
433		await tick();
434
435		expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
436		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
437	});
438
439	it('moves a caret stuck before the inserted newline onto the new line', async () => {
440		const { container } = render(ChatFormInputRich, {
441			value: BLOCK_SOURCE + '\ntext after the code block'
442		});
443
444		await tick();
445
446		const root = editableIn(container);
447
448		root.focus();
449
450		// post-break DOM some browsers produce: the inserted newline plus
451		// the artificial trailing one, with the caret stuck BEFORE the
452		// inserted one (visually at the end of the old line)
453		root.appendChild(document.createTextNode('\n'));
454		root.appendChild(document.createTextNode('\n'));
455		setSelection(root, (range) => {
456			range.setStart(root.childNodes[2], 0);
457			range.collapse(true);
458		});
459
460		root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
461		await tick();
462
463		const selection = window.getSelection();
464
465		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
466			(BLOCK_SOURCE + '\ntext after the code block\n').length
467		);
468		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
469	});
470
471	it('appends the artificial trailing newline when the browser did not add one', async () => {
472		const { container } = render(ChatFormInputRich, {
473			value: BLOCK_SOURCE + '\ntext after the code block'
474		});
475
476		await tick();
477
478		const root = editableIn(container);
479
480		root.focus();
481
482		// post-break DOM some browsers produce: a lone trailing \n (or a
483		// <br> the hatch sync strips). Collapsed by the renderer, so the
484		// caret looks stuck on the old line and the next typed character
485		// would consume the newline.
486		root.appendChild(document.createTextNode('\n'));
487		setSelection(root, (range) => {
488			range.setStart(root.childNodes[2], 1);
489			range.collapse(true);
490		});
491
492		root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
493		await tick();
494
495		const selection = window.getSelection();
496
497		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
498			(BLOCK_SOURCE + '\ntext after the code block\n').length
499		);
500		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
501	});
502
503	it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
504		const { container } = render(ChatFormInputRich, {
505			value: BLOCK_SOURCE + '\ntext after the code block'
506		});
507
508		await tick();
509
510		const root = editableIn(container);
511
512		root.focus();
513		setSelection(root, (range) => {
514			const text = root.childNodes[1];
515
516			range.setStart(text, (text.textContent ?? '').length);
517			range.collapse(true);
518		});
519
520		await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
521		await tick();
522
523		const selection = window.getSelection();
524
525		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
526			(BLOCK_SOURCE + '\ntext after the code block\n').length
527		);
528		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
529
530		// the next typed character lands on the new line
531		await userEvent.keyboard('x');
532		await tick();
533		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
534	});
535
536	it('lets Backspace at the text start move into the block without a source fight', async () => {
537		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
538
539		await tick();
540
541		const root = editableIn(container);
542
543		root.focus();
544		placeCaretInBlock(root, 'end');
545
546		await userEvent.keyboard('{ArrowDown}');
547		await userEvent.keyboard('create');
548		await tick();
549		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
550
551		// Backspace at the start of the text line: the separator newline
552		// is structural (synthesized while text follows the block), so
553		// the caret just moves to the block's edge - nothing is re-added
554		await userEvent.keyboard('{Home}');
555		await userEvent.keyboard('{Backspace}');
556		await tick();
557
558		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
559		expect(caretContainer()).toBe(root);
560	});
561
562	it('lets forward Delete eat the text after a block normally', async () => {
563		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
564
565		await tick();
566
567		const root = editableIn(container);
568
569		root.focus();
570		placeCaretInBlock(root, 'end');
571
572		await userEvent.keyboard('{ArrowDown}');
573		await userEvent.keyboard('create');
574		await tick();
575
576		await userEvent.keyboard('{Home}');
577		await userEvent.keyboard('{Delete}');
578		await tick();
579
580		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
581	});
582
583	it('renders text after a block without a phantom empty line', async () => {
584		const { container } = render(ChatFormInputRich, {
585			value: BLOCK_SOURCE + '\nhello'
586		});
587
588		await tick();
589
590		const root = editableIn(container);
591
592		expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
593		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
594	});
595
596	it('keeps an intentional blank line after a block out of the separator', async () => {
597		const { container } = render(ChatFormInputRich, {
598			value: BLOCK_SOURCE + '\n\nhello'
599		});
600
601		await tick();
602
603		const root = editableIn(container);
604
605		expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
606		expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
607	});
608
609	it('re-highlights while typing inside a block and keeps the caret', async () => {
610		const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
611
612		await tick();
613
614		const root = editableIn(container);
615
616		root.focus();
617
618		// caret at the start of the block content (after the opening fence)
619		setSelection(root, (range) => {
620			const target = textOffsetToRange(root, 6);
621
622			range.setStart(target.startContainer, target.startOffset);
623			range.collapse(true);
624		});
625
626		await userEvent.keyboard('x');
627		await tick();
628
629		const code = blockIn(root);
630
631		expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
632		expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
633		expect(code.querySelector('.hljs-number')).not.toBeNull();
634
635		const selection = window.getSelection();
636
637		expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
638		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
639	});
640});
641
642describe('ChatFormInputRich Enter in code blocks', () => {
643	const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
644
645	it('adds a line instead of submitting on plain Enter inside a block', async () => {
646		const onKeydown = vi.fn();
647		const { container } = render(ChatFormInputRich, {
648			onKeydown,
649			value: BLOCK_SOURCE
650		});
651
652		await tick();
653
654		const root = editableIn(container);
655
656		root.focus();
657
658		// caret at the start of the block content (after the opening fence)
659		setSelection(root, (range) => {
660			const target = textOffsetToRange(root, 6);
661
662			range.setStart(target.startContainer, target.startOffset);
663			range.collapse(true);
664		});
665
666		await userEvent.keyboard('{Enter}');
667		await tick();
668
669		// consumed locally: the parent's submit handler never sees it
670		expect(onKeydown).not.toHaveBeenCalled();
671		expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
672
673		const code = root.querySelector('code[data-code-token="code_block"]');
674		const selection = window.getSelection();
675
676		expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
677		expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
678	});
679
680	it('adds a line after a still-open fence (no closing ``` yet)', async () => {
681		const onKeydown = vi.fn();
682		const { container } = render(ChatFormInputRich, {
683			onKeydown,
684			value: '```js\nconst a = 1;'
685		});
686
687		await tick();
688
689		const root = editableIn(container);
690
691		root.focus();
692
693		// caret at the start of the block content (after the opening fence)
694		setSelection(root, (range) => {
695			const target = textOffsetToRange(root, 6);
696
697			range.setStart(target.startContainer, target.startOffset);
698			range.collapse(true);
699		});
700
701		await userEvent.keyboard('{Enter}');
702		await tick();
703
704		expect(onKeydown).not.toHaveBeenCalled();
705		expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
706	});
707
708	it('forwards plain Enter to the parent when the caret is outside a block', async () => {
709		const onKeydown = vi.fn();
710		const { container } = render(ChatFormInputRich, {
711			onKeydown,
712			value: BLOCK_SOURCE + '\nafter'
713		});
714
715		await tick();
716
717		const root = editableIn(container);
718
719		root.focus();
720		setSelection(root, (range) => {
721			range.selectNodeContents(root);
722			range.collapse(false);
723		});
724
725		await userEvent.keyboard('{Enter}');
726
727		expect(onKeydown).toHaveBeenCalledTimes(1);
728		expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
729	});
730
731	it('forwards plain Enter on the trailing hatch line after a block', async () => {
732		const onKeydown = vi.fn();
733		const { container } = render(ChatFormInputRich, {
734			onKeydown,
735			value: BLOCK_SOURCE
736		});
737
738		await tick();
739
740		const root = editableIn(container);
741
742		root.focus();
743		// root-level caret between the block and its trailing br hatch
744		setSelection(root, (range) => {
745			range.setStart(root, 1);
746			range.collapse(true);
747		});
748
749		await userEvent.keyboard('{Enter}');
750
751		expect(onKeydown).toHaveBeenCalledTimes(1);
752	});
753
754	it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
755		const onKeydown = vi.fn();
756		const { container } = render(ChatFormInputRich, {
757			onKeydown,
758			value: BLOCK_SOURCE
759		});
760
761		await tick();
762
763		const root = editableIn(container);
764
765		root.focus();
766		setSelection(root, (range) => {
767			const target = textOffsetToRange(root, 6);
768
769			range.setStart(target.startContainer, target.startOffset);
770			range.collapse(true);
771		});
772
773		await userEvent.keyboard('{Control>}{Enter}{/Control}');
774
775		expect(onKeydown).toHaveBeenCalledWith(
776			expect.objectContaining({ ctrlKey: true, key: 'Enter' })
777		);
778		expect(serializeContent(root)).toBe(BLOCK_SOURCE);
779	});
780
781	it('forwards Enter inside an inline code span', async () => {
782		const onKeydown = vi.fn();
783		const { container } = render(ChatFormInputRich, {
784			onKeydown,
785			value: 'run `npm test` now'
786		});
787
788		await tick();
789
790		const root = editableIn(container);
791
792		root.focus();
793		const code = root.querySelector('code[data-code-token="code_inline"]')!;
794
795		setSelection(root, (range) => {
796			range.setStart(code.firstChild!, 3);
797			range.collapse(true);
798		});
799
800		await userEvent.keyboard('{Enter}');
801
802		expect(onKeydown).toHaveBeenCalledTimes(1);
803	});
804});