Fix: enable chat input resizing (#12998)

## Summary
- add resizable support to shared textarea component
- enable vertical resizing for chat inputs in chat and share surfaces
- preserve autosize behavior while honoring manual resize height

## Test plan
- not run (not requested)

Fixes #12803

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Neel Harsola
2026-02-09 17:03:05 +05:30
committed by GitHub
parent 4bc622b409
commit a2dda8fb70
7 changed files with 50 additions and 6 deletions

View File

@ -56,6 +56,7 @@ interface NextMessageInputProps {
removeFile?(file: File): void;
showReasoning?: boolean;
showInternet?: boolean;
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
}
export function NextMessageInput({
@ -65,6 +66,7 @@ export function NextMessageInput({
sendLoading,
disabled,
showUploadIcon = true,
resize = 'none',
onUpload,
onInputChange,
stopOutputMessage,
@ -211,6 +213,7 @@ export function NextMessageInput({
disabled={isUploading || disabled || sendLoading}
onKeyDown={handleKeyDown}
autoSize={{ minRows: 1, maxRows: 8 }}
resize={resize}
/>
<div className={cn('flex items-center justify-between gap-1.5')}>
<div className="flex items-center gap-3">

View File

@ -16,25 +16,37 @@ interface TextareaProps
minRows?: number;
maxRows?: number;
};
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, autoSize, ...props }, ref) => {
({ className, autoSize, resize = 'none', ...props }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const manualHeightRef = useRef<number | null>(null);
const isAdjustingRef = useRef(false);
const getLineHeight = (element: HTMLElement): number => {
const style = window.getComputedStyle(element);
return parseInt(style.lineHeight, 10) || 20;
};
const adjustHeight = useCallback(() => {
if (!textareaRef.current) return;
if (!textareaRef.current || !autoSize) return;
const lineHeight = getLineHeight(textareaRef.current);
const maxHeight = (autoSize?.maxRows || 3) * lineHeight;
isAdjustingRef.current = true;
textareaRef.current.style.height = 'auto';
requestAnimationFrame(() => {
if (!textareaRef.current) return;
const scrollHeight = textareaRef.current.scrollHeight;
textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
const desiredHeight = Math.min(scrollHeight, maxHeight);
const manualHeight = manualHeightRef.current;
const nextHeight =
manualHeight && manualHeight > desiredHeight
? manualHeight
: desiredHeight;
textareaRef.current.style.height = `${nextHeight}px`;
isAdjustingRef.current = false;
});
}, [autoSize]);
@ -51,18 +63,42 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
ref.current = textareaRef.current;
}
}, [ref]);
useEffect(() => {
if (!textareaRef.current || !autoSize || resize === 'none') {
manualHeightRef.current = null;
return;
}
const element = textareaRef.current;
let prevHeight = element.getBoundingClientRect().height;
const observer = new ResizeObserver((entries) => {
if (isAdjustingRef.current) return;
const entry = entries[0];
if (!entry) return;
const nextHeight = entry.contentRect.height;
if (Math.abs(nextHeight - prevHeight) > 1) {
manualHeightRef.current = nextHeight;
}
prevHeight = nextHeight;
});
observer.observe(element);
return () => observer.disconnect();
}, [autoSize, resize]);
const resizable = resize !== 'none';
return (
<textarea
className={cn(
'flex min-h-[80px] w-full bg-bg-input rounded-md border border-border-button px-3 py-2 text-base ring-offset-background placeholder:text-text-disabled focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm overflow-hidden',
'flex min-h-[80px] w-full bg-bg-input rounded-md border border-border-button px-3 py-2 text-base ring-offset-background placeholder:text-text-disabled focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
resizable ? 'overflow-auto' : 'overflow-hidden',
className,
)}
rows={autoSize?.minRows ?? props.rows ?? undefined}
style={{
maxHeight: autoSize?.maxRows
maxHeight: autoSize?.maxRows && !resizable
? `${autoSize.maxRows * 20}px`
: undefined,
overflow: autoSize ? 'auto' : undefined,
resize,
}}
ref={textareaRef}
{...props}

View File

@ -127,6 +127,7 @@ function AgentChatBox() {
disabled={isWaitting}
sendDisabled={sendLoading || isWaitting}
isUploading={loading || isWaitting}
resize="vertical"
onPressEnter={handlePressEnter}
onInputChange={handleInputChange}
stopOutputMessage={stopOutputMessage}

View File

@ -193,6 +193,7 @@ const ChatContainer = () => {
value={value}
disabled={hasError || isWaitting}
sendDisabled={sendDisabled || isWaitting}
resize="vertical"
conversationId={conversationId}
onInputChange={handleInputChange}
onPressEnter={handlePressEnter}

View File

@ -255,6 +255,7 @@ export function MultipleChatBox({
sendDisabled={sendDisabled}
sendLoading={sendLoading}
value={value}
resize="vertical"
onInputChange={handleInputChange}
onPressEnter={handlePressEnter}
conversationId={conversationId}

View File

@ -112,6 +112,7 @@ export function SingleChatBox({
sendDisabled={sendDisabled}
sendLoading={sendLoading}
value={value}
resize="vertical"
onInputChange={handleInputChange}
onPressEnter={handlePressEnter}
conversationId={conversationId}

View File

@ -116,6 +116,7 @@ const ChatContainer = () => {
value={value}
disabled={hasError}
sendDisabled={sendDisabled}
resize="vertical"
conversationId={conversationId}
onInputChange={handleInputChange}
onPressEnter={handlePressEnter}