From dc26bf713e893e27fa126598dbd7a36afb97ef4a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 2 Dec 2025 08:12:02 +0000 Subject: [PATCH] feat(dictation): add resilient rpc retries and status toasts --- .../app/src/components/agent-input-area.tsx | 184 +++++++++- .../app/src/components/create-agent-modal.tsx | 233 +++++++++++- .../components/dictation-status-notice.tsx | 168 +++++++++ .../voice-note-recording-overlay.tsx | 32 +- packages/app/src/contexts/session-context.tsx | 4 + packages/app/src/hooks/use-dictation.ts | 343 ++++++++++++++++-- packages/app/src/hooks/use-session-rpc.ts | 280 +++++++++++--- packages/app/src/hooks/use-websocket.ts | 80 +++- 8 files changed, 1213 insertions(+), 111 deletions(-) create mode 100644 packages/app/src/components/dictation-status-notice.tsx diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 155944e7f..5f259f62c 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -24,6 +24,7 @@ import { useRealtime } from "@/contexts/realtime-context"; import { useDictation } from "@/hooks/use-dictation"; import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay"; +import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice"; import { generateMessageId } from "@/types/stream"; import { AgentStatusBar } from "./agent-status-bar"; import { RealtimeControls } from "./realtime-controls"; @@ -72,6 +73,16 @@ type TextAreaHandle = { } & Record; }; +type DictationToastConfig = { + variant: DictationToastVariant; + title: string; + subtitle?: string; + meta?: string; + actionLabel?: string; + onAction?: () => void; + onDismiss?: () => void; +}; + export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const { theme } = useUnistyles(); const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true }); @@ -85,6 +96,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { on: () => () => {}, sendPing: () => {}, sendUserMessage: () => {}, + subscribeConnectionStatus: () => () => {}, + getConnectionState: () => ({ isConnected: false, isConnecting: false }), }), [] ); @@ -111,6 +124,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const [selectedImages, setSelectedImages] = useState>([]); const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [audioDebugInfo, setAudioDebugInfo] = useState(null); + const [connectionStatus, setConnectionStatus] = useState(() => + ws.getConnectionState ? ws.getConnectionState() : { isConnected: ws.isConnected, isConnecting: ws.isConnecting } + ); + const [lastSuccessToastAt, setLastSuccessToastAt] = useState(null); const textInputRef = useRef unknown }) | null>(null); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); @@ -163,23 +180,25 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, []); const canStartDictation = useCallback(() => { - const allowed = !isRealtimeMode && ws.isConnected; + const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; + const allowed = !isRealtimeMode && socketConnected; console.log("[AgentInput] canStartDictation", { allowed, isRealtimeMode, - wsConnected: ws.isConnected, + wsConnected: socketConnected, }); return allowed; - }, [isRealtimeMode, ws.isConnected]); + }, [isRealtimeMode, ws]); const canConfirmDictation = useCallback(() => { - const allowed = ws.isConnected; + const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; + const allowed = socketConnected; console.log("[AgentInput] canConfirmDictation", { allowed, - wsConnected: ws.isConnected, + wsConnected: socketConnected, }); return allowed; - }, [ws.isConnected]); + }, [ws]); const { isRecording: isDictating, @@ -187,9 +206,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { volume: dictationVolume, duration: dictationDuration, pendingRequestId: dictationPendingRequestId, + error: dictationError, + status: dictationStatus, + retryAttempt: dictationRetryAttempt, + maxRetryAttempts: dictationMaxRetryAttempts, + retryInfo: dictationRetryInfo, + lastOutcome: dictationLastOutcome, startDictation, cancelDictation, confirmDictation, + retryFailedDictation, + discardFailedDictation, } = useDictation({ agentId, sendAgentAudio, @@ -208,9 +235,119 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, [dictationPendingRequestId]); useEffect(() => { - const shouldShowOverlay = isDictating || isDictationProcessing; + if (!ws.subscribeConnectionStatus) { + return; + } + return ws.subscribeConnectionStatus((status) => { + setConnectionStatus(status); + }); + }, [ws]); + + useEffect(() => { + if (dictationLastOutcome?.type === "success") { + setLastSuccessToastAt(dictationLastOutcome.timestamp); + } + }, [dictationLastOutcome]); + + useEffect(() => { + if (lastSuccessToastAt === null) { + return; + } + const timeout = setTimeout(() => { + setLastSuccessToastAt(null); + }, 4000); + return () => { + clearTimeout(timeout); + }; + }, [lastSuccessToastAt]); + + const successToastVisible = lastSuccessToastAt !== null; + + const handleRetryFailedRecording = useCallback(() => { + void retryFailedDictation(); + }, [retryFailedDictation]); + + const handleDiscardFailedRecording = useCallback(() => { + discardFailedDictation(); + }, [discardFailedDictation]); + + const dictationToast = useMemo(() => { + if (!connectionStatus.isConnected) { + return { + variant: "warning", + title: "Offline", + subtitle: "Waiting for connection…", + }; + } + + if (dictationStatus === "recording") { + return { + variant: "info", + title: "Recording voice note…", + subtitle: "Release to transcribe", + }; + } + + if (dictationStatus === "uploading") { + const attemptLabel = `Attempt ${Math.max(1, dictationRetryAttempt || 1)}/${dictationMaxRetryAttempts}`; + return { + variant: "info", + title: "Transcribing…", + meta: attemptLabel, + }; + } + + if (dictationStatus === "retrying") { + const attempt = dictationRetryInfo?.attempt ?? Math.max(1, dictationRetryAttempt || 1); + const maxAttempts = dictationRetryInfo?.maxAttempts ?? dictationMaxRetryAttempts; + const nextLabel = + dictationRetryInfo?.nextRetryMs && dictationRetryInfo.nextRetryMs > 0 + ? ` · Next in ${Math.ceil(dictationRetryInfo.nextRetryMs / 1000)}s` + : ""; + return { + variant: "warning", + title: "Retrying dictation…", + subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Network error", + meta: `Attempt ${attempt}/${maxAttempts}${nextLabel}`, + }; + } + + if (dictationStatus === "failed") { + return { + variant: "error", + title: "Dictation failed", + subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Unknown error", + actionLabel: "Retry", + onAction: handleRetryFailedRecording, + onDismiss: handleDiscardFailedRecording, + }; + } + + if (successToastVisible) { + return { + variant: "success", + title: "Transcribed", + subtitle: "Added to chat", + }; + } + + return null; + }, [ + connectionStatus.isConnected, + dictationError, + dictationMaxRetryAttempts, + dictationRetryAttempt, + dictationRetryInfo, + dictationStatus, + handleDiscardFailedRecording, + handleRetryFailedRecording, + successToastVisible, + ]); + + useEffect(() => { + const shouldShowOverlay = isDictating || isDictationProcessing || dictationStatus === "failed"; overlayTransition.value = withTiming(shouldShowOverlay ? 1 : 0, { duration: 250 }); - }, [isDictating, isDictationProcessing, overlayTransition]); + }, [dictationStatus, isDictating, isDictationProcessing, overlayTransition]); const debugInputHeight = (label: string, payload: Record) => { if (!SHOULD_DEBUG_INPUT_HEIGHT) { @@ -231,7 +368,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, [sendAgentMessage]); async function handleSendMessage() { - if (!userInput.trim() || !ws.isConnected) return; + const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; + if (!userInput.trim() || !socketConnected) return; const message = userInput.trim(); const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined; @@ -298,6 +436,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { isDictating, isDictationProcessing, }); + if (dictationStatus === "failed") { + handleDiscardFailedRecording(); + return; + } if (!isDictating && !isDictationProcessing) { return; } @@ -313,6 +455,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { isDictating, isDictationProcessing, }); + if (dictationStatus === "failed") { + handleRetryFailedRecording(); + return; + } if (!isDictating) { return; } @@ -927,10 +1073,20 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { onCancel={handleCancelRecording} onSend={handleSendRecording} isTranscribing={isDictationProcessing} + status={dictationStatus} + onRetry={dictationStatus === "failed" ? handleRetryFailedRecording : undefined} + onDiscardFailed={dictationStatus === "failed" ? handleDiscardFailedRecording : undefined} /> + {dictationToast ? ( + + + + + + ) : null} ); } @@ -938,6 +1094,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { const styles = StyleSheet.create(((theme: any) => ({ container: { flexDirection: "column", + position: "relative", + flex: 1, }, borderSeparator: { height: theme.borderWidth[1], @@ -969,8 +1127,14 @@ const styles = StyleSheet.create(((theme: any) => ({ left: 0, right: 0, bottom: 0, - height: FOOTER_HEIGHT, alignItems: "center", + paddingBottom: theme.spacing[4], + }, + dictationToastPortal: { + position: "absolute", + left: theme.spacing[4], + right: theme.spacing[4], + bottom: theme.spacing[4], }, imagePreviewContainer: { flexDirection: "row", diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index dfd4b47b9..bb52bc818 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -36,15 +36,17 @@ import Animated, { runOnJS, } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, Check, X, ChevronDown } from "lucide-react-native"; +import { Mic, Check, X, ChevronDown, RefreshCcw } 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 { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -81,6 +83,16 @@ interface AgentFlowModalProps { serverId?: string | null; } +type DictationToastConfig = { + variant: DictationToastVariant; + title: string; + subtitle?: string; + meta?: string; + actionLabel?: string; + onAction?: () => void; + onDismiss?: () => void; +}; + interface ModalWrapperProps { isVisible: boolean; onClose: () => void; @@ -274,6 +286,8 @@ function AgentFlowModal({ on: () => () => {}, sendPing: () => {}, sendUserMessage: () => {}, + subscribeConnectionStatus: () => () => {}, + getConnectionState: () => ({ isConnected: false, isConnecting: false }), }), [] ); @@ -322,7 +336,9 @@ function AgentFlowModal({ reset: resetRepoInfo, cancel: cancelRepoInfo, } = gitRepoInfoRequest; - const isWsConnected = ws?.isConnected ?? false; + const isWsConnected = effectiveWs.getConnectionState + ? effectiveWs.getConnectionState().isConnected + : effectiveWs.isConnected; const router = useRouter(); const sessionServerId = session?.serverId ?? null; const selectedDaemonId = selectedServerId ?? sessionServerId; @@ -392,6 +408,12 @@ function AgentFlowModal({ const [openDropdown, setOpenDropdown] = useState(null); const pendingRequestIdRef = useRef(null); const shouldSyncBaseBranchRef = useRef(true); + const [connectionStatus, setConnectionStatus] = useState(() => + effectiveWs.getConnectionState + ? effectiveWs.getConnectionState() + : { isConnected: effectiveWs.isConnected, isConnecting: effectiveWs.isConnecting } + ); + const [dictationSuccessToastAt, setDictationSuccessToastAt] = useState(null); const promptInputRef = useRef< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null >(null); @@ -459,10 +481,19 @@ function AgentFlowModal({ isProcessing: isDictationProcessing, volume: dictationVolume, pendingRequestId: dictationPendingRequestId, + error: dictationError, + status: dictationStatus, + retryAttempt: dictationRetryAttempt, + maxRetryAttempts: dictationMaxRetryAttempts, + retryInfo: dictationRetryInfo, + failedRecording: dictationFailedRecording, + lastOutcome: dictationLastOutcome, startDictation, cancelDictation, confirmDictation, reset: resetDictation, + retryFailedDictation, + discardFailedDictation, } = useDictation({ agentId: DICTATION_AGENT_ID, sendAgentAudio, @@ -480,6 +511,35 @@ function AgentFlowModal({ dictationRequestIdRef.current = dictationPendingRequestId; }, [dictationPendingRequestId]); + useEffect(() => { + if (!effectiveWs.subscribeConnectionStatus) { + return; + } + return effectiveWs.subscribeConnectionStatus((status) => { + setConnectionStatus(status); + }); + }, [effectiveWs]); + + useEffect(() => { + if (dictationLastOutcome?.type === "success") { + setDictationSuccessToastAt(dictationLastOutcome.timestamp); + } + }, [dictationLastOutcome]); + + useEffect(() => { + if (dictationSuccessToastAt === null) { + return; + } + const timeout = setTimeout(() => { + setDictationSuccessToastAt(null); + }, 4000); + return () => { + clearTimeout(timeout); + }; + }, [dictationSuccessToastAt]); + + const dictationSuccessToastVisible = dictationSuccessToastAt !== null; + const hasPendingCreateOrResume = pendingRequestIdRef.current !== null; const hasPendingDictation = dictationPendingRequestId !== null; const shouldListenForStatus = isVisible || hasPendingCreateOrResume; @@ -1038,6 +1098,10 @@ function AgentFlowModal({ console.log("[CreateAgentModal] handleDictationCancel", { isDictating, }); + if (dictationStatus === "failed") { + discardFailedDictation(); + return; + } if (!isDictating) { return; } @@ -1046,7 +1110,7 @@ function AgentFlowModal({ } catch (error) { console.error("[CreateAgentModal] Failed to cancel dictation:", error); } - }, [cancelDictation, isDictating]); + }, [cancelDictation, dictationStatus, discardFailedDictation, isDictating]); const handleDictationConfirm = useCallback(async () => { console.log("[CreateAgentModal] handleDictationConfirm", { @@ -1055,6 +1119,10 @@ function AgentFlowModal({ isTargetDaemonReady, hasSendAgentAudio, }); + if (dictationStatus === "failed") { + void retryFailedDictation(); + return; + } if (!isDictating || isDictationProcessing) { return; } @@ -1074,11 +1142,94 @@ function AgentFlowModal({ }, [ daemonAvailabilityError, confirmDictation, + dictationStatus, hasSendAgentAudio, isDictating, isDictationProcessing, isTargetDaemonReady, logOfflineDaemonAction, + retryFailedDictation, + ]); + + const handlePromptDictationRetry = useCallback(() => { + void retryFailedDictation(); + }, [retryFailedDictation]); + + const handlePromptDictationDiscard = useCallback(() => { + discardFailedDictation(); + }, [discardFailedDictation]); + + const promptDictationToast = useMemo(() => { + if (!connectionStatus.isConnected) { + return { + variant: "warning", + title: "Offline", + subtitle: "Waiting for connection…", + }; + } + + if (dictationStatus === "recording") { + return { + variant: "info", + title: "Recording prompt…", + subtitle: "Release to insert transcription", + }; + } + + if (dictationStatus === "uploading") { + const attemptLabel = `Attempt ${Math.max(1, dictationRetryAttempt || 1)}/${dictationMaxRetryAttempts}`; + return { + variant: "info", + title: "Transcribing prompt…", + meta: attemptLabel, + }; + } + + 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, + }; + } + + if (dictationSuccessToastVisible) { + return { + variant: "success", + title: "Transcribed", + subtitle: "Inserted into prompt", + }; + } + + return null; + }, [ + connectionStatus.isConnected, + dictationError, + dictationMaxRetryAttempts, + dictationRetryAttempt, + dictationRetryInfo, + dictationStatus, + dictationSuccessToastVisible, + handlePromptDictationDiscard, + handlePromptDictationRetry, ]); const navigateToAgentIfNeeded = useCallback(() => { @@ -1727,6 +1878,13 @@ function AgentFlowModal({ onStart={handleDictationStart} onCancel={handleDictationCancel} onConfirm={handleDictationConfirm} + status={dictationStatus} + retryAttempt={dictationRetryAttempt} + maxRetryAttempts={dictationMaxRetryAttempts} + retryCountdownMs={dictationRetryInfo?.nextRetryMs} + errorMessage={dictationRetryInfo?.errorMessage ?? dictationError ?? undefined} + onRetry={handlePromptDictationRetry} + onDiscard={handlePromptDictationDiscard} /> ) : null; @@ -2099,6 +2257,13 @@ function AgentFlowModal({ )} + {promptDictationToast ? ( + + + + + + ) : null} ) : ( void; onCancel: () => void; onConfirm: () => void; + status: DictationStatus; + retryAttempt: number; + maxRetryAttempts: number; + retryCountdownMs?: number | null; + errorMessage?: string | null; + onRetry?: () => void; + onDiscard?: () => void; } function PromptDictationControls({ @@ -2773,10 +2945,23 @@ function PromptDictationControls({ onStart, onCancel, onConfirm, + status, + retryAttempt, + maxRetryAttempts, + retryCountdownMs, + errorMessage, + onRetry, + onDiscard, }: PromptDictationControlsProps): ReactElement { const { theme } = useUnistyles(); - if (!isRecording && !isProcessing) { + 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 ( - {isProcessing ? ( + {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"} + + )} ); } @@ -3092,6 +3290,7 @@ const styles = StyleSheet.create(((theme: any) => ({ }, content: { flex: 1, + position: "relative", }, header: { paddingBottom: theme.spacing[4], @@ -3120,6 +3319,15 @@ 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], }, @@ -3319,6 +3527,11 @@ const styles = StyleSheet.create(((theme: any) => ({ 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-status-notice.tsx b/packages/app/src/components/dictation-status-notice.tsx new file mode 100644 index 000000000..442ac7dd5 --- /dev/null +++ b/packages/app/src/components/dictation-status-notice.tsx @@ -0,0 +1,168 @@ +import { View, Text, Pressable } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native"; + +export type DictationToastVariant = "info" | "success" | "warning" | "error"; + +interface DictationStatusNoticeProps { + variant: DictationToastVariant; + title: string; + subtitle?: string; + meta?: string; + actionLabel?: string; + onAction?: () => void; + onDismiss?: () => void; +} + +const variantIconMap: Record = { + info: RefreshCcw, + success: CheckCircle2, + warning: AlertTriangle, + error: AlertTriangle, +}; + +export function DictationStatusNotice({ + variant, + title, + subtitle, + meta, + actionLabel, + onAction, + onDismiss, +}: DictationStatusNoticeProps) { + const { theme } = useUnistyles(); + + const VariantIcon = (() => { + if (variant === "warning" && title.toLowerCase().includes("offline")) { + return WifiOff; + } + return variantIconMap[variant] ?? Info; + })(); + + const backgroundColor = (() => { + switch (variant) { + case "success": + return theme.colors.palette.green[500]; + case "warning": + return theme.colors.palette.amber[500]; + case "error": + return theme.colors.palette.red[500]; + default: + return theme.colors.background; + } + })(); + + const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white; + const secondaryColor = variant === "info" ? theme.colors.mutedForeground : theme.colors.palette.white; + + return ( + + + + + {title} + + {onDismiss ? ( + + + + ) : null} + + + {subtitle ? ( + {subtitle} + ) : null} + + {(meta || (actionLabel && onAction)) && ( + + {meta ? ( + {meta} + ) : ( + + )} + {actionLabel && onAction ? ( + + + + {actionLabel} + + + ) : null} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: theme.borderRadius.xl, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + gap: theme.spacing[2], + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + title: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + subtitle: { + fontSize: theme.fontSize.sm, + }, + actionsRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + meta: { + fontSize: theme.fontSize.xs, + }, + actionButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.full, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + }, + actionText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, +})); diff --git a/packages/app/src/components/voice-note-recording-overlay.tsx b/packages/app/src/components/voice-note-recording-overlay.tsx index e0a1d0f96..77886d7d1 100644 --- a/packages/app/src/components/voice-note-recording-overlay.tsx +++ b/packages/app/src/components/voice-note-recording-overlay.tsx @@ -1,8 +1,9 @@ import { View, Text, Pressable, ActivityIndicator } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { X, ArrowUp } from "lucide-react-native"; +import { X, ArrowUp, RefreshCcw } from "lucide-react-native"; import { VolumeMeter } from "./volume-meter"; import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context"; +import type { DictationStatus } from "@/hooks/use-dictation"; interface VoiceNoteRecordingOverlayProps { volume: number; @@ -10,6 +11,9 @@ interface VoiceNoteRecordingOverlayProps { onCancel: () => void; onSend: () => void; isTranscribing?: boolean; + status?: DictationStatus; + onRetry?: () => void; + onDiscardFailed?: () => void; } function formatDuration(seconds: number): string { @@ -24,13 +28,25 @@ export function VoiceNoteRecordingOverlay({ 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 */} - + @@ -49,9 +65,17 @@ export function VoiceNoteRecordingOverlay({ {/* Send button */} - - {isTranscribing ? ( + + {isRetrying ? ( + ) : isTranscribing ? ( + + ) : isFailed ? ( + ) : ( )} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index c4cf65dda..17bfd4fbd 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1521,6 +1521,10 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid options?: { mode?: "transcribe_only" | "auto_run" } ) => { try { + const isSocketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; + if (!isSocketConnected) { + throw new Error("WebSocket is disconnected"); + } // Convert blob to base64 const arrayBuffer = await audioBlob.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index 4ad5b73ca..794d54677 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -3,9 +3,33 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { SessionContextValue } from "@/contexts/session-context"; import { useAudioRecorder } from "@/hooks/use-audio-recorder"; import { useSessionRpc } from "@/hooks/use-session-rpc"; +import type { RpcFailureReason, RpcRetryAttemptEvent } from "@/hooks/use-session-rpc"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import { generateMessageId } from "@/types/stream"; +export type DictationStatus = "idle" | "recording" | "uploading" | "retrying" | "failed"; + +export type DictationRetryInfo = { + attempt: number; + maxAttempts: number; + reason: RpcFailureReason; + errorMessage: string; + nextRetryMs: number; +}; + +export type FailedDictationRecording = { + requestId: string; + durationSeconds: number; + sizeBytes: number; + format: string; + recordedAt: number; + errorMessage: string; +}; + +export type DictationOutcome = + | { type: "success"; requestId: string; timestamp: number } + | { type: "failure"; requestId: string; errorMessage: string; timestamp: number }; + export type UseDictationOptions = { agentId: string; sendAgentAudio: SessionContextValue["sendAgentAudio"]; @@ -13,6 +37,8 @@ export type UseDictationOptions = { mode?: "transcribe_only" | "auto_run"; onTranscript: (text: string, meta: { requestId: string }) => void; onError?: (error: Error) => void; + onRetryAttempt?: (info: DictationRetryInfo) => void; + onPermanentFailure?: (error: Error, context: { requestId: string }) => void; canStart?: () => boolean; canConfirm?: () => boolean; autoStopWhenHidden?: { isVisible: boolean }; @@ -26,13 +52,27 @@ export type UseDictationResult = { duration: number; pendingRequestId: string | null; error: string | null; + status: DictationStatus; + retryAttempt: number; + maxRetryAttempts: number; + retryInfo: DictationRetryInfo | null; + failedRecording: FailedDictationRecording | null; + lastOutcome: DictationOutcome | null; startDictation: () => Promise; cancelDictation: () => Promise; confirmDictation: () => Promise; + retryFailedDictation: () => Promise; + discardFailedDictation: () => void; reset: () => void; }; const DURATION_TICK_MS = 1000; +const MAX_AUTO_RETRY_ATTEMPTS = 5; +const RETRY_BASE_DELAY_MS = 2000; +const RETRY_MAX_DELAY_MS = 12000; +const RETRY_BACKOFF_FACTOR = 1.8; +const RETRY_JITTER_MS = 400; +const TRANSCRIPTION_TIMEOUT_MS = 120000; const toError = (error: unknown): Error => { if (error instanceof Error) { @@ -44,6 +84,35 @@ const toError = (error: unknown): Error => { return new Error("An unexpected error occurred while handling dictation."); }; +type CapturedAudioPayload = { + blob: Blob; + format: string; + sizeBytes: number; + durationSeconds: number; + recordedAt: number; +}; + +const deriveFormatFromMime = (mimeType?: string): string => { + if (!mimeType || mimeType.length === 0) { + return "webm"; + } + const slashIndex = mimeType.indexOf("/"); + let formatPart = slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType; + const semicolonIndex = formatPart.indexOf(";"); + if (semicolonIndex >= 0) { + formatPart = formatPart.slice(0, semicolonIndex); + } + return formatPart.trim().length > 0 ? formatPart.trim() : "webm"; +}; + +const buildCapturedAudioPayload = (blob: Blob, durationSeconds: number): CapturedAudioPayload => ({ + blob, + format: deriveFormatFromMime(blob.type), + sizeBytes: typeof blob.size === "number" ? blob.size : 0, + durationSeconds, + recordedAt: Date.now(), +}); + export function useDictation(options: UseDictationOptions): UseDictationResult { const { agentId, @@ -52,6 +121,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { mode = "transcribe_only", onTranscript, onError, + onRetryAttempt, + onPermanentFailure, canStart, canConfirm, autoStopWhenHidden, @@ -64,6 +135,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const [duration, setDuration] = useState(0); const [pendingRequestId, setPendingRequestId] = useState(null); const [error, setError] = useState(null); + const [status, setStatus] = useState("idle"); + const [retryAttempt, setRetryAttempt] = useState(0); + const [retryInfo, setRetryInfo] = useState(null); + const [failedRecording, setFailedRecording] = useState(null); + const [lastOutcome, setLastOutcome] = useState(null); + const maxRetryAttempts = MAX_AUTO_RETRY_ATTEMPTS; const { waitForResponse: waitForTranscriptionResponse, reset: resetTranscriptionRpc } = useSessionRpc({ ws, @@ -71,10 +148,16 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { responseType: "transcription_result", }); + const pendingAudioRef = useRef(null); const handleAudioLevel = useCallback((level: number) => { setVolume(level); }, []); + const durationRef = useRef(0); + useEffect(() => { + durationRef.current = duration; + }, [duration]); + const recorder = useAudioRecorder({ onAudioLevel: handleAudioLevel }); const recorderRef = useRef(recorder); useEffect(() => { @@ -91,6 +174,16 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { onErrorRef.current = onError; }, [onError]); + const onRetryAttemptRef = useRef(onRetryAttempt); + useEffect(() => { + onRetryAttemptRef.current = onRetryAttempt; + }, [onRetryAttempt]); + + const onPermanentFailureRef = useRef(onPermanentFailure); + useEffect(() => { + onPermanentFailureRef.current = onPermanentFailure; + }, [onPermanentFailure]); + const isRecordingRef = useRef(isRecording); useEffect(() => { isRecordingRef.current = isRecording; @@ -160,6 +253,137 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { return stopPromise; }, []); + const transmitDictation = useCallback( + async (requestId: string) => { + const capturedAudio = pendingAudioRef.current; + if (!capturedAudio) { + throw new Error("No recorded audio available for transcription"); + } + + setRetryAttempt(1); + setRetryInfo(null); + + console.info("[useDictation] sending transcription request", { + requestId, + attempt: 1, + agentId, + size: capturedAudio.sizeBytes, + durationSeconds: capturedAudio.durationSeconds, + }); + + const transcription = await waitForTranscriptionResponse({ + requestId, + dispatch: async (_id, attempt) => { + setStatus("uploading"); + setRetryAttempt(attempt); + await sendAgentAudio(agentId, capturedAudio.blob, requestId, { mode }); + }, + retry: { + maxAttempts: MAX_AUTO_RETRY_ATTEMPTS, + baseDelayMs: RETRY_BASE_DELAY_MS, + maxDelayMs: RETRY_MAX_DELAY_MS, + backoffFactor: RETRY_BACKOFF_FACTOR, + jitterMs: RETRY_JITTER_MS, + shouldRetry: ({ attempt, maxAttempts }) => attempt < maxAttempts, + onRetryAttempt: (event: RpcRetryAttemptEvent) => { + setStatus("retrying"); + setRetryAttempt(event.attempt); + const info: DictationRetryInfo = { + attempt: event.attempt, + maxAttempts: event.maxAttempts, + reason: event.reason, + errorMessage: event.error.message, + nextRetryMs: event.nextDelayMs, + }; + setRetryInfo(info); + onRetryAttemptRef.current?.(info); + console.warn("[useDictation] retry scheduled", { + requestId, + attempt: event.attempt, + maxAttempts: event.maxAttempts, + reason: event.reason, + error: event.error.message, + nextRetryMs: event.nextDelayMs, + }); + }, + }, + timeoutMs: TRANSCRIPTION_TIMEOUT_MS, + }); + + return transcription; + }, + [agentId, mode, onRetryAttemptRef, sendAgentAudio, waitForTranscriptionResponse] + ); + + const handleTranscriptionSuccess = useCallback( + (transcription: Awaited>, requestId: string) => { + pendingRequestIdRef.current = null; + setPendingRequestId(null); + setIsProcessing(false); + setStatus("idle"); + setRetryAttempt(0); + setRetryInfo(null); + setFailedRecording(null); + pendingAudioRef.current = null; + setLastOutcome({ type: "success", requestId, timestamp: Date.now() }); + + const transcriptText = transcription.text?.trim(); + if (!transcriptText) { + return; + } + + console.log("[useDictation] transcription_result received", { + requestId, + textLength: transcriptText.length, + }); + onTranscriptRef.current?.(transcriptText, { + requestId: transcription.requestId ?? requestId, + }); + }, + [onTranscriptRef] + ); + + const handleDictationFailure = useCallback( + (failure: unknown, requestId: string | null) => { + const normalized = toError(failure); + pendingRequestIdRef.current = null; + setPendingRequestId(null); + setIsProcessing(false); + isRecordingRef.current = false; + setIsRecording(false); + setVolume(0); + setRetryInfo(null); + + const capturedAudio = pendingAudioRef.current; + if (capturedAudio) { + setStatus("failed"); + setFailedRecording({ + requestId: requestId ?? generateMessageId(), + durationSeconds: capturedAudio.durationSeconds, + sizeBytes: capturedAudio.sizeBytes, + format: capturedAudio.format, + recordedAt: capturedAudio.recordedAt, + errorMessage: normalized.message, + }); + if (requestId) { + onPermanentFailureRef.current?.(normalized, { requestId }); + } + } else { + setStatus("idle"); + } + + setRetryAttempt(0); + setLastOutcome({ + type: "failure", + requestId: requestId ?? generateMessageId(), + errorMessage: normalized.message, + timestamp: Date.now(), + }); + reportError(normalized, "Failed to complete dictation"); + }, + [onPermanentFailureRef, reportError] + ); + const startDictation = useCallback(async () => { console.log("[useDictation] startDictation requested", { isRecording: isRecordingRef.current, @@ -182,6 +406,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setVolume(0); setDuration(0); setIsProcessing(false); + setStatus("recording"); + setRetryAttempt(0); + setRetryInfo(null); + setFailedRecording(null); + pendingAudioRef.current = null; + setLastOutcome(null); pendingRequestIdRef.current = null; setPendingRequestId(null); @@ -234,6 +464,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setIsRecording(false); setIsProcessing(false); setVolume(0); + setStatus("idle"); + setRetryAttempt(0); + setRetryInfo(null); + pendingAudioRef.current = null; + setFailedRecording(null); + setLastOutcome(null); } }, [reportError, stopDurationTracking, stopRecorder]); @@ -259,61 +495,85 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { stopDurationTracking(); setDuration(0); setIsProcessing(true); + setRetryInfo(null); + setRetryAttempt(0); + setLastOutcome(null); + let requestId: string | null = null; try { const audioData = await stopRecorder(); + const recordedDurationSeconds = durationRef.current; + pendingAudioRef.current = buildCapturedAudioPayload(audioData, recordedDurationSeconds); + setStatus("uploading"); isRecordingRef.current = false; setIsRecording(false); setVolume(0); - const requestId = generateMessageId(); + requestId = generateMessageId(); pendingRequestIdRef.current = requestId; setPendingRequestId(requestId); - const transcription = await waitForTranscriptionResponse({ - requestId, - dispatch: () => sendAgentAudio(agentId, audioData, requestId, { mode }), - }); - pendingRequestIdRef.current = null; - setPendingRequestId(null); - setIsProcessing(false); - - const transcriptText = transcription.text?.trim(); - if (!transcriptText) { - return; - } - - console.log("[useDictation] transcription_result received", { - requestId, - textLength: transcriptText.length, - }); - onTranscriptRef.current?.(transcriptText, { - requestId: transcription.requestId ?? requestId, - }); + const transcription = await transmitDictation(requestId); + handleTranscriptionSuccess(transcription, requestId); } catch (err) { - pendingRequestIdRef.current = null; - setPendingRequestId(null); - setIsProcessing(false); - isRecordingRef.current = false; - setIsRecording(false); - setVolume(0); resetTranscriptionRpc(); - reportError(err, "Failed to complete dictation"); + handleDictationFailure(err, requestId); } }, [ agentId, canConfirm, isProcessing, mode, - onTranscriptRef, - reportError, + handleDictationFailure, + handleTranscriptionSuccess, resetTranscriptionRpc, - sendAgentAudio, stopDurationTracking, stopRecorder, - waitForTranscriptionResponse, + transmitDictation, ]); + const retryFailedDictation = useCallback(async () => { + if (!pendingAudioRef.current) { + return; + } + setError(null); + setRetryInfo(null); + setRetryAttempt(0); + setStatus("uploading"); + setIsProcessing(true); + setLastOutcome(null); + + const requestId = generateMessageId(); + pendingRequestIdRef.current = requestId; + setPendingRequestId(requestId); + + try { + const transcription = await transmitDictation(requestId); + handleTranscriptionSuccess(transcription, requestId); + } catch (err) { + resetTranscriptionRpc(); + handleDictationFailure(err, requestId); + } + }, [ + handleDictationFailure, + handleTranscriptionSuccess, + resetTranscriptionRpc, + transmitDictation, + ]); + + const discardFailedDictation = useCallback(() => { + pendingAudioRef.current = null; + pendingRequestIdRef.current = null; + setPendingRequestId(null); + setIsProcessing(false); + setFailedRecording(null); + setStatus("idle"); + setRetryAttempt(0); + setRetryInfo(null); + setError(null); + setLastOutcome(null); + }, []); + const reset = useCallback(() => { pendingRequestIdRef.current = null; setPendingRequestId(null); @@ -324,6 +584,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setDuration(0); setVolume(0); setError(null); + setStatus("idle"); + setRetryAttempt(0); + setRetryInfo(null); + setFailedRecording(null); + pendingAudioRef.current = null; + setLastOutcome(null); resetTranscriptionRpc(); }, [resetTranscriptionRpc, stopDurationTracking]); @@ -352,6 +618,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { pendingRequestIdRef.current = null; setPendingRequestId(null); setIsProcessing(false); + pendingAudioRef.current = null; + setStatus("idle"); + setRetryAttempt(0); + setRetryInfo(null); + setFailedRecording(null); resetTranscriptionRpc(); }, [agentId, resetTranscriptionRpc]); @@ -377,9 +648,17 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { duration, pendingRequestId, error, + status, + retryAttempt, + maxRetryAttempts, + retryInfo, + failedRecording, + lastOutcome, startDictation, cancelDictation, confirmDictation, + retryFailedDictation, + discardFailedDictation, reset, }; } diff --git a/packages/app/src/hooks/use-session-rpc.ts b/packages/app/src/hooks/use-session-rpc.ts index 27b3f5a7c..4f4ea43fe 100644 --- a/packages/app/src/hooks/use-session-rpc.ts +++ b/packages/app/src/hooks/use-session-rpc.ts @@ -3,6 +3,21 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "@server/serv import type { UseWebSocketReturn } from "./use-websocket"; import { generateMessageId } from "@/types/stream"; +const DEFAULT_BASE_DELAY_MS = 1000; +const DEFAULT_MAX_DELAY_MS = 15000; +const DEFAULT_BACKOFF_FACTOR = 2; +const DEFAULT_JITTER_MS = 250; + +const toError = (value: unknown): Error => { + if (value instanceof Error) { + return value; + } + if (typeof value === "string") { + return new Error(value); + } + return new Error("Unexpected RPC error"); +}; + type RequestType = SessionInboundMessage["type"]; type ResponseType = SessionOutboundMessage["type"]; @@ -30,14 +45,100 @@ type SelectResponse = (message: ResponseWithE type DispatchRequest = (request: RequestOf) => void | Promise; +type DispatchOverride = (requestId: string, attempt: number) => void | Promise; + +export type RpcFailureReason = "dispatch" | "timeout" | "response" | "disconnected"; + +export interface RpcRetryContext { + requestId: string; + attempt: number; + maxAttempts: number; + reason: RpcFailureReason; + error: Error; +} + +export interface RpcRetryAttemptEvent extends RpcRetryContext { + nextDelayMs: number; +} + +export interface RpcRetryOptions { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + backoffFactor?: number; + jitterMs?: number; + shouldRetry?: (context: RpcRetryContext) => boolean; + onRetryAttempt?: (event: RpcRetryAttemptEvent) => void; +} + +export class RpcRequestError extends Error { + public readonly reason: RpcFailureReason; + public readonly attempt: number; + public readonly maxAttempts: number; + public readonly requestId: string | null; + + constructor(message: string, options: { reason: RpcFailureReason; attempt: number; maxAttempts: number; requestId?: string | null; cause?: unknown }) { + super(message); + this.name = "RpcRequestError"; + this.reason = options.reason; + this.attempt = options.attempt; + this.maxAttempts = options.maxAttempts; + this.requestId = options.requestId ?? null; + if (options.cause !== undefined) { + (this as Error & { cause?: unknown }).cause = options.cause; + } + } +} + +interface ResolvedRetryOptions { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; + backoffFactor: number; + jitterMs: number; + shouldRetry: NonNullable; + onRetryAttempt?: RpcRetryOptions["onRetryAttempt"]; +} + +const resolveRetryOptions = (retry: RpcRetryOptions | undefined, canRetry: boolean): ResolvedRetryOptions => { + const maxAttempts = Math.max(1, retry?.maxAttempts ?? 1); + const resolved: ResolvedRetryOptions = { + maxAttempts: canRetry ? maxAttempts : 1, + baseDelayMs: retry?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, + maxDelayMs: retry?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS, + backoffFactor: retry?.backoffFactor ?? DEFAULT_BACKOFF_FACTOR, + jitterMs: retry?.jitterMs ?? DEFAULT_JITTER_MS, + shouldRetry: retry?.shouldRetry ?? (() => true), + onRetryAttempt: retry?.onRetryAttempt, + }; + return resolved; +}; + +const computeDelayMs = (attempt: number, options: ResolvedRetryOptions): number => { + const exponential = options.baseDelayMs * options.backoffFactor ** Math.max(0, attempt - 1); + const capped = Math.min(options.maxDelayMs, exponential); + if (options.jitterMs <= 0) { + return capped; + } + const jitter = Math.floor(Math.random() * options.jitterMs); + return capped + jitter; +}; + type WaitForResponseOptions = { requestId: string; - dispatch?: (requestId: string) => void | Promise; + dispatch?: DispatchOverride; + retry?: RpcRetryOptions; + timeoutMs?: number | null; +}; + +type SendOptions = { + retry?: RpcRetryOptions; + timeoutMs?: number | null; }; type UseSessionRpcReturn = { state: RpcState; - send: (params: Omit, "type" | "requestId">) => Promise; + send: (params: Omit, "type" | "requestId">, options?: SendOptions) => Promise; waitForResponse: (options: WaitForResponseOptions) => Promise; reset: () => void; }; @@ -57,18 +158,39 @@ export function useSessionRpc< const [state, setState] = useState>({ status: "idle", requestId: null }); const activeRequestIdRef = useRef(null); const resolveRef = useRef<((value: TData) => void) | null>(null); - const rejectRef = useRef<((reason?: any) => void) | null>(null); + const rejectRef = useRef<((error: Error) => void) | null>(null); const dispatchRef = useRef | undefined>(dispatch); + const timeoutHandleRef = useRef | null>(null); + const retryOptionsRef = useRef(null); + const failureHandlerRef = useRef<((reason: RpcFailureReason, error: Error) => void) | null>(null); + const currentAttemptRef = useRef(0); useEffect(() => { dispatchRef.current = dispatch; }, [dispatch]); + const clearTimeoutHandle = useCallback(() => { + if (timeoutHandleRef.current) { + clearTimeout(timeoutHandleRef.current); + timeoutHandleRef.current = null; + } + }, []); + const clearActiveRequest = useCallback(() => { + clearTimeoutHandle(); activeRequestIdRef.current = null; resolveRef.current = null; rejectRef.current = null; - }, []); + retryOptionsRef.current = null; + failureHandlerRef.current = null; + currentAttemptRef.current = 0; + }, [clearTimeoutHandle]); + + useEffect(() => { + return () => { + clearActiveRequest(); + }; + }, [clearActiveRequest]); useEffect(() => { const unsubscribe = ws.on(responseType, (message) => { @@ -79,15 +201,15 @@ export function useSessionRpc< return; } + clearTimeoutHandle(); + const payloadError = payload && typeof payload === "object" && "error" in payload && typeof (payload as any).error === "string" ? ((payload as any).error as string) : null; if (payloadError) { const error = new Error(payloadError); - setState({ status: "error", requestId: payload.requestId ?? null, error }); - rejectRef.current?.(error); - clearActiveRequest(); + failureHandlerRef.current?.("response", error); return; } @@ -101,59 +223,129 @@ export function useSessionRpc< return () => { unsubscribe(); }; - }, [clearActiveRequest, responseType, select, ws]); + }, [clearActiveRequest, clearTimeoutHandle, responseType, select, ws]); useEffect(() => { - if (ws.isConnected || !activeRequestIdRef.current) { - return; + if (ws.subscribeConnectionStatus) { + return ws.subscribeConnectionStatus((status) => { + if (status.isConnected || !activeRequestIdRef.current || !failureHandlerRef.current) { + return; + } + failureHandlerRef.current("disconnected", new Error("WebSocket disconnected")); + }); } - const error = new Error("WebSocket disconnected"); - setState({ status: "error", requestId: activeRequestIdRef.current, error }); - rejectRef.current?.(error); - clearActiveRequest(); - }, [clearActiveRequest, ws.isConnected]); + if (!ws.isConnected && activeRequestIdRef.current && failureHandlerRef.current) { + failureHandlerRef.current("disconnected", new Error("WebSocket disconnected")); + } + }, [ws.isConnected, ws.subscribeConnectionStatus]); const waitForResponse = useCallback( - ({ requestId, dispatch: dispatchOverride }: WaitForResponseOptions) => { + ({ requestId, dispatch: dispatchOverride, retry, timeoutMs = null }: WaitForResponseOptions) => { return new Promise((resolve, reject) => { - if (!ws.isConnected) { - const error = new Error("WebSocket is disconnected"); - setState({ status: "error", requestId: null, error }); - reject(error); - return; - } + const finalDispatch = dispatchOverride ?? null; + const canRetry = typeof finalDispatch === "function"; + const resolvedRetry = resolveRetryOptions(retry, canRetry); activeRequestIdRef.current = requestId; - resolveRef.current = resolve; - rejectRef.current = reject; - setState({ status: "loading", requestId }); - - if (!dispatchOverride) { - return; - } - - const handleDispatchError = (error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)); - setState({ status: "error", requestId, error: err }); - reject(err); + resolveRef.current = (value) => { + resolve(value); + }; + rejectRef.current = (error) => { + reject(error); clearActiveRequest(); }; + retryOptionsRef.current = resolvedRetry; + setState({ status: "loading", requestId }); - try { - const maybePromise = dispatchOverride(requestId); - if (maybePromise && typeof (maybePromise as Promise).then === "function") { - (maybePromise as Promise).catch(handleDispatchError); + const finalizeError = (reason: RpcFailureReason, error: Error, attempt: number) => { + const rpcError = new RpcRequestError(error.message, { + reason, + attempt, + maxAttempts: resolvedRetry.maxAttempts, + requestId, + cause: error, + }); + setState({ status: "error", requestId, error: rpcError }); + rejectRef.current?.(rpcError); + }; + + const scheduleAttempt = (attemptNumber: number) => { + currentAttemptRef.current = attemptNumber; + + const runDispatch = async () => { + if (!ws.isConnected) { + throw new Error("WebSocket is disconnected"); + } + + if (finalDispatch) { + await finalDispatch(requestId, attemptNumber); + } + + if (timeoutMs !== null) { + clearTimeoutHandle(); + timeoutHandleRef.current = setTimeout(() => { + failureHandlerRef.current?.("timeout", new Error("RPC request timed out")); + }, timeoutMs); + } + }; + + runDispatch().catch((error) => { + failureHandlerRef.current?.("dispatch", toError(error)); + }); + }; + + const handleFailure = (reason: RpcFailureReason, rawError: Error) => { + const attempt = currentAttemptRef.current || 1; + const normalized = toError(rawError); + const options = retryOptionsRef.current; + if (!options) { + finalizeError(reason, normalized, attempt); + return; } - } catch (error) { - handleDispatchError(error); - } + + const withinLimit = attempt < options.maxAttempts; + const shouldRetry = withinLimit && options.shouldRetry({ + requestId, + attempt, + maxAttempts: options.maxAttempts, + reason, + error: normalized, + }); + + if (!shouldRetry) { + finalizeError(reason, normalized, attempt); + return; + } + + const nextAttempt = attempt + 1; + const delay = computeDelayMs(nextAttempt, options); + options.onRetryAttempt?.({ + requestId, + attempt: nextAttempt, + maxAttempts: options.maxAttempts, + reason, + error: normalized, + nextDelayMs: delay, + }); + + clearTimeoutHandle(); + setTimeout(() => { + scheduleAttempt(nextAttempt); + }, delay); + }; + + failureHandlerRef.current = (reason, error) => { + handleFailure(reason, toError(error)); + }; + + scheduleAttempt(1); }); }, - [clearActiveRequest, ws.isConnected] + [clearActiveRequest, clearTimeoutHandle, ws.isConnected] ); const send = useCallback( - (params: Omit, "type" | "requestId">) => { + (params: Omit, "type" | "requestId">, options?: SendOptions) => { const dispatchRequest = dispatchRef.current; return waitForResponse({ requestId: generateMessageId(), @@ -168,6 +360,8 @@ export function useSessionRpc< } ws.send({ type: "session", message: request }); }, + retry: options?.retry, + timeoutMs: options?.timeoutMs, }); }, [requestType, waitForResponse, ws] diff --git a/packages/app/src/hooks/use-websocket.ts b/packages/app/src/hooks/use-websocket.ts index c1a53218f..3dd9802ff 100644 --- a/packages/app/src/hooks/use-websocket.ts +++ b/packages/app/src/hooks/use-websocket.ts @@ -6,6 +6,11 @@ import type { SessionOutboundMessage, } from "@server/server/messages"; +export interface ConnectionStatusSnapshot { + isConnected: boolean; + isConnecting: boolean; +} + export interface UseWebSocketReturn { isConnected: boolean; isConnecting: boolean; @@ -18,6 +23,8 @@ export interface UseWebSocketReturn { ) => () => void; sendPing: () => void; sendUserMessage: (message: string) => void; + subscribeConnectionStatus?: (listener: (status: ConnectionStatusSnapshot) => void) => () => void; + getConnectionState?: () => ConnectionStatusSnapshot; } const RECONNECT_BASE_DELAY_MS = 1500; @@ -34,6 +41,25 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe const reconnectTimeoutRef = useRef | undefined>(undefined); const reconnectAttemptRef = useRef(0); const shouldReconnectRef = useRef(true); + const connectionListenersRef = useRef(new Set<(status: ConnectionStatusSnapshot) => void>()); + const connectionStateRef = useRef({ isConnected: false, isConnecting: true }); + + const notifyConnectionListeners = useCallback((state: ConnectionStatusSnapshot) => { + connectionStateRef.current = state; + for (const listener of connectionListenersRef.current) { + try { + listener(state); + } catch (error) { + console.error("[WS] Connection listener error", error); + } + } + }, []); + + const updateConnectionState = useCallback((state: ConnectionStatusSnapshot) => { + setIsConnected(state.isConnected); + setIsConnecting(state.isConnecting); + notifyConnectionListeners(state); + }, [notifyConnectionListeners]); const connect = useCallback(() => { if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { @@ -60,8 +86,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe setLastError(reason.trim()); } - setIsConnected(false); - setIsConnecting(false); + updateConnectionState({ isConnected: false, isConnecting: false }); const attempt = reconnectAttemptRef.current; const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS); @@ -76,22 +101,21 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe if (!shouldReconnectRef.current) { return; } - setIsConnecting(true); - connect(); - }, delay); - }; + updateConnectionState({ isConnected: false, isConnecting: true }); + connect(); + }, delay); + }; try { // Add conversation ID to URL if provided const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url; const ws = new WebSocket(wsUrl); wsRef.current = ws; - setIsConnecting(true); + updateConnectionState({ isConnected: false, isConnecting: true }); ws.onopen = () => { console.log("[WS] Connected to server"); - setIsConnected(true); - setIsConnecting(false); + updateConnectionState({ isConnected: true, isConnecting: false }); setLastError(null); reconnectAttemptRef.current = 0; }; @@ -102,7 +126,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe typeof event?.reason === "string" && event.reason.trim().length > 0 ? event.reason.trim() : `Socket closed (code ${event?.code ?? "unknown"})`; - setIsConnected(false); + updateConnectionState({ isConnected: false, isConnecting: false }); scheduleReconnect(reason); }; @@ -164,7 +188,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe const reason = err instanceof Error ? err.message : "Failed to create WebSocket"; scheduleReconnect(reason); } - }, [url, conversationId]); + }, [updateConnectionState, url, conversationId]); useEffect(() => { shouldReconnectRef.current = true; @@ -259,6 +283,25 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe [send] ); + const subscribeConnectionStatus = useCallback( + (listener: (status: ConnectionStatusSnapshot) => void) => { + connectionListenersRef.current.add(listener); + listener(connectionStateRef.current); + return () => { + connectionListenersRef.current.delete(listener); + }; + }, + [] + ); + + const getConnectionState = useCallback(() => { + const readyState = wsRef.current?.readyState ?? WebSocket.CLOSED; + return { + isConnected: readyState === WebSocket.OPEN, + isConnecting: readyState === WebSocket.CONNECTING, + } satisfies ConnectionStatusSnapshot; + }, []); + return useMemo( () => ({ isConnected, @@ -269,7 +312,20 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe on, sendPing, sendUserMessage, + subscribeConnectionStatus, + getConnectionState, }), - [isConnected, isConnecting, currentConversationId, lastError, send, on, sendPing, sendUserMessage] + [ + isConnected, + isConnecting, + currentConversationId, + lastError, + send, + on, + sendPing, + sendUserMessage, + subscribeConnectionStatus, + getConnectionState, + ] ); }