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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user