From ef29319bafa02a0224bacfa22e5e0ed5e3df9f8c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Feb 2026 16:19:33 +0700 Subject: [PATCH] feat(app): move realtime voice controls into input overlay --- .../app/src/components/agent-stream-view.tsx | 28 +--- packages/app/src/components/message-input.tsx | 90 ++++++++---- .../src/components/realtime-voice-overlay.tsx | 138 ++++++++++++++++++ 3 files changed, 204 insertions(+), 52 deletions(-) create mode 100644 packages/app/src/components/realtime-voice-overlay.tsx diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 12bc4e265..9cca92a92 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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>(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 ? ( - - {leftContent} - - {isVoiceMode ? : null} - - - ) : null} + {showBottomBar ? {leftContent} : null} ); @@ -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", diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 5c1702034..6fac1fc2b 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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( !!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( // 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( discardFailedDictation(); }, [discardFailedDictation]); + const handleStopRealtimeVoice = useCallback(async () => { + if (!voice || !isRealtimeVoiceForCurrentAgent) { + return; + } + + const tasks: Promise[] = []; + 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( 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( {/* Dictation overlay */} - + {showDictationOverlay ? ( + + ) : showRealtimeOverlay && voice ? ( + { + void handleStopRealtimeVoice(); + }} + /> + ) : null} ); diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx new file mode 100644 index 000000000..829720ac3 --- /dev/null +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -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 ( + + + + + + + + {isMuted ? ( + + ) : ( + + )} + + + + {isSwitching ? ( + + ) : ( + + )} + + + + ); +} + +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, + }, +}));