feat(app): move realtime voice controls into input overlay

This commit is contained in:
Mohamed Boudra
2026-02-09 16:19:33 +07:00
parent 4bb536768b
commit ef29319baf
3 changed files with 204 additions and 52 deletions

View File

@@ -53,8 +53,6 @@ import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { VoiceCompactIndicator } from "./voice-compact-indicator";
import { useVoice } from "@/contexts/voice-context";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -97,7 +95,6 @@ export function AgentStreamView({
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { isVoiceModeForAgent } = useVoice();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
@@ -109,7 +106,6 @@ export function AgentStreamView({
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
const isVoiceMode = isVoiceModeForAgent(resolvedServerId, agentId);
const client = useSessionStore(
(state) => state.sessions[resolvedServerId]?.client ?? null
@@ -516,7 +512,7 @@ export function AgentStreamView({
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator || isVoiceMode;
const showBottomBar = showWorkingIndicator;
const listHeaderComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
@@ -563,14 +559,7 @@ export function AgentStreamView({
})
: null}
{showBottomBar ? (
<View style={stylesheet.bottomBarWrapper}>
<View style={stylesheet.bottomBarLeft}>{leftContent}</View>
<View style={stylesheet.bottomBarRight}>
{isVoiceMode ? <VoiceCompactIndicator /> : null}
</View>
</View>
) : null}
{showBottomBar ? <View style={stylesheet.bottomBarWrapper}>{leftContent}</View> : null}
</View>
</View>
);
@@ -582,7 +571,6 @@ export function AgentStreamView({
renderStreamItemContent,
tightGap,
showBottomBar,
isVoiceMode,
]);
const flatListExtraData = useMemo(
@@ -590,13 +578,11 @@ export function AgentStreamView({
pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
isVoiceMode,
}),
[
pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
isVoiceMode,
]
);
@@ -1252,21 +1238,13 @@ const stylesheet = StyleSheet.create((theme) => ({
bottomBarWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
justifyContent: "flex-start",
paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
bottomBarLeft: {
flex: 1,
alignItems: "flex-start",
},
bottomBarRight: {
flexShrink: 0,
alignItems: "flex-end",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",

View File

@@ -27,6 +27,7 @@ import Animated, {
} from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useVoiceOptional } from "@/contexts/voice-context";
@@ -250,6 +251,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
!!voiceServerId &&
!!voiceAgentId &&
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
const showDictationOverlay =
isDictating || isDictationProcessing || dictationStatus === "failed";
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent;
const showOverlay = showDictationOverlay || showRealtimeOverlay;
useEffect(() => {
if (isDictating || isDictationProcessing) {
@@ -302,14 +307,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Animate overlay
useEffect(() => {
const showOverlay =
isDictating ||
isDictationProcessing ||
dictationStatus === "failed";
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
duration: 200,
});
}, [isDictating, isDictationProcessing, dictationStatus, overlayTransition]);
}, [overlayTransition, showOverlay]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayTransition.value,
@@ -361,6 +362,25 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
discardFailedDictation();
}, [discardFailedDictation]);
const handleStopRealtimeVoice = useCallback(async () => {
if (!voice || !isRealtimeVoiceForCurrentAgent) {
return;
}
const tasks: Promise<unknown>[] = [];
if (isAgentRunning && client && voiceAgentId) {
tasks.push(client.cancelAgent(voiceAgentId));
}
tasks.push(voice.stopVoice());
const results = await Promise.allSettled(tasks);
results.forEach((result) => {
if (result.status === "rejected") {
console.error("[MessageInput] Failed to stop realtime voice", result.reason);
}
});
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]);
const handleSendMessage = useCallback(() => {
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
@@ -602,7 +622,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && isConnected && !disabled}
editable={
!isDictating && !isRealtimeVoiceForCurrentAgent && isConnected && !disabled
}
onKeyPress={
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
}
@@ -705,27 +727,41 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
{/* Dictation overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<DictationOverlay
volume={dictationVolume}
duration={dictationDuration}
isRecording={isDictating}
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? dictationError ?? undefined : undefined}
onCancel={handleCancelRecording}
onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording}
onRetry={
dictationStatus === "failed"
? handleRetryFailedRecording
: undefined
}
onDiscard={
dictationStatus === "failed"
? handleDiscardFailedRecording
: undefined
}
/>
{showDictationOverlay ? (
<DictationOverlay
volume={dictationVolume}
duration={dictationDuration}
isRecording={isDictating}
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? dictationError ?? undefined : undefined}
onCancel={handleCancelRecording}
onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording}
onRetry={
dictationStatus === "failed"
? handleRetryFailedRecording
: undefined
}
onDiscard={
dictationStatus === "failed"
? handleDiscardFailedRecording
: undefined
}
/>
) : showRealtimeOverlay && voice ? (
<RealtimeVoiceOverlay
volume={voice.volume}
isMuted={voice.isMuted}
isDetecting={voice.isDetecting}
isSpeaking={voice.isSpeaking}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={() => {
void handleStopRealtimeVoice();
}}
/>
) : null}
</Animated.View>
</View>
);

View File

@@ -0,0 +1,138 @@
import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout";
import { VolumeMeter } from "./volume-meter";
interface RealtimeVoiceOverlayProps {
volume: number;
isMuted: boolean;
isDetecting: boolean;
isSpeaking: boolean;
isSwitching: boolean;
onToggleMute: () => void;
onStop: () => void;
}
const OVERLAY_BUTTON_SIZE = 44;
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
export function RealtimeVoiceOverlay({
volume,
isMuted,
isDetecting,
isSpeaking,
isSwitching,
onToggleMute,
onStop,
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
return (
<View style={styles.container}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
/>
</View>
<View style={styles.actionsContainer}>
<Pressable
onPress={onToggleMute}
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"}
style={[
styles.actionButton,
styles.muteButton,
isMuted ? styles.muteButtonMuted : undefined,
isSwitching ? styles.buttonDisabled : undefined,
]}
>
{isMuted ? (
<MicOff size={20} color={theme.colors.palette.white} strokeWidth={2.5} />
) : (
<Mic size={20} color={theme.colors.foreground} strokeWidth={2.5} />
)}
</Pressable>
<Pressable
onPress={onStop}
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel="Stop realtime voice and interrupt turn"
style={[
styles.actionButton,
styles.stopButton,
isSwitching ? styles.buttonDisabled : undefined,
]}
>
{isSwitching ? (
<ActivityIndicator size="small" color={theme.colors.palette.white} />
) : (
<Square
size={20}
color={theme.colors.palette.white}
fill={theme.colors.palette.white}
strokeWidth={2.5}
/>
)}
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
width: "100%",
height: FOOTER_HEIGHT,
borderRadius: theme.borderRadius["2xl"],
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: OVERLAY_VERTICAL_PADDING,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
meterContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
actionsContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
actionButton: {
width: OVERLAY_BUTTON_SIZE,
height: OVERLAY_BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
muteButton: {
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
muteButtonMuted: {
backgroundColor: theme.colors.palette.red[600],
borderColor: theme.colors.palette.red[800],
},
stopButton: {
backgroundColor: theme.colors.palette.red[600],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.red[800],
},
buttonDisabled: {
opacity: 0.5,
},
}));