diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index bb575c59e..38295b5a6 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -4,9 +4,8 @@ import { useEffect, useMemo, useCallback, - useLayoutEffect, } from "react"; -import type { ReactElement, RefObject, ReactNode } from "react"; +import type { ReactElement, ReactNode } from "react"; import { View, Text, @@ -21,9 +20,6 @@ import { type LayoutChangeEvent, type ListRenderItem, Platform, - NativeSyntheticEvent, - TextInputContentSizeChangeEventData, - TextInputKeyPressEventData, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; @@ -35,17 +31,12 @@ import Animated, { runOnJS, } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, Check, X, RefreshCcw, Monitor } from "lucide-react-native"; +import { Monitor, X } from "lucide-react-native"; import { theme as defaultTheme } from "@/styles/theme"; import { useRecentPaths } from "@/hooks/use-recent-paths"; import { useRouter } from "expo-router"; import { generateMessageId } from "@/types/stream"; -import { useDictation } from "@/hooks/use-dictation"; -import type { DictationStatus } from "@/hooks/use-dictation"; -import { VolumeMeter } from "@/components/volume-meter"; -import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug"; -import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice"; -import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice"; +import { MessageInput } from "./message-input"; import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context"; import type { AgentProvider, @@ -59,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons"; import { trackAnalyticsEvent } from "@/utils/analytics"; import type { SessionContextValue } from "@/contexts/session-context"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { AssistantDropdown, DropdownField, @@ -82,16 +73,6 @@ interface AgentFlowModalProps { onAfterClose?: () => void; } -type DictationToastConfig = { - variant: DictationToastVariant; - title: string; - subtitle?: string; - meta?: string; - actionLabel?: string; - onAction?: () => void; - onDismiss?: () => void; -}; - interface ModalWrapperProps { isVisible: boolean; onClose: () => void; @@ -111,52 +92,17 @@ type CreateAgentSessionSlice = { }) => void; resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void; sendAgentAudio: ( - agentId: string, + agentId: string | undefined, audioBlob: Blob, requestId?: string, options?: { mode?: "transcribe_only" | "auto_run" } ) => Promise; - agents: Map; + agents: Map; }; const BACKDROP_OPACITY = 0.55; const IMPORT_PAGE_SIZE = 20; -const PROMPT_MIN_HEIGHT = 64; -const PROMPT_MAX_HEIGHT = 200; const IS_WEB = Platform.OS === "web"; -const DICTATION_AGENT_ID = "__dictation__"; - -type WebTextInputKeyPressEvent = NativeSyntheticEvent< - TextInputKeyPressEventData & { - metaKey?: boolean; - ctrlKey?: boolean; - shiftKey?: boolean; - } ->; - -type TextAreaHandle = { - scrollHeight?: number; - style?: { - height?: string; - minHeight?: string; - maxHeight?: string; - overflowY?: string; - } & Record; -}; - -const isTextAreaLike = (node: unknown): node is TextAreaHandle => { - if (!node || typeof node !== "object") { - return false; - } - if (!("scrollHeight" in node) || !("style" in node)) { - return false; - } - const style = (node as { style?: unknown }).style; - if (!style || typeof style !== "object") { - return false; - } - return true; -}; type ImportCandidate = { provider: AgentProvider; @@ -237,7 +183,6 @@ function AgentFlowModal({ const backdropOpacity = useSharedValue(0); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const isCompactLayout = screenWidth < 720; - const shouldHandlePromptDesktopSubmit = IS_WEB; const shouldAutoFocusPrompt = IS_WEB; const isImportFlow = flow === "import"; const isCreateFlow = !isImportFlow; @@ -451,7 +396,6 @@ function AgentFlowModal({ const [isMounted, setIsMounted] = useState(isVisible); const [initialPrompt, setInitialPrompt] = useState(""); - const [promptInputHeight, setPromptInputHeight] = useState(PROMPT_MIN_HEIGHT); const [baseBranch, setBaseBranch] = useState(""); const [createNewBranch, setCreateNewBranch] = useState(false); const [branchName, setBranchName] = useState(""); @@ -469,103 +413,12 @@ function AgentFlowModal({ ); const [isImportLoading, setIsImportLoading] = useState(false); const [importError, setImportError] = useState(null); - const [dictationDebugInfo, setDictationDebugInfo] = useState(null); const [openDropdown, setOpenDropdown] = useState(null); const pendingRequestIdRef = useRef(null); const shouldSyncBaseBranchRef = useRef(true); - const promptInputRef = useRef< - TextInput | (TextInput & { getNativeRef?: () => unknown }) | null - >(null); - const focusPromptInputRef = useRef<() => void>(() => {}); - const promptInputHeightRef = useRef(PROMPT_MIN_HEIGHT); - const promptBaselineHeightRef = useRef(null); - const dictationRequestIdRef = useRef(null); - - const handleDictationTranscript = useCallback( - (text: string, _meta: { requestId: string }) => { - if (!isCreateFlow || !text) { - return; - } - setInitialPrompt((prev) => { - if (!prev) { - return text; - } - const needsSpace = /\s$/.test(prev); - return `${prev}${needsSpace ? "" : " "}${text}`; - }); - focusPromptInputRef.current?.(); - }, - [isCreateFlow, setInitialPrompt] - ); - - const handleDictationError = useCallback( - (dictationError: Error) => { - setErrorMessage(dictationError.message); - }, - [] - ); - - const canStartDictation = useCallback(() => { - const allowed = isCreateFlow && !isLoading && isTargetDaemonReady && isWsConnected; - console.log("[CreateAgentModal] canStartDictation", { - allowed, - isCreateFlow, - isLoading, - isTargetDaemonReady, - isWsConnected, - }); - return allowed; - }, [isCreateFlow, isLoading, isTargetDaemonReady, isWsConnected]); - - const canConfirmDictation = useCallback(() => { - const allowed = isTargetDaemonReady && hasSendAgentAudio; - console.log("[CreateAgentModal] canConfirmDictation", { - allowed, - isTargetDaemonReady, - hasSendAgentAudio, - }); - return allowed; - }, [hasSendAgentAudio, isTargetDaemonReady]); - - const { - isRecording: isDictating, - isProcessing: isDictationProcessing, - volume: dictationVolume, - pendingRequestId: dictationPendingRequestId, - error: dictationError, - status: dictationStatus, - retryAttempt: dictationRetryAttempt, - maxRetryAttempts: dictationMaxRetryAttempts, - retryInfo: dictationRetryInfo, - failedRecording: dictationFailedRecording, - startDictation, - cancelDictation, - confirmDictation, - reset: resetDictation, - retryFailedDictation, - discardFailedDictation, - } = useDictation({ - agentId: DICTATION_AGENT_ID, - sendAgentAudio, - ws: effectiveWs, - mode: "transcribe_only", - onTranscript: handleDictationTranscript, - onError: handleDictationError, - canStart: canStartDictation, - canConfirm: canConfirmDictation, - autoStopWhenHidden: { isVisible }, - }); - const shouldShowAudioDebug = AUDIO_DEBUG_ENABLED; - - useEffect(() => { - dictationRequestIdRef.current = dictationPendingRequestId; - }, [dictationPendingRequestId]); const hasPendingCreateOrResume = pendingRequestIdRef.current !== null; - const hasPendingDictation = dictationPendingRequestId !== null; const shouldListenForStatus = isVisible || hasPendingCreateOrResume; - const shouldListenForDictation = - isCreateFlow && (isVisible || hasPendingDictation); const idleProviderPrefetchHandleRef = useRef { - const bounded = Math.min( - PROMPT_MAX_HEIGHT, - Math.max(PROMPT_MIN_HEIGHT, nextHeight) - ); - if (Math.abs(promptInputHeightRef.current - bounded) < 1) { - return; - } - promptInputHeightRef.current = bounded; - setPromptInputHeight(bounded); - }, []); - const applyPromptMeasuredHeight = useCallback( - (measuredHeight: number) => { - if (promptBaselineHeightRef.current === null) { - promptBaselineHeightRef.current = measuredHeight; - } - const baseline = promptBaselineHeightRef.current ?? measuredHeight; - const normalized = measuredHeight - baseline + PROMPT_MIN_HEIGHT; - const bounded = Math.min( - PROMPT_MAX_HEIGHT, - Math.max(PROMPT_MIN_HEIGHT, normalized) - ); - setPromptHeight(bounded); - return bounded; - }, - [setPromptHeight] - ); - - const getPromptWebTextArea = useCallback((): TextAreaHandle | null => { - if (!IS_WEB) { - return null; - } - const node = promptInputRef.current; - if (!node) { - return null; - } - if (isTextAreaLike(node)) { - return node; - } - if ( - typeof (node as { getNativeRef?: () => unknown }).getNativeRef === - "function" - ) { - const native = ( - node as { getNativeRef?: () => unknown } - ).getNativeRef?.(); - if (isTextAreaLike(native)) { - return native; - } - } - return null; - }, []); - - const measurePromptWebInputHeight = useCallback(() => { - if (!IS_WEB) { - return false; - } - const element = getPromptWebTextArea(); - if (!element?.style || typeof element.scrollHeight !== "number") { - return false; - } - const previousHeight = element.style.height; - element.style.height = "auto"; - const measuredHeight = element.scrollHeight; - element.style.height = previousHeight ?? ""; - const bounded = applyPromptMeasuredHeight(measuredHeight); - element.style.height = `${bounded}px`; - element.style.minHeight = `${PROMPT_MIN_HEIGHT}px`; - element.style.maxHeight = `${PROMPT_MAX_HEIGHT}px`; - element.style.overflowY = bounded >= PROMPT_MAX_HEIGHT ? "auto" : "hidden"; - return true; - }, [applyPromptMeasuredHeight, getPromptWebTextArea]); - - const handlePromptContentSizeChange = useCallback( - (event: NativeSyntheticEvent) => { - if (IS_WEB && measurePromptWebInputHeight()) { - return; - } - applyPromptMeasuredHeight(event.nativeEvent.contentSize.height); - }, - [applyPromptMeasuredHeight, measurePromptWebInputHeight] - ); - - const focusPromptInput = useCallback(() => { - if (!shouldAutoFocusPrompt) { - return; - } - const node = promptInputRef.current; - if (!node) { - return; - } - const target: - | (TextInput & { focus?: () => void }) - | { focus?: () => void } - | null = - typeof (node as { focus?: () => void }).focus === "function" - ? node - : typeof (node as { getNativeRef?: () => unknown }).getNativeRef === - "function" - ? ((node as { getNativeRef?: () => unknown }).getNativeRef?.() as { - focus?: () => void; - } | null) - : null; - if (target && typeof target.focus === "function") { - const exec = () => target.focus?.(); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(exec); - } else { - setTimeout(exec, 0); - } - } - }, [shouldAutoFocusPrompt]); - useEffect(() => { - focusPromptInputRef.current = focusPromptInput; - }, [focusPromptInput]); const activeSessionIds = useMemo(() => { const ids = new Set(); if (!agents) { return ids; } - agents.forEach((agent: any) => { - if (agent.sessionId) { - ids.add(agent.sessionId); - } + // Use persistence.sessionId for filtering - this is the canonical reference + // to the provider's session file (Claude resume token, Codex thread ID, etc.) + agents.forEach((agent) => { const persistedSessionId = agent.persistence?.sessionId; if (persistedSessionId) { ids.add(persistedSessionId); @@ -769,23 +506,6 @@ function AgentFlowModal({ importSearchQuery, ]); - useLayoutEffect(() => { - if (!IS_WEB) { - return; - } - measurePromptWebInputHeight(); - }, [initialPrompt, measurePromptWebInputHeight]); - - useEffect(() => { - if (!shouldAutoFocusPrompt) { - return; - } - if (!isVisible || !isCreateFlow) { - return; - } - focusPromptInput(); - }, [focusPromptInput, isCreateFlow, isVisible, shouldAutoFocusPrompt]); - const logOfflineDaemonAction = useCallback( (action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => { trackAnalyticsEvent({ @@ -805,9 +525,6 @@ function AgentFlowModal({ const resetFormState = useCallback(() => { setInitialPrompt(""); - promptBaselineHeightRef.current = null; - promptInputHeightRef.current = PROMPT_MIN_HEIGHT; - setPromptHeight(PROMPT_MIN_HEIGHT); setBaseBranch(""); setCreateNewBranch(false); setBranchName(""); @@ -822,160 +539,8 @@ function AgentFlowModal({ pendingRequestIdRef.current = null; pendingNavigationServerIdRef.current = null; shouldSyncBaseBranchRef.current = true; - dictationRequestIdRef.current = null; - setDictationDebugInfo(null); - void cancelDictation(); - resetDictation(); cancelRepoInfo(); - }, [cancelRepoInfo, cancelDictation, resetDictation, resetRepoInfo, setPromptHeight]); - - const handleDictationStart = useCallback(async () => { - console.log("[CreateAgentModal] handleDictationStart", { - isCreateFlow, - isLoading, - isDictating, - isDictationProcessing, - isTargetDaemonReady, - isWsConnected, - }); - if (!isCreateFlow || isLoading || isDictating || isDictationProcessing) { - return; - } - if (!isTargetDaemonReady || !isWsConnected) { - logOfflineDaemonAction( - "dictation", - daemonAvailabilityError ?? "WebSocket disconnected" - ); - return; - } - try { - if (shouldShowAudioDebug) { - setDictationDebugInfo(null); - } - setErrorMessage(""); - await startDictation(); - console.log("[CreateAgentModal] startDictation invoked"); - } catch (error) { - const isCancelled = error instanceof Error && error.message.includes("Recording cancelled"); - if (!isCancelled) { - console.error("[CreateAgentModal] Failed to start dictation:", error); - } - } - }, [ - daemonAvailabilityError, - isCreateFlow, - isDictating, - isDictationProcessing, - isLoading, - isTargetDaemonReady, - isWsConnected, - logOfflineDaemonAction, - shouldShowAudioDebug, - startDictation, - ]); - - const handleDictationCancel = useCallback(async () => { - console.log("[CreateAgentModal] handleDictationCancel", { - isDictating, - }); - if (dictationStatus === "failed") { - discardFailedDictation(); - return; - } - if (!isDictating) { - return; - } - try { - await cancelDictation(); - } catch (error) { - console.error("[CreateAgentModal] Failed to cancel dictation:", error); - } - }, [cancelDictation, dictationStatus, discardFailedDictation, isDictating]); - - const handleDictationConfirm = useCallback(async () => { - console.log("[CreateAgentModal] handleDictationConfirm", { - isDictating, - isDictationProcessing, - isTargetDaemonReady, - hasSendAgentAudio, - }); - if (dictationStatus === "failed") { - void retryFailedDictation(); - return; - } - if (!isDictating || isDictationProcessing) { - return; - } - if (!isTargetDaemonReady || !hasSendAgentAudio) { - logOfflineDaemonAction("dictation"); - setErrorMessage( - daemonAvailabilityError ?? - "Dictation is unavailable until the selected host is online. Paseo reconnects automatically—try again once it comes back." - ); - return; - } - try { - await confirmDictation(); - } catch (error) { - console.error("[CreateAgentModal] Failed to complete dictation:", error); - } - }, [ - daemonAvailabilityError, - confirmDictation, - dictationStatus, - hasSendAgentAudio, - isDictating, - isDictationProcessing, - isTargetDaemonReady, - logOfflineDaemonAction, - retryFailedDictation, - ]); - - const handlePromptDictationRetry = useCallback(() => { - void retryFailedDictation(); - }, [retryFailedDictation]); - - const handlePromptDictationDiscard = useCallback(() => { - discardFailedDictation(); - }, [discardFailedDictation]); - - const promptDictationToast = useMemo(() => { - if (dictationStatus === "retrying") { - const attempt = dictationRetryInfo?.attempt ?? Math.max(1, dictationRetryAttempt || 1); - const maxAttempts = dictationRetryInfo?.maxAttempts ?? dictationMaxRetryAttempts; - const retryMeta = - dictationRetryInfo?.nextRetryMs && dictationRetryInfo.nextRetryMs > 0 - ? `Attempt ${attempt}/${maxAttempts} · Next in ${Math.ceil(dictationRetryInfo.nextRetryMs / 1000)}s` - : `Attempt ${attempt}/${maxAttempts}`; - return { - variant: "warning", - title: "Retrying dictation…", - subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Network error", - meta: retryMeta, - }; - } - - if (dictationStatus === "failed") { - return { - variant: "error", - title: "Dictation failed", - subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Unknown error", - actionLabel: "Retry", - onAction: handlePromptDictationRetry, - onDismiss: handlePromptDictationDiscard, - }; - } - - return null; - }, [ - dictationError, - dictationMaxRetryAttempts, - dictationRetryAttempt, - dictationRetryInfo, - dictationStatus, - handlePromptDictationDiscard, - handlePromptDictationRetry, - ]); + }, [cancelRepoInfo, resetRepoInfo]); const navigateToAgentIfNeeded = useCallback(() => { const agentId = pendingNavigationAgentIdRef.current; @@ -1591,36 +1156,6 @@ function AgentFlowModal({ }; }, [handleClose, shouldListenForStatus, ws]); - useEffect(() => { - if (!shouldListenForDictation || !ws || !shouldShowAudioDebug) { - return; - } - const unsubscribe = ws.on("transcription_result", (message) => { - if (message.type !== "transcription_result") { - return; - } - const pendingId = dictationRequestIdRef.current; - if (!pendingId || message.payload.requestId !== pendingId) { - return; - } - dictationRequestIdRef.current = null; - setDictationDebugInfo({ - requestId: pendingId, - transcript: message.payload.text?.trim(), - debugRecordingPath: message.payload.debugRecordingPath ?? undefined, - format: message.payload.format, - byteLength: message.payload.byteLength, - duration: message.payload.duration, - avgLogprob: message.payload.avgLogprob, - isLowConfidence: message.payload.isLowConfidence, - }); - }); - - return () => { - unsubscribe(); - }; - }, [shouldListenForDictation, shouldShowAudioDebug, ws]); - useEffect(() => { if (!isVisible || !isImportFlow) { return; @@ -1638,24 +1173,6 @@ function AgentFlowModal({ const shouldRender = isVisible || isMounted; const modalTitle = isImportFlow ? "Import Agent" : "Create New Agent"; - const dictationAccessory = isCreateFlow ? ( - - ) : null; const gitBlockingError = useMemo(() => { if (isNonGitDirectory) { @@ -1720,26 +1237,6 @@ function AgentFlowModal({ Boolean(gitBlockingError) || isLoading || !isTargetDaemonReady; - const handlePromptDesktopSubmitKeyPress = useCallback( - (event: WebTextInputKeyPressEvent) => { - if (!shouldHandlePromptDesktopSubmit) { - return; - } - if (event.nativeEvent.key !== "Enter") { - return; - } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - if (shiftKey || metaKey || ctrlKey) { - return; - } - if (createDisabled) { - return; - } - event.preventDefault(); - void handleCreate(); - }, - [createDisabled, handleCreate, shouldHandlePromptDesktopSubmit] - ); const headerPaddingTop = useMemo( () => insets.top + defaultTheme.spacing[4], [insets.top] @@ -1875,29 +1372,25 @@ function AgentFlowModal({ ) : null} - { - setInitialPrompt(text); - setErrorMessage(""); - }} - inputRef={promptInputRef} - inputHeight={promptInputHeight} - onContentSizeChange={handlePromptContentSizeChange} - onDesktopSubmit={handlePromptDesktopSubmitKeyPress} - autoFocus={shouldAutoFocusPrompt} - scrollEnabled={promptInputHeight >= PROMPT_MAX_HEIGHT} - accessory={dictationAccessory} - /> - - {shouldShowAudioDebug && dictationDebugInfo ? ( - setDictationDebugInfo(null)} - title="Prompt Dictation Debug" + + Initial Prompt + { + setInitialPrompt(text); + setErrorMessage(""); + }} + onSubmit={() => { + void handleCreate(); + }} + ws={effectiveWs} + sendAgentAudio={sendAgentAudio} + placeholder="Describe what you want the agent to do" + autoFocus={shouldAutoFocusPrompt} + disabled={isLoading || !isTargetDaemonReady} + isSubmitDisabled={createDisabled} /> - ) : null} + - {promptDictationToast ? ( - - - - - - ) : null} ) : ( void; - inputRef: RefObject< - TextInput | (TextInput & { getNativeRef?: () => unknown }) | null - >; - inputHeight: number; - onContentSizeChange: ( - event: NativeSyntheticEvent - ) => void; - onDesktopSubmit?: (event: WebTextInputKeyPressEvent) => void; - autoFocus?: boolean; - scrollEnabled: boolean; - accessory?: ReactNode; -} - -function PromptSection({ - value, - isLoading, - onChange, - inputRef, - inputHeight, - onContentSizeChange, - onDesktopSubmit, - autoFocus, - scrollEnabled, - accessory, -}: PromptSectionProps): ReactElement { - return ( - - - Initial Prompt - {accessory} - - - - ); -} - -interface PromptDictationControlsProps { - isRecording: boolean; - isProcessing: boolean; - disabled: boolean; - volume: number; - onStart: () => void; - onCancel: () => void; - onConfirm: () => void; - status: DictationStatus; - retryAttempt: number; - maxRetryAttempts: number; - retryCountdownMs?: number | null; - errorMessage?: string | null; - onRetry?: () => void; - onDiscard?: () => void; -} - -function PromptDictationControls({ - isRecording, - isProcessing, - disabled, - volume, - onStart, - onCancel, - onConfirm, - status, - retryAttempt, - maxRetryAttempts, - retryCountdownMs, - errorMessage, - onRetry, - onDiscard, -}: PromptDictationControlsProps): ReactElement { - const { theme } = useUnistyles(); - - const isRetrying = status === "retrying"; - const isFailed = status === "failed"; - const showActiveState = isRecording || isProcessing || isRetrying || isFailed; - const cancelHandler = isFailed ? onDiscard ?? onCancel : onCancel; - const confirmHandler = isFailed ? onRetry ?? onConfirm : onConfirm; - - if (!showActiveState) { - return ( - - - - ); - } - - return ( - - - - - - - - - - {isProcessing || isRetrying ? ( - - ) : isFailed ? ( - - ) : ( - - )} - - - {(isRetrying || isFailed) && ( - - {isRetrying - ? `Retrying ${Math.max(1, retryAttempt)} / ${Math.max(1, maxRetryAttempts)}${ - retryCountdownMs && retryCountdownMs > 0 - ? ` in ${Math.ceil(retryCountdownMs / 1000)}s` - : "" - }` - : errorMessage ?? "Dictation failed"} - - )} - - ); -} - interface GitOptionsSectionProps { baseBranch: string; onBaseBranchChange: (value: string) => void; @@ -2757,24 +2065,9 @@ const styles = StyleSheet.create(((theme: any) => ({ paddingBottom: theme.spacing[8], gap: theme.spacing[6], }, - dictationToastPortal: { - position: "absolute", - left: theme.spacing[4], - right: theme.spacing[4], - bottom: theme.spacing[6], - }, - dictationNoticeWrapper: { - marginTop: theme.spacing[3], - }, formSection: { gap: theme.spacing[3], }, - labelRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[3], - }, label: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, @@ -2899,13 +2192,6 @@ const styles = StyleSheet.create(((theme: any) => ({ alignItems: "center", paddingVertical: theme.spacing[4], }, - promptInput: { - minHeight: theme.spacing[24], - textAlignVertical: "top", - outlineWidth: 0, - outlineColor: "transparent", - outlineStyle: "none", - }, inputDisabled: { opacity: theme.opacity[50], }, @@ -2921,55 +2207,6 @@ const styles = StyleSheet.create(((theme: any) => ({ color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, }, - dictationButton: { - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.full, - padding: theme.spacing[2], - alignItems: "center", - justifyContent: "center", - }, - dictationButtonDisabled: { - opacity: theme.opacity[50], - }, - dictationActiveContainer: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - dictationMeterWrapper: { - width: 48, - alignItems: "center", - justifyContent: "center", - }, - dictationActionGroup: { - flexDirection: "row", - gap: theme.spacing[2], - }, - dictationActionButton: { - width: 32, - height: 32, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - borderWidth: theme.borderWidth[1], - }, - dictationActionButtonCancel: { - borderColor: theme.colors.border, - backgroundColor: theme.colors.background, - }, - dictationActionButtonConfirm: { - borderColor: theme.colors.foreground, - backgroundColor: theme.colors.foreground, - }, - dictationActionButtonDisabled: { - opacity: theme.opacity[40], - }, - dictationStatusLabel: { - marginTop: theme.spacing[1], - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, selectorRow: { flexDirection: "row", gap: theme.spacing[4], diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx new file mode 100644 index 000000000..5d34a566d --- /dev/null +++ b/packages/app/src/components/dictation-controls.tsx @@ -0,0 +1,339 @@ +import { View, Text, Pressable, ActivityIndicator } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { X, ArrowUp, RefreshCcw, Check, Mic } from "lucide-react-native"; +import { VolumeMeter } from "./volume-meter"; +import { FOOTER_HEIGHT } from "@/constants/layout"; +import type { DictationStatus } from "@/hooks/use-dictation"; + +interface DictationControlsProps { + volume: number; + duration: number; + isRecording: boolean; + isProcessing: boolean; + status: DictationStatus; + onStart: () => void; + onCancel: () => void; + onAccept: () => void; + onAcceptAndSend: () => void; + onRetry?: () => void; + onDiscard?: () => void; + disabled?: boolean; + retryStatusText?: string; +} + +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 DictationControls({ + volume, + duration, + isRecording, + isProcessing, + status, + onStart, + onCancel, + onAccept, + onAcceptAndSend, + onRetry, + onDiscard, + disabled = false, + retryStatusText, +}: DictationControlsProps) { + const { theme } = useUnistyles(); + const isRetrying = status === "retrying"; + const isFailed = status === "failed"; + const showActiveState = isRecording || isProcessing || isRetrying || isFailed; + const actionsDisabled = isProcessing || isRetrying; + const handleCancel = isFailed && onDiscard ? onDiscard : onCancel; + + if (!showActiveState) { + return ( + + + + ); + } + + return ( + + + + + + {formatDuration(duration)} + + + + + + {actionsDisabled ? ( + + + + ) : isFailed ? ( + + + + ) : ( + <> + + + + + + + + )} + + {retryStatusText && (isRetrying || isFailed) && ( + + {retryStatusText} + + )} + + ); +} + +/** + * Full-width overlay variant for the agent input footer. + * Uses blue background with white icons. + */ +export function DictationOverlay({ + volume, + duration, + isRecording, + isProcessing, + status, + onCancel, + onAccept, + onAcceptAndSend, + onRetry, + onDiscard, +}: Omit) { + const { theme } = useUnistyles(); + const isRetrying = status === "retrying"; + const isFailed = status === "failed"; + const showActiveState = isRecording || isProcessing || isRetrying || isFailed; + const actionsDisabled = isProcessing || isRetrying; + const handleCancel = isFailed && onDiscard ? onDiscard : onCancel; + + if (!showActiveState) { + return null; + } + + return ( + + + + + + + + + {formatDuration(duration)} + + + + + {actionsDisabled ? ( + + + + ) : isFailed ? ( + + + + ) : ( + <> + + + + + + + + )} + + + ); +} + +const BUTTON_SIZE = 32; + +const styles = StyleSheet.create((theme) => ({ + micButton: { + width: BUTTON_SIZE, + height: BUTTON_SIZE, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.background, + }, + activeContainer: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + meterWrapper: { + width: 80, + alignItems: "center", + justifyContent: "center", + }, + timerText: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + fontVariant: ["tabular-nums"], + }, + actionGroup: { + flexDirection: "row", + gap: theme.spacing[2], + }, + actionButton: { + width: BUTTON_SIZE, + height: BUTTON_SIZE, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + borderWidth: theme.borderWidth[1], + }, + actionButtonCancel: { + borderColor: theme.colors.border, + backgroundColor: theme.colors.background, + }, + actionButtonSecondary: { + borderColor: theme.colors.border, + backgroundColor: theme.colors.background, + }, + actionButtonConfirm: { + borderColor: theme.colors.foreground, + backgroundColor: theme.colors.foreground, + }, + buttonDisabled: { + opacity: 0.4, + }, + loadingContainer: { + width: BUTTON_SIZE, + height: BUTTON_SIZE, + alignItems: "center", + justifyContent: "center", + }, + statusLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, +})); + +const OVERLAY_BUTTON_SIZE = 44; +const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2; + +const overlayStyles = StyleSheet.create((theme) => ({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: theme.spacing[4], + paddingVertical: OVERLAY_VERTICAL_PADDING, + height: FOOTER_HEIGHT, + }, + cancelButton: { + width: OVERLAY_BUTTON_SIZE, + height: OVERLAY_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"], + }, + actionButtonsContainer: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + actionButton: { + width: OVERLAY_BUTTON_SIZE, + height: OVERLAY_BUTTON_SIZE, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + buttonDisabled: { + opacity: 0.5, + }, + loadingContainer: { + width: OVERLAY_BUTTON_SIZE, + height: OVERLAY_BUTTON_SIZE, + alignItems: "center", + justifyContent: "center", + }, +})); diff --git a/packages/app/src/components/voice-note-recording-overlay.tsx b/packages/app/src/components/voice-note-recording-overlay.tsx deleted file mode 100644 index a9e8f3b96..000000000 --- a/packages/app/src/components/voice-note-recording-overlay.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { View, Text, Pressable, ActivityIndicator } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { X, ArrowUp, RefreshCcw } from "lucide-react-native"; -import { VolumeMeter } from "./volume-meter"; -import { FOOTER_HEIGHT } from "@/constants/layout"; -import type { DictationStatus } from "@/hooks/use-dictation"; - -interface VoiceNoteRecordingOverlayProps { - volume: number; - duration: number; - onCancel: () => void; - onSend: () => void; - isTranscribing?: boolean; - status?: DictationStatus; - onRetry?: () => void; - onDiscardFailed?: () => void; -} - -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, - status = "idle", - onRetry, - onDiscardFailed, -}: VoiceNoteRecordingOverlayProps) { - const { theme } = useUnistyles(); - const isRetrying = status === "retrying"; - const isFailed = status === "failed"; - const primaryDisabled = isTranscribing || isRetrying; - const handlePrimary = isFailed ? onRetry ?? onSend : onSend; - const handleCancel = isFailed && onDiscardFailed ? onDiscardFailed : onCancel; - - return ( - - {/* Cancel button */} - - - - - {/* Center: Volume meter and timer */} - - - - {formatDuration(duration)} - - - - {/* Send button */} - - {isRetrying ? ( - - ) : isTranscribing ? ( - - ) : isFailed ? ( - - ) : ( - - )} - - - ); -} - -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/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f391e0fd6..0925a7b77 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -134,7 +134,6 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string updatedAt, lastUserMessageAt, lastActivityAt: updatedAt, - sessionId: snapshot.sessionId, capabilities: snapshot.capabilities, currentModeId: snapshot.currentModeId, availableModes: snapshot.availableModes ?? [], @@ -196,7 +195,7 @@ export interface SessionContextValue { images?: Array<{ uri: string; mimeType?: string }> ) => Promise; sendAgentAudio: ( - agentId: string, + agentId: string | undefined, audioBlob: Blob, requestId?: string, options?: { mode?: "transcribe_only" | "auto_run" } @@ -265,6 +264,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const activeAudioGroupsRef = useRef>(new Set()); const previousAgentStatusRef = useRef>(new Map()); const providerModelRequestIdsRef = useRef>(new Map()); + const sendAgentMessageRef = useRef<((agentId: string, message: string, images?: Array<{ uri: string; mimeType?: string }>) => Promise) | null>(null); const hasHydratedSnapshotRef = useRef(false); const hasRequestedInitialSnapshotRef = useRef(false); const sessionStateTimeoutRef = useRef | null>(null); @@ -751,6 +751,26 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid } return next; }); + + // Flush queued messages when agent transitions from running to not running + const prevStatus = previousAgentStatusRef.current.get(agent.id); + if (prevStatus === "running" && agent.status !== "running") { + const session = useSessionStore.getState().sessions[serverId]; + const queue = session?.queuedMessages.get(agent.id); + if (queue && queue.length > 0) { + const [next, ...rest] = queue; + console.log("[Session] Flushing queued message for agent:", agent.id, next.text); + if (sendAgentMessageRef.current) { + void sendAgentMessageRef.current(agent.id, next.text, next.images); + } + setQueuedMessages(serverId, (prev) => { + const updated = new Map(prev); + updated.set(agent.id, rest); + return updated; + }); + } + } + previousAgentStatusRef.current.set(agent.id, agent.status); }); const unsubAgentStream = ws.on("agent_stream", (message) => { @@ -1254,29 +1274,6 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }; }, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setAgentStreamingBuffer, clearAgentStreamingBuffer, setInitializingAgents, setAgents, setAgentLastActivity, setCommands, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]); - // Auto-flush queued messages when agent transitions from running -> not running - useEffect(() => { - const session = getSession(serverId); - if (!session) return; - - for (const [agentId, agent] of session.agents.entries()) { - const prevStatus = previousAgentStatusRef.current.get(agentId); - if (prevStatus === "running" && agent.status !== "running") { - const queue = session.queuedMessages.get(agentId); - if (queue && queue.length > 0) { - const [next, ...rest] = queue; - void sendAgentMessage(agentId, next.text, next.images); - setQueuedMessages(serverId, (prev) => { - const updated = new Map(prev); - updated.set(agentId, rest); - return updated; - }); - } - } - previousAgentStatusRef.current.set(agentId, agent.status); - } - }, [serverId, getSession, setQueuedMessages]); - const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => { console.log("[Session] initializeAgent called", { agentId, requestId }); setInitializingAgents(serverId, (prev) => { @@ -1459,6 +1456,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid ws.send(msg); }, [encodeImages, serverId, ws, setAgentStreamState]); + // Keep the ref updated so the agent_state handler can call it + sendAgentMessageRef.current = sendAgentMessage; + const cancelAgentRun = useCallback((agentId: string) => { const msg: WSInboundMessage = { type: "session", @@ -1493,7 +1493,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }, [ws]); const sendAgentAudio = useCallback(async ( - agentId: string, + agentId: string | undefined, audioBlob: Blob, requestId?: string, options?: { mode?: "transcribe_only" | "auto_run" } @@ -1530,7 +1530,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid type: "session", message: { type: "send_agent_audio", - agentId, + ...(agentId ? { agentId } : {}), audio: base64Audio, format, isLast: true, @@ -1540,7 +1540,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }; ws.send(msg); - console.log("[Session] Sent audio to agent:", agentId, format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : ""); + console.log("[Session] Sent audio:", agentId ?? "(no agent)", format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : ""); } catch (error) { console.error("[Session] Failed to send audio:", error); throw error; diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index d1ae39241..92f5d641a 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -31,7 +31,7 @@ export type DictationOutcome = | { type: "failure"; requestId: string; errorMessage: string; timestamp: number }; export type UseDictationOptions = { - agentId: string; + agentId?: string; sendAgentAudio: SessionContextValue["sendAgentAudio"]; ws: UseWebSocketReturn; mode?: "transcribe_only" | "auto_run"; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 9edd09f65..2fb55ee41 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -91,7 +91,6 @@ export interface Agent { updatedAt: Date; lastUserMessageAt: Date | null; lastActivityAt: Date; - sessionId: string | null; capabilities: AgentCapabilityFlags; currentModeId: string | null; availableModes: AgentMode[]; @@ -191,7 +190,7 @@ export interface SessionState { images?: Array<{ uri: string; mimeType?: string }> ) => Promise; sendAgentAudio: ( - agentId: string, + agentId: string | undefined, audioBlob: Blob, requestId?: string, options?: { mode?: "transcribe_only" | "auto_run" } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5aa67f66f..c64dfbf75 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -89,7 +89,10 @@ class TestAgentSession implements AgentSession { async respondToPermission(): Promise {} describePersistence() { - return null; + return { + provider: this.provider, + sessionId: this.id, + }; } async interrupt(): Promise {} @@ -165,7 +168,7 @@ describe("AgentManager", () => { expect(snapshot.runtimeInfo).toBeDefined(); expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex"); - expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.sessionId); + expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.persistence?.sessionId); }); test("runAgent refreshes runtimeInfo after completion", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 3a491d66a..e7e8788f9 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -93,7 +93,6 @@ type ManagedAgentBase = { persistence: AgentPersistenceHandle | null; historyPrimed: boolean; lastUserMessageAt: Date | null; - sessionId: string | null; lastUsage?: AgentUsage; lastError?: string; attention: AttentionState; @@ -368,7 +367,6 @@ export class AgentManager { lifecycle: "closed", session: null, pendingRun: null, - sessionId: agent.sessionId, }; await session.close(); this.emitState(closedAgent); @@ -421,8 +419,14 @@ export class AgentManager { } const agent = this.requireAgent(agentId); + const sessionId = agent.persistence?.sessionId; + if (!sessionId) { + throw new Error( + `Agent ${agentId} has no persistence.sessionId after run completed` + ); + } return { - sessionId: agent.sessionId ?? agent.id, + sessionId, finalText, usage, timeline, @@ -767,7 +771,6 @@ export class AgentManager { provider: config.provider, cwd: config.cwd, session, - sessionId: session.id, capabilities: session.capabilities, config, runtimeInfo: undefined, @@ -878,7 +881,9 @@ export class AgentManager { switch (event.type) { case "thread_started": - agent.sessionId = event.sessionId; + // Update persistence with the new session ID from the provider. + // persistence.sessionId is the single source of truth for session identity. + agent.persistence = agent.session.describePersistence(); break; case "timeline": this.recordTimeline(agent, event.item); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 658fe8bd5..e4497929a 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -407,7 +407,7 @@ describe("agent MCP end-to-end", () => { cwd: agentCwd, title: "MCP interrupt test", agentType: "codex", - initialMode: "full-auto", + initialMode: "full-access", background: true, // Start in background so create returns immediately }, })) as McpToolResult; diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 60ab5f642..6172e14f9 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -234,7 +234,6 @@ describe("toAgentPayload", () => { expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString()); expect(payload.title).toBe("UI Payload"); expect(payload.model).toBe(agent.config.model); - expect(payload.sessionId).toBe(agent.sessionId); expect(payload.pendingPermissions.map((item) => item.id)).toEqual([ "perm-a", "perm-b", diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index f5decdc19..f04b5ce5b 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -77,7 +77,6 @@ export function toAgentPayload( ? agent.lastUserMessageAt.toISOString() : null, status: agent.lifecycle, - sessionId: agent.sessionId, capabilities: cloneCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: cloneAvailableModes(agent.availableModes), diff --git a/packages/server/src/server/agent/agent-registry.test.ts b/packages/server/src/server/agent/agent-registry.test.ts index 56805e2ea..43c271852 100644 --- a/packages/server/src/server/agent/agent-registry.test.ts +++ b/packages/server/src/server/agent/agent-registry.test.ts @@ -51,7 +51,6 @@ function createManagedAgent( provider, cwd, session, - sessionId: overrides.sessionId ?? "session-123", capabilities: overrides.capabilities ?? { diff --git a/packages/server/src/server/agent/agent-registry.ts b/packages/server/src/server/agent/agent-registry.ts index 9e84919e2..32326ec60 100644 --- a/packages/server/src/server/agent/agent-registry.ts +++ b/packages/server/src/server/agent/agent-registry.ts @@ -66,6 +66,7 @@ export class AgentRegistry { private cache: Map = new Map(); private loaded = false; private filePath: string; + private loadPromise: Promise | null = null; constructor(filePath: string) { this.filePath = filePath; @@ -75,6 +76,15 @@ export class AgentRegistry { if (this.loaded) { return Array.from(this.cache.values()); } + + if (!this.loadPromise) { + this.loadPromise = this.doLoad(); + } + + return this.loadPromise; + } + + private async doLoad(): Promise { try { const content = await fs.readFile(this.filePath, "utf8"); const parsed = await this.parseContent(content); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index a3ad5effc..9e4d61a1f 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -36,6 +36,47 @@ export interface AgentMcpServerOptions { callerAgentId?: string; } +const CLAUDE_TO_CODEX_MODE: Record = { + plan: "read-only", + default: "auto", + acceptEdits: "auto", + bypassPermissions: "full-access", +}; + +const CODEX_TO_CLAUDE_MODE: Record = { + "read-only": "plan", + auto: "default", + "full-access": "bypassPermissions", +}; + +function mapModeAcrossProviders( + sourceMode: string, + sourceProvider: AgentProvider, + targetProvider: AgentProvider +): string { + if (sourceProvider === targetProvider) { + return sourceMode; + } + + if (sourceProvider === "claude" && targetProvider === "codex") { + const mapped = CLAUDE_TO_CODEX_MODE[sourceMode]; + if (mapped) { + return mapped; + } + return "auto"; + } + + if (sourceProvider === "codex" && targetProvider === "claude") { + const mapped = CODEX_TO_CLAUDE_MODE[sourceMode]; + if (mapped) { + return mapped; + } + return "default"; + } + + return sourceMode; +} + const AgentProviderEnum = z.enum( AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [ AgentProvider, @@ -138,59 +179,91 @@ export async function createAgentMcpServer( version: "2.0.0", }); + const agentToAgentInputSchema = { + title: z + .string() + .trim() + .min(1, "Title is required") + .max(40, "Title must be 40 characters or fewer") + .describe( + "Short descriptive title (<= 40 chars) summarizing the agent's focus. Use a single concise sentence that fits on mobile." + ), + agentType: AgentProviderEnum.optional().describe( + "Optional agent implementation to spawn. Defaults to 'claude'." + ), + initialPrompt: z + .string() + .optional() + .describe( + "Optional task to start immediately after creation (non-blocking)." + ), + background: z + .boolean() + .optional() + .default(false) + .describe( + "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately." + ), + }; + + const topLevelInputSchema = { + cwd: z + .string() + .describe( + "Required working directory for the agent (absolute, relative, or ~)." + ), + title: z + .string() + .trim() + .min(1, "Title is required") + .max(40, "Title must be 40 characters or fewer") + .describe( + "Short descriptive title (<= 40 chars) summarizing the agent's focus. Use a single concise sentence that fits on mobile." + ), + agentType: AgentProviderEnum.optional().describe( + "Optional agent implementation to spawn. Defaults to 'claude'." + ), + initialPrompt: z + .string() + .optional() + .describe( + "Optional task to start immediately after creation (non-blocking)." + ), + initialMode: z + .string() + .describe("Required session mode to configure before the first run."), + worktreeName: z + .string() + .optional() + .describe( + "Optional git worktree branch name (lowercase alphanumerics + hyphen)." + ), + background: z + .boolean() + .optional() + .default(false) + .describe( + "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately." + ), + parentAgentId: z + .string() + .optional() + .describe( + "Optional parent agent ID. When set, this agent is a child of the specified parent agent." + ), + }; + + const createAgentInputSchema = callerAgentId + ? agentToAgentInputSchema + : topLevelInputSchema; + server.registerTool( "create_agent", { title: "Create Agent", description: "Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.", - inputSchema: { - cwd: z - .string() - .describe( - "Required working directory for the agent (absolute, relative, or ~)." - ), - title: z - .string() - .trim() - .min(1, "Title is required") - .max(40, "Title must be 40 characters or fewer") - .describe( - "Short descriptive title (<= 40 chars) summarizing the agent's focus. Use a single concise sentence that fits on mobile." - ), - agentType: AgentProviderEnum.optional().describe( - "Optional agent implementation to spawn. Defaults to 'claude'." - ), - initialPrompt: z - .string() - .optional() - .describe( - "Optional task to start immediately after creation (non-blocking)." - ), - initialMode: z - .string() - .optional() - .describe("Optional session mode to configure before the first run."), - worktreeName: z - .string() - .optional() - .describe( - "Optional git worktree branch name (lowercase alphanumerics + hyphen)." - ), - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately." - ), - parentAgentId: z - .string() - .optional() - .describe( - "Optional parent agent ID. When set, this agent is a child of the specified parent agent." - ), - }, + inputSchema: createAgentInputSchema, outputSchema: { agentId: z.string(), type: AgentProviderEnum, @@ -208,35 +281,79 @@ export async function createAgentMcpServer( permission: AgentPermissionRequestPayloadSchema.nullable().optional(), }, }, - async ({ - cwd, - agentType, - initialPrompt, - initialMode, - worktreeName, - background = false, - title, - parentAgentId, - }) => { - let resolvedCwd = expandPath(cwd); + async (args) => { + const { + agentType, + initialPrompt, + background = false, + title, + } = args as { + cwd?: string; + agentType?: AgentProvider; + initialPrompt?: string; + initialMode?: string; + worktreeName?: string; + background?: boolean; + title: string; + parentAgentId?: string; + }; - if (worktreeName) { - const worktree = await createWorktree({ - branchName: worktreeName, - cwd: resolvedCwd, - worktreeSlug: worktreeName, - }); - resolvedCwd = worktree.worktreePath; + let resolvedCwd: string; + let resolvedMode: string | undefined; + let resolvedParentAgentId: string | undefined; + + if (callerAgentId) { + const parentAgent = agentManager.getAgent(callerAgentId); + if (!parentAgent) { + throw new Error(`Parent agent ${callerAgentId} not found`); + } + resolvedCwd = parentAgent.cwd; + resolvedParentAgentId = callerAgentId; + + const provider: AgentProvider = agentType ?? "claude"; + const parentMode = parentAgent.currentModeId; + if (parentMode) { + resolvedMode = mapModeAcrossProviders( + parentMode, + parentAgent.provider, + provider + ); + } + } else { + const topLevelArgs = args as unknown as { + cwd: string; + initialMode: string; + worktreeName?: string; + parentAgentId?: string; + }; + const { + cwd, + initialMode, + worktreeName, + parentAgentId, + } = topLevelArgs; + + resolvedCwd = expandPath(cwd); + + if (worktreeName) { + const worktree = await createWorktree({ + branchName: worktreeName, + cwd: resolvedCwd, + worktreeSlug: worktreeName, + }); + resolvedCwd = worktree.worktreePath; + } + + resolvedMode = initialMode; + resolvedParentAgentId = parentAgentId; } const provider: AgentProvider = agentType ?? "claude"; const normalizedTitle = title?.trim() ?? null; - // Use explicit parentAgentId if provided, otherwise default to caller agent ID - const resolvedParentAgentId = parentAgentId ?? callerAgentId; const snapshot = await agentManager.createAgent({ provider, cwd: resolvedCwd, - modeId: initialMode, + modeId: resolvedMode, title: normalizedTitle ?? undefined, parentAgentId: resolvedParentAgentId, }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index a71ec82b8..bcca98d48 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -728,6 +728,12 @@ class ClaudeAgentSession implements AgentSession { stderr: (data: string) => { console.error("[ClaudeAgentSDK]", data.trim()); }, + env: { + ...process.env, + // Increase MCP timeouts for long-running tool calls (10 minutes) + MCP_TIMEOUT: "600000", + MCP_TOOL_TIMEOUT: "600000", + }, ...this.config.extra?.claude, }; diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index 65b7f25ba..b9d29f956 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -117,6 +117,15 @@ const MODE_PRESETS: Record< }, }; +function validateCodexMode(modeId: string): void { + if (!(modeId in MODE_PRESETS)) { + const validModes = Object.keys(MODE_PRESETS).join(", "); + throw new Error( + `Invalid Codex mode "${modeId}". Valid modes are: ${validModes}` + ); + } +} + function createToolCallTimelineItem( data: Omit ): AgentTimelineItem { @@ -2757,9 +2766,13 @@ class CodexMcpAgentSession implements AgentSession { private pendingResumeFile: string | null = null; constructor(config: CodexMcpAgentConfig, resumeHandle?: AgentPersistenceHandle) { + if (config.modeId === undefined) { + throw new Error("Codex agent requires modeId to be specified"); + } + validateCodexMode(config.modeId); + this.config = config; - this.currentMode = - config.modeId !== undefined ? config.modeId : DEFAULT_CODEX_MODE_ID; + this.currentMode = config.modeId; this.pendingLocalId = `codex-${randomUUID()}`; if (resumeHandle) { @@ -3053,6 +3066,8 @@ class CodexMcpAgentSession implements AgentSession { } async setMode(modeId: string): Promise { + validateCodexMode(modeId); + this.currentMode = modeId; this.config.modeId = modeId; diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 7fc66cc7d..48db3040e 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -243,7 +243,6 @@ export const AgentSnapshotPayloadSchema = z.object({ updatedAt: z.string(), lastUserMessageAt: z.string().nullable(), status: AgentStatusSchema, - sessionId: z.string().nullable(), capabilities: AgentCapabilityFlagsSchema, currentModeId: z.string().nullable(), availableModes: z.array(AgentModeSchema), @@ -340,7 +339,7 @@ export const SendAgentMessageSchema = z.object({ export const SendAgentAudioSchema = z.object({ type: z.literal("send_agent_audio"), - agentId: z.string(), + agentId: z.string().optional(), // Required for auto_run mode, optional for transcribe_only audio: z.string(), // base64 encoded format: z.string(), isLast: z.boolean(), diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index e22e24aab..4291c5748 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -49,7 +49,6 @@ function createManagedAgent( provider, cwd, session, - sessionId: overrides.sessionId ?? "session-123", capabilities: overrides.capabilities ?? { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fc169063a..274b5e700 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -609,7 +609,6 @@ export class Session { updatedAt: updatedAt.toISOString(), lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, status: record.lastStatus, - sessionId: null, capabilities: defaultCapabilities, currentModeId: record.lastModeId ?? null, availableModes: [], @@ -1107,6 +1106,17 @@ export class Session { const { agentId, audio, format, isLast, requestId, mode } = msg; const shouldAutoRun = mode === "auto_run"; + // agentId is required for auto_run mode + if (shouldAutoRun && !agentId) { + console.error( + `[Session ${this.clientId}] agentId is required for auto_run mode` + ); + return; + } + + // Use placeholder for logging when agentId is not provided + const logAgentId = agentId ?? "transcription"; + // Decode base64 const audioBuffer = Buffer.from(audio, "base64"); @@ -1114,20 +1124,20 @@ export class Session { // In the future, we might want to buffer chunks similar to realtime audio if (!isLast) { console.log( - `[Session ${this.clientId}] Buffering agent audio chunk for agent ${agentId}` + `[Session ${this.clientId}] Buffering agent audio chunk for agent ${logAgentId}` ); // TODO: Implement buffering if needed return; } console.log( - `[Session ${this.clientId}] Transcribing audio for agent ${agentId}` + `[Session ${this.clientId}] Transcribing audio for agent ${logAgentId}` ); try { // Transcribe the audio const result = await this.sttManager.transcribe(audioBuffer, format, { - agentId, + agentId: logAgentId, requestId, label: shouldAutoRun ? "dictation:auto_run" : "dictation", }); @@ -1135,13 +1145,13 @@ export class Session { const transcriptText = result.text.trim(); if (!transcriptText) { console.log( - `[Session ${this.clientId}] Empty transcription for agent ${agentId}, ignoring` + `[Session ${this.clientId}] Empty transcription for agent ${logAgentId}, ignoring` ); return; } console.log( - `[Session ${this.clientId}] Transcribed audio for agent ${agentId}: ${transcriptText}` + `[Session ${this.clientId}] Transcribed audio for agent ${logAgentId}: ${transcriptText}` ); // Emit transcription result to client with requestId @@ -1162,16 +1172,19 @@ export class Session { if (!shouldAutoRun) { console.log( - `[Session ${this.clientId}] Completed transcription for agent ${agentId} (requestId: ${requestId ?? "n/a"})` + `[Session ${this.clientId}] Completed transcription for agent ${logAgentId} (requestId: ${requestId ?? "n/a"})` ); return; } + // At this point, agentId is guaranteed to be defined (validated at function start) + const validAgentId = agentId!; + try { - await this.ensureAgentLoaded(agentId); + await this.ensureAgentLoaded(validAgentId); } catch (error) { this.handleAgentRunError( - agentId, + validAgentId, error, "Failed to initialize agent before sending audio prompt" ); @@ -1179,10 +1192,10 @@ export class Session { } try { - await this.interruptAgentIfRunning(agentId); + await this.interruptAgentIfRunning(validAgentId); } catch (error) { this.handleAgentRunError( - agentId, + validAgentId, error, "Failed to interrupt running agent before sending audio prompt" ); @@ -1190,22 +1203,22 @@ export class Session { } try { - this.agentManager.recordUserMessage(agentId, transcriptText); + this.agentManager.recordUserMessage(validAgentId, transcriptText); } catch (recordError) { console.error( - `[Session ${this.clientId}] Failed to record transcribed user message for agent ${agentId}:`, + `[Session ${this.clientId}] Failed to record transcribed user message for agent ${validAgentId}:`, recordError ); } // Send transcribed text to agent - this.startAgentStream(agentId, transcriptText); + this.startAgentStream(validAgentId, transcriptText); console.log( - `[Session ${this.clientId}] Sent transcribed text to agent ${agentId}` + `[Session ${this.clientId}] Sent transcribed text to agent ${validAgentId}` ); } catch (error: any) { console.error( - `[Session ${this.clientId}] Failed to process audio for agent ${agentId}:`, + `[Session ${this.clientId}] Failed to process audio for agent ${logAgentId}:`, error ); this.emit({ diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index 592e7e465..c706d3183 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -14,6 +14,7 @@ import type { AgentPersistenceHandle, AgentProvider, } from "../agent/agent-sdk-types.js"; +import { getAgentProviderDefinition } from "../agent/provider-manifest.js"; // ============================================================================ // Configuration @@ -147,6 +148,12 @@ export class DaemonClient { // Record the current queue position so we only check NEW messages const startPosition = this.messageQueue.length; + // Apply default modeId if not provided (mimics frontend behavior) + const modeId = + options.modeId ?? + getAgentProviderDefinition(options.provider).defaultModeId ?? + undefined; + this.send({ type: "create_agent_request", requestId, @@ -155,7 +162,7 @@ export class DaemonClient { cwd: options.cwd, title: options.title, model: options.model, - modeId: options.modeId, + modeId, mcpServers: options.mcpServers, extra: options.extra, }, @@ -209,6 +216,12 @@ export class DaemonClient { // Record the current queue position so we only check NEW messages const startPosition = this.messageQueue.length; + // Apply default modeId if not provided (mimics frontend behavior) + const modeId = + options.modeId ?? + getAgentProviderDefinition(options.provider).defaultModeId ?? + undefined; + this.send({ type: "create_agent_request", requestId, @@ -217,7 +230,7 @@ export class DaemonClient { cwd: options.cwd, title: options.title, model: options.model, - modeId: options.modeId, + modeId, mcpServers: options.mcpServers, extra: options.extra, }, diff --git a/test-list-persisted.ts b/test-list-persisted.ts new file mode 100644 index 000000000..80cd3223c --- /dev/null +++ b/test-list-persisted.ts @@ -0,0 +1,18 @@ +import { ClaudeAgentClient } from "./packages/server/src/server/agent/providers/claude-agent.js"; + +async function main() { + const client = new ClaudeAgentClient(); + const results = await client.listPersistedAgents({ limit: 30 }); + console.log("Total results:", results.length); + console.log("Results:"); + for (const r of results) { + console.log(` - ${r.sessionId.slice(0,8)} | ${r.cwd} | ${r.title?.slice(0,30)}`); + } + const target = results.find(r => r.sessionId.includes("0fea55e9")); + if (target) { + console.log("\nFound target session:", target.sessionId); + } else { + console.log("\nTarget session 0fea55e9 NOT FOUND in results"); + } +} +main().catch(console.error);