fix(composer): keep message input visible after dictation (#2194)

The voice overlay transition could retain stale native opacity after the app was backgrounded, leaving the mounted composer transparent. Derive both surfaces directly from voice state so they cannot disagree after resume.
This commit is contained in:
Mohamed Boudra
2026-07-18 15:15:25 +02:00
committed by GitHub
parent c97f823236
commit 05d1f838d8
3 changed files with 64 additions and 26 deletions

View File

@@ -25,7 +25,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
@@ -68,6 +67,7 @@ import {
} from "./labels";
import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
stopRealtimeVoice,
@@ -1290,7 +1290,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const overlayTransition = useSharedValue(0);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
@@ -1407,6 +1406,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent;
const showOverlay = showDictationOverlay || showRealtimeOverlay;
const surfacePresentation = resolveComposerSurfacePresentation(showOverlay);
useEffect(() => {
if (isDictating || isDictationProcessing) {
@@ -1427,22 +1427,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
);
// Animate overlay
useEffect(() => {
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
duration: 200,
});
}, [overlayTransition, showOverlay]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayTransition.value,
pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none",
}));
const inputAnimatedStyle = useAnimatedStyle(() => ({
opacity: 1 - overlayTransition.value,
}));
const handleVoicePress = useCallback(
() =>
handleVoicePressImpl({
@@ -1760,8 +1744,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [handleStopRealtimeVoice]);
const inputWrapperCombinedStyle = useMemo(
() => [styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle],
[inputWrapperStyle, inputAnimatedStyle],
() => [
styles.inputWrapper,
inputWrapperStyle,
{ opacity: surfacePresentation.input.opacity },
],
[inputWrapperStyle, surfacePresentation.input.opacity],
);
const textInputStyle = useMemo(
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
@@ -1772,8 +1760,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[isSendButtonDisabled],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, overlayAnimatedStyle],
[overlayAnimatedStyle],
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
[surfacePresentation.overlay.opacity],
);
const renderAttachButtonIcon = useCallback(
@@ -1802,7 +1790,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
<View
ref={inputWrapperRef}
style={inputWrapperCombinedStyle}
pointerEvents={surfacePresentation.input.pointerEvents}
>
{attachmentSlot}
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
@@ -1881,9 +1873,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
</View>
</View>
</Animated.View>
</View>
<Animated.View style={overlayContainerStyle}>
<View
style={overlayContainerStyle}
pointerEvents={surfacePresentation.overlay.pointerEvents}
>
<MessageInputOverlay
showDictationOverlay={showDictationOverlay}
showRealtimeOverlay={showRealtimeOverlay}
@@ -1901,7 +1896,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onDiscardFailedRecording={handleDiscardFailedRecording}
onRealtimeVoiceStop={handleRealtimeVoiceStop}
/>
</Animated.View>
</View>
</View>
);
},

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
runAlternateSendAction,
runDefaultSendAction,
stopRealtimeVoice,
@@ -9,6 +10,22 @@ import {
const connected = { isConnected: true } as never;
const disconnected = { isConnected: false } as never;
describe("composer surface presentation", () => {
it("shows only the input when no voice overlay is active", () => {
expect(resolveComposerSurfacePresentation(false)).toEqual({
input: { opacity: 1, pointerEvents: "auto" },
overlay: { opacity: 0, pointerEvents: "none" },
});
});
it("shows only the voice overlay while voice UI is active", () => {
expect(resolveComposerSurfacePresentation(true)).toEqual({
input: { opacity: 0, pointerEvents: "none" },
overlay: { opacity: 1, pointerEvents: "auto" },
});
});
});
describe("computeCanStartDictation", () => {
it("returns false when socket is disconnected", () => {
expect(

View File

@@ -3,6 +3,32 @@ import type { MessagePayload } from "@/composer/types";
export type SendBehavior = "interrupt" | "queue";
interface ComposerSurfaceState {
opacity: 0 | 1;
pointerEvents: "auto" | "none";
}
export interface ComposerSurfacePresentation {
input: ComposerSurfaceState;
overlay: ComposerSurfaceState;
}
const INPUT_PRESENTATION: ComposerSurfacePresentation = {
input: { opacity: 1, pointerEvents: "auto" },
overlay: { opacity: 0, pointerEvents: "none" },
};
const OVERLAY_PRESENTATION: ComposerSurfacePresentation = {
input: { opacity: 0, pointerEvents: "none" },
overlay: { opacity: 1, pointerEvents: "auto" },
};
export function resolveComposerSurfacePresentation(
showOverlay: boolean,
): ComposerSurfacePresentation {
return showOverlay ? OVERLAY_PRESENTATION : INPUT_PRESENTATION;
}
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;