feat: add voice note recording overlay with transcription feedback and worktree support
This commit is contained in:
Generated
+4
-4
@@ -21233,7 +21233,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.4.9",
|
"@agentclientprotocol/sdk": "^0.4.9",
|
||||||
"@ai-sdk/openai": "^2.0.52",
|
"@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",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
"@openrouter/ai-sdk-provider": "^1.2.0",
|
||||||
@@ -21261,9 +21261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/server/node_modules/@boudra/claude-code-acp": {
|
"packages/server/node_modules/@boudra/claude-code-acp": {
|
||||||
"version": "0.8.4",
|
"version": "0.8.5",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.4/1256c7f6f05f766914a479208f9905d85e064471",
|
"resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.5/f4bc31a9496d5ec72b306e2f891eeaaef38aeaf5",
|
||||||
"integrity": "sha512-GgfjAkDtjC5HFYjZIWyN8Z4E66gQaZEnRbLq7ENRcuGw5WYAfq5f7uOlN1w7rjNSg4yqMGYpZIpavCM9n5FJhg==",
|
"integrity": "sha512-ZGa3gNtlGnwUlPZUgVS0+hys9BPYztHJlE16EvWX8pdBsD4TxMlH/WflNKQ3s7isovTJuAljRrCUF0YXH3FD0g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.4.9",
|
"@agentclientprotocol/sdk": "0.4.9",
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Alert,
|
Alert,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native";
|
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
import { useRealtime } from "@/contexts/realtime-context";
|
import { useRealtime } from "@/contexts/realtime-context";
|
||||||
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
||||||
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||||
|
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
|
||||||
|
import { generateMessageId } from "@/types/stream";
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -28,13 +35,24 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
|
const { ws, sendAgentMessage, sendAgentAudio } = useSession();
|
||||||
const { startRealtime } = useRealtime();
|
const { startRealtime } = useRealtime();
|
||||||
const audioRecorder = useAudioRecorder();
|
|
||||||
|
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [selectedImages, setSelectedImages] = useState<Array<{ uri: string; mimeType: string }>>([]);
|
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() {
|
async function handleSendMessage() {
|
||||||
if (!userInput.trim() || !ws.isConnected) return;
|
if (!userInput.trim() || !ws.isConnected) return;
|
||||||
@@ -90,41 +108,117 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
|
|
||||||
async function handleVoicePress() {
|
async function handleVoicePress() {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
// Stop recording
|
// This shouldn't happen as button is hidden when recording
|
||||||
try {
|
return;
|
||||||
setIsRecording(false);
|
}
|
||||||
const audioData = await audioRecorder.stop();
|
|
||||||
|
|
||||||
if (audioData) {
|
// Start recording
|
||||||
setIsProcessing(true);
|
try {
|
||||||
console.log("[AgentInput] Audio recorded:", audioData.size, "bytes");
|
await audioRecorder.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
setRecordingDuration(0);
|
||||||
|
overlayTransition.value = withTiming(1, { duration: 250 });
|
||||||
|
|
||||||
try {
|
// Start duration timer
|
||||||
// Send audio to agent for transcription and processing
|
recordingIntervalRef.current = setInterval(() => {
|
||||||
await sendAgentAudio(agentId, audioData);
|
setRecordingDuration((prev) => prev + 1);
|
||||||
console.log("[AgentInput] Audio sent to agent");
|
}, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentInput] Failed to send audio:", error);
|
console.error("[AgentInput] Failed to start recording:", 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function handleContentSizeChange(
|
||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||||
) {
|
) {
|
||||||
@@ -146,115 +240,155 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
const hasText = userInput.trim().length > 0;
|
const hasText = userInput.trim().length > 0;
|
||||||
const hasImages = selectedImages.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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Image preview pills */}
|
{/* Regular input controls */}
|
||||||
{hasImages && (
|
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
||||||
<View style={styles.imagePreviewContainer}>
|
{/* Image preview pills */}
|
||||||
{selectedImages.map((image, index) => (
|
{hasImages && (
|
||||||
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
|
<View style={styles.imagePreviewContainer}>
|
||||||
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
|
{selectedImages.map((image, index) => (
|
||||||
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
|
<View key={`${image.uri}-${index}`} style={styles.imagePill}>
|
||||||
<X size={16} color={theme.colors.foreground} />
|
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
|
||||||
</Pressable>
|
<Pressable onPress={() => handleRemoveImage(index)} style={styles.removeImageButton}>
|
||||||
</View>
|
<X size={16} color={theme.colors.foreground} />
|
||||||
))}
|
</Pressable>
|
||||||
</View>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Text input */}
|
{/* Input row */}
|
||||||
<TextInput
|
<View style={styles.inputRow}>
|
||||||
value={userInput}
|
{/* Attachment button */}
|
||||||
onChangeText={setUserInput}
|
{!isRecording && (
|
||||||
placeholder="Message agent..."
|
<Pressable
|
||||||
placeholderTextColor={theme.colors.mutedForeground}
|
onPress={handlePickImage}
|
||||||
style={[
|
disabled={!ws.isConnected}
|
||||||
styles.textInput,
|
style={[
|
||||||
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
|
styles.attachButton,
|
||||||
]}
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
multiline
|
]}
|
||||||
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
>
|
||||||
onContentSizeChange={handleContentSizeChange}
|
<Paperclip size={20} color={theme.colors.foreground} />
|
||||||
editable={!isRecording && ws.isConnected}
|
</Pressable>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Text input */}
|
||||||
<View style={styles.buttonRow}>
|
<TextInput
|
||||||
{hasText || hasImages ? (
|
value={userInput}
|
||||||
// Send button when text is entered or images are selected
|
onChangeText={setUserInput}
|
||||||
<Pressable
|
placeholder="Message agent..."
|
||||||
onPress={handleSendMessage}
|
placeholderTextColor={theme.colors.mutedForeground}
|
||||||
disabled={!ws.isConnected || isProcessing}
|
style={[
|
||||||
style={[
|
styles.textInput,
|
||||||
styles.sendButton,
|
{ height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT },
|
||||||
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
|
]}
|
||||||
]}
|
multiline
|
||||||
>
|
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||||
<ArrowUp size={20} color="white" />
|
onContentSizeChange={handleContentSizeChange}
|
||||||
</Pressable>
|
editable={!isRecording && ws.isConnected}
|
||||||
) : (
|
/>
|
||||||
// Voice and Realtime buttons when no text
|
|
||||||
<>
|
{/* Buttons */}
|
||||||
{/* Voice recording button */}
|
<View style={styles.buttonRow}>
|
||||||
|
{hasText || hasImages ? (
|
||||||
|
// Send button when text is entered or images are selected
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleVoicePress}
|
onPress={handleSendMessage}
|
||||||
disabled={!ws.isConnected}
|
disabled={!ws.isConnected || isProcessing}
|
||||||
style={[
|
style={[
|
||||||
styles.voiceButton,
|
styles.sendButton,
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
|
||||||
isRecording && styles.voiceButtonRecording,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isRecording ? (
|
<ArrowUp size={20} color="white" />
|
||||||
<Square size={14} color="white" fill="white" />
|
|
||||||
) : (
|
|
||||||
<Mic size={20} color={theme.colors.foreground} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</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 */}
|
{/* Realtime button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={startRealtime}
|
onPress={startRealtime}
|
||||||
disabled={!ws.isConnected}
|
disabled={!ws.isConnected}
|
||||||
style={[
|
style={[
|
||||||
styles.realtimeButton,
|
styles.realtimeButton,
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<AudioLines size={20} color={theme.colors.background} />
|
<AudioLines size={20} color={theme.colors.background} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
|
position: "relative",
|
||||||
|
minHeight: FOOTER_HEIGHT,
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: BASE_VERTICAL_PADDING,
|
paddingVertical: BASE_VERTICAL_PADDING,
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minHeight: FOOTER_HEIGHT,
|
minHeight: FOOTER_HEIGHT,
|
||||||
},
|
},
|
||||||
|
overlayContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: FOOTER_HEIGHT,
|
||||||
|
},
|
||||||
imagePreviewContainer: {
|
imagePreviewContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ export function AgentStreamView({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [selectedToolCall, setSelectedToolCall] =
|
const [selectedToolCall, setSelectedToolCall] =
|
||||||
useState<SelectedToolCall | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
if (isNearBottom) {
|
||||||
}, [streamItems]);
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}
|
||||||
|
}, [streamItems, isNearBottom]);
|
||||||
|
|
||||||
function handleOpenToolCallDetails(toolCall: SelectedToolCall) {
|
function handleOpenToolCallDetails(toolCall: SelectedToolCall) {
|
||||||
setSelectedToolCall(toolCall);
|
setSelectedToolCall(toolCall);
|
||||||
@@ -55,6 +58,15 @@ export function AgentStreamView({
|
|||||||
setSelectedToolCall(null);
|
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 (
|
return (
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
{/* Content list */}
|
{/* Content list */}
|
||||||
@@ -65,6 +77,8 @@ export function AgentStreamView({
|
|||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: Math.max(insets.bottom, 32),
|
paddingBottom: Math.max(insets.bottom, 32),
|
||||||
}}
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
>
|
>
|
||||||
{streamItems.length === 0 ? (
|
{streamItems.length === 0 ? (
|
||||||
<View style={stylesheet.emptyState}>
|
<View style={stylesheet.emptyState}>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetView,
|
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetFooter,
|
BottomSheetFooter,
|
||||||
@@ -286,18 +285,16 @@ export function CreateAgentModal({
|
|||||||
android_keyboardInputMode="adjustResize"
|
android_keyboardInputMode="adjustResize"
|
||||||
topInset={insets.top}
|
topInset={insets.top}
|
||||||
>
|
>
|
||||||
<BottomSheetView style={styles.sheetContent}>
|
<BottomSheetScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>Create New Agent</Text>
|
<Text style={styles.headerTitle}>Create New Agent</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<BottomSheetScrollView
|
|
||||||
style={styles.scrollView}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
{/* Working Directory Input */}
|
{/* Working Directory Input */}
|
||||||
<View style={styles.formSection}>
|
<View style={styles.formSection}>
|
||||||
<Text style={styles.label}>Working Directory</Text>
|
<Text style={styles.label}>Working Directory</Text>
|
||||||
@@ -403,8 +400,7 @@ export function CreateAgentModal({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -416,9 +412,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
handleIndicator: {
|
handleIndicator: {
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
sheetContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
header: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -431,12 +424,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: theme.spacing[6],
|
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: {
|
formSection: {
|
||||||
marginBottom: theme.spacing[6],
|
marginBottom: theme.spacing[6],
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { X, ArrowUp } from "lucide-react-native";
|
||||||
|
import { VolumeMeter } from "./volume-meter";
|
||||||
|
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||||
|
|
||||||
|
interface VoiceNoteRecordingOverlayProps {
|
||||||
|
volume: number;
|
||||||
|
duration: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSend: () => void;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceNoteRecordingOverlay({
|
||||||
|
volume,
|
||||||
|
duration,
|
||||||
|
onCancel,
|
||||||
|
onSend,
|
||||||
|
isTranscribing = false,
|
||||||
|
}: VoiceNoteRecordingOverlayProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
|
||||||
|
{/* Cancel button */}
|
||||||
|
<Pressable onPress={onCancel} disabled={isTranscribing} style={[styles.cancelButton, isTranscribing && styles.buttonDisabled]}>
|
||||||
|
<X size={24} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Center: Volume meter and timer */}
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<VolumeMeter
|
||||||
|
volume={volume}
|
||||||
|
isMuted={false}
|
||||||
|
isDetecting={true}
|
||||||
|
isSpeaking={false}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<Text style={[styles.timerText, { color: theme.colors.palette.white }]}>
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<Pressable onPress={onSend} disabled={isTranscribing} style={[styles.sendButton, { backgroundColor: theme.colors.palette.white }]}>
|
||||||
|
{isTranscribing ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.palette.blue[600]} />
|
||||||
|
) : (
|
||||||
|
<ArrowUp size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_SIZE = 56;
|
||||||
|
const VERTICAL_PADDING = (FOOTER_HEIGHT - BUTTON_SIZE) / 2;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: VERTICAL_PADDING,
|
||||||
|
height: FOOTER_HEIGHT,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
width: BUTTON_SIZE,
|
||||||
|
height: BUTTON_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.15)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
timerText: {
|
||||||
|
fontSize: theme.fontSize.xl,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
sendButton: {
|
||||||
|
width: BUTTON_SIZE,
|
||||||
|
height: BUTTON_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import ReanimatedAnimated, {
|
import ReanimatedAnimated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -24,54 +24,52 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
|||||||
|
|
||||||
// Base dimensions
|
// Base dimensions
|
||||||
const LINE_SPACING = 8;
|
const LINE_SPACING = 8;
|
||||||
|
const LINE_WIDTH = 8;
|
||||||
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
|
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
|
||||||
const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20;
|
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 line1Height = useSharedValue(MIN_HEIGHT);
|
||||||
const line2Height = useSharedValue(MIN_HEIGHT);
|
const line2Height = useSharedValue(MIN_HEIGHT);
|
||||||
const line3Height = useSharedValue(MIN_HEIGHT);
|
const line3Height = useSharedValue(MIN_HEIGHT);
|
||||||
|
|
||||||
// Idle pulse animations (when no volume)
|
|
||||||
const line1Pulse = useSharedValue(1);
|
const line1Pulse = useSharedValue(1);
|
||||||
const line2Pulse = useSharedValue(1);
|
const line2Pulse = useSharedValue(1);
|
||||||
const line3Pulse = useSharedValue(1);
|
const line3Pulse = useSharedValue(1);
|
||||||
|
|
||||||
// Start idle animations with different phases
|
// Start idle animations with different phases for all dots
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
// When muted, set pulse to 1 (no animation)
|
// When muted, set all pulses to 1 (no animation)
|
||||||
line1Pulse.value = 1;
|
line1Pulse.value = 1;
|
||||||
line2Pulse.value = 1;
|
line2Pulse.value = 1;
|
||||||
line3Pulse.value = 1;
|
line3Pulse.value = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Line 1 - fastest pulse
|
// Animate each dot with different phases and durations
|
||||||
line1Pulse.value = withRepeat(
|
line1Pulse.value = withRepeat(
|
||||||
withSequence(
|
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) })
|
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
|
||||||
),
|
),
|
||||||
-1,
|
-1,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
// Line 2 - medium pulse with offset
|
|
||||||
line2Pulse.value = withRepeat(
|
line2Pulse.value = withRepeat(
|
||||||
withSequence(
|
withSequence(
|
||||||
withTiming(1, { duration: 400 }),
|
withTiming(1, { duration: 200 }),
|
||||||
withTiming(1.15, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
withTiming(1.20, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||||||
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) })
|
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) })
|
||||||
),
|
),
|
||||||
-1,
|
-1,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
// Line 3 - slowest pulse with different offset
|
|
||||||
line3Pulse.value = withRepeat(
|
line3Pulse.value = withRepeat(
|
||||||
withSequence(
|
withSequence(
|
||||||
withTiming(1, { duration: 600 }),
|
withTiming(1, { duration: 400 }),
|
||||||
withTiming(1.25, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
|
withTiming(1.25, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
|
||||||
withTiming(1, { 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]);
|
}, [isMuted]);
|
||||||
|
|
||||||
// Update heights based on volume with different responsiveness per line
|
// Update heights based on volume with different responsiveness for all dots
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMuted) {
|
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;
|
line1Height.value = MIN_HEIGHT;
|
||||||
line2Height.value = MIN_HEIGHT;
|
line2Height.value = MIN_HEIGHT;
|
||||||
line3Height.value = MIN_HEIGHT;
|
line3Height.value = MIN_HEIGHT;
|
||||||
@@ -92,25 +90,23 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
|||||||
|
|
||||||
if (volume > 0.01) {
|
if (volume > 0.01) {
|
||||||
// Active volume - animate heights based on volume
|
// Active volume - animate heights based on volume
|
||||||
// Line 1 - most responsive, follows volume closely
|
|
||||||
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
|
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, {
|
line1Height.value = withSpring(target1, {
|
||||||
damping: 10,
|
damping: 10,
|
||||||
stiffness: 200,
|
stiffness: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Line 2 - medium responsiveness
|
|
||||||
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
|
|
||||||
line2Height.value = withSpring(target2, {
|
line2Height.value = withSpring(target2, {
|
||||||
damping: 12,
|
damping: 12.5,
|
||||||
stiffness: 150,
|
stiffness: 175,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Line 3 - smoothest, lags behind
|
|
||||||
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.7);
|
|
||||||
line3Height.value = withSpring(target3, {
|
line3Height.value = withSpring(target3, {
|
||||||
damping: 15,
|
damping: 15,
|
||||||
stiffness: 100,
|
stiffness: 150,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No volume - return to minimum
|
// No volume - return to minimum
|
||||||
@@ -118,10 +114,12 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
|||||||
damping: 20,
|
damping: 20,
|
||||||
stiffness: 150,
|
stiffness: 150,
|
||||||
});
|
});
|
||||||
|
|
||||||
line2Height.value = withSpring(MIN_HEIGHT, {
|
line2Height.value = withSpring(MIN_HEIGHT, {
|
||||||
damping: 20,
|
damping: 20,
|
||||||
stiffness: 150,
|
stiffness: 150,
|
||||||
});
|
});
|
||||||
|
|
||||||
line3Height.value = withSpring(MIN_HEIGHT, {
|
line3Height.value = withSpring(MIN_HEIGHT, {
|
||||||
damping: 20,
|
damping: 20,
|
||||||
stiffness: 150,
|
stiffness: 150,
|
||||||
@@ -129,7 +127,10 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
|||||||
}
|
}
|
||||||
}, [volume, isMuted]);
|
}, [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 line1Style = useAnimatedStyle(() => {
|
||||||
const isActive = isDetecting || isSpeaking;
|
const isActive = isDetecting || isSpeaking;
|
||||||
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
|
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 (
|
return (
|
||||||
<View style={[styles.container, { height: containerHeight }]}>
|
<View style={[styles.container, { height: containerHeight }]}>
|
||||||
<ReanimatedAnimated.View
|
<ReanimatedAnimated.View
|
||||||
style={[
|
style={[
|
||||||
styles.line,
|
styles.line,
|
||||||
{
|
{ width: LINE_WIDTH, backgroundColor: lineColor },
|
||||||
width: lineWidth,
|
|
||||||
backgroundColor: lineColor,
|
|
||||||
},
|
|
||||||
line1Style,
|
line1Style,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -181,10 +174,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
|||||||
<ReanimatedAnimated.View
|
<ReanimatedAnimated.View
|
||||||
style={[
|
style={[
|
||||||
styles.line,
|
styles.line,
|
||||||
{
|
{ width: LINE_WIDTH, backgroundColor: lineColor },
|
||||||
width: lineWidth,
|
|
||||||
backgroundColor: lineColor,
|
|
||||||
},
|
|
||||||
line2Style,
|
line2Style,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -192,10 +182,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
|||||||
<ReanimatedAnimated.View
|
<ReanimatedAnimated.View
|
||||||
style={[
|
style={[
|
||||||
styles.line,
|
styles.line,
|
||||||
{
|
{ width: LINE_WIDTH, backgroundColor: lineColor },
|
||||||
width: lineWidth,
|
|
||||||
backgroundColor: lineColor,
|
|
||||||
},
|
|
||||||
line3Style,
|
line3Style,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ interface SessionContextValue {
|
|||||||
// Helpers
|
// Helpers
|
||||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||||
sendAgentAudio: (agentId: string, audioBlob: Blob) => Promise<void>;
|
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
|
||||||
createAgent: (options: { cwd: string; initialMode?: string; requestId?: string }) => void;
|
createAgent: (options: { cwd: string; initialMode?: string; worktreeName?: string; requestId?: string }) => void;
|
||||||
setAgentMode: (agentId: string, modeId: string) => void;
|
setAgentMode: (agentId: string, modeId: string) => void;
|
||||||
respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: 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.send(msg);
|
||||||
}, [ws]);
|
}, [ws]);
|
||||||
|
|
||||||
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob) => {
|
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob, requestId?: string) => {
|
||||||
try {
|
try {
|
||||||
// Convert blob to base64
|
// Convert blob to base64
|
||||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
@@ -693,18 +693,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
audio: base64Audio,
|
audio: base64Audio,
|
||||||
format,
|
format,
|
||||||
isLast: true,
|
isLast: true,
|
||||||
|
requestId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ws.send(msg);
|
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) {
|
} catch (error) {
|
||||||
console.error("[Session] Failed to send audio:", error);
|
console.error("[Session] Failed to send audio:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [ws]);
|
}, [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 = {
|
const msg: WSInboundMessage = {
|
||||||
type: "session",
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
+133
-1
@@ -224,7 +224,139 @@
|
|||||||
"sessionId": "9f844826-2c8e-414f-a236-a9279233b053"
|
"sessionId": "9f844826-2c8e-414f-a236-a9279233b053"
|
||||||
},
|
},
|
||||||
"createdAt": "2025-10-26T08:32:55.726Z",
|
"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"
|
"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",
|
title: "Create Coding Agent",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
cwd: z
|
cwd: z
|
||||||
.string()
|
.string()
|
||||||
@@ -79,6 +79,12 @@ export async function createAgentMcpServer(
|
|||||||
.describe(
|
.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."
|
"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: {
|
outputSchema: {
|
||||||
agentId: z.string().describe("Unique identifier for the created agent"),
|
agentId: z.string().describe("Unique identifier for the created agent"),
|
||||||
@@ -87,7 +93,7 @@ export async function createAgentMcpServer(
|
|||||||
.describe(
|
.describe(
|
||||||
"Current agent status: 'initializing', 'ready', 'processing', etc."
|
"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"),
|
currentModeId: z.string().nullable().describe("The agent's current session mode"),
|
||||||
availableModes: z.array(z.object({
|
availableModes: z.array(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -96,9 +102,21 @@ export async function createAgentMcpServer(
|
|||||||
})).nullable().describe("Available session modes for this agent"),
|
})).nullable().describe("Available session modes for this agent"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ cwd, initialPrompt, initialMode }) => {
|
async ({ cwd, initialPrompt, initialMode, worktreeName }) => {
|
||||||
// Expand and resolve the working directory
|
// 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({
|
const agentId = await agentManager.createAgent({
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
|
|||||||
@@ -87,12 +87,14 @@ export const SendAgentAudioSchema = z.object({
|
|||||||
audio: z.string(), // base64 encoded
|
audio: z.string(), // base64 encoded
|
||||||
format: z.string(),
|
format: z.string(),
|
||||||
isLast: z.boolean(),
|
isLast: z.boolean(),
|
||||||
|
requestId: z.string().optional(), // Client-provided ID for tracking transcription
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateAgentRequestMessageSchema = z.object({
|
export const CreateAgentRequestMessageSchema = z.object({
|
||||||
type: z.literal("create_agent_request"),
|
type: z.literal("create_agent_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
initialMode: z.string().optional(),
|
initialMode: z.string().optional(),
|
||||||
|
worktreeName: z.string().optional(),
|
||||||
requestId: z.string().optional(),
|
requestId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,6 +183,7 @@ export const TranscriptionResultMessageSchema = z.object({
|
|||||||
text: z.string(),
|
text: z.string(),
|
||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
duration: z.number().optional(),
|
duration: z.number().optional(),
|
||||||
|
requestId: z.string().optional(), // Echoed back from request for tracking
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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