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:
@@ -25,7 +25,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
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 { useDictation } from "@/hooks/use-dictation";
|
||||||
import { DictationOverlay } from "@/components/dictation-controls";
|
import { DictationOverlay } from "@/components/dictation-controls";
|
||||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
||||||
@@ -68,6 +67,7 @@ import {
|
|||||||
} from "./labels";
|
} from "./labels";
|
||||||
import {
|
import {
|
||||||
computeCanStartDictation,
|
computeCanStartDictation,
|
||||||
|
resolveComposerSurfacePresentation,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
stopRealtimeVoice,
|
stopRealtimeVoice,
|
||||||
@@ -1290,7 +1290,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||||
}));
|
}));
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
const overlayTransition = useSharedValue(0);
|
|
||||||
const sendAfterTranscriptRef = useRef(false);
|
const sendAfterTranscriptRef = useRef(false);
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
const serverInfo = useSessionStore(
|
const serverInfo = useSessionStore(
|
||||||
@@ -1407,6 +1406,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
);
|
);
|
||||||
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent;
|
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent;
|
||||||
const showOverlay = showDictationOverlay || showRealtimeOverlay;
|
const showOverlay = showDictationOverlay || showRealtimeOverlay;
|
||||||
|
const surfacePresentation = resolveComposerSurfacePresentation(showOverlay);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDictating || isDictationProcessing) {
|
if (isDictating || isDictationProcessing) {
|
||||||
@@ -1427,22 +1427,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
|
[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(
|
const handleVoicePress = useCallback(
|
||||||
() =>
|
() =>
|
||||||
handleVoicePressImpl({
|
handleVoicePressImpl({
|
||||||
@@ -1760,8 +1744,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
}, [handleStopRealtimeVoice]);
|
}, [handleStopRealtimeVoice]);
|
||||||
|
|
||||||
const inputWrapperCombinedStyle = useMemo(
|
const inputWrapperCombinedStyle = useMemo(
|
||||||
() => [styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle],
|
() => [
|
||||||
[inputWrapperStyle, inputAnimatedStyle],
|
styles.inputWrapper,
|
||||||
|
inputWrapperStyle,
|
||||||
|
{ opacity: surfacePresentation.input.opacity },
|
||||||
|
],
|
||||||
|
[inputWrapperStyle, surfacePresentation.input.opacity],
|
||||||
);
|
);
|
||||||
const textInputStyle = useMemo(
|
const textInputStyle = useMemo(
|
||||||
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
|
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
|
||||||
@@ -1772,8 +1760,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
[isSendButtonDisabled],
|
[isSendButtonDisabled],
|
||||||
);
|
);
|
||||||
const overlayContainerStyle = useMemo(
|
const overlayContainerStyle = useMemo(
|
||||||
() => [styles.overlayContainer, overlayAnimatedStyle],
|
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||||
[overlayAnimatedStyle],
|
[surfacePresentation.overlay.opacity],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAttachButtonIcon = useCallback(
|
const renderAttachButtonIcon = useCallback(
|
||||||
@@ -1802,7 +1790,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
return (
|
return (
|
||||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||||
{/* Regular input */}
|
{/* Regular input */}
|
||||||
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
|
<View
|
||||||
|
ref={inputWrapperRef}
|
||||||
|
style={inputWrapperCombinedStyle}
|
||||||
|
pointerEvents={surfacePresentation.input.pointerEvents}
|
||||||
|
>
|
||||||
{attachmentSlot}
|
{attachmentSlot}
|
||||||
{/* Text input */}
|
{/* Text input */}
|
||||||
<View style={styles.textInputScrollWrapper}>
|
<View style={styles.textInputScrollWrapper}>
|
||||||
@@ -1881,9 +1873,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={overlayContainerStyle}>
|
<View
|
||||||
|
style={overlayContainerStyle}
|
||||||
|
pointerEvents={surfacePresentation.overlay.pointerEvents}
|
||||||
|
>
|
||||||
<MessageInputOverlay
|
<MessageInputOverlay
|
||||||
showDictationOverlay={showDictationOverlay}
|
showDictationOverlay={showDictationOverlay}
|
||||||
showRealtimeOverlay={showRealtimeOverlay}
|
showRealtimeOverlay={showRealtimeOverlay}
|
||||||
@@ -1901,7 +1896,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
onDiscardFailedRecording={handleDiscardFailedRecording}
|
onDiscardFailedRecording={handleDiscardFailedRecording}
|
||||||
onRealtimeVoiceStop={handleRealtimeVoiceStop}
|
onRealtimeVoiceStop={handleRealtimeVoiceStop}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeCanStartDictation,
|
computeCanStartDictation,
|
||||||
|
resolveComposerSurfacePresentation,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
stopRealtimeVoice,
|
stopRealtimeVoice,
|
||||||
@@ -9,6 +10,22 @@ import {
|
|||||||
const connected = { isConnected: true } as never;
|
const connected = { isConnected: true } as never;
|
||||||
const disconnected = { isConnected: false } 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", () => {
|
describe("computeCanStartDictation", () => {
|
||||||
it("returns false when socket is disconnected", () => {
|
it("returns false when socket is disconnected", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -3,6 +3,32 @@ import type { MessagePayload } from "@/composer/types";
|
|||||||
|
|
||||||
export type SendBehavior = "interrupt" | "queue";
|
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 {
|
interface StopRealtimeVoiceContext {
|
||||||
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
||||||
isRealtimeVoiceForCurrentAgent: boolean;
|
isRealtimeVoiceForCurrentAgent: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user