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;