mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: add voice note recording overlay with transcription feedback and worktree support
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
105
packages/app/src/components/voice-note-recording-overlay.tsx
Normal file
105
packages/app/src/components/voice-note-recording-overlay.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
132
test-worktree-agent.ts
Normal 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
182
test-worktree-e2e.ts
Normal 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();
|
||||
Reference in New Issue
Block a user