From 856b312da1eb3a7f8f740b521fe4cf6c96d1bff8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 7 Jul 2026 01:40:00 +0300 Subject: [PATCH] fix: grow composer with dictation transcript like typed text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dictation overlay is absolutely positioned over the composer, so the transcript could not expand it — long dictations clipped after two lines. ComposerDictation now measures the transcript text block (not the flex-1 container, which would feed the composer's own height back and creep a few px per update) and reports it to ChatInput, which feeds it into the textarea autosize: same line cap as typing, transcript area scrolls past it and follows the newest words. Idle/unmount releases the height, and an idle sibling instance (mobile footer + wrapper engine) can no longer zero the active one's report. --- packages/ui/src/components/chat/ChatInput.tsx | 21 ++++- .../dictation/ComposerDictation.tsx | 93 +++++++++++++++---- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 5fad14e5..89f84968 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -2819,6 +2819,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }, [agents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); + // Height the dictation transcript needs (null when idle): the overlay sits + // absolutely over the composer, so the underlying textarea must grow for + // the composer to grow — feed this into the autosize below. + const dictationContentHeightRef = React.useRef(null); + const [dictationContentHeight, setDictationContentHeight] = React.useState(null); + const handleDictationContentHeightChange = React.useCallback((height: number | null) => { + setDictationContentHeight((prev) => (prev === height ? prev : height)); + }, []); + const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => { const textarea = textareaRef.current; if (!textarea) { @@ -2854,7 +2863,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight; const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal; const scrollHeight = textarea.scrollHeight || textarea.offsetHeight; - const nextHeight = Math.min(scrollHeight, maxHeight); + const dictationHeight = dictationContentHeightRef.current ?? 0; + const nextHeight = Math.min(Math.max(scrollHeight, dictationHeight), maxHeight); textarea.style.height = `${nextHeight}px`; textarea.style.maxHeight = `${maxHeight}px`; @@ -2876,6 +2886,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo adjustTextareaHeight({ allowShrink }); }, [adjustTextareaHeight, message, isMobile]); + React.useLayoutEffect(() => { + dictationContentHeightRef.current = dictationContentHeight; + // Growing transcript never shrinks mid-recording (matches typing); + // dictation ending (null) releases the height back to the message. + adjustTextareaHeight({ allowShrink: dictationContentHeight === null }); + }, [adjustTextareaHeight, dictationContentHeight]); + const updateAutocompleteState = React.useCallback(( value: string, cursorPosition: number, @@ -5363,6 +5380,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo sendIconSizeClass={sendIconSizeClass} onInsert={handleDictationInsert} onInsertAndSend={handleDictationInsertAndSend} + onContentHeightChange={handleDictationContentHeightChange} /> = ({ onOpenSettings, scrollTo onInsert={handleDictationInsert} onInsertAndSend={handleDictationInsertAndSend} onActiveChange={handleMobileDictationActiveChange} + onContentHeightChange={handleDictationContentHeightChange} renderTrigger={false} topAccessory={mobileComposerHandle} /> diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx index 6b791168..c458e5d1 100644 --- a/packages/ui/src/components/dictation/ComposerDictation.tsx +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -33,6 +33,9 @@ interface ComposerDictationProps { onInsertAndSend: (text: string) => void; /** Reports whether dictation is active (recording/transcribing/failed overlay shown). */ onActiveChange?: (active: boolean) => void; + /** Reports the height (px) the transcript needs, so the host can grow the + composer like typed text would; null when dictation is idle. */ + onContentHeightChange?: (height: number | null) => void; /** Render the mic trigger button (default). Pass false when the host renders its own trigger and only needs the overlay + recording engine. */ renderTrigger?: boolean; @@ -123,6 +126,7 @@ export const ComposerDictation: React.FC = ({ onInsert, onInsertAndSend, onActiveChange, + onContentHeightChange, renderTrigger = true, topAccessory, }) => { @@ -233,8 +237,54 @@ export const ComposerDictation: React.FC = ({ // picker), so measure it — it stays mounted underneath the overlay — and // give our action row the same height so the icons line up exactly. const overlayRef = React.useRef(null); + const transcriptAreaRef = React.useRef(null); + const transcriptContentRef = React.useRef(null); const [footerHeight, setFooterHeight] = React.useState(null); const isActiveStatus = status !== 'idle'; + + // Grow the composer with the transcript, the way typing grows the + // textarea. The overlay is absolutely positioned over the composer, so it + // can't push the composer's height itself — measure how much room the + // transcript wants (scrollHeight ignores the clamped box) and report it to + // the host, which feeds it into the textarea autosize (same line cap, then + // the transcript area scrolls). + const onContentHeightChangeRef = React.useRef(onContentHeightChange); + React.useEffect(() => { + onContentHeightChangeRef.current = onContentHeightChange; + }, [onContentHeightChange]); + // Two instances can coexist (mobile footer + wrapper engine); only the one + // that actually reported a height may clear it, or an idle sibling + // mounting mid-recording would zero the active transcript's height. + const hasReportedHeightRef = React.useRef(false); + React.useLayoutEffect(() => { + if (!isActiveStatus) { + if (hasReportedHeightRef.current) { + hasReportedHeightRef.current = false; + onContentHeightChangeRef.current?.(null); + } + return; + } + const area = transcriptAreaRef.current; + const content = transcriptContentRef.current; + if (!area || !content) return; + // Measure the text block, not the container: the container is flex-1 + // inside the overlay, so its scrollHeight tracks the composer's own + // height — feeding that back would creep a few px on every transcript + // update instead of stepping per wrapped line. + const style = window.getComputedStyle(area); + const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0); + hasReportedHeightRef.current = true; + onContentHeightChangeRef.current?.(content.offsetHeight + padding); + // Once the composer hits its line cap the transcript area starts + // scrolling — follow the newest words like a textarea caret would. + area.scrollTop = area.scrollHeight; + }, [isActiveStatus, partialTranscript, status, error]); + React.useEffect(() => () => { + if (hasReportedHeightRef.current) { + hasReportedHeightRef.current = false; + onContentHeightChangeRef.current?.(null); + } + }, []); React.useLayoutEffect(() => { if (!isActiveStatus) { return; @@ -332,6 +382,7 @@ export const ComposerDictation: React.FC = ({ > {topAccessory}
= ({ isMobile ? 'pt-3.5 pb-2.5' : 'pt-5 pb-2', )} > - {partialTranscript ? ( -

- {partialTranscript} -

- ) : ( -

- {placeholderText} -

- )} - {status === 'failed' ? ( -

- {error || t('chat.dictation.failed')} -

- ) : null} - {status === 'recording' && error && !isModelDownloading ? ( -

- {error} -

- ) : null} + {/* Measured for the composer-growth report — keep all + transcript/placeholder/error content inside. */} +
+ {partialTranscript ? ( +

+ {partialTranscript} +

+ ) : ( +

+ {placeholderText} +

+ )} + {status === 'failed' ? ( +

+ {error || t('chat.dictation.failed')} +

+ ) : null} + {status === 'recording' && error && !isModelDownloading ? ( +

+ {error} +

+ ) : null} +