diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap index 3928b6f13be..a70b1db3a10 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap @@ -89,7 +89,7 @@ exports[`AskAssistantChat > limits maximum input length when maxCharacterLength data-test-id="chat-input" disabled="false" maxlength="100" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" @@ -323,7 +323,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = ` data-test-id="chat-input" disabled="false" maxlength="5000" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" @@ -425,7 +425,7 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = ` data-test-id="chat-input" disabled="false" maxlength="5000" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" @@ -570,7 +570,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = ` data-test-id="chat-input" disabled="true" maxlength="5000" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" @@ -700,7 +700,7 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1` data-test-id="chat-input" disabled="false" maxlength="5000" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" @@ -830,7 +830,7 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = ` data-test-id="chat-input" disabled="false" maxlength="5000" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" @@ -960,7 +960,7 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = ` data-test-id="chat-input" disabled="false" maxlength="5000" - maxlinesbeforescroll="6" + maxlinesbeforescroll="10" minlines="1" modelvalue="" placeholder="assistantChat.inputPlaceholder" diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.test.ts index d6d0a18957e..41c34626f2a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.test.ts @@ -968,4 +968,110 @@ describe('N8nPromptInput', () => { expect(disabledContainer).toBeTruthy(); }); }); + + describe('height adjustment optimization', () => { + it('should skip height adjustment when content fits within current height', async () => { + // Mock scrollHeight and clientHeight to simulate content that fits + const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'scrollHeight', + ); + const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'clientHeight', + ); + + Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', { + configurable: true, + get(this: HTMLTextAreaElement) { + return 72; // Content height + }, + }); + + Object.defineProperty(HTMLTextAreaElement.prototype, 'clientHeight', { + configurable: true, + get(this: HTMLTextAreaElement) { + return 72; // Container height (content fits perfectly) + }, + }); + + try { + const wrapper = mount(N8nPromptInput, { + props: { + modelValue: 'Line 1\nLine 2\nLine 3\nLine 4', + }, + global: { + stubs: ['N8nCallout', 'N8nScrollArea', 'N8nSendStopButton'], + }, + }); + + // Wait for initial mount to complete + await wrapper.vm.$nextTick(); + + const textarea = wrapper.find('textarea').element as HTMLTextAreaElement; + + // Start spying after mount + const setHeightSpy = vi.spyOn(textarea.style, 'height', 'set'); + + // Type some text that doesn't change the height requirement + await wrapper.setProps({ modelValue: 'Line 1\nLine 2\nLine 3\nLine 4a' }); + + // Wait for the watcher to trigger + await wrapper.vm.$nextTick(); + + // Since content fits, adjustHeight should return early without setting height to '0' + const heightCalls = setHeightSpy.mock.calls; + const hasZeroHeightCall = heightCalls.some((call) => call[0] === '0'); + + // If early return works, there should be no '0' height call after the prop update + expect(hasZeroHeightCall).toBe(false); + + wrapper.unmount(); + } finally { + // Restore original descriptors + if (originalScrollHeightDescriptor) { + Object.defineProperty( + HTMLTextAreaElement.prototype, + 'scrollHeight', + originalScrollHeightDescriptor, + ); + } + if (originalClientHeightDescriptor) { + Object.defineProperty( + HTMLTextAreaElement.prototype, + 'clientHeight', + originalClientHeightDescriptor, + ); + } + } + }); + + it('should use ScrollArea to constrain visible area', () => { + // This test verifies that the component uses N8nScrollArea with max-height + // to constrain the visible area, rather than capping the textarea height itself + const wrapper = mount(N8nPromptInput, { + props: { + modelValue: 'Line1\nLine2', + maxLinesBeforeScroll: 10, + minLines: 2, + }, + global: { + stubs: { + N8nCallout: true, + N8nScrollArea: { + props: ['maxHeight', 'type'], + template: '
', + }, + N8nSendStopButton: true, + }, + }, + }); + + // Verify that N8nScrollArea is used in multiline mode + const scrollArea = wrapper.find('.scroll-area-stub'); + expect(scrollArea.exists()).toBe(true); + + wrapper.unmount(); + }); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue index 1298ba76050..adfa95eec25 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue @@ -30,7 +30,7 @@ const props = withDefaults(defineProps(), { modelValue: '', placeholder: '', maxLength: 5000, - maxLinesBeforeScroll: 6, + maxLinesBeforeScroll: 10, minLines: 1, streaming: false, disabled: false, @@ -53,6 +53,7 @@ const { t } = useI18n(); const textareaRef = ref(); const containerRef = ref(); +const scrollAreaRef = ref>(); const isFocused = ref(false); const textValue = ref(props.modelValue || ''); const singleLineHeight = 24; @@ -136,9 +137,8 @@ const textareaStyle = computed<{ height?: string; overflowY?: 'hidden' }>(() => return {}; } - const height = Math.min(textareaHeight.value, textAreaMaxHeight.value); return { - height: `${height}px`, + height: `${textareaHeight.value}px`, overflowY: 'hidden', }; }); @@ -168,26 +168,49 @@ function adjustHeight() { return; } - // Measure the natural height if (!textareaRef.value) return; - textareaRef.value.style.height = '0'; + + // Save scroll position BEFORE any measurements or height changes + // Only save if we're in multiline mode and have a scroll area + let viewportEl: HTMLElement | null = null; + let savedScrollTop = 0; + + if (wasMultiline && scrollAreaRef.value) { + const scrollAreaElement = scrollAreaRef.value.$el as HTMLElement | undefined; + viewportEl = scrollAreaElement?.querySelector( + '[data-reka-scroll-area-viewport]', + ) as HTMLElement | null; + if (viewportEl) { + savedScrollTop = viewportEl.scrollTop; + } + } + + // Measure required height using 'auto' instead of '0' to minimize visual disruption + const currentHeight = textareaRef.value.style.height; + textareaRef.value.style.height = 'auto'; const scrollHeight = textareaRef.value.scrollHeight; + textareaRef.value.style.height = currentHeight; // Restore immediately to minimize flash // Check if we need multiline mode - // Switch to multiline when text would wrap, when there's actual line breaks, or when minLines > 1 const shouldBeMultiline = props.minLines > 1 || scrollHeight > singleLineHeight || textValue.value.includes('\n'); - // Update height tracking - use at least the minimum height - textareaHeight.value = Math.max(scrollHeight, minHeight); + // Update height tracking + const newHeight = Math.max(scrollHeight, minHeight); + textareaHeight.value = newHeight; isMultiline.value = shouldBeMultiline; // Apply the appropriate height if (!isMultiline.value) { textareaRef.value.style.height = `${singleLineHeight}px`; } else { - // For multiline, set at least minHeight - textareaRef.value.style.height = `${Math.max(scrollHeight, minHeight)}px`; + textareaRef.value.style.height = `${newHeight}px`; + + // Restore scroll position immediately after setting height + // This needs to happen before browser recalculates layout + if (viewportEl && wasMultiline && savedScrollTop > 0) { + viewportEl.scrollTop = savedScrollTop; + } } // Restore focus if mode changed or if scrollbar appeared/disappeared @@ -358,6 +381,7 @@ defineExpose({