mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(ai-builder): Prompt input scrolling fix and update max line from 6 -> 10 (#21165)
This commit is contained in:
+7
-7
@@ -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"
|
||||
|
||||
+106
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+34
-10
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user