diff --git a/packages/app/src/components/composer-height-mirror.d.ts b/packages/app/src/components/composer-height-mirror.d.ts new file mode 100644 index 000000000..5e1a1c639 --- /dev/null +++ b/packages/app/src/components/composer-height-mirror.d.ts @@ -0,0 +1 @@ +export * from "./composer-height-mirror.native"; diff --git a/packages/app/src/components/composer-height-mirror.native.ts b/packages/app/src/components/composer-height-mirror.native.ts new file mode 100644 index 000000000..3946c9c79 --- /dev/null +++ b/packages/app/src/components/composer-height-mirror.native.ts @@ -0,0 +1,13 @@ +import type { RefObject } from "react"; + +interface Args { + value: string; + textareaRef: RefObject; + minHeight: number; + maxHeight: number; + onHeight: (height: number) => void; +} + +export function useComposerHeightMirror(_args: Args): void { + // No-op on native: onContentSizeChange drives height natively. +} diff --git a/packages/app/src/components/composer-height-mirror.web.ts b/packages/app/src/components/composer-height-mirror.web.ts new file mode 100644 index 000000000..7fb335cf1 --- /dev/null +++ b/packages/app/src/components/composer-height-mirror.web.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import type { RefObject } from "react"; + +interface Args { + value: string; + textareaRef: RefObject; + 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(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]); +} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 28b855c93..9719474a1 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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(funct }, })); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); - const baselineInputHeightRef = useRef(null); const overlayTransition = useSharedValue(0); const sendAfterTranscriptRef = useRef(false); const valueRef = useRef(value); @@ -400,12 +400,6 @@ export const MessageInput = forwardRef(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(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, ) { - 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) {