fix(frontend): Fixed prompt box resizing behavior (fixes #11025) (#11035)

This commit is contained in:
Atharv Patil 2025-10-06 05:29:45 -07:00 committed by GitHub
parent 50b38e9081
commit 0e2edb63f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 228 additions and 111 deletions

View File

@ -82,6 +82,7 @@ export function CustomChatInput({
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
resetManualResize,
} = useGripResize(
chatInputRef as React.RefObject<HTMLDivElement | null>,
messageToSend,
@ -92,6 +93,7 @@ export function CustomChatInput({
fileInputRef as React.RefObject<HTMLInputElement | null>,
smartResize,
onSubmit,
resetManualResize,
);
const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } =
@ -113,7 +115,6 @@ export function CustomChatInput({
},
[setShouldHideSuggestions, clearAllFiles],
);
return (
<div className={`w-full ${className}`}>
{/* Hidden file input */}

View File

@ -12,6 +12,7 @@ export const useChatSubmission = (
fileInputRef: React.RefObject<HTMLInputElement | null>,
smartResize: () => void,
onSubmit: (message: string) => void,
resetManualResize?: () => void,
) => {
// Send button click handler
const handleSubmit = useCallback(() => {
@ -30,7 +31,10 @@ export const useChatSubmission = (
// Reset height and show suggestions again
smartResize();
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
// Reset manual resize state for next message
resetManualResize?.();
}, [chatInputRef, fileInputRef, smartResize, onSubmit, resetManualResize]);
// Resume agent button click handler
const handleResumeAgent = useCallback(() => {
@ -44,7 +48,10 @@ export const useChatSubmission = (
// Reset height and show suggestions again
smartResize();
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
// Reset manual resize state for next message
resetManualResize?.();
}, [chatInputRef, fileInputRef, smartResize, onSubmit, resetManualResize]);
// Handle stop button click
const handleStop = useCallback((onStop?: () => void) => {

View File

@ -58,6 +58,7 @@ export const useGripResize = (
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
resetManualResize,
} = useAutoResize(chatInputRef as React.RefObject<HTMLElement | null>, {
minHeight: 20,
maxHeight: 400,
@ -76,5 +77,6 @@ export const useGripResize = (
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
resetManualResize,
};
};

View File

@ -1,5 +1,7 @@
import { useCallback, useEffect, RefObject } from "react";
import { useCallback, useEffect, RefObject, useRef } from "react";
import { IMessageToSend } from "#/state/conversation-store";
import { EPS } from "#/utils/constants";
import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils";
import { useDragResize } from "./use-drag-resize";
// Constants
@ -8,6 +10,13 @@ const DEFAULT_MAX_HEIGHT = 120;
const HEIGHT_INCREMENT = 20;
const MANUAL_OVERSIZE_THRESHOLD = 50;
// Manual height tracking utilities
const useManualHeight = () => {
const hasUserResizedRef = useRef(false);
const manualHeightRef = useRef<number | null>(null);
return { hasUserResizedRef, manualHeightRef };
};
interface UseAutoResizeOptions {
minHeight?: number;
maxHeight?: number;
@ -19,11 +28,11 @@ interface UseAutoResizeOptions {
}
interface UseAutoResizeReturn {
autoResize: () => void;
smartResize: () => void;
handleGripMouseDown: (e: React.MouseEvent) => void;
handleGripTouchStart: (e: React.TouchEvent) => void;
increaseHeightForEmptyContent: () => void;
resetManualResize: () => void;
}
// Height management utilities
@ -60,27 +69,6 @@ const applyHeightToElement = (
return finalHeight;
};
const calculateOptimalHeight = (
element: HTMLElement,
constraints: HeightConstraints,
): number => {
const { minHeight, maxHeight, scrollHeight } = {
...constraints,
scrollHeight: element.scrollHeight,
};
if (scrollHeight <= maxHeight) {
return Math.max(scrollHeight, minHeight);
}
return maxHeight;
};
const getCurrentElementHeight = (
element: HTMLElement,
minHeight: number,
): number =>
element.offsetHeight || parseInt(element.style.height || `${minHeight}`, 10);
const isManuallyOversized = (
currentHeight: number,
contentHeight: number,
@ -91,18 +79,16 @@ const measureElementHeights = (
element: HTMLElement,
minHeight: number,
): HeightMeasurements => {
const currentHeight = getCurrentElementHeight(element, minHeight);
const currentStyleHeight = parseInt(
element.style.height || `${minHeight}`,
10,
);
// Use the previous explicit style height as the "current" for restore, not offsetHeight
const currentStyleHeight = getStyleHeightPx(element, minHeight);
const currentHeight = currentStyleHeight;
// Temporarily reset to measure content
element.style.setProperty("height", "auto");
const contentHeight = element.scrollHeight;
// Restore height
element.style.setProperty("height", `${currentStyleHeight}px`);
setStyleHeightPx(element, currentStyleHeight);
return {
currentHeight,
@ -111,44 +97,6 @@ const measureElementHeights = (
};
};
const determineResizeStrategy = (
measurements: HeightMeasurements,
minHeight: number,
maxHeight: number,
): ResizeStrategy => {
const { currentHeight, contentHeight } = measurements;
// If content fits in current height, just manage overflow
if (contentHeight <= currentHeight) {
return {
finalHeight: currentHeight,
overflowY: "hidden",
};
}
// If content exceeds current height but is within normal auto-resize range
if (contentHeight <= maxHeight) {
// Only grow if the current height is close to the content height (not manually resized much larger)
if (!isManuallyOversized(currentHeight, contentHeight)) {
return {
finalHeight: Math.max(contentHeight, minHeight),
overflowY: "hidden",
};
}
// Keep manual height but show scrollbar since content exceeds visible area
return {
finalHeight: currentHeight,
overflowY: "auto",
};
}
// Content exceeds max height
return {
finalHeight: maxHeight,
overflowY: "auto",
};
};
const applyResizeStrategy = (
element: HTMLElement,
strategy: ResizeStrategy,
@ -169,15 +117,12 @@ const executeHeightCallback = (
};
// DOM manipulation utilities
const resetElementHeight = (element: HTMLElement): void => {
element.style.setProperty("height", "auto");
element.style.setProperty("overflow-y", "hidden");
};
export const useAutoResize = (
elementRef: RefObject<HTMLElement | null>,
options: UseAutoResizeOptions = {},
): UseAutoResizeReturn => {
const pendingSmartRef = useRef<number | null>(null);
const {
minHeight = DEFAULT_MIN_HEIGHT,
maxHeight = DEFAULT_MAX_HEIGHT,
@ -189,65 +134,184 @@ export const useAutoResize = (
} = options;
const constraints: HeightConstraints = { minHeight, maxHeight };
const { hasUserResizedRef, manualHeightRef } = useManualHeight();
const resetManualResize = () => {
hasUserResizedRef.current = false;
manualHeightRef.current = null;
};
// Wrap onHeightChange to track manual height during drag
const handleExternalHeightChange = useCallback(
(elementHeight: number) => {
onHeightChange?.(elementHeight);
if (hasUserResizedRef.current) {
manualHeightRef.current = elementHeight;
}
},
[onHeightChange],
);
// Handle drag start - set manual mode flag
const handleDragStart = useCallback(() => {
hasUserResizedRef.current = true;
onGripDragStart?.();
}, [onGripDragStart]);
// Handle drag end - clear manual mode if at minimum height
const handleDragEnd = useCallback(() => {
const textareaElement = elementRef.current;
if (textareaElement) {
const currentHeight = getStyleHeightPx(textareaElement, minHeight);
if (Math.abs(currentHeight - minHeight) <= EPS) {
hasUserResizedRef.current = false;
manualHeightRef.current = null;
}
}
onGripDragEnd?.();
}, [minHeight, onGripDragEnd]);
// Use the drag resize hook for manual resizing functionality
const { handleGripMouseDown, handleGripTouchStart } = useDragResize({
elementRef,
minHeight,
maxHeight,
onGripDragStart: enableManualResize ? onGripDragStart : undefined,
onGripDragEnd: enableManualResize ? onGripDragEnd : undefined,
onHeightChange,
onGripDragStart: enableManualResize ? handleDragStart : undefined,
onGripDragEnd: enableManualResize ? handleDragEnd : undefined,
onHeightChange: handleExternalHeightChange,
});
// Auto-resize functionality for contenteditable div
const autoResize = () => {
// Handle content that fits within current height
const handleContentFitsInCurrentHeight = useCallback(
(
element: HTMLElement,
currentHeight: number,
contentHeight: number,
): void => {
// If user manually resized and we're above min height, preserve their chosen height
if (hasUserResizedRef.current && currentHeight > minHeight) {
applyResizeStrategy(element, {
finalHeight: currentHeight,
overflowY: "hidden",
});
executeHeightCallback(currentHeight, onHeightChange);
return;
}
// Otherwise allow shrinking towards content (respect minHeight)
const finalHeight = Math.max(contentHeight, minHeight);
applyResizeStrategy(element, {
finalHeight,
overflowY: "hidden",
});
executeHeightCallback(finalHeight, onHeightChange);
},
[minHeight, onHeightChange],
);
// Handle content that exceeds current height but within max height
const handleContentExceedsCurrentHeight = useCallback(
(
element: HTMLElement,
currentHeight: number,
contentHeight: number,
): void => {
// Grow unless the element is manually oversized beyond content significantly
if (!isManuallyOversized(currentHeight, contentHeight)) {
const finalHeight = Math.max(contentHeight, minHeight);
applyResizeStrategy(element, {
finalHeight,
overflowY: "hidden",
});
executeHeightCallback(finalHeight, onHeightChange);
return;
}
// Keep manual height and allow scrolling as needed
applyResizeStrategy(element, {
finalHeight: currentHeight,
overflowY: "auto",
});
executeHeightCallback(currentHeight, onHeightChange);
},
[minHeight, onHeightChange],
);
// Handle content that exceeds max height
const handleContentExceedsMaxHeight = useCallback(
(element: HTMLElement) => {
applyResizeStrategy(element, {
finalHeight: maxHeight,
overflowY: "auto",
});
executeHeightCallback(maxHeight, onHeightChange);
},
[maxHeight, onHeightChange],
);
// Debounced smartResize body
const smartResizeBody = useCallback(() => {
const element = elementRef.current;
if (!element) return;
// Reset height to auto to get the actual content height
resetElementHeight(element);
const textIsEmpty = (element.textContent ?? "").trim().length === 0;
// Calculate and apply optimal height
const optimalHeight = calculateOptimalHeight(element, constraints);
const finalHeight = applyHeightToElement(
element,
optimalHeight,
constraints,
);
// Execute height change callback
executeHeightCallback(finalHeight, onHeightChange);
};
// Smart resize that respects manual height
const smartResize = useCallback(() => {
const element = elementRef.current;
if (!element) return;
// If empty content and we have a manual height above min, preserve it
if (
textIsEmpty &&
hasUserResizedRef.current &&
manualHeightRef.current &&
manualHeightRef.current > minHeight + EPS
) {
setStyleHeightPx(element, manualHeightRef.current);
element.style.overflowY = "hidden";
executeHeightCallback(manualHeightRef.current, onHeightChange);
return;
}
// Measure element heights
const measurements = measureElementHeights(element, minHeight);
const { currentHeight, contentHeight } = measurements;
// Determine the best resize strategy
const strategy = determineResizeStrategy(
measurements,
minHeight,
maxHeight,
);
// If content fits within current height
if (contentHeight <= currentHeight) {
handleContentFitsInCurrentHeight(element, currentHeight, contentHeight);
return;
}
// Apply the resize strategy
applyResizeStrategy(element, strategy);
// If content exceeds current height but within max
if (contentHeight <= maxHeight) {
handleContentExceedsCurrentHeight(element, currentHeight, contentHeight);
return;
}
// Execute height change callback
executeHeightCallback(strategy.finalHeight, onHeightChange);
}, [elementRef, minHeight, maxHeight, onHeightChange]);
// Content exceeds max height
handleContentExceedsMaxHeight(element);
}, [
elementRef,
minHeight,
maxHeight,
onHeightChange,
handleContentFitsInCurrentHeight,
handleContentExceedsCurrentHeight,
handleContentExceedsMaxHeight,
]);
// rAF-debounced smartResize wrapper to collapse bursts
const smartResize = useCallback(() => {
if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current);
pendingSmartRef.current = requestAnimationFrame(() => {
pendingSmartRef.current = null;
smartResizeBody();
});
}, [smartResizeBody]);
// Function to increase height when content is empty
const increaseHeightForEmptyContent = () => {
const element = elementRef.current;
if (!element) return;
const currentHeight = element.offsetHeight;
const currentHeight = getStyleHeightPx(element, minHeight);
const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight);
if (newHeight > currentHeight) {
@ -255,6 +319,10 @@ export const useAutoResize = (
// Execute height change callback
executeHeightCallback(finalHeight, onHeightChange);
// Set manual mode for Shift+Enter height increases
hasUserResizedRef.current = true;
manualHeightRef.current = finalHeight;
}
};
@ -273,10 +341,10 @@ export const useAutoResize = (
}, [smartResize]);
return {
autoResize,
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
resetManualResize,
};
};

View File

@ -1,4 +1,5 @@
import { RefObject } from "react";
import { EPS } from "#/utils/constants";
import { isMobileDevice } from "#/utils/utils";
// Drag handling hook
@ -9,6 +10,7 @@ interface UseDragResizeOptions {
onGripDragStart?: () => void;
onGripDragEnd?: () => void;
onHeightChange?: (height: number) => void;
onReachedMinHeight?: () => void; // Notify when user drags to minimum height
}
export const useDragResize = ({
@ -18,6 +20,7 @@ export const useDragResize = ({
onGripDragStart,
onGripDragEnd,
onHeightChange,
onReachedMinHeight,
}: UseDragResizeOptions) => {
const getClientY = (event: MouseEvent | TouchEvent): number => {
if ("touches" in event && event.touches.length > 0) {
@ -56,6 +59,11 @@ export const useDragResize = ({
if (onHeightChange) {
onHeightChange(newHeight);
}
// Notify when dragged to minimum height to clear manual mode
if (onReachedMinHeight && Math.abs(newHeight - minHeight) <= EPS) {
onReachedMinHeight();
}
};
return handleDragMove;
};

View File

@ -67,3 +67,6 @@ export const CONTEXT_MENU_ICON_TEXT_CLASSNAME = "h-[30px]";
export const CHAT_INPUT = {
HEIGHT_THRESHOLD: 100, // Height in pixels when suggestions should be hidden
};
// UI tolerance constants
export const EPS = 1.5; // px tolerance for "near min" height comparisons

View File

@ -10,6 +10,34 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Get the numeric height value from an element's style property
* @param el The HTML element to get the height from
* @param fallback The fallback value to return if style height is invalid
* @returns The numeric height value in pixels, or the fallback value
*
* @example
* getStyleHeightPx(element, 20) // Returns 20 if element.style.height is "auto" or invalid
* getStyleHeightPx(element, 20) // Returns 100 if element.style.height is "100px"
*/
export const getStyleHeightPx = (el: HTMLElement, fallback: number): number => {
const elementHeight = parseFloat(el.style.height || "");
return Number.isFinite(elementHeight) ? elementHeight : fallback;
};
/**
* Set the height style property of an element to a specific pixel value
* @param el The HTML element to set the height for
* @param height The height value in pixels to set
*
* @example
* setStyleHeightPx(element, 100) // Sets element.style.height to "100px"
* setStyleHeightPx(textarea, 200) // Sets textarea.style.height to "200px"
*/
export const setStyleHeightPx = (el: HTMLElement, height: number): void => {
el.style.setProperty("height", `${height}px`);
};
/**
* Detect if the user is on a mobile device
* @returns True if the user is on a mobile device, false otherwise