mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
This commit is contained in:
parent
50b38e9081
commit
0e2edb63f5
@ -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 */}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user