From 435ed5feff3b8ccdbc66bd4833ac079c2419a68c Mon Sep 17 00:00:00 2001 From: Jan <3185243+JanOstrowka@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:46:15 +0000 Subject: [PATCH] feat(editor): Add adaptive layout to ChatInput (#36939) Co-authored-by: Cursor Co-authored-by: kgrhartlage --- .../N8nChatInput/ChatInput.stories.ts | 17 +- .../components/N8nChatInput/ChatInput.test.ts | 249 ++++++++++++++++++ .../src/components/N8nChatInput/ChatInput.vue | 138 ++++++++-- .../composables/useAutosizeTextarea.test.ts | 20 +- .../src/composables/useAutosizeTextarea.ts | 5 +- 5 files changed, 403 insertions(+), 26 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.stories.ts index 87da3011298..5e8ca6f7f94 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.stories.ts @@ -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', diff --git a/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.test.ts index c3c05c12f97..b4d7612febd 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.test.ts @@ -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]: '', + }, + 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({ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.vue index 56d00b5f26c..7af9ff7bc80 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nChatInput/ChatInput.vue @@ -1,5 +1,15 @@