fix: grow composer with dictation transcript like typed text

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.
This commit is contained in:
Bohdan Triapitsyn
2026-07-07 01:40:00 +03:00
parent 24c40b4930
commit 856b312da1
2 changed files with 94 additions and 20 deletions
+20 -1
View File
@@ -2819,6 +2819,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<number | null>(null);
const [dictationContentHeight, setDictationContentHeight] = React.useState<number | null>(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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
sendIconSizeClass={sendIconSizeClass}
onInsert={handleDictationInsert}
onInsertAndSend={handleDictationInsertAndSend}
onContentHeightChange={handleDictationContentHeightChange}
/>
<ComposerActionButtons
isMobile={isMobile}
@@ -5402,6 +5420,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onInsert={handleDictationInsert}
onInsertAndSend={handleDictationInsertAndSend}
onActiveChange={handleMobileDictationActiveChange}
onContentHeightChange={handleDictationContentHeightChange}
renderTrigger={false}
topAccessory={mobileComposerHandle}
/>
@@ -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<ComposerDictationProps> = ({
onInsert,
onInsertAndSend,
onActiveChange,
onContentHeightChange,
renderTrigger = true,
topAccessory,
}) => {
@@ -233,8 +237,54 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
// 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<HTMLDivElement | null>(null);
const transcriptAreaRef = React.useRef<HTMLDivElement | null>(null);
const transcriptContentRef = React.useRef<HTMLDivElement | null>(null);
const [footerHeight, setFooterHeight] = React.useState<number | null>(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<ComposerDictationProps> = ({
>
{topAccessory}
<div
ref={transcriptAreaRef}
className={cn(
// Text paddings match the composer textarea, plus the
// 4px (pt-1) attachment-chips row that always renders
@@ -345,25 +396,29 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
isMobile ? 'pt-3.5 pb-2.5' : 'pt-5 pb-2',
)}
>
{partialTranscript ? (
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
{partialTranscript}
</p>
) : (
<p className="typography-markdown md:typography-ui-label" style={{ color: currentTheme.colors.surface.mutedForeground }}>
{placeholderText}
</p>
)}
{status === 'failed' ? (
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.error }}>
{error || t('chat.dictation.failed')}
</p>
) : null}
{status === 'recording' && error && !isModelDownloading ? (
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.warning }}>
{error}
</p>
) : null}
{/* Measured for the composer-growth report — keep all
transcript/placeholder/error content inside. */}
<div ref={transcriptContentRef}>
{partialTranscript ? (
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
{partialTranscript}
</p>
) : (
<p className="typography-markdown md:typography-ui-label" style={{ color: currentTheme.colors.surface.mutedForeground }}>
{placeholderText}
</p>
)}
{status === 'failed' ? (
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.error }}>
{error || t('chat.dictation.failed')}
</p>
) : null}
{status === 'recording' && error && !isModelDownloading ? (
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.warning }}>
{error}
</p>
) : null}
</div>
</div>
<div
className={cn('flex flex-shrink-0 items-center gap-x-3', footerPaddingClass)}