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

1
dummy.txt Normal file
View File

@@ -0,0 +1 @@
This is a dummy file.

8
package-lock.json generated
View File

@@ -21233,7 +21233,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.4.9",
"@ai-sdk/openai": "^2.0.52",
"@boudra/claude-code-acp": "^0.8.4",
"@boudra/claude-code-acp": "^0.8.5",
"@deepgram/sdk": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",
@@ -21261,9 +21261,9 @@
}
},
"packages/server/node_modules/@boudra/claude-code-acp": {
"version": "0.8.4",
"resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.4/1256c7f6f05f766914a479208f9905d85e064471",
"integrity": "sha512-GgfjAkDtjC5HFYjZIWyN8Z4E66gQaZEnRbLq7ENRcuGw5WYAfq5f7uOlN1w7rjNSg4yqMGYpZIpavCM9n5FJhg==",
"version": "0.8.5",
"resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.5/f4bc31a9496d5ec72b306e2f891eeaaef38aeaf5",
"integrity": "sha512-ZGa3gNtlGnwUlPZUgVS0+hys9BPYztHJlE16EvWX8pdBsD4TxMlH/WflNKQ3s7isovTJuAljRrCUF0YXH3FD0g==",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.4.9",

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],

View File

@@ -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}>

View File

@@ -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],

View File

@@ -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,
},
}));

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,
]}
/>

View File

@@ -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: {

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"
}
]

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,

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
}),
});

132
test-worktree-agent.ts Normal file
View File

@@ -0,0 +1,132 @@
import { AgentManager } from "./packages/server/src/server/acp/agent-manager.js";
import { createWorktree } from "./packages/server/src/utils/worktree.js";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function testWorktreeAgent() {
console.log("=== Testing Worktree Agent Creation ===\n");
const testBranchName = "test-worktree-feature";
const cwd = process.cwd(); // voice-dev directory
try {
// Step 1: Test worktree creation directly
console.log("Step 1: Testing worktree creation utility...");
console.log(` Current directory: ${cwd}`);
console.log(` Branch name: ${testBranchName}\n`);
const worktreeConfig = await createWorktree({
branchName: testBranchName,
cwd,
});
console.log("✓ Worktree created successfully!");
console.log(` Branch: ${worktreeConfig.branchName}`);
console.log(` Path: ${worktreeConfig.worktreePath}`);
console.log(` Repo type: ${worktreeConfig.repoType}`);
console.log(` Repo path: ${worktreeConfig.repoPath}\n`);
// Step 2: Verify worktree exists in git
console.log("Step 2: Verifying worktree in git...");
const { stdout: worktreeList } = await execAsync("git worktree list");
console.log("Git worktree list:");
console.log(worktreeList);
if (!worktreeList.includes(worktreeConfig.worktreePath)) {
throw new Error("Worktree not found in git worktree list!");
}
console.log("✓ Worktree verified in git\n");
// Step 3: Check pwd and git status in worktree
console.log("Step 3: Checking pwd and git status in worktree...");
const { stdout: pwd } = await execAsync("pwd", {
cwd: worktreeConfig.worktreePath,
});
console.log(` pwd: ${pwd.trim()}`);
const { stdout: gitStatus } = await execAsync("git status", {
cwd: worktreeConfig.worktreePath,
});
console.log(" git status:");
console.log(gitStatus);
if (!pwd.trim().includes(testBranchName)) {
throw new Error(`pwd doesn't include branch name! Got: ${pwd}`);
}
if (!gitStatus.includes(testBranchName)) {
throw new Error(`git status doesn't show branch! Got: ${gitStatus}`);
}
console.log("✓ Working directory and git status verified\n");
// Step 4: Create agent in worktree
console.log("Step 4: Creating agent in worktree...");
const agentManager = new AgentManager();
await agentManager.initialize();
const agentId = await agentManager.createAgent({
cwd: worktreeConfig.worktreePath,
});
console.log(`✓ Agent created: ${agentId}`);
// Step 5: Initialize agent and check its cwd
console.log("\nStep 5: Initializing agent and verifying cwd...");
const { info } = await agentManager.initializeAgentAndGetHistory(agentId);
console.log(` Agent cwd: ${info.cwd}`);
console.log(` Expected: ${worktreeConfig.worktreePath}`);
if (info.cwd !== worktreeConfig.worktreePath) {
throw new Error(
`Agent cwd mismatch! Expected ${worktreeConfig.worktreePath}, got ${info.cwd}`
);
}
console.log("✓ Agent cwd verified\n");
// Step 6: Send a prompt to verify it's working in the worktree
console.log("Step 6: Sending test prompt to agent...");
const { didComplete } = await agentManager.sendPrompt(
agentId,
"Run pwd and git status to verify you're in a worktree. Reply with the output.",
{ maxWait: 30000 }
);
console.log(` Prompt completed: ${didComplete}`);
// Get agent updates to see the response
const updates = agentManager.getAgentUpdates(agentId);
const lastFewUpdates = updates.slice(-5);
console.log("\n Last few agent updates:");
lastFewUpdates.forEach((update, i) => {
console.log(
` ${i + 1}. [${update.notification.type}] ${JSON.stringify(update.notification).substring(0, 100)}...`
);
});
// Kill the agent
console.log("\nStep 7: Cleaning up agent...");
await agentManager.killAgent(agentId);
console.log("✓ Agent killed\n");
console.log("=== ALL TESTS PASSED ===");
console.log("\n⚠ IMPORTANT: Worktree still exists at:");
console.log(` ${worktreeConfig.worktreePath}`);
console.log("\nTo clean up manually, run:");
console.log(` git worktree remove ${worktreeConfig.worktreePath}`);
console.log(` git branch -D ${testBranchName}`);
process.exit(0);
} catch (error) {
console.error("\n❌ TEST FAILED:");
console.error(error);
console.log("\n⚠ If worktree was created, clean up manually with:");
console.log(` git worktree remove voice-dev-${testBranchName} || true`);
console.log(` git branch -D ${testBranchName} || true`);
process.exit(1);
}
}
testWorktreeAgent();

182
test-worktree-e2e.ts Normal file
View File

@@ -0,0 +1,182 @@
import { Session } from "./packages/server/src/server/session.js";
import { AgentManager } from "./packages/server/src/server/acp/agent-manager.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./packages/server/src/server/messages.js";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function testWorktreeE2E() {
console.log("=== Testing End-to-End Worktree Agent Creation ===");
console.log("(Simulating frontend -> WebSocket -> Session -> AgentManager flow)\n");
const testBranchName = "test-e2e-worktree";
const cwd = process.cwd(); // voice-dev directory
let capturedMessages: SessionOutboundMessage[] = [];
let createdAgentId: string | null = null;
let worktreePath: string | null = null;
try {
// Step 1: Set up Session (like the WebSocket handler does)
console.log("Step 1: Creating Session and AgentManager...");
const agentManager = new AgentManager();
await agentManager.initialize();
const session = new Session(
"test-client",
(msg: SessionOutboundMessage) => {
capturedMessages.push(msg);
console.log(` [Session emit] ${msg.type}`);
// Capture agent_created message
if (msg.type === "agent_created") {
createdAgentId = msg.payload.agentId;
worktreePath = msg.payload.cwd;
console.log(` Agent ID: ${createdAgentId}`);
console.log(` CWD: ${worktreePath}`);
}
},
agentManager
);
console.log("✓ Session created\n");
// Step 2: Send create_agent_request message (like frontend does)
console.log("Step 2: Sending create_agent_request with worktreeName...");
const createAgentMessage: SessionInboundMessage = {
type: "create_agent_request",
cwd: cwd,
worktreeName: testBranchName,
requestId: "test-request-123",
};
console.log(` Message: ${JSON.stringify(createAgentMessage, null, 2)}`);
await session.handleMessage(createAgentMessage);
console.log("✓ Message handled\n");
// Step 3: Verify agent_created message was emitted
console.log("Step 3: Verifying agent_created message...");
const agentCreatedMsg = capturedMessages.find(m => m.type === "agent_created");
if (!agentCreatedMsg) {
throw new Error("No agent_created message emitted!");
}
if (agentCreatedMsg.type !== "agent_created") {
throw new Error("Wrong message type!");
}
console.log(`✓ agent_created message emitted`);
console.log(` Agent ID: ${agentCreatedMsg.payload.agentId}`);
console.log(` CWD: ${agentCreatedMsg.payload.cwd}`);
console.log(` Request ID: ${agentCreatedMsg.payload.requestId}\n`);
if (!createdAgentId) {
throw new Error("Agent ID not captured!");
}
if (!worktreePath) {
throw new Error("Worktree path not captured!");
}
// Step 4: Verify worktree was actually created
console.log("Step 4: Verifying worktree exists...");
const { stdout: worktreeList } = await execAsync("git worktree list");
if (!worktreeList.includes(worktreePath)) {
console.error("Git worktree list:");
console.error(worktreeList);
throw new Error(`Worktree not found in git! Expected: ${worktreePath}`);
}
console.log("✓ Worktree exists in git\n");
// Step 5: Verify the worktree path contains branch name
console.log("Step 5: Verifying worktree path...");
if (!worktreePath.includes(testBranchName)) {
throw new Error(`Worktree path doesn't contain branch name! Path: ${worktreePath}`);
}
console.log(`✓ Worktree path correct: ${worktreePath}\n`);
// Step 6: Verify agent is in the worktree
console.log("Step 6: Verifying agent CWD...");
const agentInfo = agentManager.listAgents().find(a => a.id === createdAgentId);
if (!agentInfo) {
throw new Error("Agent not found in manager!");
}
console.log(` Agent CWD: ${agentInfo.cwd}`);
console.log(` Expected: ${worktreePath}`);
if (agentInfo.cwd !== worktreePath) {
throw new Error(`Agent CWD mismatch! Expected ${worktreePath}, got ${agentInfo.cwd}`);
}
console.log("✓ Agent CWD matches worktree path\n");
// Step 7: Test agent can run commands in worktree
console.log("Step 7: Testing agent execution in worktree...");
const { didComplete } = await agentManager.sendPrompt(
createdAgentId,
"Run 'pwd' and 'git branch --show-current'. Just show me the output.",
{ maxWait: 30000 }
);
console.log(` Prompt completed: ${didComplete}`);
const updates = agentManager.getAgentUpdates(createdAgentId);
const messageChunks = updates
.filter(u => u.notification.type === "session" &&
u.notification.notification.update.sessionUpdate === "agent_message_chunk")
.map(u => {
const update = u.notification as any;
return update.notification.update.content?.text || "";
})
.join("");
console.log("\n Agent response:");
console.log(messageChunks);
if (!messageChunks.includes(testBranchName)) {
throw new Error("Agent response doesn't mention branch name!");
}
if (!messageChunks.includes(worktreePath)) {
throw new Error("Agent response doesn't show worktree path!");
}
console.log("\n✓ Agent successfully executing in worktree\n");
// Step 8: Cleanup
console.log("Step 8: Cleaning up...");
await agentManager.killAgent(createdAgentId);
console.log("✓ Agent killed\n");
console.log("=== END-TO-END TEST PASSED ===");
console.log("\n✅ The complete workflow works:");
console.log(" Frontend creates agent with worktreeName");
console.log(" → Message flows through Session");
console.log(" → Session creates worktree");
console.log(" → Agent is created in worktree");
console.log(" → Agent executes commands in worktree");
console.log("\n⚠ Worktree still exists at:");
console.log(` ${worktreePath}`);
console.log("\nCleanup commands:");
console.log(` git worktree remove ${worktreePath}`);
console.log(` git branch -D ${testBranchName}`);
process.exit(0);
} catch (error) {
console.error("\n❌ END-TO-END TEST FAILED:");
console.error(error);
console.log("\n⚠ Manual cleanup may be needed:");
console.log(` git worktree remove voice-dev-${testBranchName} 2>/dev/null || true`);
console.log(` git branch -D ${testBranchName} 2>/dev/null || true`);
process.exit(1);
}
}
testWorktreeE2E();