diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6e61e2c04..f1b062432 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -7,11 +7,10 @@ import { } from "react-native"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ArrowUp, AudioLines, MicOff, Square, Pencil } from "lucide-react-native"; +import { ArrowUp, Square, Pencil } from "lucide-react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useVoice } from "@/contexts/voice-context"; import { useIsFocused } from "@react-navigation/native"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { generateMessageId } from "@/types/stream"; @@ -94,8 +93,6 @@ export function AgentInputArea({ const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice(); - const [internalInput, setInternalInput] = useState(""); const userInput = value ?? internalInput; const setUserInput = onChangeText ?? setInternalInput; @@ -573,35 +570,6 @@ export function AgentInputArea({ /> )} - {/* Voice quick mute indicator */} - {isVoiceMode && ( - - - {isVoiceMuted ? ( - - ) : ( - - )} - - {isVoiceMuted ? "Voice muted" : "Voice on"} - - - - )} - {/* MessageInput handles everything: text, dictation, attachments, all buttons */} ({ buttonDisabled: { opacity: 0.5, }, - voiceIndicatorRow: { - flexDirection: "row", - justifyContent: "flex-end", - }, - voiceIndicatorPill: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - height: 32, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - voiceIndicatorPillMuted: { - backgroundColor: theme.colors.palette.red[600], - borderColor: theme.colors.palette.red[800], - }, - voiceIndicatorText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, - voiceIndicatorTextMuted: { - color: theme.colors.surface0, - }, queueContainer: { flexDirection: "column", gap: theme.spacing[2], diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 3418ddfdb..8b4077db0 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -52,6 +52,7 @@ 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"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -541,6 +542,7 @@ export function AgentStreamView({ {showWorkingIndicator ? ( + ) : null} @@ -1179,8 +1181,11 @@ const stylesheet = StyleSheet.create((theme) => ({ gap: theme.spacing[3], }, workingIndicatorWrapper: { - alignItems: "flex-start", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingLeft: 3, + paddingRight: 3, paddingTop: theme.spacing[3], paddingBottom: theme.spacing[2], }, diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 5ef241307..bd2c96967 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -179,6 +179,7 @@ export function DictationOverlay({ isDetecting isSpeaking={false} orientation="horizontal" + color={theme.colors.palette.white} /> - - - - - Choose a host - {voiceEligibleHosts.map((entry) => ( - handleSelectVoiceHost(entry.daemon.id)} - > - {entry.daemon.label} - - ))} - - Cancel + + {voiceEligibleHosts.map((entry) => ( + handleSelectVoiceHost(entry.daemon.id)} + > + {entry.daemon.label} - + ))} + + Cancel + - + ); } @@ -460,6 +461,32 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { + + + + {voiceEligibleHosts.map((entry) => ( + handleSelectVoiceHost(entry.daemon.id)} + > + {entry.daemon.label} + + ))} + + Cancel + + + ); } @@ -547,33 +574,10 @@ const styles = StyleSheet.create((theme) => ({ footerButtonTextHovered: { color: theme.colors.foreground, }, - hostPickerOverlay: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: "rgba(0, 0, 0, 0.5)", - padding: theme.spacing[4], - }, - hostPickerBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - hostPickerContainer: { - width: "100%", - maxWidth: 360, - backgroundColor: theme.colors.surface0, - borderRadius: theme.borderRadius["2xl"], - padding: theme.spacing[4], - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + hostPickerList: { gap: theme.spacing[2], }, - hostPickerTitle: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - marginBottom: theme.spacing[2], - }, - hostPickerButton: { + hostPickerOption: { paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, @@ -581,12 +585,11 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, }, - hostPickerButtonText: { + hostPickerOptionText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, hostPickerCancel: { - marginTop: theme.spacing[2], paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx new file mode 100644 index 000000000..e1215d85c --- /dev/null +++ b/packages/app/src/components/voice-compact-indicator.tsx @@ -0,0 +1,76 @@ +import { Pressable, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AudioLines, MicOff } from "lucide-react-native"; +import { VolumeMeter } from "@/components/volume-meter"; +import { useVoice } from "@/contexts/voice-context"; + +export function VoiceCompactIndicator() { + const { theme } = useUnistyles(); + const { isVoiceMode, volume, isMuted, isDetecting, isSpeaking, toggleMute } = + useVoice(); + + if (!isVoiceMode) { + return null; + } + + return ( + + + + + + + {isMuted ? ( + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[1], + height: 32, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + meterContainer: { + justifyContent: "center", + }, + muteButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + 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], + }, +})); + diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx index 0043eaad3..c5875f972 100644 --- a/packages/app/src/components/voice-panel.tsx +++ b/packages/app/src/components/voice-panel.tsx @@ -35,40 +35,42 @@ export function VoicePanel() { - - - - - - - + + - + - void stopVoice()} - accessibilityRole="button" - accessibilityLabel="Stop voice mode" - style={[styles.iconButton, styles.iconButtonStop]} - > - - + + + + + + void stopVoice()} + accessibilityRole="button" + accessibilityLabel="Stop voice mode" + style={[styles.iconButton, styles.iconButtonStop]} + > + + + ); @@ -109,7 +111,14 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, - meterRow: { + contentRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, + meterContainer: { + flex: 1, justifyContent: "center", }, actionsRow: { diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx index 6dab61d88..dd55dd98b 100644 --- a/packages/app/src/components/volume-meter.tsx +++ b/packages/app/src/components/volume-meter.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect } from "react"; import { View } from "react-native"; import ReanimatedAnimated, { useSharedValue, @@ -17,16 +17,29 @@ interface VolumeMeterProps { isDetecting?: boolean; isSpeaking?: boolean; orientation?: "vertical" | "horizontal"; + variant?: "default" | "compact"; + color?: string; } -export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSpeaking = false, orientation = "vertical" }: VolumeMeterProps) { +export function VolumeMeter({ + volume, + isMuted = false, + isDetecting = false, + isSpeaking = false, + orientation = "vertical", + variant = "default", + color, +}: VolumeMeterProps) { const { theme } = useUnistyles(); + const isCompact = variant === "compact"; // Base dimensions - const LINE_SPACING = 8; - const LINE_WIDTH = 8; - const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50; - const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20; + const LINE_SPACING = isCompact ? 6 : 8; + const LINE_WIDTH = isCompact ? 6 : 8; + const MAX_HEIGHT = + orientation === "horizontal" ? (isCompact ? 18 : 30) : isCompact ? 32 : 50; + const MIN_HEIGHT = + orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20; // Create shared values for 3 dots unconditionally const line1Height = useSharedValue(MIN_HEIGHT); @@ -127,8 +140,9 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp } }, [volume, isMuted]); - const lineColor = "#FFFFFF"; - const containerHeight = orientation === "horizontal" ? 60 : 100; + const lineColor = color ?? theme.colors.foreground; + const containerHeight = + orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100; // Create animated styles unconditionally at top level const line1Style = useAnimatedStyle(() => {