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: {
+133 -1
View File
@@ -224,7 +224,139 @@
"sessionId": "9f844826-2c8e-414f-a236-a9279233b053"
},
"createdAt": "2025-10-26T08:32:55.726Z",
"lastActivityAt": "2025-10-26T08:43:50.999Z",
"lastActivityAt": "2025-10-26T08:52:29.872Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "01005d0b-bfa2-4af0-9a3f-be30abc13416",
"title": "Plan Dummy File Creation",
"sessionId": "bb0abc76-ea48-42e6-826d-4eabe6673934",
"options": {
"type": "claude",
"sessionId": "bb0abc76-ea48-42e6-826d-4eabe6673934"
},
"createdAt": "2025-10-26T08:55:15.517Z",
"lastActivityAt": "2025-10-26T09:38:15.566Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "f9243dd7-c8c1-464a-b1b1-bcbb117a855a",
"title": "Fix Agent Page Auto-Scroll",
"sessionId": "5d373fa4-551f-457c-94b3-80b93fceb8db",
"options": {
"type": "claude",
"sessionId": "5d373fa4-551f-457c-94b3-80b93fceb8db"
},
"createdAt": "2025-10-26T08:58:52.052Z",
"lastActivityAt": "2025-10-26T08:59:37.873Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "cb049284-a950-4d09-88b2-19a53a6537c8",
"title": "Update Voice Note Recording UI",
"sessionId": "8211a874-e907-4e2c-8be9-65340d8b5474",
"options": {
"type": "claude",
"sessionId": "8211a874-e907-4e2c-8be9-65340d8b5474"
},
"createdAt": "2025-10-26T09:15:28.908Z",
"lastActivityAt": "2025-10-26T09:16:35.102Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "a10721ba-5054-4141-8937-96bd1de43c80",
"title": "Lower Agent Page Bottom Threshold",
"sessionId": "7a77e68c-b5bb-4af4-8aa9-75df179bb5a0",
"options": {
"type": "claude",
"sessionId": "7a77e68c-b5bb-4af4-8aa9-75df179bb5a0"
},
"createdAt": "2025-10-26T09:31:25.429Z",
"lastActivityAt": "2025-10-26T09:32:08.991Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "ce150913-d726-45f6-8ce8-b273a6eb846b",
"title": "Create Agent with Work Tree",
"sessionId": "20b0b2c2-18ec-409b-b932-a20c9ca8e0d3",
"options": {
"type": "claude",
"sessionId": "20b0b2c2-18ec-409b-b932-a20c9ca8e0d3"
},
"createdAt": "2025-10-26T09:45:29.563Z",
"lastActivityAt": "2025-10-26T12:25:20.827Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "a81597c0-5a56-480c-a39f-774018078af2",
"title": "Show Pwd and Git Status",
"sessionId": "8b38e1a5-161a-4adb-a5e1-875d50b803ab",
"options": {
"type": "claude",
"sessionId": "8b38e1a5-161a-4adb-a5e1-875d50b803ab"
},
"createdAt": "2025-10-26T09:54:57.233Z",
"lastActivityAt": "2025-10-26T09:55:19.464Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "445fccbe-9d88-4205-ae42-342785c7ff85",
"title": "Agent 445fccbe",
"sessionId": "8c5f56b1-d61c-46d0-9bb2-acd0d5761e34",
"options": {
"type": "claude",
"sessionId": "8c5f56b1-d61c-46d0-9bb2-acd0d5761e34"
},
"createdAt": "2025-10-26T09:57:56.313Z",
"lastActivityAt": "2025-10-26T09:58:07.755Z",
"cwd": "/Users/moboudra/dev/voice-dev-test-worktree-feature"
},
{
"id": "667220be-001a-4975-b06d-af2385d67d16",
"title": "Agent 667220be",
"sessionId": "bb024cfa-304a-407b-bd66-f865ad33cd22",
"options": {
"type": "claude",
"sessionId": "bb024cfa-304a-407b-bd66-f865ad33cd22"
},
"createdAt": "2025-10-26T10:00:25.342Z",
"lastActivityAt": "2025-10-26T10:00:35.188Z",
"cwd": "/Users/moboudra/dev/voice-dev-test-e2e-worktree"
},
{
"id": "acbf1000-1516-455b-8c76-5e33429242eb",
"title": "Check Repo Path and Status",
"sessionId": "e5e0f403-e8fd-47d3-9ea9-fe7aa532e49a",
"options": {
"type": "claude",
"sessionId": "e5e0f403-e8fd-47d3-9ea9-fe7aa532e49a"
},
"createdAt": "2025-10-26T10:01:31.658Z",
"lastActivityAt": "2025-10-26T10:01:55.618Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "e31d3ad4-f5cd-445e-9d1d-582f0ffee01f",
"title": "Agent e31d3ad4",
"sessionId": "019a2076-05ac-7468-8c48-17959cc1afc1",
"options": {
"type": "claude",
"sessionId": null
},
"createdAt": "2025-10-26T12:19:56.347Z",
"lastActivityAt": "2025-10-26T12:19:56.347Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "be7b076f-14b1-4ce4-9faf-74e107a1a3ce",
"title": "Git Status and Working Directory",
"sessionId": "1d9fed78-3021-4fc5-91b0-a8f4cfb3bb4f",
"options": {
"type": "claude",
"sessionId": "1d9fed78-3021-4fc5-91b0-a8f4cfb3bb4f"
},
"createdAt": "2025-10-26T12:25:48.081Z",
"lastActivityAt": "2025-10-26T12:26:13.370Z",
"cwd": "/Users/moboudra/dev/voice-dev-gland"
}
]
+22 -4
View File
@@ -60,7 +60,7 @@ export async function createAgentMcpServer(
{
title: "Create Coding Agent",
description:
"Creates a new Claude Code agent via ACP. The agent runs as a separate process and can execute coding tasks autonomously in a specified directory. Returns immediately with agent ID and status. If an initial prompt is provided, the agent will start working on it automatically.",
"Creates a new Claude Code agent via ACP. The agent runs as a separate process and can execute coding tasks autonomously in a specified directory. Returns immediately with agent ID and status. If an initial prompt is provided, the agent will start working on it automatically. Optionally create a git worktree for isolated development.",
inputSchema: {
cwd: z
.string()
@@ -79,6 +79,12 @@ export async function createAgentMcpServer(
.describe(
"Initial session mode for the agent (e.g., 'ask', 'code', 'architect'). If not specified, the agent will use its default mode. The available modes depend on the specific agent implementation."
),
worktreeName: z
.string()
.optional()
.describe(
"Optional git worktree branch name for isolated development. Must be a valid slug: lowercase letters, numbers, and hyphens only (e.g., 'feature-auth', 'fix-bug-123'). Creates a new git worktree with this branch name and runs the agent in the worktree directory. Only works if cwd is inside a git repository."
),
},
outputSchema: {
agentId: z.string().describe("Unique identifier for the created agent"),
@@ -87,7 +93,7 @@ export async function createAgentMcpServer(
.describe(
"Current agent status: 'initializing', 'ready', 'processing', etc."
),
cwd: z.string().describe("The resolved absolute working directory the agent is running in"),
cwd: z.string().describe("The resolved absolute working directory the agent is running in (worktree path if worktreeName was provided)"),
currentModeId: z.string().nullable().describe("The agent's current session mode"),
availableModes: z.array(z.object({
id: z.string(),
@@ -96,9 +102,21 @@ export async function createAgentMcpServer(
})).nullable().describe("Available session modes for this agent"),
},
},
async ({ cwd, initialPrompt, initialMode }) => {
async ({ cwd, initialPrompt, initialMode, worktreeName }) => {
// Expand and resolve the working directory
const resolvedCwd = expandPath(cwd);
let resolvedCwd = expandPath(cwd);
// Handle worktree creation if requested
if (worktreeName) {
const { createWorktree } = await import("../../utils/worktree.js");
const worktreeConfig = await createWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
});
resolvedCwd = worktreeConfig.worktreePath;
}
const agentId = await agentManager.createAgent({
cwd: resolvedCwd,
+3
View File
@@ -87,12 +87,14 @@ export const SendAgentAudioSchema = z.object({
audio: z.string(), // base64 encoded
format: z.string(),
isLast: z.boolean(),
requestId: z.string().optional(), // Client-provided ID for tracking transcription
});
export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
cwd: z.string(),
initialMode: z.string().optional(),
worktreeName: z.string().optional(),
requestId: z.string().optional(),
});
@@ -181,6 +183,7 @@ export const TranscriptionResultMessageSchema = z.object({
text: z.string(),
language: z.string().optional(),
duration: z.number().optional(),
requestId: z.string().optional(), // Echoed back from request for tracking
}),
});