From 05d1f838d83a8a6156365438f2a1baf0aac114b5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Jul 2026 15:15:25 +0200 Subject: [PATCH] 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. --- packages/app/src/composer/input/input.tsx | 47 +++++++++---------- packages/app/src/composer/input/state.test.ts | 17 +++++++ packages/app/src/composer/input/state.ts | 26 ++++++++++ 3 files changed, 64 insertions(+), 26 deletions(-) diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 6d57619ea..8a58c9e98 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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( 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( ); const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; const showOverlay = showDictationOverlay || showRealtimeOverlay; + const surfacePresentation = resolveComposerSurfacePresentation(showOverlay); useEffect(() => { if (isDictating || isDictationProcessing) { @@ -1427,22 +1427,6 @@ export const MessageInput = forwardRef( [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( }, [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( [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( return ( {/* Regular input */} - + {attachmentSlot} {/* Text input */} @@ -1881,9 +1873,12 @@ export const MessageInput = forwardRef( /> - + - + ( onDiscardFailedRecording={handleDiscardFailedRecording} onRealtimeVoiceStop={handleRealtimeVoiceStop} /> - + ); }, diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 8a11806ed..5e8420e77 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -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( diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index e2f714623..716433f4b 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -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 } | null | undefined; isRealtimeVoiceForCurrentAgent: boolean;