allozaur/feat/chat form contenteditable (#26717)

* feat: Add contenteditable tokenizer for badge/code-chip chat input

* feat: Add source-space undo/redo history for the rich input

* feat: Split text glued to a closing code fence onto its own line

* feat: Add ChatFormContenteditable rich input renderer

* feat : wire the contenteditable into ChatForm with auto-switch gating
This commit is contained in:
Aleksander Grygier
2026-08-07 20:40:10 +02:00
committed by GitHub
parent 1621a3d388
commit fc6545d322
27 changed files with 3497 additions and 110 deletions
@@ -0,0 +1,156 @@
// Guards the newline contract of the chat-form contenteditable: browsers
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
// serialization must fold those back into `\n` so the emitted value never
// diverges from what is on screen.
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
const SOURCE = 'see [docs](file:///a/b) here';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
return el;
}
function fireInput(root: HTMLElement) {
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
}
function setCaret(node: Node, offset: number) {
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
const selection = window.getSelection();
if (!selection) throw new Error('no selection');
selection.removeAllRanges();
selection.addRange(range);
}
describe('ChatFormContenteditable browser newline shapes', () => {
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
const div = document.createElement('div');
div.textContent = 'second line';
root.appendChild(div);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
});
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
const first = document.createElement('div');
while (root.firstChild) first.appendChild(root.firstChild);
const second = document.createElement('div');
second.textContent = 'second line';
root.appendChild(first);
root.appendChild(second);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
});
it('serializes a <br> as a newline', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
await tick();
const root = editableIn(screen.container);
root.appendChild(document.createElement('br'));
root.appendChild(document.createTextNode('second line'));
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('here\nsecond line');
});
it('ignores a trailing <br> (browser caret placeholder)', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
root.appendChild(document.createElement('br'));
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('serializes one newline per empty-line <div><br></div>', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
for (let i = 0; i < 2; i++) {
const div = document.createElement('div');
div.appendChild(document.createElement('br'));
root.appendChild(div);
}
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('abc\n\n');
});
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
const div = document.createElement('div');
div.appendChild(document.createElement('br'));
root.replaceChildren(div);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('');
expect(root.dataset.empty).toBe('true');
});
it('maps the caret across block boundaries in both directions', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
await tick();
// Rebuild into the Chromium block shape; the source is unchanged,
// so no re-render fires.
const root = editableIn(screen.container);
const div = document.createElement('div');
div.textContent = 'def';
root.replaceChildren(document.createTextNode('abc'), div);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('abc\ndef');
const divText = div.firstChild;
if (!divText) throw new Error('div text missing');
setCaret(divText, 2);
expect(screen.component.getCaretOffset()).toBe(6);
screen.component.setCaretOffset(6);
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(divText);
expect(selection?.anchorOffset).toBe(2);
// The boundary newline itself: offset 3 is the end of "abc", offset
// 4 the start of the "def" line.
screen.component.setCaretOffset(4);
expect(window.getSelection()?.anchorNode).toBe(divText);
expect(window.getSelection()?.anchorOffset).toBe(0);
screen.component.setCaretOffset(3);
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
expect(window.getSelection()?.anchorOffset).toBe(3);
});
});
@@ -0,0 +1,142 @@
// Guards the editing-key contract of the chat-form contenteditable:
// undo/redo is replayed from source snapshots (the token rebuilds destroy
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
// keyboard trap), matching the plain textarea.
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
const SOURCE = 'see [docs](file:///a/b)';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
return el;
}
function type(root: HTMLElement, text: string, inputType = 'insertText') {
root.appendChild(document.createTextNode(text));
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
}
function keydown(root: HTMLElement, init: KeyboardEventInit) {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
root.dispatchEvent(event);
return event;
}
describe('ChatFormContenteditable undo/redo', () => {
it('undoes and redoes an edit across a badge-containing buffer', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
type(root, ' more');
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(undoEvent.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe(SOURCE);
const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
await tick();
expect(redoEvent.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
});
it('redoes with Ctrl+Y as well', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
type(root, ' more');
await tick();
keydown(root, { key: 'z', metaKey: true });
await tick();
expect(screen.component.getValue()).toBe(SOURCE);
keydown(root, { key: 'y', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
});
it('coalesces a typing burst into one undo step', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
type(root, 'd');
type(root, 'e');
await tick();
expect(screen.component.getValue()).toBe('abcde');
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('keeps a newline as its own undo step', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
type(root, 'd');
type(root, '\n', 'insertLineBreak');
await tick();
expect(screen.component.getValue()).toBe('abcd\n');
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abcd');
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('is a no-op when there is nothing to undo', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
const event = keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(event.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe('abc');
});
it('abandons the redo branch after a fresh edit', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
type(root, 'd');
await tick();
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abc');
type(root, 'e');
await tick();
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
await tick();
expect(screen.component.getValue()).toBe('abce');
});
});
describe('ChatFormContenteditable Tab key', () => {
it('does not trap Tab (focus can leave the editable)', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
const event = keydown(root, { key: 'Tab' });
expect(event.defaultPrevented).toBe(false);
});
});
@@ -0,0 +1,701 @@
// Guards the clipboard contract of the chat-form contenteditable:
// copy/cut expose the markdown SOURCE of the selection (each badge
// contributes its full `[name](file://...)` link) and pasting such
// markdown re-renders the badges.
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import { tick } from 'svelte';
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
const SOURCE = 'hello [docs](file:///a/b) world';
const BADGE_SELECTOR = '[data-mention-badge="true"]';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
return el;
}
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
const range = document.createRange();
place(range, root);
const selection = window.getSelection();
if (!selection) throw new Error('no selection');
selection.removeAllRanges();
selection.addRange(range);
}
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
const data = new DataTransfer();
if (text) data.setData('text/plain', text);
const event = new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true });
return { event, data };
}
describe('ChatFormContenteditable clipboard', () => {
it('copy exposes the markdown source of the selection', async () => {
const { container } = render(ChatFormContenteditable, { value: SOURCE });
await tick();
const root = editableIn(container);
setSelection(root, (range) => range.selectNodeContents(root));
const { event, data } = clipboardEvent('copy');
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(data.getData('text/plain')).toBe(SOURCE);
});
it('cut exposes the markdown source and removes the slice', async () => {
const { container } = render(ChatFormContenteditable, { value: SOURCE });
await tick();
const root = editableIn(container);
setSelection(root, (range) => {
const badge = root.querySelector(BADGE_SELECTOR);
if (!badge) throw new Error('badge not rendered');
range.setStartBefore(badge);
range.setEndAfter(badge);
});
const { event, data } = clipboardEvent('cut');
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
expect(root.textContent).toBe('hello world');
});
it('paste of markdown mention links re-renders badges', async () => {
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
const badge = root.querySelector(BADGE_SELECTOR);
expect(badge).not.toBeNull();
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
expect(root.textContent).toContain('world');
});
it('paste without mention links keeps the DOM untouched', async () => {
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const firstChild = root.firstChild;
const { event } = clipboardEvent('paste', 'plain text');
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
// no rebuild: the live text node is the same instance
expect(root.firstChild).toBe(firstChild);
});
});
describe('ChatFormContenteditable code spans', () => {
it('renders inline code from the initial value', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="inline"]');
expect(code).not.toBeNull();
expect(code!.textContent).toBe('`npm test`');
});
it('renders a fenced code block with a language', async () => {
const source = 'before\n```js\nconst a = 1;\n```\nafter';
const { container } = render(ChatFormContenteditable, { value: source });
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="block"]');
expect(code).not.toBeNull();
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
});
it('copy exposes the markdown source of a selection spanning code', async () => {
const source = 'run `npm test` now';
const { container } = render(ChatFormContenteditable, { value: source });
await tick();
const root = editableIn(container);
setSelection(root, (range) => range.selectNodeContents(root));
const { event, data } = clipboardEvent('copy');
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(data.getData('text/plain')).toBe(source);
});
it('paste of a code span renders the styled element', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run ' });
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const { event } = clipboardEvent('paste', '`npm test` now');
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
const code = root.querySelector('code[data-code-token="inline"]');
expect(code).not.toBeNull();
expect(code!.textContent).toBe('`npm test`');
expect(root.textContent).toContain('now');
});
it('highlights a fenced block content and stays byte-exact', async () => {
const source = '```js\nconst a = 1;\n```';
const { container } = render(ChatFormContenteditable, { value: source });
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="block"]');
expect(code).not.toBeNull();
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
expect(code!.textContent).toBe(source);
});
it('does not highlight inline code', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
await tick();
const root = editableIn(container);
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
});
});
describe('ChatFormContenteditable code block escape hatches', () => {
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
const BLOCK_SELECTOR = 'code[data-code-token="block"]';
function blockIn(root: HTMLElement): HTMLElement {
const el = root.querySelector(BLOCK_SELECTOR);
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
return el;
}
// Caret at the very start/end of the block's text (across highlight spans)
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
const code = blockIn(root);
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
let target: Node | null = null;
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
target = where === 'start' ? (target ?? n) : n;
}
if (!target) throw new Error('no text inside code block');
setSelection(root, (range) => {
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
range.collapse(true);
});
}
function caretContainer(): Node {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
return selection.getRangeAt(0).startContainer;
}
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
// no permanent empty line above a leading block
expect(root.firstChild).toBe(blockIn(root));
expect(root.lastChild?.nodeName).toBe('BR');
setSelection(root, (range) => range.selectNodeContents(root));
const { event, data } = clipboardEvent('copy');
root.dispatchEvent(event);
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
});
it('escapes a trailing code block with ArrowDown and types after it', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{ArrowDown}');
expect(blockIn(root).contains(caretContainer())).toBe(false);
await userEvent.keyboard('x');
await tick();
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
// the DOM holds no separator newline (it would render as a
// phantom empty line); serialization synthesizes it so the
// markdown source keeps the text below the block
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
// the stale trailing hatch is removed once real text follows the block
expect(root.lastChild?.nodeName).not.toBe('BR');
});
it('escapes a leading code block with ArrowUp and types before it', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'start');
await userEvent.keyboard('{ArrowUp}');
expect(blockIn(root).contains(caretContainer())).toBe(false);
// the transient hatch line exists while the caret sits on it
expect(root.firstChild?.nodeName).toBe('BR');
await userEvent.keyboard('y');
await tick();
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
expect(root.textContent).toBe('y' + BLOCK_SOURCE);
expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
// the typed text consumed the hatch
expect(root.firstChild?.nodeName).not.toBe('BR');
});
it('escapes a leading code block with ArrowLeft from its first character', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'start');
await userEvent.keyboard('{ArrowLeft}');
expect(blockIn(root).contains(caretContainer())).toBe(false);
expect(root.firstChild?.nodeName).toBe('BR');
});
it('removes the transient leading hatch when the caret moves back into the block', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'start');
await userEvent.keyboard('{ArrowUp}');
expect(root.firstChild?.nodeName).toBe('BR');
await userEvent.keyboard('{ArrowDown}');
await tick();
expect(blockIn(root).contains(caretContainer())).toBe(true);
expect(root.firstChild).toBe(blockIn(root));
});
it('extends the selection out of the block with Shift+ArrowDown', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
const selection = window.getSelection();
expect(selection).not.toBeNull();
expect(selection!.isCollapsed).toBe(false);
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
});
it('line-separates text typed right after the closing fence', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
// no arrow keys: the caret sits at the block's end edge, where the
// post-rebuild restore lands it, and the typed text renders on the
// line below the block
await userEvent.keyboard('x');
await tick();
// the text stays on the caret's line in the DOM (no phantom empty
// line); the source gets the separator newline
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
});
it('does not double the newline when Shift+Enter already added one', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.keyboard('x');
await tick();
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
});
it('moves a caret stuck before the inserted newline onto the new line', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
await tick();
const root = editableIn(container);
root.focus();
// post-break DOM some browsers produce: the inserted newline plus
// the artificial trailing one, with the caret stuck BEFORE the
// inserted one (visually at the end of the old line)
root.appendChild(document.createTextNode('\n'));
root.appendChild(document.createTextNode('\n'));
setSelection(root, (range) => {
range.setStart(root.childNodes[2], 0);
range.collapse(true);
});
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
await tick();
const selection = window.getSelection();
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
});
it('appends the artificial trailing newline when the browser did not add one', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
await tick();
const root = editableIn(container);
root.focus();
// post-break DOM some browsers produce: a lone trailing \n (or a
// <br> the hatch sync strips). Collapsed by the renderer, so the
// caret looks stuck on the old line and the next typed character
// would consume the newline.
root.appendChild(document.createTextNode('\n'));
setSelection(root, (range) => {
range.setStart(root.childNodes[2], 1);
range.collapse(true);
});
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
await tick();
const selection = window.getSelection();
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
});
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
const text = root.childNodes[1];
range.setStart(text, (text.textContent ?? '').length);
range.collapse(true);
});
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await tick();
const selection = window.getSelection();
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
// the next typed character lands on the new line
await userEvent.keyboard('x');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
});
it('lets Backspace at the text start move into the block without a source fight', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('create');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
// Backspace at the start of the text line: the separator newline
// is structural (synthesized while text follows the block), so
// the caret just moves to the block's edge - nothing is re-added
await userEvent.keyboard('{Home}');
await userEvent.keyboard('{Backspace}');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
expect(caretContainer()).toBe(root);
});
it('lets forward Delete eat the text after a block normally', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('create');
await tick();
await userEvent.keyboard('{Home}');
await userEvent.keyboard('{Delete}');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
});
it('renders text after a block without a phantom empty line', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\nhello'
});
await tick();
const root = editableIn(container);
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
});
it('keeps an intentional blank line after a block out of the separator', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\n\nhello'
});
await tick();
const root = editableIn(container);
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
});
it('re-highlights while typing inside a block and keeps the caret', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('x');
await tick();
const code = blockIn(root);
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
expect(code.querySelector('.hljs-number')).not.toBeNull();
const selection = window.getSelection();
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
});
});
describe('ChatFormContenteditable Enter in code blocks', () => {
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
it('adds a line instead of submitting on plain Enter inside a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE,
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
await tick();
// consumed locally: the parent's submit handler never sees it
expect(onKeydown).not.toHaveBeenCalled();
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
const code = root.querySelector('code[data-code-token="block"]');
const selection = window.getSelection();
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
});
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: '```js\nconst a = 1;',
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
await tick();
expect(onKeydown).not.toHaveBeenCalled();
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
});
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\nafter',
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
await userEvent.keyboard('{Enter}');
expect(onKeydown).toHaveBeenCalledTimes(1);
expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
});
it('forwards plain Enter on the trailing hatch line after a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE,
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
// root-level caret between the block and its trailing br hatch
setSelection(root, (range) => {
range.setStart(root, 1);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
expect(onKeydown).toHaveBeenCalledTimes(1);
});
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE,
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Control>}{Enter}{/Control}');
expect(onKeydown).toHaveBeenCalledWith(
expect.objectContaining({ key: 'Enter', ctrlKey: true })
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
});
it('forwards Enter inside an inline code span', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: 'run `npm test` now',
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
const code = root.querySelector('code[data-code-token="inline"]')!;
setSelection(root, (range) => {
range.setStart(code.firstChild!, 3);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
expect(onKeydown).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,112 @@
// Guards the Enter-key contract of the chat form against the
// fenced-code-block flow: while the caret sits inside a fenced
// block region - closed, or still OPEN while the user is typing
// one - plain Enter adds a line instead of submitting the message.
// The textarea path is covered here end-to-end (the contenteditable
// consumes the same case locally; see chat-form-contenteditable).
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import { tick } from 'svelte';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { settingsStore } from '$lib/stores/settings.svelte';
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
const el = container.querySelector('textarea');
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
return el;
}
describe('ChatForm Enter in code blocks', () => {
beforeEach(() => {
settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
});
it('adds a line after a still-open fence instead of submitting', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).not.toHaveBeenCalled();
expect(textarea.value).toBe('```\n');
});
it('keeps adding lines while the block stays open', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```js');
await tick();
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('const a = 1;');
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).not.toHaveBeenCalled();
expect(textarea.value).toBe('```js\nconst a = 1;\n');
});
it('submits when the caret is before the opening fence', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
textarea.setSelectionRange(0, 0);
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('submits on Enter outside a code block', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('hello');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('submits on Ctrl+Enter even inside a code block', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await tick();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,27 @@
<script lang="ts">
import { untrack } from 'svelte';
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
interface Props {
value?: string;
}
let { value: initial = '' }: Props = $props();
let value = $state(untrack(() => initial));
let inputRef: ChatFormContenteditable | undefined = $state(undefined);
export function getValue() {
return value;
}
export function getCaretOffset() {
return inputRef?.getCaretOffset();
}
export function setCaretOffset(offset: number) {
inputRef?.setCaretOffset(offset);
}
</script>
<ChatFormContenteditable bind:this={inputRef} bind:value />
@@ -0,0 +1,12 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
let { onSubmit }: { onSubmit?: () => void } = $props();
let value = $state('');
</script>
<Tooltip.Provider>
<ChatForm bind:value {onSubmit} />
</Tooltip.Provider>