From 5c987b0c34847cb182dab9b0baa604d3f96184c5 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Fri, 18 Sep 2026 15:23:02 -0500 Subject: [PATCH] fix(web): composer text no longer rewraps when the expand button appears The editor only reserved the expand control's right gutter once the control became visible, so typing past the third line rewrapped the whole prompt. Reserve the gutter at all times on desktop. The control also appeared a frame or two after the height change that reveals it, because its visibility waited for an animation frame and then a render of the whole composer. A long paste showed the scrollbar first, then the button, then a shorter scrollbar. The editor now flags its own height with a data attribute from the resize observer, which runs before paint, and the control's visibility is plain CSS keyed on that attribute. Model: Claude Fable 5.1 via Claude Code. --- .../components/ComposerPromptEditorTiptap.tsx | 27 ++++++++++++++----- apps/web/src/components/chat/ChatComposer.tsx | 20 ++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) 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",