From 0e2dd60f144140169a776f8ed0b95ad135a54d6d Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:35:15 +0200 Subject: [PATCH] fix: smooth the handover into dictation --- .../components/Chat/Input/Composer/Bar.tsx | 50 ++++++++++++++----- .../Chat/Input/Composer/Palette.tsx | 5 +- client/src/hooks/Input/useDictation.ts | 29 ++++++++++- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/client/src/components/Chat/Input/Composer/Bar.tsx b/client/src/components/Chat/Input/Composer/Bar.tsx index e2d212f870..e06324fdda 100644 --- a/client/src/components/Chat/Input/Composer/Bar.tsx +++ b/client/src/components/Chat/Input/Composer/Bar.tsx @@ -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({ )} >
+ {/* 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. */}
{above.map(renderChip)}
@@ -320,10 +335,19 @@ function Bar({ anchorRef={anchorRef} /> - {/* `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. */} -
+ {/* 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. */} +
{inline.map(renderChip)}
{/* Auto margin on the main-start side, so the group sits at the end of @@ -354,14 +378,14 @@ function Bar({
+ {/* Fades in place. The elapsed time is not one of the things that + stepped aside, so it has nowhere to travel from. */}
diff --git a/client/src/components/Chat/Input/Composer/Palette.tsx b/client/src/components/Chat/Input/Composer/Palette.tsx index 8d8ac15086..3da7075cf3 100644 --- a/client/src/components/Chat/Input/Composer/Palette.tsx +++ b/client/src/components/Chat/Input/Composer/Palette.tsx @@ -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 diff --git a/client/src/hooks/Input/useDictation.ts b/client/src/hooks/Input/useDictation.ts index 5b6dd2f815..3dc815fe57 100644 --- a/client/src/hooks/Input/useDictation.ts +++ b/client/src/hooks/Input/useDictation.ts @@ -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,