feat(dictation): add resilient rpc retries and status toasts

This commit is contained in:
Mohamed Boudra
2025-12-02 08:12:02 +00:00
parent 71b238c0cd
commit dc26bf713e
8 changed files with 1213 additions and 111 deletions

View File

@@ -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<string, unknown>;
};
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<Array<{ uri: string; mimeType: string }>>([]);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [audioDebugInfo, setAudioDebugInfo] = useState<AudioDebugInfo | null>(null);
const [connectionStatus, setConnectionStatus] = useState(() =>
ws.getConnectionState ? ws.getConnectionState() : { isConnected: ws.isConnected, isConnecting: ws.isConnecting }
);
const [lastSuccessToastAt, setLastSuccessToastAt] = useState<number | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => 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<DictationToastConfig | null>(() => {
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<string, unknown>) => {
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}
/>
</View>
</Animated.View>
</View>
{dictationToast ? (
<View style={styles.dictationToastPortal} pointerEvents="box-none">
<View pointerEvents="auto">
<DictationStatusNotice {...dictationToast} />
</View>
</View>
) : null}
</View>
);
}
@@ -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",

View File

@@ -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<DropdownKey | null>(null);
const pendingRequestIdRef = useRef<string | null>(null);
const shouldSyncBaseBranchRef = useRef(true);
const [connectionStatus, setConnectionStatus] = useState(() =>
effectiveWs.getConnectionState
? effectiveWs.getConnectionState()
: { isConnected: effectiveWs.isConnected, isConnecting: effectiveWs.isConnecting }
);
const [dictationSuccessToastAt, setDictationSuccessToastAt] = useState<number | null>(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<DictationToastConfig | null>(() => {
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({
)}
</Pressable>
</Animated.View>
{promptDictationToast ? (
<View style={styles.dictationToastPortal} pointerEvents="box-none">
<View pointerEvents="auto">
<DictationStatusNotice {...promptDictationToast} />
</View>
</View>
) : null}
</>
) : (
<View
@@ -2763,6 +2928,13 @@ interface PromptDictationControlsProps {
onStart: () => 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 (
<Pressable
onPress={onStart}
@@ -2803,34 +2988,47 @@ function PromptDictationControls({
</View>
<View style={styles.dictationActionGroup}>
<Pressable
onPress={onCancel}
disabled={isProcessing}
onPress={cancelHandler}
disabled={isProcessing && !isFailed}
accessibilityLabel="Cancel dictation"
style={[
styles.dictationActionButton,
styles.dictationActionButtonCancel,
isProcessing ? styles.dictationActionButtonDisabled : undefined,
isProcessing && !isFailed ? styles.dictationActionButtonDisabled : undefined,
]}
>
<X size={14} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onConfirm}
onPress={confirmHandler}
disabled={isProcessing}
accessibilityLabel="Insert transcription"
accessibilityLabel={isFailed ? "Retry dictation" : "Insert transcription"}
style={[
styles.dictationActionButton,
styles.dictationActionButtonConfirm,
isProcessing ? styles.dictationActionButtonDisabled : undefined,
]}
>
{isProcessing ? (
{isProcessing || isRetrying ? (
<ActivityIndicator size="small" color={theme.colors.background} />
) : isFailed ? (
<RefreshCcw size={14} color={theme.colors.background} />
) : (
<Check size={14} color={theme.colors.background} />
)}
</Pressable>
</View>
{(isRetrying || isFailed) && (
<Text style={[styles.dictationStatusLabel, { color: theme.colors.mutedForeground }]}>
{isRetrying
? `Retrying ${Math.max(1, retryAttempt)} / ${Math.max(1, maxRetryAttempts)}${
retryCountdownMs && retryCountdownMs > 0
? ` in ${Math.ceil(retryCountdownMs / 1000)}s`
: ""
}`
: errorMessage ?? "Dictation failed"}
</Text>
)}
</View>
);
}
@@ -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],

View File

@@ -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<DictationToastVariant, typeof Info> = {
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 (
<View
style={[
styles.container,
{
backgroundColor,
borderColor: variant === "info" ? theme.colors.border : "transparent",
},
]}
>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<VariantIcon size={16} color={foregroundColor} />
<Text style={[styles.title, { color: foregroundColor }]}>{title}</Text>
</View>
{onDismiss ? (
<Pressable accessibilityLabel="Dismiss dictation status" hitSlop={8} onPress={onDismiss}>
<X size={14} color={foregroundColor} />
</Pressable>
) : null}
</View>
{subtitle ? (
<Text style={[styles.subtitle, { color: secondaryColor }]}>{subtitle}</Text>
) : null}
{(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}>
{meta ? (
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
) : (
<View />
)}
{actionLabel && onAction ? (
<Pressable
style={[
styles.actionButton,
{
backgroundColor: variant === "info" ? theme.colors.primary : "rgba(0,0,0,0.2)",
},
]}
onPress={onAction}
accessibilityRole="button"
accessibilityLabel={actionLabel}
>
<RotateCcw
size={14}
color={variant === "info" ? theme.colors.primaryForeground : foregroundColor}
/>
<Text
style={[
styles.actionText,
{ color: variant === "info" ? theme.colors.primaryForeground : foregroundColor },
]}
>
{actionLabel}
</Text>
</Pressable>
) : null}
</View>
)}
</View>
);
}
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,
},
}));

View File

@@ -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 (
<View style={[styles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
{/* Cancel button */}
<Pressable onPress={onCancel} disabled={isTranscribing} style={[styles.cancelButton, isTranscribing && styles.buttonDisabled]}>
<Pressable
onPress={handleCancel}
disabled={isTranscribing}
style={[styles.cancelButton, isTranscribing && styles.buttonDisabled]}
>
<X size={24} color={theme.colors.palette.white} strokeWidth={2.5} />
</Pressable>
@@ -49,9 +65,17 @@ export function VoiceNoteRecordingOverlay({
</View>
{/* Send button */}
<Pressable onPress={onSend} disabled={isTranscribing} style={[styles.sendButton, { backgroundColor: theme.colors.palette.white }]}>
{isTranscribing ? (
<Pressable
onPress={handlePrimary}
disabled={primaryDisabled}
style={[styles.sendButton, { backgroundColor: theme.colors.palette.white }, primaryDisabled && styles.buttonDisabled]}
>
{isRetrying ? (
<ActivityIndicator size="small" color={theme.colors.palette.blue[600]} />
) : isTranscribing ? (
<ActivityIndicator size="small" color={theme.colors.palette.blue[600]} />
) : isFailed ? (
<RefreshCcw size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
) : (
<ArrowUp size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
)}

View File

@@ -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);

View File

@@ -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<void>;
cancelDictation: () => Promise<void>;
confirmDictation: () => Promise<void>;
retryFailedDictation: () => Promise<void>;
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle");
const [retryAttempt, setRetryAttempt] = useState(0);
const [retryInfo, setRetryInfo] = useState<DictationRetryInfo | null>(null);
const [failedRecording, setFailedRecording] = useState<FailedDictationRecording | null>(null);
const [lastOutcome, setLastOutcome] = useState<DictationOutcome | null>(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<CapturedAudioPayload | null>(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<ReturnType<typeof waitForTranscriptionResponse>>, 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,
};
}

View File

@@ -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<TType extends ResponseType, TData> = (message: ResponseWithE
type DispatchRequest<TType extends RequestType> = (request: RequestOf<TType>) => void | Promise<void>;
type DispatchOverride = (requestId: string, attempt: number) => void | Promise<void>;
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<RpcRetryOptions["shouldRetry"]>;
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<void>;
dispatch?: DispatchOverride;
retry?: RpcRetryOptions;
timeoutMs?: number | null;
};
type SendOptions = {
retry?: RpcRetryOptions;
timeoutMs?: number | null;
};
type UseSessionRpcReturn<TRequest extends RequestType, TData> = {
state: RpcState<TData>;
send: (params: Omit<RequestOf<TRequest>, "type" | "requestId">) => Promise<TData>;
send: (params: Omit<RequestOf<TRequest>, "type" | "requestId">, options?: SendOptions) => Promise<TData>;
waitForResponse: (options: WaitForResponseOptions) => Promise<TData>;
reset: () => void;
};
@@ -57,18 +158,39 @@ export function useSessionRpc<
const [state, setState] = useState<RpcState<TData>>({ status: "idle", requestId: null });
const activeRequestIdRef = useRef<string | null>(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<DispatchRequest<TRequest> | undefined>(dispatch);
const timeoutHandleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryOptionsRef = useRef<ResolvedRetryOptions | null>(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<TData>((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<unknown>).then === "function") {
(maybePromise as Promise<unknown>).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<RequestOf<TRequest>, "type" | "requestId">) => {
(params: Omit<RequestOf<TRequest>, "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]

View File

@@ -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<ReturnType<typeof setTimeout> | undefined>(undefined);
const reconnectAttemptRef = useRef(0);
const shouldReconnectRef = useRef(true);
const connectionListenersRef = useRef(new Set<(status: ConnectionStatusSnapshot) => void>());
const connectionStateRef = useRef<ConnectionStatusSnapshot>({ 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,
]
);
}