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]);
|
}, [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 adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
@@ -2854,7 +2863,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
|
const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
|
||||||
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
|
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
|
||||||
const scrollHeight = textarea.scrollHeight || textarea.offsetHeight;
|
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.height = `${nextHeight}px`;
|
||||||
textarea.style.maxHeight = `${maxHeight}px`;
|
textarea.style.maxHeight = `${maxHeight}px`;
|
||||||
@@ -2876,6 +2886,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
adjustTextareaHeight({ allowShrink });
|
adjustTextareaHeight({ allowShrink });
|
||||||
}, [adjustTextareaHeight, message, isMobile]);
|
}, [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((
|
const updateAutocompleteState = React.useCallback((
|
||||||
value: string,
|
value: string,
|
||||||
cursorPosition: number,
|
cursorPosition: number,
|
||||||
@@ -5363,6 +5380,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
sendIconSizeClass={sendIconSizeClass}
|
sendIconSizeClass={sendIconSizeClass}
|
||||||
onInsert={handleDictationInsert}
|
onInsert={handleDictationInsert}
|
||||||
onInsertAndSend={handleDictationInsertAndSend}
|
onInsertAndSend={handleDictationInsertAndSend}
|
||||||
|
onContentHeightChange={handleDictationContentHeightChange}
|
||||||
/>
|
/>
|
||||||
<ComposerActionButtons
|
<ComposerActionButtons
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -5402,6 +5420,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
onInsert={handleDictationInsert}
|
onInsert={handleDictationInsert}
|
||||||
onInsertAndSend={handleDictationInsertAndSend}
|
onInsertAndSend={handleDictationInsertAndSend}
|
||||||
onActiveChange={handleMobileDictationActiveChange}
|
onActiveChange={handleMobileDictationActiveChange}
|
||||||
|
onContentHeightChange={handleDictationContentHeightChange}
|
||||||
renderTrigger={false}
|
renderTrigger={false}
|
||||||
topAccessory={mobileComposerHandle}
|
topAccessory={mobileComposerHandle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ interface ComposerDictationProps {
|
|||||||
onInsertAndSend: (text: string) => void;
|
onInsertAndSend: (text: string) => void;
|
||||||
/** Reports whether dictation is active (recording/transcribing/failed overlay shown). */
|
/** Reports whether dictation is active (recording/transcribing/failed overlay shown). */
|
||||||
onActiveChange?: (active: boolean) => void;
|
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
|
/** Render the mic trigger button (default). Pass false when the host renders
|
||||||
its own trigger and only needs the overlay + recording engine. */
|
its own trigger and only needs the overlay + recording engine. */
|
||||||
renderTrigger?: boolean;
|
renderTrigger?: boolean;
|
||||||
@@ -123,6 +126,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
|||||||
onInsert,
|
onInsert,
|
||||||
onInsertAndSend,
|
onInsertAndSend,
|
||||||
onActiveChange,
|
onActiveChange,
|
||||||
|
onContentHeightChange,
|
||||||
renderTrigger = true,
|
renderTrigger = true,
|
||||||
topAccessory,
|
topAccessory,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -233,8 +237,54 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
|||||||
// picker), so measure it — it stays mounted underneath the overlay — and
|
// picker), so measure it — it stays mounted underneath the overlay — and
|
||||||
// give our action row the same height so the icons line up exactly.
|
// give our action row the same height so the icons line up exactly.
|
||||||
const overlayRef = React.useRef<HTMLDivElement | null>(null);
|
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 [footerHeight, setFooterHeight] = React.useState<number | null>(null);
|
||||||
const isActiveStatus = status !== 'idle';
|
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(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!isActiveStatus) {
|
if (!isActiveStatus) {
|
||||||
return;
|
return;
|
||||||
@@ -332,6 +382,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
|||||||
>
|
>
|
||||||
{topAccessory}
|
{topAccessory}
|
||||||
<div
|
<div
|
||||||
|
ref={transcriptAreaRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Text paddings match the composer textarea, plus the
|
// Text paddings match the composer textarea, plus the
|
||||||
// 4px (pt-1) attachment-chips row that always renders
|
// 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',
|
isMobile ? 'pt-3.5 pb-2.5' : 'pt-5 pb-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{partialTranscript ? (
|
{/* Measured for the composer-growth report — keep all
|
||||||
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
|
transcript/placeholder/error content inside. */}
|
||||||
{partialTranscript}
|
<div ref={transcriptContentRef}>
|
||||||
</p>
|
{partialTranscript ? (
|
||||||
) : (
|
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
|
||||||
<p className="typography-markdown md:typography-ui-label" style={{ color: currentTheme.colors.surface.mutedForeground }}>
|
{partialTranscript}
|
||||||
{placeholderText}
|
</p>
|
||||||
</p>
|
) : (
|
||||||
)}
|
<p className="typography-markdown md:typography-ui-label" style={{ color: currentTheme.colors.surface.mutedForeground }}>
|
||||||
{status === 'failed' ? (
|
{placeholderText}
|
||||||
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.error }}>
|
</p>
|
||||||
{error || t('chat.dictation.failed')}
|
)}
|
||||||
</p>
|
{status === 'failed' ? (
|
||||||
) : null}
|
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.error }}>
|
||||||
{status === 'recording' && error && !isModelDownloading ? (
|
{error || t('chat.dictation.failed')}
|
||||||
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.warning }}>
|
</p>
|
||||||
{error}
|
) : null}
|
||||||
</p>
|
{status === 'recording' && error && !isModelDownloading ? (
|
||||||
) : null}
|
<p className="typography-meta mt-1" style={{ color: currentTheme.colors.status.warning }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-shrink-0 items-center gap-x-3', footerPaddingClass)}
|
className={cn('flex flex-shrink-0 items-center gap-x-3', footerPaddingClass)}
|
||||||
|
|||||||
Reference in New Issue
Block a user