fix(ai-builder): Prompt input scrolling fix and update max line from 6 -> 10 (#21165)

This commit is contained in:
Michael Drury
2025-10-27 11:41:17 +00:00
committed by GitHub
parent 1c4b838512
commit 3caa5ac3a5
3 changed files with 147 additions and 17 deletions
@@ -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"
@@ -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: '<div class="scroll-area-stub"><slot /></div>',
},
N8nSendStopButton: true,
},
},
});
// Verify that N8nScrollArea is used in multiline mode
const scrollArea = wrapper.find('.scroll-area-stub');
expect(scrollArea.exists()).toBe(true);
wrapper.unmount();
});
});
});
@@ -30,7 +30,7 @@ const props = withDefaults(defineProps<N8nPromptInputProps>(), {
modelValue: '',
placeholder: '',
maxLength: 5000,
maxLinesBeforeScroll: 6,
maxLinesBeforeScroll: 10,
minLines: 1,
streaming: false,
disabled: false,
@@ -53,6 +53,7 @@ const { t } = useI18n();
const textareaRef = ref<HTMLTextAreaElement>();
const containerRef = ref<HTMLDivElement>();
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
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({
<template v-else>
<!-- Use ScrollArea when content exceeds max height -->
<N8nScrollArea
ref="scrollAreaRef"
:class="$style.scrollAreaWrapper"
:max-height="`${textAreaMaxHeight}px`"
type="auto"