mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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 { 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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user