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,