diff --git a/dummy.txt b/dummy.txt new file mode 100644 index 000000000..e43fdb915 --- /dev/null +++ b/dummy.txt @@ -0,0 +1 @@ +This is a dummy file. diff --git a/package-lock.json b/package-lock.json index da224bc16..f197e4ca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index b1f433bd3..588143e80 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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>([]); + const [recordingVolume, setRecordingVolume] = useState(0); + const [recordingDuration, setRecordingDuration] = useState(0); + const [transcribingRequestId, setTranscribingRequestId] = useState(null); + + const recordingIntervalRef = useRef | 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, ) { @@ -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 ( - {/* Image preview pills */} - {hasImages && ( - - {selectedImages.map((image, index) => ( - - - handleRemoveImage(index)} style={styles.removeImageButton}> - - - - ))} - - )} - - {/* Input row */} - - {/* Attachment button */} - {!isRecording && ( - - - + {/* Regular input controls */} + + {/* Image preview pills */} + {hasImages && ( + + {selectedImages.map((image, index) => ( + + + handleRemoveImage(index)} style={styles.removeImageButton}> + + + + ))} + )} - {/* Text input */} - = MAX_INPUT_HEIGHT} - onContentSizeChange={handleContentSizeChange} - editable={!isRecording && ws.isConnected} - /> + {/* Input row */} + + {/* Attachment button */} + {!isRecording && ( + + + + )} - {/* Buttons */} - - {hasText || hasImages ? ( - // Send button when text is entered or images are selected - - - - ) : ( - // Voice and Realtime buttons when no text - <> - {/* Voice recording button */} + {/* Text input */} + = MAX_INPUT_HEIGHT} + onContentSizeChange={handleContentSizeChange} + editable={!isRecording && ws.isConnected} + /> + + {/* Buttons */} + + {hasText || hasImages ? ( + // Send button when text is entered or images are selected - {isRecording ? ( - - ) : ( - - )} + + ) : ( + // Voice and Realtime buttons when no text + <> + {/* Voice recording button */} + + {isRecording ? ( + + ) : ( + + )} + - {/* Realtime button */} - - - - - )} + {/* Realtime button */} + + + + + )} + - + + + {/* Voice note recording overlay */} + + + ); } 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], diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index bcc77ba12..d64a35486 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -37,11 +37,14 @@ export function AgentStreamView({ const insets = useSafeAreaInsets(); const [selectedToolCall, setSelectedToolCall] = useState(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 ( {/* Content list */} @@ -65,6 +77,8 @@ export function AgentStreamView({ paddingTop: 24, paddingBottom: Math.max(insets.bottom, 32), }} + onScroll={handleScroll} + scrollEventThrottle={16} > {streamItems.length === 0 ? ( diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index d36d7c2ed..e49ad8834 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -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} > - + {/* Header */} Create New Agent {/* Form */} - {/* Working Directory Input */} Working Directory @@ -403,8 +400,7 @@ export function CreateAgentModal({ ))} - - + ); } @@ -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], diff --git a/packages/app/src/components/voice-note-recording-overlay.tsx b/packages/app/src/components/voice-note-recording-overlay.tsx new file mode 100644 index 000000000..e0a1d0f96 --- /dev/null +++ b/packages/app/src/components/voice-note-recording-overlay.tsx @@ -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 ( + + {/* Cancel button */} + + + + + {/* Center: Volume meter and timer */} + + + + {formatDuration(duration)} + + + + {/* Send button */} + + {isTranscribing ? ( + + ) : ( + + )} + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx index 2023d3c29..ff5b54d6d 100644 --- a/packages/app/src/components/volume-meter.tsx +++ b/packages/app/src/components/volume-meter.tsx @@ -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 ( @@ -181,10 +174,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp @@ -192,10 +182,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f6e168241..2adf476ed 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -116,8 +116,8 @@ interface SessionContextValue { // Helpers initializeAgent: (params: { agentId: string; requestId?: string }) => void; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise; - sendAgentAudio: (agentId: string, audioBlob: Blob) => Promise; - createAgent: (options: { cwd: string; initialMode?: string; requestId?: string }) => void; + sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise; + 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: { diff --git a/packages/server/agents.json b/packages/server/agents.json index dbae4c5cb..4cdc710dc 100644 --- a/packages/server/agents.json +++ b/packages/server/agents.json @@ -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" } ] \ No newline at end of file diff --git a/packages/server/src/server/acp/mcp-server.ts b/packages/server/src/server/acp/mcp-server.ts index ebbf094a5..16aa7e00a 100644 --- a/packages/server/src/server/acp/mcp-server.ts +++ b/packages/server/src/server/acp/mcp-server.ts @@ -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, diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 404d1dea5..82d5950f1 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -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 }), }); diff --git a/test-worktree-agent.ts b/test-worktree-agent.ts new file mode 100644 index 000000000..6a5d996c7 --- /dev/null +++ b/test-worktree-agent.ts @@ -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(); diff --git a/test-worktree-e2e.ts b/test-worktree-e2e.ts new file mode 100644 index 000000000..47b2f3331 --- /dev/null +++ b/test-worktree-e2e.ts @@ -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();