diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 484718efc6f6..3572f4a718eb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -102,6 +102,9 @@ export interface ComposerPromptEditorHandle { isCaretOnVisualEdge: (edge: "start" | "end") => boolean; } +/** Present on the editor element while its content is taller than its minimum height. */ +export const COMPOSER_BEYOND_MINIMUM_HEIGHT_ATTRIBUTE = "data-beyond-minimum-height"; + export function isComposerPromptEditorBeyondMinimumHeight( element: Pick, minimumHeight: number, @@ -1037,13 +1040,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { if (!rootElement) return; let animationFrame: number | null = null; - const measureHeight = () => { - animationFrame = null; + const readBeyondMinimumHeight = () => { const minimumHeight = Number.parseFloat(window.getComputedStyle(rootElement).minHeight); - onBeyondMinimumHeightChange( + const beyondMinimumHeight = Number.isFinite(minimumHeight) && - isComposerPromptEditorBeyondMinimumHeight(rootElement, minimumHeight), - ); + isComposerPromptEditorBeyondMinimumHeight(rootElement, minimumHeight); + // Styles keyed on this attribute apply in the same frame as the height + // change, without waiting for the parent to render. + rootElement.toggleAttribute(COMPOSER_BEYOND_MINIMUM_HEIGHT_ATTRIBUTE, beyondMinimumHeight); + return beyondMinimumHeight; + }; + const measureHeight = () => { + animationFrame = null; + onBeyondMinimumHeightChange(readBeyondMinimumHeight()); }; const scheduleMeasurement = () => { if (animationFrame !== null) return; @@ -1051,8 +1060,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }; measureHeight(); + // Resize observers run after layout and before paint. const resizeObserver = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasurement); + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + readBeyondMinimumHeight(); + scheduleMeasurement(); + }); resizeObserver?.observe(rootElement); const mutationObserver = new MutationObserver(scheduleMeasurement); mutationObserver.observe(rootElement, { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6810d4135ad7..0c1d575fb0fb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2134,7 +2134,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) restoreAfterTimelineReachedEnd, } = useComposerFocusState(); const [isComposerExpanded, setIsComposerExpanded] = useState(false); - const [isComposerExpandAvailable, setIsComposerExpandAvailable] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -3199,7 +3198,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsComposerExpanded(false); - setIsComposerExpandAvailable(false); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); @@ -3387,7 +3385,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) => { if (shouldCollapseExpandedComposer(nextPrompt)) { setIsComposerExpanded(false); - setIsComposerExpandAvailable(false); } expandComposerForEditorChange(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { @@ -4056,7 +4053,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const handleComposerBeyondMinimumHeightChange = useCallback((beyondMinimumHeight: boolean) => { - setIsComposerExpandAvailable(beyondMinimumHeight); if (!beyondMinimumHeight) { setIsComposerExpanded(false); } @@ -6533,7 +6529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ref={setComposerMenuAnchor} data-chat-composer-body="true" className={cn( - "relative px-3 pb-2 sm:px-4", + "group/composer-body relative px-3 pb-2 sm:px-4", isComposerExpanded && "flex min-h-0 flex-1 flex-col", "pt-3.5 sm:pt-4", isComposerApprovalState && "pb-3 sm:pb-4", @@ -6574,7 +6570,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} - {!isMobileViewport && (isComposerExpandAvailable || isComposerExpanded) ? ( + {!isMobileViewport && !isComposerResting ? ( event.preventDefault()} @@ -7012,8 +7014,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expanded={isComposerExpanded} onBeyondMinimumHeightChange={handleComposerBeyondMinimumHeightChange} className={cn( + // Always reserve the expand control's gutter so text does not + // rewrap when the control appears. !isMobileViewport && - (isComposerExpandAvailable || isComposerExpanded) && + !isComposerResting && "composer-editor-expand-control-visible pr-8", showMobilePendingAnswerActions && "max-sm:pb-11", isComposerApprovalState && "min-h-8",