fix(app): composer textarea shrinks back down on web

Measuring scrollHeight on the live textarea with an explicit height
set can only grow — it can never report a shorter content height.
The old `height: auto` toggle flickered across browsers.

Use a hidden off-DOM textarea that mirrors the composer's text-metric
styles and width, and read its scrollHeight on value/resize changes.
Gated by Metro file extension (.web / .native) so it's trivial to
rip out later.
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:50 +07:00
parent fbc2371b95
commit 2238171955
4 changed files with 131 additions and 66 deletions
@@ -0,0 +1 @@
export * from "./composer-height-mirror.native";
@@ -0,0 +1,13 @@
import type { RefObject } from "react";
interface Args {
value: string;
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onHeight: (height: number) => void;
}
export function useComposerHeightMirror(_args: Args): void {
// No-op on native: onContentSizeChange drives height natively.
}
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { RefObject } from "react";
interface Args {
value: string;
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onHeight: (height: number) => void;
}
const COPIED_STYLES = [
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"fontVariant",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textTransform",
"textIndent",
"whiteSpace",
"wordWrap",
"overflowWrap",
"wordBreak",
"tabSize",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
] as const;
export function useComposerHeightMirror({
value,
textareaRef,
minHeight,
maxHeight,
onHeight,
}: Args): void {
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
paramsRef.current = { value, minHeight, maxHeight, onHeight };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
if (typeof document === "undefined") return;
const mirror = document.createElement("textarea");
mirror.setAttribute("aria-hidden", "true");
mirror.setAttribute("tabindex", "-1");
mirror.readOnly = true;
mirror.rows = 1;
const style = mirror.style;
style.position = "absolute";
style.top = "0";
style.left = "0";
style.visibility = "hidden";
style.pointerEvents = "none";
style.overflow = "hidden";
style.border = "0";
style.margin = "0";
style.resize = "none";
style.zIndex = "-1";
style.boxSizing = "border-box";
document.body.appendChild(mirror);
mirrorRef.current = mirror;
return () => {
mirror.remove();
mirrorRef.current = null;
};
}, []);
const measure = useCallback(() => {
const mirror = mirrorRef.current;
const source = textareaRef.current;
if (!mirror || !source || typeof window === "undefined") return;
if (!(source instanceof HTMLElement)) return;
const cs = window.getComputedStyle(source);
const ms = mirror.style;
for (const prop of COPIED_STYLES) {
ms[prop] = cs[prop];
}
ms.width = `${source.clientWidth}px`;
const { value, minHeight, maxHeight, onHeight } = paramsRef.current;
// Trailing newline is collapsed by textarea measurement — pad with a space.
mirror.value = value.endsWith("\n") ? `${value} ` : value;
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
onHeight(next);
}, [textareaRef]);
useLayoutEffect(() => {
measure();
}, [value, measure]);
useEffect(() => {
const source = textareaRef.current;
if (!source || !(source instanceof HTMLElement)) return;
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measure());
observer.observe(source);
return () => observer.disconnect();
}, [textareaRef, measure]);
}
+11 -66
View File
@@ -56,6 +56,7 @@ import {
markScrollInvestigationRender,
} from "@/utils/scroll-jank-investigation";
import { isWeb } from "@/constants/platform";
import { useComposerHeightMirror } from "./composer-height-mirror";
export type ImageAttachment = AttachmentMetadata;
@@ -330,7 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
},
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
@@ -400,12 +400,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
} else {
onChangeText(nextValue);
}
if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
measureWebInputHeight("dictation");
});
}
},
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
@@ -832,76 +826,27 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
};
}, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean {
if (!isWeb) return false;
const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0;
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
baselineInputHeightRef.current = scrollHeight;
logWebStickyBottom("composer_baseline_measured", {
source,
baseline: scrollHeight,
});
}
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline;
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget));
const previousHeight = inputHeightRef.current;
if (Math.abs(previousHeight - bounded) >= 1) {
inputHeightRef.current = bounded;
setInputHeight(bounded);
onHeightChange?.(bounded);
logWebStickyBottom("composer_height_changed", {
source,
previousHeight,
nextHeight: bounded,
scrollHeight,
clientHeight: textarea.clientHeight ?? null,
offsetHeight: textarea.offsetHeight ?? null,
baseline,
rawTarget,
});
return true;
}
return false;
}
function setBoundedInputHeight(nextHeight: number) {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
const previousHeight = inputHeightRef.current;
inputHeightRef.current = bounded;
setInputHeight(bounded);
onHeightChange?.(bounded);
logWebStickyBottom("composer_height_changed_native", {
previousHeight,
nextHeight: bounded,
});
}
useComposerHeightMirror({
value,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
onHeight: setBoundedInputHeight,
});
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
const contentHeight = event.nativeEvent.contentSize.height;
if (isWeb) {
logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight,
});
if (baselineInputHeightRef.current === null && contentHeight > 0) {
baselineInputHeightRef.current = contentHeight;
logWebStickyBottom("composer_baseline_measured", {
source: "contentSizeChange",
baseline: contentHeight,
});
}
setBoundedInputHeight(contentHeight);
return;
}
setBoundedInputHeight(contentHeight);
if (isWeb) return;
setBoundedInputHeight(event.nativeEvent.contentSize.height);
}
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {