Update files

This commit is contained in:
Mohamed Boudra
2026-02-04 14:23:12 +07:00
parent de16c6b6dd
commit d0caf5b78c
7 changed files with 200 additions and 151 deletions
@@ -7,11 +7,10 @@ import {
} from "react-native"; } from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 Animated, { useAnimatedStyle } from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useVoice } from "@/contexts/voice-context";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
@@ -94,8 +93,6 @@ export function AgentInputArea({
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
const [internalInput, setInternalInput] = useState(""); const [internalInput, setInternalInput] = useState("");
const userInput = value ?? internalInput; const userInput = value ?? internalInput;
const setUserInput = onChangeText ?? setInternalInput; const setUserInput = onChangeText ?? setInternalInput;
@@ -573,35 +570,6 @@ export function AgentInputArea({
/> />
)} )}
{/* Voice quick mute indicator */}
{isVoiceMode && (
<View style={styles.voiceIndicatorRow}>
<Pressable
onPress={toggleVoiceMute}
accessibilityRole="button"
accessibilityLabel={isVoiceMuted ? "Unmute voice" : "Mute voice"}
style={[
styles.voiceIndicatorPill,
isVoiceMuted && styles.voiceIndicatorPillMuted,
]}
>
{isVoiceMuted ? (
<MicOff size={14} color={theme.colors.surface0} />
) : (
<AudioLines size={14} color={theme.colors.foreground} />
)}
<Text
style={[
styles.voiceIndicatorText,
isVoiceMuted && styles.voiceIndicatorTextMuted,
]}
>
{isVoiceMuted ? "Voice muted" : "Voice on"}
</Text>
</Pressable>
</View>
)}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */} {/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput <MessageInput
ref={messageInputRef} ref={messageInputRef}
@@ -666,33 +634,6 @@ const styles = StyleSheet.create(((theme: Theme) => ({
buttonDisabled: { buttonDisabled: {
opacity: 0.5, 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: { queueContainer: {
flexDirection: "column", flexDirection: "column",
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -52,6 +52,7 @@ import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { VoiceCompactIndicator } from "./voice-compact-indicator";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -541,6 +542,7 @@ export function AgentStreamView({
{showWorkingIndicator ? ( {showWorkingIndicator ? (
<View style={stylesheet.workingIndicatorWrapper}> <View style={stylesheet.workingIndicatorWrapper}>
<WorkingIndicator /> <WorkingIndicator />
<VoiceCompactIndicator />
</View> </View>
) : null} ) : null}
</View> </View>
@@ -1179,8 +1181,11 @@ const stylesheet = StyleSheet.create((theme) => ({
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
workingIndicatorWrapper: { workingIndicatorWrapper: {
alignItems: "flex-start", flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingLeft: 3, paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3], paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
}, },
@@ -179,6 +179,7 @@ export function DictationOverlay({
isDetecting isDetecting
isSpeaking={false} isSpeaking={false}
orientation="horizontal" orientation="horizontal"
color={theme.colors.palette.white}
/> />
<Text <Text
style={[ style={[
+52 -49
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, useEffect } from "react"; import { useCallback, useMemo, useState, useEffect } from "react";
import { View, Pressable, Text, Platform, Modal, Alert } from "react-native"; import { View, Pressable, Text, Platform, Alert } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
@@ -23,6 +23,7 @@ import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts"; import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
const DESKTOP_SIDEBAR_WIDTH = 320; const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 15; const SIDEBAR_AGENT_LIMIT = 15;
@@ -351,31 +352,31 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
<Modal <AdaptiveModalSheet
title="Choose a host"
visible={showVoiceHostPicker} visible={showVoiceHostPicker}
transparent onClose={handleDismissVoiceHostPicker}
animationType="fade" snapPoints={["45%", "70%"]}
onRequestClose={handleDismissVoiceHostPicker} testID="voice-host-picker"
> >
<View style={styles.hostPickerOverlay}> <View style={styles.hostPickerList}>
<Pressable style={styles.hostPickerBackdrop} onPress={handleDismissVoiceHostPicker} /> {voiceEligibleHosts.map((entry) => (
<View style={styles.hostPickerContainer}> <Pressable
<Text style={styles.hostPickerTitle}>Choose a host</Text> key={entry.daemon.id}
{voiceEligibleHosts.map((entry) => ( style={styles.hostPickerOption}
<Pressable onPress={() => handleSelectVoiceHost(entry.daemon.id)}
key={entry.daemon.id} >
style={styles.hostPickerButton} <Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
>
<Text style={styles.hostPickerButtonText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable style={styles.hostPickerCancel} onPress={handleDismissVoiceHostPicker}>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable> </Pressable>
</View> ))}
<Pressable
style={styles.hostPickerCancel}
onPress={handleDismissVoiceHostPicker}
>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View> </View>
</Modal> </AdaptiveModalSheet>
</View> </View>
); );
} }
@@ -460,6 +461,32 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Pressable> </Pressable>
</View> </View>
</View> </View>
<AdaptiveModalSheet
title="Choose a host"
visible={showVoiceHostPicker}
onClose={handleDismissVoiceHostPicker}
snapPoints={["45%", "70%"]}
testID="voice-host-picker"
>
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable
style={styles.hostPickerCancel}
onPress={handleDismissVoiceHostPicker}
>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View>
</AdaptiveModalSheet>
</View> </View>
); );
} }
@@ -547,33 +574,10 @@ const styles = StyleSheet.create((theme) => ({
footerButtonTextHovered: { footerButtonTextHovered: {
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
hostPickerOverlay: { hostPickerList: {
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,
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
hostPickerTitle: { hostPickerOption: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginBottom: theme.spacing[2],
},
hostPickerButton: {
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -581,12 +585,11 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
}, },
hostPickerButtonText: { hostPickerOptionText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
hostPickerCancel: { hostPickerCancel: {
marginTop: theme.spacing[2],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -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 (
<View style={styles.container}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"
/>
</View>
<Pressable
onPress={toggleMute}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
style={[styles.muteButton, isMuted && styles.muteButtonMuted]}
>
{isMuted ? (
<MicOff size={14} color={theme.colors.surface0} />
) : (
<AudioLines size={14} color={theme.colors.foreground} />
)}
</Pressable>
</View>
);
}
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],
},
}));
+42 -33
View File
@@ -35,40 +35,42 @@ export function VoicePanel() {
</Text> </Text>
</View> </View>
<View style={styles.meterRow}> <View style={styles.contentRow}>
<VolumeMeter <View style={styles.meterContainer}>
volume={volume} <VolumeMeter
isMuted={isMuted} volume={volume}
isDetecting={isDetecting} isMuted={isMuted}
isSpeaking={isSpeaking} isDetecting={isDetecting}
orientation="horizontal" isSpeaking={isSpeaking}
/> orientation="horizontal"
</View>
<View style={styles.actionsRow}>
<Pressable
onPress={toggleMute}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
style={[
styles.iconButton,
isMuted && styles.iconButtonMuted,
]}
>
<MicOff
size={18}
color={isMuted ? theme.colors.surface0 : theme.colors.foreground}
/> />
</Pressable> </View>
<Pressable <View style={styles.actionsRow}>
onPress={() => void stopVoice()} <Pressable
accessibilityRole="button" onPress={toggleMute}
accessibilityLabel="Stop voice mode" accessibilityRole="button"
style={[styles.iconButton, styles.iconButtonStop]} accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
> style={[
<Square size={16} color="white" fill="white" /> styles.iconButton,
</Pressable> isMuted && styles.iconButtonMuted,
]}
>
<MicOff
size={18}
color={isMuted ? theme.colors.surface0 : theme.colors.foreground}
/>
</Pressable>
<Pressable
onPress={() => void stopVoice()}
accessibilityRole="button"
accessibilityLabel="Stop voice mode"
style={[styles.iconButton, styles.iconButtonStop]}
>
<Square size={16} color="white" fill="white" />
</Pressable>
</View>
</View> </View>
</View> </View>
); );
@@ -109,7 +111,14 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
meterRow: { contentRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
meterContainer: {
flex: 1,
justifyContent: "center", justifyContent: "center",
}, },
actionsRow: { actionsRow: {
+22 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react"; import { useEffect } from "react";
import { View } from "react-native"; import { View } from "react-native";
import ReanimatedAnimated, { import ReanimatedAnimated, {
useSharedValue, useSharedValue,
@@ -17,16 +17,29 @@ interface VolumeMeterProps {
isDetecting?: boolean; isDetecting?: boolean;
isSpeaking?: boolean; isSpeaking?: boolean;
orientation?: "vertical" | "horizontal"; 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 { theme } = useUnistyles();
const isCompact = variant === "compact";
// Base dimensions // Base dimensions
const LINE_SPACING = 8; const LINE_SPACING = isCompact ? 6 : 8;
const LINE_WIDTH = 8; const LINE_WIDTH = isCompact ? 6 : 8;
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50; const MAX_HEIGHT =
const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20; 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 // Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT); const line1Height = useSharedValue(MIN_HEIGHT);
@@ -127,8 +140,9 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
} }
}, [volume, isMuted]); }, [volume, isMuted]);
const lineColor = "#FFFFFF"; const lineColor = color ?? theme.colors.foreground;
const containerHeight = orientation === "horizontal" ? 60 : 100; const containerHeight =
orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100;
// Create animated styles unconditionally at top level // Create animated styles unconditionally at top level
const line1Style = useAnimatedStyle(() => { const line1Style = useAnimatedStyle(() => {