feat : wire the contenteditable into ChatForm with auto-switch gating

This commit is contained in:
Aleksander Grygier
2026-08-07 20:20:04 +02:00
committed by Pascal
parent e260464575
commit 7c1482535e
15 changed files with 288 additions and 109 deletions
@@ -2,6 +2,7 @@
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormContenteditable,
ChatFormFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
@@ -46,10 +47,12 @@
} from '$lib/types';
import {
buildMentionInsertion,
containsCodeSpan,
containsFileMentionLink,
findCommandToken,
findMentionToken,
isIMEComposing,
mentionLinkEndingAt,
isOffsetInCodeBlock,
parseClipboardContent,
uuid
} from '$lib/utils';
@@ -109,12 +112,28 @@
onValueChange
}: Props = $props();
// Component References
// Shared handle of the two input renderers (textarea + contenteditable).
type ChatInputHandle = {
focus(): void;
resetHeight(): void;
getElement(): HTMLElement | undefined;
getCaretOffset(): number;
setCaretOffset(offset: number): void;
};
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let inputRef: ChatFormTextarea | undefined = $state(undefined);
let inputRef: ChatInputHandle | undefined = $state(undefined);
// Render-mode gate: the plain textarea by default, the contenteditable
// while the buffer carries a `file://` mention link or a complete code
// span (badges and code chips need a DOM the textarea cannot provide).
// Demotes back once neither remains.
let useContenteditable = $state(false);
// Audio Recording State
let isRecording = $state(false);
@@ -201,6 +220,34 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
// Caret offset restored after a renderer swap. Callers that mutate
// `value` themselves (e.g. the mention picker) pin the target offset
// BEFORE the assignment; otherwise the swap effect snapshots the
// current caret.
let pendingCaretOffset = 0;
let caretOffsetPinned = false;
function queueCaretRestore() {
queueMicrotask(() => {
inputRef?.focus();
inputRef?.setCaretOffset(pendingCaretOffset);
caretOffsetPinned = false;
});
}
$effect(() => {
const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useContenteditable === wantContenteditable) return;
if (!caretOffsetPinned) {
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
}
useContenteditable = wantContenteditable;
queueCaretRestore();
});
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
@@ -252,25 +299,19 @@
return;
}
// Backspace at a mention link's end deletes the whole token at once.
if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
const el = inputRef?.getElement();
if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) {
const link = mentionLinkEndingAt(value, el.selectionStart);
if (link) {
event.preventDefault();
value = value.slice(0, link.start) + value.slice(link.end);
onValueChange?.(value);
queueMicrotask(() => inputRef?.setCaretOffset(link.start));
return;
}
}
}
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
const isModifier = event.ctrlKey || event.metaKey;
const sendOnEnter = currentConfig.sendOnEnter !== false;
// Caret inside a fenced code block (closed, or still open
// while being typed): Enter adds a line, never submits. The
// contenteditable consumes this case locally; this gate
// covers the plain textarea, where skipping submit lets the
// native newline through.
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
return;
}
if (sendOnEnter || isModifier) {
event.preventDefault();
@@ -444,14 +485,20 @@
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs;
// otherwise the effect clobbers it with the textarea's selection
// at promotion time (browser-dependent: usually reset to 0).
pendingCaretOffset = built.caretOffset;
caretOffsetPinned = true;
value = built.newValue;
onValueChange?.(built.newValue);
// bind:value applies on the next microtask; restore the caret after.
queueMicrotask(() => {
inputRef?.focus();
inputRef?.setCaretOffset(built.caretOffset);
});
// Already in contenteditable mode: no renderer flip, so the swap
// effect's caret restore never runs.
if (useContenteditable) {
queueCaretRestore();
}
}
async function handleMicClick() {
@@ -541,19 +588,35 @@
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
>
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{#if useContenteditable}
<ChatFormContenteditable
class="px-5 py-1.5 md:pt-0 mb-0.5"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{:else}
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{/if}
{#if mcpHasResourceAttachments()}
<ChatFormMcpResourcesList
@@ -2,7 +2,7 @@
import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import * as Popover from '$lib/components/ui/popover';
@@ -162,6 +162,17 @@
}
export function handleKeydown(event: KeyboardEvent): boolean {
// Always consume Enter while the picker is open - even with no
// result yet (skeletons) or no matches - so the chat form's
// Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]);
}
return true;
}
return nav.handleKeydown(event);
}
</script>
@@ -48,7 +48,8 @@
}
}
// Plain-text caret offsets for the picker/paste/mention-splice flows.
// Plain-text caret offsets, shared with the contenteditable variant so
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
+12 -4
View File
@@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts
* - Composes ChatFormTextarea (or ChatFormContenteditable for messages with
* file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelectorDropdown for model selection in router mode
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
@@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
/**
* Auto-resizing textarea with IME composition support. Mention links stay
* plain markdown text in the input; the chip rendering happens in the
* message view via the rehype file-badge plugin.
* Auto-resizing contenteditable input that renders `[name](file://...)`
* mention links as inline chips while keeping the value as the markdown
* source string. ChatForm swaps it in once a mention link lands in the
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
*/
export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte';
/**
* Plain auto-resizing textarea with IME composition support. Default input
* renderer inside ChatForm until a file mention lands.
*/
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
@@ -34,7 +34,8 @@
preprocessLaTeX,
getImageErrorFallbackHtml,
copyCodeToClipboard,
copyToClipboard
copyToClipboard,
splitGluedClosingCodeFences
} from '$lib/utils';
import {
IMAGE_NOT_ERROR_BOUND_SELECTOR,
@@ -342,7 +343,11 @@
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
* @param markdown - The raw markdown string to process
*/
async function processMarkdown(markdown: string) {
async function processMarkdown(rawMarkdown: string) {
// Text glued to a closing code fence is not a fence to the parser -
// the block would swallow it. Split it onto its own line first.
const markdown = splitGluedClosingCodeFences(rawMarkdown);
// Early exit if content unchanged (can happen with rapid coalescing)
if (markdown === previousContent) {
return;
@@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) {
/* Code blocks */
.markdown-content :global(.code-block-wrapper) {
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
@@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) {
max-height: var(--max-message-height);
}
.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) {
margin-top: 1rem;
}
.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) {
margin-bottom: 1rem;
}
.markdown-content:global(.dark) :global(.code-block-wrapper) {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
@@ -1,7 +1,7 @@
/**
* Rehype plugin that rewrites `file://` markdown anchors into the inline
* @-mention chip, reusing the visual contract from
* `$lib/constants/mention-badge`.
* mention chip, sharing the class string with the contenteditable
* tokenizer via `$lib/constants/mention-badge`.
*
* The chip is presentational: `file://` navigation is blocked from
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
+3 -2
View File
@@ -19,8 +19,9 @@ export const PANEL_CLASSES = `
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
export const DIALOG_SUBMENU_CONTENT = 'w-60';
/** Selects the chat-form input to restore focus after model actions. */
export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea';
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
+9 -6
View File
@@ -1,8 +1,9 @@
/**
* Visual contract for message @-mention badges. Svelte cannot be mounted
* from a hast tree, so the rehype file-badge plugin emits the shared class
* string below; keeping it here as a literal lets Tailwind's source
* scanner generate the utility classes.
* Shared visual contract between the two DOM-only badge paths (the
* contenteditable tokenizer + the rehype plugin). Svelte cannot be
* mounted at the per-keystroke tokenizer hot path nor from a hast tree,
* so both emit the badge with the same class string literal; Tailwind's
* scanner picks it up in both sources.
*/
export const MENTION_BADGE_CLASSNAME =
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
@@ -10,8 +11,10 @@ export const MENTION_BADGE_CLASSNAME =
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
/**
* SVG attributes shared by the hast-built badge icons; the rehype plugin
* spreads them onto the `<svg>` `properties`.
* SVG attributes shared by the DOM-built and hast-built badge icons.
* The tokenizer applies them via `setAttribute`, the rehype plugin
* spreads them onto the hast `<svg>` `properties`; string values are
* valid for both.
*/
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
xmlns: 'http://www.w3.org/2000/svg',
-1
View File
@@ -8,7 +8,6 @@ export enum KeyboardKey {
ARROW_DOWN = 'ArrowDown',
ARROW_LEFT = 'ArrowLeft',
ARROW_RIGHT = 'ArrowRight',
BACKSPACE = 'Backspace',
TAB = 'Tab',
B_LOWER = 'b',
D_LOWER = 'd',
+4 -4
View File
@@ -226,9 +226,10 @@ export {
// Source-space undo/redo history for the chat-form contenteditable
export { SourceHistory, type SourceHistoryEntry } from './source-history';
// Mention-chip visual contract shared by the rehype file-badge plugin,
// plus the `[name](file://...)` link helpers the mention picker splices in
// Mention-badge visual contract (used by the contenteditable / rehype
// DOM paths that build the same chip without a Svelte mount)
export {
containsFileMentionLink,
fileMentionLinkRe,
encodeFileLinkPath,
decodeFileLinkPath,
@@ -239,8 +240,7 @@ export {
MENTION_BADGE_FOLDER_ICON_PATHS,
getMentionBadgeIconPaths,
getMentionBadgeLabel,
buildMentionInsertion,
mentionLinkEndingAt
buildMentionInsertion
} from './mention-badge';
// Agentic content utilities (structured section derivation)
+4 -19
View File
@@ -23,6 +23,10 @@ export function fileMentionLinkRe(flags = ''): RegExp {
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
}
export function containsFileMentionLink(value: string): boolean {
return fileMentionLinkRe().test(value);
}
// Escape each path segment for a markdown link destination (spaces/parens
// break CommonMark); keeps the trailing slash that marks a directory.
export function encodeFileLinkPath(path: string): string {
@@ -60,25 +64,6 @@ export function getMentionBadgeLabel(
return abbreviateHome(decoded, home);
}
/**
* Extent of the mention link ending exactly at `caret`, so Backspace there
* deletes the whole `[name](file://...)` token in one keystroke instead of
* unraveling it character by character. Null when no link ends at `caret`.
*/
export function mentionLinkEndingAt(
value: string,
caret: number
): { start: number; end: number } | null {
const re = fileMentionLinkRe('g');
let match: RegExpExecArray | null;
while ((match = re.exec(value)) !== null) {
const end = match.index + match[0].length;
if (end === caret) return { start: match.index, end };
if (end > caret) break;
}
return null;
}
/**
* Build the markdown link that replaces a mention token. Entry `path` is
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
@@ -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,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>
+3 -31
View File
@@ -3,12 +3,12 @@ import {
MENTION_BADGE_FILE_ICON_PATHS,
MENTION_BADGE_FOLDER_ICON_PATHS,
buildMentionInsertion,
containsFileMentionLink,
decodeFileLinkPath,
encodeFileLinkPath,
fileMentionLinkRe,
getMentionBadgeIconPaths,
getMentionBadgeLabel,
mentionLinkEndingAt
getMentionBadgeLabel
} from '$lib/utils';
import { FileMentionEntryType } from '$lib/enums';
@@ -35,6 +35,7 @@ describe('encodeFileLinkPath', () => {
describe('fileMentionLinkRe', () => {
it('matches a standard mention link', () => {
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true);
});
it('does not match non-file links', () => {
@@ -171,32 +172,3 @@ describe('buildMentionInsertion', () => {
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
});
});
describe('mentionLinkEndingAt', () => {
const LINK = '[docs](file:///a/b)';
it('returns the extent when the caret is exactly at the link end', () => {
expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({
start: 4,
end: 4 + LINK.length
});
});
it('returns null when the caret is inside or past the link', () => {
expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull();
expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull();
});
it('returns null for non-file links and plain text', () => {
expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull();
expect(mentionLinkEndingAt('plain', 5)).toBeNull();
});
it('picks the link that ends at the caret when several exist', () => {
const value = `${LINK} and ${LINK}`;
expect(mentionLinkEndingAt(value, value.length)).toEqual({
start: value.length - LINK.length,
end: value.length
});
});
});