feat(editor): Add adaptive layout to ChatInput (#36939)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: kgrhartlage <kai.hartlage@n8n.io>
This commit is contained in:
Jan
2026-08-26 08:46:15 +00:00
committed by GitHub
parent 532911cd70
commit 435ed5feff
5 changed files with 403 additions and 26 deletions
@@ -18,7 +18,7 @@ export default {
},
layout: {
control: 'select',
options: ['single-line', 'multiline'],
options: ['single-line', 'multiline', 'adaptive'],
},
placeholder: {
control: 'text',
@@ -122,6 +122,21 @@ MultiLine.args = {
maxLength: 1000,
};
export const Adaptive = Template.bind({});
Adaptive.args = {
placeholder: 'Starts as one line and grows with your text...',
maxLength: 1000,
layout: 'adaptive',
};
Adaptive.parameters = {
docs: {
description: {
story:
'Adaptive supports the default icon-only send/stop button. A custom button label or action slot falls back to the multiline layout.',
},
},
};
const workflowSuggestions: WorkflowSuggestion[] = [
{
id: 'invoice-pipeline',
@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import N8nChatInput from './ChatInput.vue';
import { createComponentRenderer } from '../../__tests__/render';
@@ -177,6 +178,254 @@ describe('N8nChatInput', () => {
});
});
describe('adaptive layout', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('does not reserve the multiline minimum height', () => {
const { container } = renderComponent({
props: {
layout: 'adaptive',
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const chatContainer = container.querySelector('.container') as HTMLElement;
expect(chatContainer.style.minHeight).toBe('');
expect(chatContainer.classList.toString()).toContain('adaptiveContainer');
});
it('keeps the multiline minimum height for the default layout', () => {
const { container } = renderComponent({
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const chatContainer = container.querySelector('.container') as HTMLElement;
expect(chatContainer.style.minHeight).toBe('80px');
});
it('falls back to multiline for a custom send button label', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { container } = renderComponent({
props: {
layout: 'adaptive',
buttonLabel: 'Send message',
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const chatContainer = container.querySelector('.container') as HTMLElement;
expect(chatContainer.style.minHeight).toBe('80px');
expect(chatContainer.classList.toString()).not.toContain('adaptiveContainer');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Falling back to `multiline`'));
});
it.each(['left-actions', 'actions', 'extra-actions', 'right-actions'])(
'falls back to multiline for the %s slot',
(slotName) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { container } = renderComponent({
props: {
layout: 'adaptive',
},
slots: {
[slotName]: '<button>Custom action</button>',
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const chatContainer = container.querySelector('.container') as HTMLElement;
expect(chatContainer.style.minHeight).toBe('80px');
expect(chatContainer.classList.toString()).not.toContain('adaptiveContainer');
expect(container).toHaveTextContent('Custom action');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Falling back to `multiline`'));
},
);
it('falls back to multiline when an action slot appears after mount', async () => {
const showAction = ref(false);
const Host = defineComponent({
setup() {
return () =>
h(
N8nChatInput,
{ layout: 'adaptive' },
showAction.value ? { 'right-actions': () => h('button', 'Custom action') } : {},
);
},
});
const wrapper = mount(Host, {
attachTo: document.body,
global: { stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'] },
});
const chatContainer = wrapper.find('.container');
expect(chatContainer.classes().join(' ')).toContain('adaptiveContainer');
showAction.value = true;
await nextTick();
expect(wrapper.find('.container').classes().join(' ')).not.toContain('adaptiveContainer');
expect(wrapper.find('.container').attributes('style')).toContain('min-height: 80px');
wrapper.unmount();
});
it('remeasures the height when a slot flips the effective layout', async () => {
// Adaptive and multiline use different textarea padding, so identical content
// measures differently; stand in for that with a mutable measurement.
let measured = 32;
const originalDescriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'scrollHeight',
);
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
configurable: true,
get: () => measured,
});
try {
const showAction = ref(false);
const Host = defineComponent({
setup() {
return () =>
h(
N8nChatInput,
{ layout: 'adaptive', modelValue: 'hello' },
showAction.value ? { 'right-actions': () => h('button', 'Action') } : {},
);
},
});
const wrapper = mount(Host, {
attachTo: document.body,
global: { stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'] },
});
await vi.waitFor(() =>
expect(wrapper.find('textarea').attributes('style')).toContain('height: 32px'),
);
measured = 36;
showAction.value = true;
await nextTick();
await vi.waitFor(() =>
expect(wrapper.find('textarea').attributes('style')).toContain('height: 36px'),
);
wrapper.unmount();
} finally {
if (originalDescriptor) {
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalDescriptor);
} else {
Reflect.deleteProperty(HTMLTextAreaElement.prototype, 'scrollHeight');
}
}
});
it('remeasures the height when the layout prop changes', async () => {
let measured = 32;
const originalDescriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'scrollHeight',
);
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
configurable: true,
get: () => measured,
});
try {
const wrapper = mount(N8nChatInput, {
attachTo: document.body,
props: { layout: 'adaptive', modelValue: 'hello' },
global: { stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'] },
});
await vi.waitFor(() =>
expect(wrapper.find('textarea').attributes('style')).toContain('height: 32px'),
);
measured = 36;
await wrapper.setProps({ layout: 'multiline' });
await vi.waitFor(() =>
expect(wrapper.find('textarea').attributes('style')).toContain('height: 36px'),
);
wrapper.unmount();
} finally {
if (originalDescriptor) {
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalDescriptor);
} else {
Reflect.deleteProperty(HTMLTextAreaElement.prototype, 'scrollHeight');
}
}
});
it('autosizes the textarea like multiline', async () => {
const originalDescriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'scrollHeight',
);
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
configurable: true,
get(this: HTMLTextAreaElement) {
return this.value?.includes('\n') ? 72 : 24;
},
});
try {
const { container } = renderComponent({
props: {
layout: 'adaptive',
modelValue: '',
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.value = 'Line 1\nLine 2\nLine 3';
await fireEvent.input(textarea);
await vi.waitFor(() => expect(textarea.getAttribute('style')).toContain('height: 72px'));
} finally {
if (originalDescriptor) {
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalDescriptor);
} else {
Reflect.deleteProperty(HTMLTextAreaElement.prototype, 'scrollHeight');
}
}
});
it('submits on Enter and inserts a newline on Shift+Enter, like multiline', async () => {
const render = renderComponent({
props: {
layout: 'adaptive',
modelValue: 'Test message',
},
global: {
stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'],
},
});
const textarea = render.container.querySelector('textarea') as HTMLTextAreaElement;
await fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
expect(render.emitted('submit')).toBeFalsy();
expect(render.emitted('update:modelValue')).toBeTruthy();
await fireEvent.keyDown(textarea, { key: 'Enter' });
expect(render.emitted('submit')).toBeTruthy();
});
});
describe('character limit', () => {
it('should show warning banner when at character limit', () => {
const { container } = renderComponent({
@@ -1,5 +1,15 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue';
import {
computed,
nextTick,
onMounted,
onUpdated,
ref,
toRef,
useSlots,
watch,
watchEffect,
} from 'vue';
import { useAutosizeTextarea } from '../../composables/useAutosizeTextarea';
import { useCharacterLimit } from '../../composables/useCharacterLimit';
@@ -30,7 +40,17 @@ export interface N8nChatInputProps {
refocusAfterSend?: boolean;
autofocus?: boolean;
buttonLabel?: string;
layout?: 'multiline' | 'single-line';
/**
* - 'multiline': the tall composer — a fixed minimum height with the actions on
* their own bottom row.
* - 'single-line': one fixed row; Enter always submits.
* - 'adaptive': starts as a single row with the send button inline and grows
* with the content, up to `maxLinesBeforeScroll`. Keyboard behavior matches
* 'multiline' (Shift+Enter inserts a newline). Supports only the default
* icon-only send/stop button; custom labels or action slots fall back to
* 'multiline'.
*/
layout?: 'multiline' | 'single-line' | 'adaptive';
autosize?: boolean | { minRows: number; maxRows: number };
submitDisabled?: boolean;
sendButtonTestId?: string;
@@ -69,11 +89,40 @@ const emit = defineEmits<{
'upgrade-click': [];
}>();
const slots = useSlots();
const { t } = useI18n();
const textareaRef = ref<HTMLTextAreaElement>();
const isFocused = ref(false);
const textValue = ref(props.modelValue || '');
function hasCustomActions() {
return Boolean(
props.buttonLabel ||
slots['left-actions'] ||
slots.actions ||
slots['extra-actions'] ||
slots['right-actions'],
);
}
// Read fresh on every use instead of via a computed: slots are not reactive, so a
// computed would cache the first result and miss action slots that appear later.
function effectiveLayout() {
return props.layout === 'adaptive' && hasCustomActions() ? 'multiline' : props.layout;
}
if (import.meta.env.DEV) {
watchEffect(() => {
if (props.layout === 'adaptive' && hasCustomActions()) {
// eslint-disable-next-line no-console
console.warn(
'[N8nChatInput] `layout="adaptive"` supports only the default icon-only send/stop button. ' +
'Falling back to `multiline` because a custom button label or action slot was provided.',
);
}
});
}
const autosizeRows = computed(() =>
typeof props.autosize === 'object'
? props.autosize
@@ -104,9 +153,11 @@ const sendDisabled = computed(
props.creditsRemaining === 0),
);
const containerStyle = computed(() => {
return props.layout === 'single-line' ? undefined : { minHeight: '80px' };
});
// Only the classic multiline composer reserves the tall block; 'adaptive' starts
// at one row and lets the autosized textarea drive the height.
function containerStyle() {
return effectiveLayout() === 'multiline' ? { minHeight: '80px' } : undefined;
}
const hasNoCredits = computed(() => {
return (
@@ -133,7 +184,7 @@ watch(
async (newValue) => {
textValue.value = newValue || '';
await nextTick();
if (props.layout === 'single-line' || props.autosize === false) return;
if (!isAutosizeEnabled.value) return;
// Wait for an additional animation frame to ensure DOM has fully updated
await new Promise(requestAnimationFrame);
@@ -141,19 +192,10 @@ watch(
},
);
watch([() => props.layout, () => props.autosize], ([layout, autosize]) => {
if (layout === 'single-line' || autosize === false) {
clearTextareaHeight();
return;
}
void nextTick(() => adjustHeight());
});
watch(textValue, (newValue, oldValue) => {
emit('update:modelValue', newValue);
// Single-line layout has fixed height; only multiline needs autosizing.
if (props.layout === 'single-line' || props.autosize === false) return;
if (!isAutosizeEnabled.value) return;
// Only adjust height if value actually changed
if (newValue !== oldValue) {
@@ -182,7 +224,7 @@ async function handleStop() {
}
async function handleKeyDown(event: KeyboardEvent) {
if (props.layout === 'single-line' && event.key === 'Enter' && !event.isComposing) {
if (effectiveLayout() === 'single-line' && event.key === 'Enter' && !event.isComposing) {
event.preventDefault();
if (!sendDisabled.value) {
await handleSubmit();
@@ -250,6 +292,17 @@ function focusInput(options?: FocusOptions) {
textareaRef.value?.focus(options);
}
// Each layout measures to a different height, so switching leaves a stale one.
// Slot-driven switches aren't reactive, so re-check on render, not in a watcher.
let renderedLayout = effectiveLayout();
onUpdated(() => {
const currentLayout = effectiveLayout();
if (currentLayout === renderedLayout) return;
renderedLayout = currentLayout;
adjustHeight();
});
onMounted(() => {
// Adjust height on mount to respect initial content
void nextTick(() => adjustHeight());
@@ -273,10 +326,11 @@ defineExpose({
{
[$style.focused]: isFocused,
[$style.disabled]: disabled || hasNoCredits,
[$style.singleLineContainer]: layout === 'single-line',
[$style.singleLineContainer]: effectiveLayout() === 'single-line',
[$style.adaptiveContainer]: effectiveLayout() === 'adaptive',
},
]"
:style="containerStyle"
:style="containerStyle()"
@click.self="handleContainerClick"
>
<slot name="leading" />
@@ -290,7 +344,10 @@ defineExpose({
v-model="textValue"
:class="[
$style.textarea,
{ [$style.singleLineTextarea]: layout === 'single-line' },
{
[$style.singleLineTextarea]: effectiveLayout() === 'single-line',
[$style.adaptiveTextarea]: effectiveLayout() === 'adaptive',
},
'ignore-key-press-node-creator',
'ignore-key-press-canvas',
]"
@@ -301,11 +358,17 @@ defineExpose({
@keydown="handleKeyDown"
@focus="handleFocus"
@blur="handleBlur"
@input="layout === 'single-line' || autosize === false ? undefined : adjustHeight"
@input="isAutosizeEnabled ? adjustHeight : undefined"
@click="handleFocusableRegionClick"
/>
<div
:class="[$style.bottomActions, { [$style.singleLineActions]: layout === 'single-line' }]"
:class="[
$style.bottomActions,
{
[$style.singleLineActions]: effectiveLayout() === 'single-line',
[$style.adaptiveActions]: effectiveLayout() === 'adaptive',
},
]"
@click="handleFocusableRegionClick"
>
<div
@@ -337,7 +400,7 @@ defineExpose({
</div>
</div>
</div>
<div v-if="$slots.trailing && layout !== 'single-line'" :class="$style.trailing">
<div v-if="$slots.trailing && effectiveLayout() !== 'single-line'" :class="$style.trailing">
<slot name="trailing" />
</div>
</div>
@@ -386,6 +449,18 @@ defineExpose({
align-items: center;
}
/* Adaptive: one visual row that grows with the autosized textarea. The send button
is pinned to the bottom-right corner instead of holding a dedicated actions row,
so the textarea height is the only thing that moves while typing — the button
just rides the bottom edge. See the overrides next to the single-line ones at
the end of this stylesheet. */
.adaptiveContainer {
position: relative;
/* The pinned button is taller than one line of text; without this floor it
would poke out of the single-row state. */
min-height: calc(var(--height--md) + 2 * var(--spacing--2xs));
}
.textarea {
width: 100%;
border: none;
@@ -458,6 +533,23 @@ defineExpose({
padding: 0;
}
/* Placed after `.textarea`, whose `padding` shorthand would otherwise win the tie. */
.adaptiveTextarea {
/* Reserve the pinned button's column at every height: a width that differed
between the compact and grown states would re-wrap the text and make the
two states oscillate. */
padding-right: calc(var(--height--md) + var(--spacing--3xs));
/* A 24px text line plus 4px above and below matches the 32px send button,
leaving the same 8px inset on every side in the compact state. */
padding-block: var(--spacing--4xs);
}
.adaptiveActions {
position: absolute;
right: var(--spacing--2xs);
bottom: var(--spacing--2xs);
}
.leading {
display: flex;
align-items: center;
@@ -102,6 +102,7 @@ describe('useAutosizeTextarea', () => {
const TestComponent = defineComponent({
props: {
enabled: { type: Boolean, default: true },
content: { type: String, default: 'one\ntwo\nthree' },
},
setup(props) {
const textarea = ref<HTMLTextAreaElement>();
@@ -114,7 +115,7 @@ describe('useAutosizeTextarea', () => {
return { textarea, textareaStyles, calculateTextareaHeight, clearTextareaHeight };
},
template: '<textarea ref="textarea" value="one\ntwo\nthree" />',
template: '<textarea ref="textarea" :value="content" />',
});
it('recalculates height into reactive styles', () => {
@@ -151,6 +152,23 @@ describe('useAutosizeTextarea', () => {
expect(textarea.scrollTop).toBe(textarea.scrollHeight);
});
it('does not scroll a textarea whose content still fits within maxRows', async () => {
const wrapper = mount(TestComponent, {
attachTo: document.body,
props: { content: 'one\ntwo' },
});
const textarea = wrapper.vm.textarea as HTMLTextAreaElement;
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
textarea.scrollTop = 0;
wrapper.vm.calculateTextareaHeight();
await nextTick();
expect(wrapper.vm.textareaStyles.overflowY).toBe('hidden');
expect(textarea.scrollTop).toBe(0);
});
it('preserves the scroll position when the caret is mid-content', async () => {
const wrapper = mount(TestComponent, { attachTo: document.body });
const textarea = wrapper.vm.textarea as HTMLTextAreaElement;
@@ -143,7 +143,10 @@ export function useAutosizeTextarea({
if (!toValue(enabled) || !textareaElement) return;
textareaStyles.value = calcTextareaHeight(textareaElement, toValue(rows));
void scrollCaretIntoView();
// Only scroll when the content genuinely exceeds maxRows.
if (textareaStyles.value.overflowY === 'auto') {
void scrollCaretIntoView();
}
};
function clearTextareaHeight() {