diff --git a/packages/app/src/components/composer-height-mirror.web.ts b/packages/app/src/components/composer-height-mirror.web.ts index 58ddbb277..c24b49b0d 100644 --- a/packages/app/src/components/composer-height-mirror.web.ts +++ b/packages/app/src/components/composer-height-mirror.web.ts @@ -98,13 +98,19 @@ export function useComposerHeightMirror({ useLayoutEffect(() => { measure(); - }, [value, measure]); + }, [maxHeight, minHeight, value, measure]); useEffect(() => { const source = textareaRef.current; if (!source || !(source instanceof HTMLElement)) return; if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => measure()); + let previousWidth = source.clientWidth; + const observer = new ResizeObserver(() => { + const nextWidth = source.clientWidth; + if (Math.abs(nextWidth - previousWidth) < 1) return; + previousWidth = nextWidth; + 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 e921e071e..3fc41f11d 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -3,6 +3,7 @@ import { Text, TextInput, ActivityIndicator, + useWindowDimensions, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, @@ -144,7 +145,8 @@ export interface MessageInputRef { const MIN_INPUT_HEIGHT_MOBILE = 30; const MIN_INPUT_HEIGHT_DESKTOP = 46; -const MAX_INPUT_HEIGHT = 160; +const DEFAULT_MAX_INPUT_HEIGHT = 160; +const MAX_INPUT_VIEWPORT_RATIO = 0.5; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; type WebTextInputKeyPressEvent = NativeSyntheticEvent< @@ -983,17 +985,22 @@ function computeIsDictationStartEnabled( return (isReadyForDictation ?? isConnected) && !disabled; } -function computeTextInputHeightStyle(inputHeight: number) { +function resolveMaxInputHeight(windowHeight: number): number { + if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT; + 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: MAX_INPUT_HEIGHT, + maxHeight: maxInputHeight, }; } return { minHeight: MIN_INPUT_HEIGHT, - maxHeight: MAX_INPUT_HEIGHT, + maxHeight: maxInputHeight, }; } @@ -1191,6 +1198,8 @@ export const MessageInput = forwardRef( attachmentSlot, } = resolveMessageInputProps(props); const isCompact = useIsCompactFormFactor(); + const { height: windowHeight } = useWindowDimensions(); + const maxInputHeight = resolveMaxInputHeight(windowHeight); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); @@ -1522,7 +1531,7 @@ export const MessageInput = forwardRef( }, [getWebTextArea]); const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, + enabled: isWeb, }); usePasteImagesEffect({ @@ -1536,20 +1545,24 @@ export const MessageInput = forwardRef( const setBoundedInputHeight = useCallback( (nextHeight: number) => { - const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight)); if (Math.abs(inputHeightRef.current - bounded) < 1) return; inputHeightRef.current = bounded; setInputHeight(bounded); onHeightChange?.(bounded); }, - [onHeightChange], + [maxInputHeight, onHeightChange], ); + useEffect(() => { + setBoundedInputHeight(inputHeightRef.current); + }, [setBoundedInputHeight]); + useComposerHeightMirror({ value, textareaRef: webTextareaRef, minHeight: MIN_INPUT_HEIGHT, - maxHeight: MAX_INPUT_HEIGHT, + maxHeight: maxInputHeight, onHeight: setBoundedInputHeight, }); @@ -1674,8 +1687,8 @@ export const MessageInput = forwardRef( [inputWrapperStyle, inputAnimatedStyle], ); const textInputStyle = useMemo( - () => [styles.textInput, computeTextInputHeightStyle(inputHeight)], - [inputHeight], + () => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)], + [inputHeight, maxInputHeight], ); const sendButtonCombinedStyle = useMemo( () => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled], @@ -1727,7 +1740,7 @@ export const MessageInput = forwardRef( onBlur={handleInputBlur} style={textInputStyle} multiline - scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} + scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx index febdfbef6..ab6e13e6e 100644 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ b/packages/app/src/components/use-web-scrollbar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react"; +import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; import { type FlatList, type LayoutChangeEvent, @@ -21,8 +21,25 @@ function ensureHideScrollbarStyle(): void { if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return; const style = document.createElement("style"); style.id = HIDE_SCROLLBAR_STYLE_ID; - style.textContent = - "[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }"; + style.textContent = ` + [data-hide-scrollbar] { + scrollbar-width: none; + -ms-overflow-style: none; + scrollbar-gutter: auto; + } + + [data-hide-scrollbar]::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + + [data-hide-scrollbar]::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + `; document.head.appendChild(style); } @@ -54,18 +71,25 @@ export function useWebElementScrollbar( contentSize: 0, }); - useEffect(() => { + useLayoutEffect(() => { if (!enabled) return; const element = elementRef.current; if (!element) return; + type ScrollbarStyle = CSSStyleDeclaration & { + scrollbarWidth: string; + msOverflowStyle: string; + scrollbarGutter: string; + }; + const style = element.style as ScrollbarStyle; + const previousScrollbarWidth = style.scrollbarWidth; + const previousMsOverflowStyle = style.msOverflowStyle; + const previousScrollbarGutter = style.scrollbarGutter; + element.setAttribute("data-hide-scrollbar", ""); - ( - element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } - ).scrollbarWidth = "none"; - ( - element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } - ).msOverflowStyle = "none"; + style.scrollbarWidth = "none"; + style.msOverflowStyle = "none"; + style.scrollbarGutter = "auto"; ensureHideScrollbarStyle(); function update() { @@ -94,12 +118,9 @@ export function useWebElementScrollbar( element.removeEventListener("scroll", update); resizeObserver.disconnect(); element.removeAttribute("data-hide-scrollbar"); - ( - element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } - ).scrollbarWidth = ""; - ( - element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string } - ).msOverflowStyle = ""; + style.scrollbarWidth = previousScrollbarWidth; + style.msOverflowStyle = previousMsOverflowStyle; + style.scrollbarGutter = previousScrollbarGutter; }; }, [contentRef, elementRef, enabled]);