From 0e2edb63f52a26162c641d65669db3a9e8adea40 Mon Sep 17 00:00:00 2001 From: Atharv Patil <65488894+patilatharv@users.noreply.github.com> Date: Mon, 6 Oct 2025 05:29:45 -0700 Subject: [PATCH] fix(frontend): Fixed prompt box resizing behavior (fixes #11025) (#11035) --- .../features/chat/custom-chat-input.tsx | 3 +- .../src/hooks/chat/use-chat-submission.ts | 11 +- frontend/src/hooks/chat/use-grip-resize.ts | 2 + frontend/src/hooks/use-auto-resize.ts | 284 +++++++++++------- frontend/src/hooks/use-drag-resize.ts | 8 + frontend/src/utils/constants.ts | 3 + frontend/src/utils/utils.ts | 28 ++ 7 files changed, 228 insertions(+), 111 deletions(-) diff --git a/frontend/src/components/features/chat/custom-chat-input.tsx b/frontend/src/components/features/chat/custom-chat-input.tsx index c48b1e2314..5cf3c13442 100644 --- a/frontend/src/components/features/chat/custom-chat-input.tsx +++ b/frontend/src/components/features/chat/custom-chat-input.tsx @@ -82,6 +82,7 @@ export function CustomChatInput({ handleGripMouseDown, handleGripTouchStart, increaseHeightForEmptyContent, + resetManualResize, } = useGripResize( chatInputRef as React.RefObject, messageToSend, @@ -92,6 +93,7 @@ export function CustomChatInput({ fileInputRef as React.RefObject, smartResize, onSubmit, + resetManualResize, ); const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } = @@ -113,7 +115,6 @@ export function CustomChatInput({ }, [setShouldHideSuggestions, clearAllFiles], ); - return (
{/* Hidden file input */} diff --git a/frontend/src/hooks/chat/use-chat-submission.ts b/frontend/src/hooks/chat/use-chat-submission.ts index 21ee0f7afb..42b5f598bf 100644 --- a/frontend/src/hooks/chat/use-chat-submission.ts +++ b/frontend/src/hooks/chat/use-chat-submission.ts @@ -12,6 +12,7 @@ export const useChatSubmission = ( fileInputRef: React.RefObject, 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) => { diff --git a/frontend/src/hooks/chat/use-grip-resize.ts b/frontend/src/hooks/chat/use-grip-resize.ts index d57097fcdb..b29f820399 100644 --- a/frontend/src/hooks/chat/use-grip-resize.ts +++ b/frontend/src/hooks/chat/use-grip-resize.ts @@ -58,6 +58,7 @@ export const useGripResize = ( handleGripMouseDown, handleGripTouchStart, increaseHeightForEmptyContent, + resetManualResize, } = useAutoResize(chatInputRef as React.RefObject, { minHeight: 20, maxHeight: 400, @@ -76,5 +77,6 @@ export const useGripResize = ( handleGripMouseDown, handleGripTouchStart, increaseHeightForEmptyContent, + resetManualResize, }; }; diff --git a/frontend/src/hooks/use-auto-resize.ts b/frontend/src/hooks/use-auto-resize.ts index d92b111e0e..6a86784e0f 100644 --- a/frontend/src/hooks/use-auto-resize.ts +++ b/frontend/src/hooks/use-auto-resize.ts @@ -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(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, options: UseAutoResizeOptions = {}, ): UseAutoResizeReturn => { + const pendingSmartRef = useRef(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, }; }; diff --git a/frontend/src/hooks/use-drag-resize.ts b/frontend/src/hooks/use-drag-resize.ts index fb2169de95..0a75598d0a 100644 --- a/frontend/src/hooks/use-drag-resize.ts +++ b/frontend/src/hooks/use-drag-resize.ts @@ -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; }; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index d44bf40e18..64cf632d61 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -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 diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index bc990e0ccf..dc9e311f71 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -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