feat: add voice note recording overlay with transcription feedback and worktree support

This commit is contained in:
Mohamed Boudra
2025-10-26 13:48:14 +01:00
parent dcfdccf07f
commit c4bb10d3c9
13 changed files with 895 additions and 194 deletions
+254 -120
View File
@@ -7,14 +7,21 @@ import {
Image,
Alert,
} from "react-native";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native";
import * as ImagePicker from "expo-image-picker";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
import { useSession } from "@/contexts/session-context";
import { useRealtime } from "@/contexts/realtime-context";
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
import { generateMessageId } from "@/types/stream";
interface AgentInputAreaProps {
agentId: string;
@@ -28,13 +35,24 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles();
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
const { startRealtime } = useRealtime();
const audioRecorder = useAudioRecorder();
const [userInput, setUserInput] = useState("");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [selectedImages, setSelectedImages] = useState<Array<{ uri: string; mimeType: string }>>([]);
const [recordingVolume, setRecordingVolume] = useState(0);
const [recordingDuration, setRecordingDuration] = useState(0);
const [transcribingRequestId, setTranscribingRequestId] = useState<string | null>(null);
const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const overlayTransition = useSharedValue(0);
const audioRecorder = useAudioRecorder({
onAudioLevel: (level) => {
setRecordingVolume(level);
},
});
async function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return;
@@ -90,41 +108,117 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
async function handleVoicePress() {
if (isRecording) {
// Stop recording
try {
setIsRecording(false);
const audioData = await audioRecorder.stop();
if (audioData) {
setIsProcessing(true);
console.log("[AgentInput] Audio recorded:", audioData.size, "bytes");
try {
// Send audio to agent for transcription and processing
await sendAgentAudio(agentId, audioData);
console.log("[AgentInput] Audio sent to agent");
} catch (error) {
console.error("[AgentInput] Failed to send audio:", error);
} finally {
setIsProcessing(false);
}
}
} catch (error) {
console.error("[AgentInput] Failed to stop recording:", error);
setIsRecording(false);
setIsProcessing(false);
}
} else {
// Start recording
try {
await audioRecorder.start();
setIsRecording(true);
} catch (error) {
console.error("[AgentInput] Failed to start recording:", error);
}
// This shouldn't happen as button is hidden when recording
return;
}
// Start recording
try {
await audioRecorder.start();
setIsRecording(true);
setRecordingDuration(0);
overlayTransition.value = withTiming(1, { duration: 250 });
// Start duration timer
recordingIntervalRef.current = setInterval(() => {
setRecordingDuration((prev) => prev + 1);
}, 1000);
} catch (error) {
console.error("[AgentInput] Failed to start recording:", error);
}
}
async function handleCancelRecording() {
try {
// Stop recording without sending
await audioRecorder.stop();
setIsRecording(false);
overlayTransition.value = withTiming(0, { duration: 250 });
// Clear timer
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
recordingIntervalRef.current = null;
}
setRecordingDuration(0);
setRecordingVolume(0);
} catch (error) {
console.error("[AgentInput] Failed to cancel recording:", error);
setIsRecording(false);
overlayTransition.value = withTiming(0, { duration: 250 });
}
}
async function handleSendRecording() {
try {
const audioData = await audioRecorder.stop();
setIsRecording(false);
// Clear timer
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
recordingIntervalRef.current = null;
}
setRecordingDuration(0);
setRecordingVolume(0);
if (audioData) {
// Generate request ID for tracking transcription
const requestId = generateMessageId();
setTranscribingRequestId(requestId);
console.log("[AgentInput] Audio recorded:", audioData.size, "bytes", "requestId:", requestId);
try {
// Send audio to agent for transcription and processing
await sendAgentAudio(agentId, audioData, requestId);
console.log("[AgentInput] Audio sent to agent");
} catch (error) {
console.error("[AgentInput] Failed to send audio:", error);
// Clear transcribing state on error
setTranscribingRequestId(null);
overlayTransition.value = withTiming(0, { duration: 250 });
}
} else {
// No audio data, dismiss overlay immediately
overlayTransition.value = withTiming(0, { duration: 250 });
}
} catch (error) {
console.error("[AgentInput] Failed to stop recording:", error);
setIsRecording(false);
setTranscribingRequestId(null);
overlayTransition.value = withTiming(0, { duration: 250 });
}
}
// Listen for transcription completion
useEffect(() => {
if (!transcribingRequestId) return;
const unsubscribe = ws.on("transcription_result", (message) => {
if (message.type !== "transcription_result") return;
// Check if this transcription result matches our request
if (message.payload.requestId === transcribingRequestId) {
console.log("[AgentInput] Transcription completed for requestId:", transcribingRequestId);
setTranscribingRequestId(null);
overlayTransition.value = withTiming(0, { duration: 250 });
}
});
return () => {
unsubscribe();
};
}, [transcribingRequestId, ws, overlayTransition]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
}
};
}, []);
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
@@ -146,115 +240,155 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const hasText = userInput.trim().length > 0;
const hasImages = selectedImages.length > 0;
const overlayAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: (1 - overlayTransition.value) * FOOTER_HEIGHT }],
opacity: overlayTransition.value,
pointerEvents: overlayTransition.value > 0.5 ? ("auto" as const) : ("none" as const),
};
});
const inputAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: 1 - overlayTransition.value,
pointerEvents: overlayTransition.value < 0.5 ? ("auto" as const) : ("none" as const),
};
});
return (
<View style={styles.container}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer}>
{selectedImages.map((image, index) => (
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
<X size={16} color={theme.colors.foreground} />
</Pressable>
</View>
))}
</View>
)}
{/* Input row */}
<View style={styles.inputRow}>
{/* Attachment button */}
{!isRecording && (
<Pressable
onPress={handlePickImage}
disabled={!ws.isConnected}
style={[
styles.attachButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
{/* Regular input controls */}
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer}>
{selectedImages.map((image, index) => (
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
<X size={16} color={theme.colors.foreground} />
</Pressable>
</View>
))}
</View>
)}
{/* Text input */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
/>
{/* Input row */}
<View style={styles.inputRow}>
{/* Attachment button */}
{!isRecording && (
<Pressable
onPress={handlePickImage}
disabled={!ws.isConnected}
style={[
styles.attachButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
)}
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText || hasImages ? (
// Send button when text is entered or images are selected
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
) : (
// Voice and Realtime buttons when no text
<>
{/* Voice recording button */}
{/* Text input */}
<TextInput
value={userInput}
onChangeText={setUserInput}
placeholder="Message agent..."
placeholderTextColor={theme.colors.mutedForeground}
style={[
styles.textInput,
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
]}
multiline
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected}
/>
{/* Buttons */}
<View style={styles.buttonRow}>
{hasText || hasImages ? (
// Send button when text is entered or images are selected
<Pressable
onPress={handleVoicePress}
disabled={!ws.isConnected}
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.voiceButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
>
{isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
<ArrowUp size={20} color="white" />
</Pressable>
) : (
// Voice and Realtime buttons when no text
<>
{/* Voice recording button */}
<Pressable
onPress={handleVoicePress}
disabled={!ws.isConnected}
style={[
styles.voiceButton,
!ws.isConnected && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
]}
>
{isRecording ? (
<Square size={14} color="white" fill="white" />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable>
{/* Realtime button */}
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
)}
{/* Realtime button */}
<Pressable
onPress={startRealtime}
disabled={!ws.isConnected}
style={[
styles.realtimeButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<AudioLines size={20} color={theme.colors.background} />
</Pressable>
</>
)}
</View>
</View>
</View>
</Animated.View>
{/* Voice note recording overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<VoiceNoteRecordingOverlay
volume={recordingVolume}
duration={recordingDuration}
onCancel={handleCancelRecording}
onSend={handleSendRecording}
isTranscribing={transcribingRequestId !== null}
/>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "relative",
minHeight: FOOTER_HEIGHT,
},
inputContainer: {
flexDirection: "column",
paddingHorizontal: theme.spacing[4],
paddingVertical: BASE_VERTICAL_PADDING,
gap: theme.spacing[2],
minHeight: FOOTER_HEIGHT,
},
overlayContainer: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: FOOTER_HEIGHT,
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -37,11 +37,14 @@ export function AgentStreamView({
const insets = useSafeAreaInsets();
const [selectedToolCall, setSelectedToolCall] =
useState<SelectedToolCall | null>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
// Auto-scroll to bottom when new items arrive
// Auto-scroll to bottom when new items arrive, but only if user is already at bottom
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [streamItems]);
if (isNearBottom) {
scrollViewRef.current?.scrollToEnd({ animated: true });
}
}, [streamItems, isNearBottom]);
function handleOpenToolCallDetails(toolCall: SelectedToolCall) {
setSelectedToolCall(toolCall);
@@ -55,6 +58,15 @@ export function AgentStreamView({
setSelectedToolCall(null);
}
function handleScroll(event: any) {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const distanceFromBottom =
contentSize.height - contentOffset.y - layoutMeasurement.height;
// Consider user "at bottom" if within 10px of the end
const nearBottom = distanceFromBottom < 10;
setIsNearBottom(nearBottom);
}
return (
<View style={stylesheet.container}>
{/* Content list */}
@@ -65,6 +77,8 @@ export function AgentStreamView({
paddingTop: 24,
paddingBottom: Math.max(insets.bottom, 32),
}}
onScroll={handleScroll}
scrollEventThrottle={16}
>
{streamItems.length === 0 ? (
<View style={stylesheet.emptyState}>
@@ -13,7 +13,6 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
import {
BottomSheetModal,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
BottomSheetTextInput,
BottomSheetFooter,
@@ -286,18 +285,16 @@ export function CreateAgentModal({
android_keyboardInputMode="adjustResize"
topInset={insets.top}
>
<BottomSheetView style={styles.sheetContent}>
<BottomSheetScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Create New Agent</Text>
</View>
{/* Form */}
<BottomSheetScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{/* Working Directory Input */}
<View style={styles.formSection}>
<Text style={styles.label}>Working Directory</Text>
@@ -403,8 +400,7 @@ export function CreateAgentModal({
))}
</View>
</View>
</BottomSheetScrollView>
</BottomSheetView>
</BottomSheetScrollView>
</BottomSheetModal>
);
}
@@ -416,9 +412,6 @@ const styles = StyleSheet.create((theme) => ({
handleIndicator: {
backgroundColor: theme.colors.border,
},
sheetContent: {
flex: 1,
},
header: {
alignItems: "center",
justifyContent: "center",
@@ -431,12 +424,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: theme.spacing[6],
paddingBottom: theme.spacing[6],
// Add extra padding at bottom to account for fixed footer button
// Footer height is roughly: padding (16) + button (48) + margin (16) = 80
paddingBottom: 100,
},
formSection: {
marginBottom: theme.spacing[6],
@@ -0,0 +1,105 @@
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
interface VoiceNoteRecordingOverlayProps {
volume: number;
duration: number;
onCancel: () => void;
onSend: () => void;
isTranscribing?: boolean;
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function VoiceNoteRecordingOverlay({
volume,
duration,
onCancel,
onSend,
isTranscribing = false,
}: VoiceNoteRecordingOverlayProps) {
const { theme } = useUnistyles();
return (
<View style={[styles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
{/* Cancel button */}
<Pressable onPress={onCancel} disabled={isTranscribing} style={[styles.cancelButton, isTranscribing && styles.buttonDisabled]}>
<X size={24} color={theme.colors.palette.white} strokeWidth={2.5} />
</Pressable>
{/* Center: Volume meter and timer */}
<View style={styles.centerContainer}>
<VolumeMeter
volume={volume}
isMuted={false}
isDetecting={true}
isSpeaking={false}
orientation="horizontal"
/>
<Text style={[styles.timerText, { color: theme.colors.palette.white }]}>
{formatDuration(duration)}
</Text>
</View>
{/* Send button */}
<Pressable onPress={onSend} disabled={isTranscribing} style={[styles.sendButton, { backgroundColor: theme.colors.palette.white }]}>
{isTranscribing ? (
<ActivityIndicator size="small" color={theme.colors.palette.blue[600]} />
) : (
<ArrowUp size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
)}
</Pressable>
</View>
);
}
const BUTTON_SIZE = 56;
const VERTICAL_PADDING = (FOOTER_HEIGHT - BUTTON_SIZE) / 2;
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: VERTICAL_PADDING,
height: FOOTER_HEIGHT,
},
cancelButton: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: "rgba(0, 0, 0, 0.15)",
alignItems: "center",
justifyContent: "center",
},
centerContainer: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[4],
},
timerText: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
fontVariant: ["tabular-nums"],
},
sendButton: {
width: BUTTON_SIZE,
height: BUTTON_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
}));
+28 -41
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState, useRef } from "react";
import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
@@ -24,54 +24,52 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
// Base dimensions
const LINE_SPACING = 8;
const LINE_WIDTH = 8;
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20;
// Shared values for each line's height
// Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT);
const line2Height = useSharedValue(MIN_HEIGHT);
const line3Height = useSharedValue(MIN_HEIGHT);
// Idle pulse animations (when no volume)
const line1Pulse = useSharedValue(1);
const line2Pulse = useSharedValue(1);
const line3Pulse = useSharedValue(1);
// Start idle animations with different phases
// Start idle animations with different phases for all dots
useEffect(() => {
if (isMuted) {
// When muted, set pulse to 1 (no animation)
// When muted, set all pulses to 1 (no animation)
line1Pulse.value = 1;
line2Pulse.value = 1;
line3Pulse.value = 1;
return;
}
// Line 1 - fastest pulse
// Animate each dot with different phases and durations
line1Pulse.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 0 }),
withTiming(1.15, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Line 2 - medium pulse with offset
line2Pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 400 }),
withTiming(1.15, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 200 }),
withTiming(1.20, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Line 3 - slowest pulse with different offset
line3Pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 600 }),
withTiming(1, { duration: 400 }),
withTiming(1.25, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 1200, easing: Easing.inOut(Easing.ease) })
),
@@ -80,10 +78,10 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
);
}, [isMuted]);
// Update heights based on volume with different responsiveness per line
// Update heights based on volume with different responsiveness for all dots
useEffect(() => {
if (isMuted) {
// When muted, keep lines at minimum height without animation
// When muted, keep all lines at minimum height without animation
line1Height.value = MIN_HEIGHT;
line2Height.value = MIN_HEIGHT;
line3Height.value = MIN_HEIGHT;
@@ -92,25 +90,23 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
if (volume > 0.01) {
// Active volume - animate heights based on volume
// Line 1 - most responsive, follows volume closely
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05);
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line1Height.value = withSpring(target1, {
damping: 10,
stiffness: 200,
});
// Line 2 - medium responsiveness
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line2Height.value = withSpring(target2, {
damping: 12,
stiffness: 150,
damping: 12.5,
stiffness: 175,
});
// Line 3 - smoothest, lags behind
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.7);
line3Height.value = withSpring(target3, {
damping: 15,
stiffness: 100,
stiffness: 150,
});
} else {
// No volume - return to minimum
@@ -118,10 +114,12 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
damping: 20,
stiffness: 150,
});
line2Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line3Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
@@ -129,7 +127,10 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
}
}, [volume, isMuted]);
// Animated styles for each line
const lineColor = "#FFFFFF";
const containerHeight = orientation === "horizontal" ? 60 : 100;
// Create animated styles unconditionally at top level
const line1Style = useAnimatedStyle(() => {
const isActive = isDetecting || isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
@@ -160,20 +161,12 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
};
});
const lineColor = "#FFFFFF";
const lineWidth = 8;
const containerHeight = orientation === "horizontal" ? 60 : 100;
return (
<View style={[styles.container, { height: containerHeight }]}>
<ReanimatedAnimated.View
style={[
styles.line,
{
width: lineWidth,
backgroundColor: lineColor,
},
{ width: LINE_WIDTH, backgroundColor: lineColor },
line1Style,
]}
/>
@@ -181,10 +174,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
<ReanimatedAnimated.View
style={[
styles.line,
{
width: lineWidth,
backgroundColor: lineColor,
},
{ width: LINE_WIDTH, backgroundColor: lineColor },
line2Style,
]}
/>
@@ -192,10 +182,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
<ReanimatedAnimated.View
style={[
styles.line,
{
width: lineWidth,
backgroundColor: lineColor,
},
{ width: LINE_WIDTH, backgroundColor: lineColor },
line3Style,
]}
/>
@@ -116,8 +116,8 @@ interface SessionContextValue {
// Helpers
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentAudio: (agentId: string, audioBlob: Blob) => Promise<void>;
createAgent: (options: { cwd: string; initialMode?: string; requestId?: string }) => void;
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
createAgent: (options: { cwd: string; initialMode?: string; worktreeName?: string; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void;
}
@@ -670,7 +670,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob) => {
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob, requestId?: string) => {
try {
// Convert blob to base64
const arrayBuffer = await audioBlob.arrayBuffer();
@@ -693,18 +693,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
audio: base64Audio,
format,
isLast: true,
requestId,
},
};
ws.send(msg);
console.log("[Session] Sent audio to agent:", agentId, format, audioBlob.size, "bytes");
console.log("[Session] Sent audio to agent:", agentId, format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : "");
} catch (error) {
console.error("[Session] Failed to send audio:", error);
throw error;
}
}, [ws]);
const createAgent = useCallback((options: { cwd: string; initialMode?: string; requestId?: string }) => {
const createAgent = useCallback((options: { cwd: string; initialMode?: string; worktreeName?: string; requestId?: string }) => {
const msg: WSInboundMessage = {
type: "session",
message: {