From b4e0ff7685295335b2bf537a782ff883b5bbabfa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 11:36:22 +0200 Subject: [PATCH] fix(app): restore native composer auto-growth (#3681) Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it. --- .../src/composer/input/height-style.test.ts | 38 +++++++++++++++++++ .../app/src/composer/input/height-style.ts | 24 ++++++++++++ packages/app/src/composer/input/input.tsx | 24 ++++-------- 3 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/composer/input/height-style.test.ts create mode 100644 packages/app/src/composer/input/height-style.ts diff --git a/packages/app/src/composer/input/height-style.test.ts b/packages/app/src/composer/input/height-style.test.ts new file mode 100644 index 000000000..420d5bf3d --- /dev/null +++ b/packages/app/src/composer/input/height-style.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style"; + +describe("composer input height style", () => { + it("applies the measured height alongside its bounds", () => { + expect( + resolveComposerInputHeightStyle({ + inputHeight: 96, + minInputHeight: 30, + maxInputHeight: 240, + applyMeasuredHeight: true, + }), + ).toEqual({ + height: 96, + minHeight: 30, + maxHeight: 240, + }); + }); + + it("lets a native multiline input expose its intrinsic content height", () => { + expect( + resolveComposerInputHeightStyle({ + inputHeight: 96, + minInputHeight: 30, + maxInputHeight: 240, + applyMeasuredHeight: false, + }), + ).toEqual({ + minHeight: 30, + maxHeight: 240, + }); + }); + + it("keeps native measurement unconstrained until the composer reaches its cap", () => { + expect(shouldScrollComposerInput({ inputHeight: 30, maxInputHeight: 240 })).toBe(false); + expect(shouldScrollComposerInput({ inputHeight: 240, maxInputHeight: 240 })).toBe(true); + }); +}); diff --git a/packages/app/src/composer/input/height-style.ts b/packages/app/src/composer/input/height-style.ts new file mode 100644 index 000000000..5d2635c86 --- /dev/null +++ b/packages/app/src/composer/input/height-style.ts @@ -0,0 +1,24 @@ +interface ComposerInputHeightStyleInput { + inputHeight: number; + minInputHeight: number; + maxInputHeight: number; + applyMeasuredHeight: boolean; +} + +export function resolveComposerInputHeightStyle(input: ComposerInputHeightStyleInput) { + // Native must remain intrinsically sized below the cap so + // onContentSizeChange can observe growth. Web gets its height from the DOM + // mirror and therefore needs that measurement applied explicitly. + return { + ...(input.applyMeasuredHeight ? { height: input.inputHeight } : {}), + minHeight: input.minInputHeight, + maxHeight: input.maxInputHeight, + }; +} + +export function shouldScrollComposerInput(input: { + inputHeight: number; + maxInputHeight: number; +}): boolean { + return input.inputHeight >= input.maxInputHeight; +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index c89e367ba..4b79b8166 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -56,6 +56,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeightMirror } from "./height-mirror"; +import { resolveComposerInputHeightStyle, shouldScrollComposerInput } from "./height-style"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { @@ -990,20 +991,6 @@ function resolveMaxInputHeight(windowHeight: number): number { return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); } -function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) { - if (isWeb) { - return { - height: inputHeight, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - }; - } - return { - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - }; -} - function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; } @@ -1764,7 +1751,12 @@ export const MessageInput = forwardRef( () => [ styles.textInput, mode.isMonospace && styles.textInputMonospace, - computeTextInputHeightStyle(inputHeight, maxInputHeight), + resolveComposerInputHeightStyle({ + inputHeight, + minInputHeight: MIN_INPUT_HEIGHT, + maxInputHeight, + applyMeasuredHeight: isWeb, + }), ], [inputHeight, maxInputHeight, mode.isMonospace], ); @@ -1838,7 +1830,7 @@ export const MessageInput = forwardRef( onFocus={handleInputFocus} onBlur={handleInputBlur} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} - scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} + scrollEnabled={shouldScrollComposerInput({ inputHeight, maxInputHeight })} autoFocus={false} onContentSizeChange={handleContentSizeChange} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}