fix: smooth the handover into dictation

This commit is contained in:
Marco Beretta 2026-07-26 18:35:15 +02:00
parent 911959282a
commit 0e2dd60f14
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 69 additions and 15 deletions

View file

@ -243,11 +243,20 @@ function Bar({
chipsFitInline(packedEntries, widths, rowWidth - plusWidth - controlsWidth - CHIP_GAP * 2),
[packedEntries, widths, rowWidth, plusWidth, controlsWidth],
);
/* The recording controls take the bottom row over, so every chip moves into
the block above and leaves with it rather than being cut off mid-row. */
const dictating = dictation.active || dictation.transcribing;
const above = inlineChips && !dictating ? EMPTY_ENTRIES : packedEntries;
const inline = inlineChips && !dictating ? packedEntries : EMPTY_ENTRIES;
/* The arrangement is frozen for the length of a recording. The controls
shrink to just the elapsed time while one runs, which would otherwise let
chips qualify for the bottom row and re-mount there mid-animation: a chip
that changes parent restarts from its new parent's resting state, which is
no transition at all. */
const [restingInline, setRestingInline] = useState(true);
if (!dictating && restingInline !== inlineChips) {
setRestingInline(inlineChips);
}
const chipsInline = dictating ? restingInline : inlineChips;
const above = chipsInline ? EMPTY_ENTRIES : packedEntries;
const inline = chipsInline ? packedEntries : EMPTY_ENTRIES;
const rowClass = cn(
'flex flex-wrap items-center gap-1.5',
@ -287,10 +296,16 @@ function Bar({
)}
>
<div className="overflow-hidden">
{/* Travels down as the row above closes over it, so the chips read as
moving out of the way rather than being wiped from the bottom up. */}
<div
role="list"
aria-label={localize('com_ui_composer_tools')}
className={cn(rowClass, 'pb-1.5')}
className={cn(
rowClass,
'pb-1.5 transition-transform duration-200 ease-out',
dictating ? 'translate-y-3' : 'translate-y-0',
)}
>
{above.map(renderChip)}
</div>
@ -320,10 +335,19 @@ function Bar({
anchorRef={anchorRef}
/>
</span>
{/* `contents`, so these chips sit on the same line as the buttons rather
than in a box of their own. The element keeps its list semantics for
assistive tech; only its box is dropped. */}
<div role="list" aria-label={localize('com_ui_composer_tools')} className="contents">
{/* A box rather than `contents` so it has something to move: the split
only puts chips here when they all fit on this line, so wrapping as
one unit and wrapping as siblings come to the same layout. */}
<div
role="list"
aria-label={localize('com_ui_composer_tools')}
aria-hidden={dictating}
className={cn(
'flex min-w-0 flex-wrap items-center gap-1.5',
'transition-[transform,opacity] duration-200 ease-out',
dictating ? 'pointer-events-none translate-y-3 opacity-0' : 'translate-y-0 opacity-100',
)}
>
{inline.map(renderChip)}
</div>
{/* Auto margin on the main-start side, so the group sits at the end of
@ -354,14 +378,14 @@ function Bar({
<Thinking />
<TokenUsage index={index} conversation={conversation} isSubmitting={isSubmitting} />
</div>
{/* Fades in place. The elapsed time is not one of the things that
stepped aside, so it has nowhere to travel from. */}
<div
aria-hidden={!dictating}
className={cn(
'col-start-1 row-start-1 flex items-center justify-end px-1',
'transition-[transform,opacity] duration-200 ease-out',
dictating
? 'translate-y-0 opacity-100'
: 'pointer-events-none translate-y-3 opacity-0',
'transition-opacity duration-200 ease-out',
dictating ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<span className="text-xs tabular-nums text-text-secondary">

View file

@ -662,7 +662,10 @@ function Palette({
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
'disabled:cursor-not-allowed disabled:opacity-40',
open && 'bg-surface-hover text-text-primary',
/* The close mark carries the same weight whichever job it is
doing: dimmer and unbacked while dictating read as a smaller
glyph next to the one the open palette shows. */
(open || dictating) && 'bg-surface-hover text-text-primary',
)}
>
{/* The same glyph turned a quarter of the way round is the close

View file

@ -10,6 +10,10 @@ import useLocalize from '../useLocalize';
const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external';
/** How long to hold the recording layout after a stop before giving up on a
* transcription request ever being reported. */
const SETTLE_MS = 500;
/** How the in-flight transcription should be spent once recording ends. */
type StopMode = 'compose' | 'send' | 'cancel';
@ -123,6 +127,27 @@ export default function useDictation({
const active = isListening === true;
const levels = useAudioLevels(active);
/* Bridges the gap between the recorder being told to stop and the upload
starting: `isListening` clears synchronously, while the recorder's own
`stop` event, which is what begins the transcription request, arrives a
tick later. Without this the composer reads as idle for that tick and every
control it had put away flashes back in and out again. */
const [settling, setSettling] = useState(false);
useEffect(() => {
if (!settling) {
return;
}
if (isLoading === true) {
setSettling(false);
return;
}
/* Backstop for the engines that never report a request at all: the browser
recogniser hands its transcript straight over, so nothing else would ever
clear this. */
const timer = setTimeout(() => setSettling(false), SETTLE_MS);
return () => clearTimeout(timer);
}, [settling, isLoading]);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!active) {
@ -142,6 +167,7 @@ export default function useDictation({
const stopWith = useCallback(
(mode: StopMode) => {
modeRef.current = mode;
setSettling(true);
stopRecording();
},
[stopRecording],
@ -153,6 +179,7 @@ export default function useDictation({
that was already in flight lands anyway. */
const cancel = useCallback(() => {
modeRef.current = 'cancel';
setSettling(false);
abortRecording();
reset({ text: existingTextRef.current });
existingTextRef.current = '';
@ -160,7 +187,7 @@ export default function useDictation({
return {
active,
transcribing: isLoading === true,
transcribing: isLoading === true || settling,
levels,
elapsed,
start,