feat(dictation): add resilient rpc retries and status toasts
This commit is contained in:
@@ -24,6 +24,7 @@ import { useRealtime } from "@/contexts/realtime-context";
|
|||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { useDictation } from "@/hooks/use-dictation";
|
||||||
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||||
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
|
import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay";
|
||||||
|
import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { AgentStatusBar } from "./agent-status-bar";
|
import { AgentStatusBar } from "./agent-status-bar";
|
||||||
import { RealtimeControls } from "./realtime-controls";
|
import { RealtimeControls } from "./realtime-controls";
|
||||||
@@ -72,6 +73,16 @@ type TextAreaHandle = {
|
|||||||
} & Record<string, unknown>;
|
} & 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) {
|
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||||
@@ -85,6 +96,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
on: () => () => {},
|
on: () => () => {},
|
||||||
sendPing: () => {},
|
sendPing: () => {},
|
||||||
sendUserMessage: () => {},
|
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 [selectedImages, setSelectedImages] = useState<Array<{ uri: string; mimeType: string }>>([]);
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||||
const [audioDebugInfo, setAudioDebugInfo] = useState<AudioDebugInfo | null>(null);
|
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 textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
@@ -163,23 +180,25 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const canStartDictation = useCallback(() => {
|
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", {
|
console.log("[AgentInput] canStartDictation", {
|
||||||
allowed,
|
allowed,
|
||||||
isRealtimeMode,
|
isRealtimeMode,
|
||||||
wsConnected: ws.isConnected,
|
wsConnected: socketConnected,
|
||||||
});
|
});
|
||||||
return allowed;
|
return allowed;
|
||||||
}, [isRealtimeMode, ws.isConnected]);
|
}, [isRealtimeMode, ws]);
|
||||||
|
|
||||||
const canConfirmDictation = useCallback(() => {
|
const canConfirmDictation = useCallback(() => {
|
||||||
const allowed = ws.isConnected;
|
const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
||||||
|
const allowed = socketConnected;
|
||||||
console.log("[AgentInput] canConfirmDictation", {
|
console.log("[AgentInput] canConfirmDictation", {
|
||||||
allowed,
|
allowed,
|
||||||
wsConnected: ws.isConnected,
|
wsConnected: socketConnected,
|
||||||
});
|
});
|
||||||
return allowed;
|
return allowed;
|
||||||
}, [ws.isConnected]);
|
}, [ws]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isRecording: isDictating,
|
isRecording: isDictating,
|
||||||
@@ -187,9 +206,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
volume: dictationVolume,
|
volume: dictationVolume,
|
||||||
duration: dictationDuration,
|
duration: dictationDuration,
|
||||||
pendingRequestId: dictationPendingRequestId,
|
pendingRequestId: dictationPendingRequestId,
|
||||||
|
error: dictationError,
|
||||||
|
status: dictationStatus,
|
||||||
|
retryAttempt: dictationRetryAttempt,
|
||||||
|
maxRetryAttempts: dictationMaxRetryAttempts,
|
||||||
|
retryInfo: dictationRetryInfo,
|
||||||
|
lastOutcome: dictationLastOutcome,
|
||||||
startDictation,
|
startDictation,
|
||||||
cancelDictation,
|
cancelDictation,
|
||||||
confirmDictation,
|
confirmDictation,
|
||||||
|
retryFailedDictation,
|
||||||
|
discardFailedDictation,
|
||||||
} = useDictation({
|
} = useDictation({
|
||||||
agentId,
|
agentId,
|
||||||
sendAgentAudio,
|
sendAgentAudio,
|
||||||
@@ -208,9 +235,119 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}, [dictationPendingRequestId]);
|
}, [dictationPendingRequestId]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 });
|
overlayTransition.value = withTiming(shouldShowOverlay ? 1 : 0, { duration: 250 });
|
||||||
}, [isDictating, isDictationProcessing, overlayTransition]);
|
}, [dictationStatus, isDictating, isDictationProcessing, overlayTransition]);
|
||||||
|
|
||||||
const debugInputHeight = (label: string, payload: Record<string, unknown>) => {
|
const debugInputHeight = (label: string, payload: Record<string, unknown>) => {
|
||||||
if (!SHOULD_DEBUG_INPUT_HEIGHT) {
|
if (!SHOULD_DEBUG_INPUT_HEIGHT) {
|
||||||
@@ -231,7 +368,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}, [sendAgentMessage]);
|
}, [sendAgentMessage]);
|
||||||
|
|
||||||
async function handleSendMessage() {
|
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 message = userInput.trim();
|
||||||
const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined;
|
const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined;
|
||||||
@@ -298,6 +436,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
isDictating,
|
isDictating,
|
||||||
isDictationProcessing,
|
isDictationProcessing,
|
||||||
});
|
});
|
||||||
|
if (dictationStatus === "failed") {
|
||||||
|
handleDiscardFailedRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDictating && !isDictationProcessing) {
|
if (!isDictating && !isDictationProcessing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,6 +455,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
isDictating,
|
isDictating,
|
||||||
isDictationProcessing,
|
isDictationProcessing,
|
||||||
});
|
});
|
||||||
|
if (dictationStatus === "failed") {
|
||||||
|
handleRetryFailedRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDictating) {
|
if (!isDictating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -927,10 +1073,20 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
onCancel={handleCancelRecording}
|
onCancel={handleCancelRecording}
|
||||||
onSend={handleSendRecording}
|
onSend={handleSendRecording}
|
||||||
isTranscribing={isDictationProcessing}
|
isTranscribing={isDictationProcessing}
|
||||||
|
status={dictationStatus}
|
||||||
|
onRetry={dictationStatus === "failed" ? handleRetryFailedRecording : undefined}
|
||||||
|
onDiscardFailed={dictationStatus === "failed" ? handleDiscardFailedRecording : undefined}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
{dictationToast ? (
|
||||||
|
<View style={styles.dictationToastPortal} pointerEvents="box-none">
|
||||||
|
<View pointerEvents="auto">
|
||||||
|
<DictationStatusNotice {...dictationToast} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -938,6 +1094,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
const styles = StyleSheet.create(((theme: any) => ({
|
const styles = StyleSheet.create(((theme: any) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
borderSeparator: {
|
borderSeparator: {
|
||||||
height: theme.borderWidth[1],
|
height: theme.borderWidth[1],
|
||||||
@@ -969,8 +1127,14 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
height: FOOTER_HEIGHT,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
dictationToastPortal: {
|
||||||
|
position: "absolute",
|
||||||
|
left: theme.spacing[4],
|
||||||
|
right: theme.spacing[4],
|
||||||
|
bottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
imagePreviewContainer: {
|
imagePreviewContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -36,15 +36,17 @@ import Animated, {
|
|||||||
runOnJS,
|
runOnJS,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { theme as defaultTheme } from "@/styles/theme";
|
||||||
import { useRecentPaths } from "@/hooks/use-recent-paths";
|
import { useRecentPaths } from "@/hooks/use-recent-paths";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { useDictation } from "@/hooks/use-dictation";
|
||||||
|
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||||
import { VolumeMeter } from "@/components/volume-meter";
|
import { VolumeMeter } from "@/components/volume-meter";
|
||||||
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
||||||
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
||||||
|
import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
type AgentProviderDefinition,
|
type AgentProviderDefinition,
|
||||||
@@ -81,6 +83,16 @@ interface AgentFlowModalProps {
|
|||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DictationToastConfig = {
|
||||||
|
variant: DictationToastVariant;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
interface ModalWrapperProps {
|
interface ModalWrapperProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -274,6 +286,8 @@ function AgentFlowModal({
|
|||||||
on: () => () => {},
|
on: () => () => {},
|
||||||
sendPing: () => {},
|
sendPing: () => {},
|
||||||
sendUserMessage: () => {},
|
sendUserMessage: () => {},
|
||||||
|
subscribeConnectionStatus: () => () => {},
|
||||||
|
getConnectionState: () => ({ isConnected: false, isConnecting: false }),
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -322,7 +336,9 @@ function AgentFlowModal({
|
|||||||
reset: resetRepoInfo,
|
reset: resetRepoInfo,
|
||||||
cancel: cancelRepoInfo,
|
cancel: cancelRepoInfo,
|
||||||
} = gitRepoInfoRequest;
|
} = gitRepoInfoRequest;
|
||||||
const isWsConnected = ws?.isConnected ?? false;
|
const isWsConnected = effectiveWs.getConnectionState
|
||||||
|
? effectiveWs.getConnectionState().isConnected
|
||||||
|
: effectiveWs.isConnected;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sessionServerId = session?.serverId ?? null;
|
const sessionServerId = session?.serverId ?? null;
|
||||||
const selectedDaemonId = selectedServerId ?? sessionServerId;
|
const selectedDaemonId = selectedServerId ?? sessionServerId;
|
||||||
@@ -392,6 +408,12 @@ function AgentFlowModal({
|
|||||||
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
|
||||||
const pendingRequestIdRef = useRef<string | null>(null);
|
const pendingRequestIdRef = useRef<string | null>(null);
|
||||||
const shouldSyncBaseBranchRef = useRef(true);
|
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<
|
const promptInputRef = useRef<
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -459,10 +481,19 @@ function AgentFlowModal({
|
|||||||
isProcessing: isDictationProcessing,
|
isProcessing: isDictationProcessing,
|
||||||
volume: dictationVolume,
|
volume: dictationVolume,
|
||||||
pendingRequestId: dictationPendingRequestId,
|
pendingRequestId: dictationPendingRequestId,
|
||||||
|
error: dictationError,
|
||||||
|
status: dictationStatus,
|
||||||
|
retryAttempt: dictationRetryAttempt,
|
||||||
|
maxRetryAttempts: dictationMaxRetryAttempts,
|
||||||
|
retryInfo: dictationRetryInfo,
|
||||||
|
failedRecording: dictationFailedRecording,
|
||||||
|
lastOutcome: dictationLastOutcome,
|
||||||
startDictation,
|
startDictation,
|
||||||
cancelDictation,
|
cancelDictation,
|
||||||
confirmDictation,
|
confirmDictation,
|
||||||
reset: resetDictation,
|
reset: resetDictation,
|
||||||
|
retryFailedDictation,
|
||||||
|
discardFailedDictation,
|
||||||
} = useDictation({
|
} = useDictation({
|
||||||
agentId: DICTATION_AGENT_ID,
|
agentId: DICTATION_AGENT_ID,
|
||||||
sendAgentAudio,
|
sendAgentAudio,
|
||||||
@@ -480,6 +511,35 @@ function AgentFlowModal({
|
|||||||
dictationRequestIdRef.current = dictationPendingRequestId;
|
dictationRequestIdRef.current = dictationPendingRequestId;
|
||||||
}, [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 hasPendingCreateOrResume = pendingRequestIdRef.current !== null;
|
||||||
const hasPendingDictation = dictationPendingRequestId !== null;
|
const hasPendingDictation = dictationPendingRequestId !== null;
|
||||||
const shouldListenForStatus = isVisible || hasPendingCreateOrResume;
|
const shouldListenForStatus = isVisible || hasPendingCreateOrResume;
|
||||||
@@ -1038,6 +1098,10 @@ function AgentFlowModal({
|
|||||||
console.log("[CreateAgentModal] handleDictationCancel", {
|
console.log("[CreateAgentModal] handleDictationCancel", {
|
||||||
isDictating,
|
isDictating,
|
||||||
});
|
});
|
||||||
|
if (dictationStatus === "failed") {
|
||||||
|
discardFailedDictation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDictating) {
|
if (!isDictating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1110,7 @@ function AgentFlowModal({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CreateAgentModal] Failed to cancel dictation:", error);
|
console.error("[CreateAgentModal] Failed to cancel dictation:", error);
|
||||||
}
|
}
|
||||||
}, [cancelDictation, isDictating]);
|
}, [cancelDictation, dictationStatus, discardFailedDictation, isDictating]);
|
||||||
|
|
||||||
const handleDictationConfirm = useCallback(async () => {
|
const handleDictationConfirm = useCallback(async () => {
|
||||||
console.log("[CreateAgentModal] handleDictationConfirm", {
|
console.log("[CreateAgentModal] handleDictationConfirm", {
|
||||||
@@ -1055,6 +1119,10 @@ function AgentFlowModal({
|
|||||||
isTargetDaemonReady,
|
isTargetDaemonReady,
|
||||||
hasSendAgentAudio,
|
hasSendAgentAudio,
|
||||||
});
|
});
|
||||||
|
if (dictationStatus === "failed") {
|
||||||
|
void retryFailedDictation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDictating || isDictationProcessing) {
|
if (!isDictating || isDictationProcessing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1074,11 +1142,94 @@ function AgentFlowModal({
|
|||||||
}, [
|
}, [
|
||||||
daemonAvailabilityError,
|
daemonAvailabilityError,
|
||||||
confirmDictation,
|
confirmDictation,
|
||||||
|
dictationStatus,
|
||||||
hasSendAgentAudio,
|
hasSendAgentAudio,
|
||||||
isDictating,
|
isDictating,
|
||||||
isDictationProcessing,
|
isDictationProcessing,
|
||||||
isTargetDaemonReady,
|
isTargetDaemonReady,
|
||||||
logOfflineDaemonAction,
|
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(() => {
|
const navigateToAgentIfNeeded = useCallback(() => {
|
||||||
@@ -1727,6 +1878,13 @@ function AgentFlowModal({
|
|||||||
onStart={handleDictationStart}
|
onStart={handleDictationStart}
|
||||||
onCancel={handleDictationCancel}
|
onCancel={handleDictationCancel}
|
||||||
onConfirm={handleDictationConfirm}
|
onConfirm={handleDictationConfirm}
|
||||||
|
status={dictationStatus}
|
||||||
|
retryAttempt={dictationRetryAttempt}
|
||||||
|
maxRetryAttempts={dictationMaxRetryAttempts}
|
||||||
|
retryCountdownMs={dictationRetryInfo?.nextRetryMs}
|
||||||
|
errorMessage={dictationRetryInfo?.errorMessage ?? dictationError ?? undefined}
|
||||||
|
onRetry={handlePromptDictationRetry}
|
||||||
|
onDiscard={handlePromptDictationDiscard}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -2099,6 +2257,13 @@ function AgentFlowModal({
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
{promptDictationToast ? (
|
||||||
|
<View style={styles.dictationToastPortal} pointerEvents="box-none">
|
||||||
|
<View pointerEvents="auto">
|
||||||
|
<DictationStatusNotice {...promptDictationToast} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
@@ -2763,6 +2928,13 @@ interface PromptDictationControlsProps {
|
|||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
status: DictationStatus;
|
||||||
|
retryAttempt: number;
|
||||||
|
maxRetryAttempts: number;
|
||||||
|
retryCountdownMs?: number | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDiscard?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PromptDictationControls({
|
function PromptDictationControls({
|
||||||
@@ -2773,10 +2945,23 @@ function PromptDictationControls({
|
|||||||
onStart,
|
onStart,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
status,
|
||||||
|
retryAttempt,
|
||||||
|
maxRetryAttempts,
|
||||||
|
retryCountdownMs,
|
||||||
|
errorMessage,
|
||||||
|
onRetry,
|
||||||
|
onDiscard,
|
||||||
}: PromptDictationControlsProps): ReactElement {
|
}: PromptDictationControlsProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
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 (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onStart}
|
onPress={onStart}
|
||||||
@@ -2803,34 +2988,47 @@ function PromptDictationControls({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.dictationActionGroup}>
|
<View style={styles.dictationActionGroup}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onCancel}
|
onPress={cancelHandler}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing && !isFailed}
|
||||||
accessibilityLabel="Cancel dictation"
|
accessibilityLabel="Cancel dictation"
|
||||||
style={[
|
style={[
|
||||||
styles.dictationActionButton,
|
styles.dictationActionButton,
|
||||||
styles.dictationActionButtonCancel,
|
styles.dictationActionButtonCancel,
|
||||||
isProcessing ? styles.dictationActionButtonDisabled : undefined,
|
isProcessing && !isFailed ? styles.dictationActionButtonDisabled : undefined,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<X size={14} color={theme.colors.foreground} />
|
<X size={14} color={theme.colors.foreground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onConfirm}
|
onPress={confirmHandler}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
accessibilityLabel="Insert transcription"
|
accessibilityLabel={isFailed ? "Retry dictation" : "Insert transcription"}
|
||||||
style={[
|
style={[
|
||||||
styles.dictationActionButton,
|
styles.dictationActionButton,
|
||||||
styles.dictationActionButtonConfirm,
|
styles.dictationActionButtonConfirm,
|
||||||
isProcessing ? styles.dictationActionButtonDisabled : undefined,
|
isProcessing ? styles.dictationActionButtonDisabled : undefined,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing || isRetrying ? (
|
||||||
<ActivityIndicator size="small" color={theme.colors.background} />
|
<ActivityIndicator size="small" color={theme.colors.background} />
|
||||||
|
) : isFailed ? (
|
||||||
|
<RefreshCcw size={14} color={theme.colors.background} />
|
||||||
) : (
|
) : (
|
||||||
<Check size={14} color={theme.colors.background} />
|
<Check size={14} color={theme.colors.background} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3092,6 +3290,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
position: "relative",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
@@ -3120,6 +3319,15 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
paddingBottom: theme.spacing[8],
|
paddingBottom: theme.spacing[8],
|
||||||
gap: theme.spacing[6],
|
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: {
|
formSection: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
@@ -3319,6 +3527,11 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
dictationActionButtonDisabled: {
|
dictationActionButtonDisabled: {
|
||||||
opacity: theme.opacity[40],
|
opacity: theme.opacity[40],
|
||||||
},
|
},
|
||||||
|
dictationStatusLabel: {
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
selectorRow: {
|
selectorRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { VolumeMeter } from "./volume-meter";
|
||||||
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
import { FOOTER_HEIGHT } from "@/contexts/footer-controls-context";
|
||||||
|
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||||
|
|
||||||
interface VoiceNoteRecordingOverlayProps {
|
interface VoiceNoteRecordingOverlayProps {
|
||||||
volume: number;
|
volume: number;
|
||||||
@@ -10,6 +11,9 @@ interface VoiceNoteRecordingOverlayProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
isTranscribing?: boolean;
|
isTranscribing?: boolean;
|
||||||
|
status?: DictationStatus;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDiscardFailed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
@@ -24,13 +28,25 @@ export function VoiceNoteRecordingOverlay({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onSend,
|
onSend,
|
||||||
isTranscribing = false,
|
isTranscribing = false,
|
||||||
|
status = "idle",
|
||||||
|
onRetry,
|
||||||
|
onDiscardFailed,
|
||||||
}: VoiceNoteRecordingOverlayProps) {
|
}: VoiceNoteRecordingOverlayProps) {
|
||||||
const { theme } = useUnistyles();
|
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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
|
<View style={[styles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
|
||||||
{/* Cancel button */}
|
{/* 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} />
|
<X size={24} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
@@ -49,9 +65,17 @@ export function VoiceNoteRecordingOverlay({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Send button */}
|
{/* Send button */}
|
||||||
<Pressable onPress={onSend} disabled={isTranscribing} style={[styles.sendButton, { backgroundColor: theme.colors.palette.white }]}>
|
<Pressable
|
||||||
{isTranscribing ? (
|
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]} />
|
<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} />
|
<ArrowUp size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1521,6 +1521,10 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const isSocketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
||||||
|
if (!isSocketConnected) {
|
||||||
|
throw new Error("WebSocket is disconnected");
|
||||||
|
}
|
||||||
// Convert blob to base64
|
// Convert blob to base64
|
||||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
const bytes = new Uint8Array(arrayBuffer);
|
const bytes = new Uint8Array(arrayBuffer);
|
||||||
|
|||||||
@@ -3,9 +3,33 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import type { SessionContextValue } from "@/contexts/session-context";
|
import type { SessionContextValue } from "@/contexts/session-context";
|
||||||
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
||||||
import { useSessionRpc } from "@/hooks/use-session-rpc";
|
import { useSessionRpc } from "@/hooks/use-session-rpc";
|
||||||
|
import type { RpcFailureReason, RpcRetryAttemptEvent } from "@/hooks/use-session-rpc";
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { generateMessageId } from "@/types/stream";
|
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 = {
|
export type UseDictationOptions = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||||
@@ -13,6 +37,8 @@ export type UseDictationOptions = {
|
|||||||
mode?: "transcribe_only" | "auto_run";
|
mode?: "transcribe_only" | "auto_run";
|
||||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
|
onRetryAttempt?: (info: DictationRetryInfo) => void;
|
||||||
|
onPermanentFailure?: (error: Error, context: { requestId: string }) => void;
|
||||||
canStart?: () => boolean;
|
canStart?: () => boolean;
|
||||||
canConfirm?: () => boolean;
|
canConfirm?: () => boolean;
|
||||||
autoStopWhenHidden?: { isVisible: boolean };
|
autoStopWhenHidden?: { isVisible: boolean };
|
||||||
@@ -26,13 +52,27 @@ export type UseDictationResult = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
pendingRequestId: string | null;
|
pendingRequestId: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
status: DictationStatus;
|
||||||
|
retryAttempt: number;
|
||||||
|
maxRetryAttempts: number;
|
||||||
|
retryInfo: DictationRetryInfo | null;
|
||||||
|
failedRecording: FailedDictationRecording | null;
|
||||||
|
lastOutcome: DictationOutcome | null;
|
||||||
startDictation: () => Promise<void>;
|
startDictation: () => Promise<void>;
|
||||||
cancelDictation: () => Promise<void>;
|
cancelDictation: () => Promise<void>;
|
||||||
confirmDictation: () => Promise<void>;
|
confirmDictation: () => Promise<void>;
|
||||||
|
retryFailedDictation: () => Promise<void>;
|
||||||
|
discardFailedDictation: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DURATION_TICK_MS = 1000;
|
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 => {
|
const toError = (error: unknown): Error => {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -44,6 +84,35 @@ const toError = (error: unknown): Error => {
|
|||||||
return new Error("An unexpected error occurred while handling dictation.");
|
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 {
|
export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||||
const {
|
const {
|
||||||
agentId,
|
agentId,
|
||||||
@@ -52,6 +121,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
mode = "transcribe_only",
|
mode = "transcribe_only",
|
||||||
onTranscript,
|
onTranscript,
|
||||||
onError,
|
onError,
|
||||||
|
onRetryAttempt,
|
||||||
|
onPermanentFailure,
|
||||||
canStart,
|
canStart,
|
||||||
canConfirm,
|
canConfirm,
|
||||||
autoStopWhenHidden,
|
autoStopWhenHidden,
|
||||||
@@ -64,6 +135,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||||
const [error, setError] = 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({
|
const { waitForResponse: waitForTranscriptionResponse, reset: resetTranscriptionRpc } = useSessionRpc({
|
||||||
ws,
|
ws,
|
||||||
@@ -71,10 +148,16 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
responseType: "transcription_result",
|
responseType: "transcription_result",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingAudioRef = useRef<CapturedAudioPayload | null>(null);
|
||||||
const handleAudioLevel = useCallback((level: number) => {
|
const handleAudioLevel = useCallback((level: number) => {
|
||||||
setVolume(level);
|
setVolume(level);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
durationRef.current = duration;
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
const recorder = useAudioRecorder({ onAudioLevel: handleAudioLevel });
|
const recorder = useAudioRecorder({ onAudioLevel: handleAudioLevel });
|
||||||
const recorderRef = useRef(recorder);
|
const recorderRef = useRef(recorder);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,6 +174,16 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
}, [onError]);
|
}, [onError]);
|
||||||
|
|
||||||
|
const onRetryAttemptRef = useRef(onRetryAttempt);
|
||||||
|
useEffect(() => {
|
||||||
|
onRetryAttemptRef.current = onRetryAttempt;
|
||||||
|
}, [onRetryAttempt]);
|
||||||
|
|
||||||
|
const onPermanentFailureRef = useRef(onPermanentFailure);
|
||||||
|
useEffect(() => {
|
||||||
|
onPermanentFailureRef.current = onPermanentFailure;
|
||||||
|
}, [onPermanentFailure]);
|
||||||
|
|
||||||
const isRecordingRef = useRef(isRecording);
|
const isRecordingRef = useRef(isRecording);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isRecordingRef.current = isRecording;
|
isRecordingRef.current = isRecording;
|
||||||
@@ -160,6 +253,137 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
return stopPromise;
|
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 () => {
|
const startDictation = useCallback(async () => {
|
||||||
console.log("[useDictation] startDictation requested", {
|
console.log("[useDictation] startDictation requested", {
|
||||||
isRecording: isRecordingRef.current,
|
isRecording: isRecordingRef.current,
|
||||||
@@ -182,6 +406,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
setVolume(0);
|
setVolume(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
setStatus("recording");
|
||||||
|
setRetryAttempt(0);
|
||||||
|
setRetryInfo(null);
|
||||||
|
setFailedRecording(null);
|
||||||
|
pendingAudioRef.current = null;
|
||||||
|
setLastOutcome(null);
|
||||||
pendingRequestIdRef.current = null;
|
pendingRequestIdRef.current = null;
|
||||||
setPendingRequestId(null);
|
setPendingRequestId(null);
|
||||||
|
|
||||||
@@ -234,6 +464,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
setVolume(0);
|
setVolume(0);
|
||||||
|
setStatus("idle");
|
||||||
|
setRetryAttempt(0);
|
||||||
|
setRetryInfo(null);
|
||||||
|
pendingAudioRef.current = null;
|
||||||
|
setFailedRecording(null);
|
||||||
|
setLastOutcome(null);
|
||||||
}
|
}
|
||||||
}, [reportError, stopDurationTracking, stopRecorder]);
|
}, [reportError, stopDurationTracking, stopRecorder]);
|
||||||
|
|
||||||
@@ -259,61 +495,85 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
stopDurationTracking();
|
stopDurationTracking();
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
setRetryInfo(null);
|
||||||
|
setRetryAttempt(0);
|
||||||
|
setLastOutcome(null);
|
||||||
|
|
||||||
|
let requestId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const audioData = await stopRecorder();
|
const audioData = await stopRecorder();
|
||||||
|
const recordedDurationSeconds = durationRef.current;
|
||||||
|
pendingAudioRef.current = buildCapturedAudioPayload(audioData, recordedDurationSeconds);
|
||||||
|
setStatus("uploading");
|
||||||
isRecordingRef.current = false;
|
isRecordingRef.current = false;
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setVolume(0);
|
setVolume(0);
|
||||||
|
|
||||||
const requestId = generateMessageId();
|
requestId = generateMessageId();
|
||||||
pendingRequestIdRef.current = requestId;
|
pendingRequestIdRef.current = requestId;
|
||||||
setPendingRequestId(requestId);
|
setPendingRequestId(requestId);
|
||||||
const transcription = await waitForTranscriptionResponse({
|
|
||||||
requestId,
|
|
||||||
dispatch: () => sendAgentAudio(agentId, audioData, requestId, { mode }),
|
|
||||||
});
|
|
||||||
|
|
||||||
pendingRequestIdRef.current = null;
|
const transcription = await transmitDictation(requestId);
|
||||||
setPendingRequestId(null);
|
handleTranscriptionSuccess(transcription, requestId);
|
||||||
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,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pendingRequestIdRef.current = null;
|
|
||||||
setPendingRequestId(null);
|
|
||||||
setIsProcessing(false);
|
|
||||||
isRecordingRef.current = false;
|
|
||||||
setIsRecording(false);
|
|
||||||
setVolume(0);
|
|
||||||
resetTranscriptionRpc();
|
resetTranscriptionRpc();
|
||||||
reportError(err, "Failed to complete dictation");
|
handleDictationFailure(err, requestId);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
agentId,
|
agentId,
|
||||||
canConfirm,
|
canConfirm,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
mode,
|
mode,
|
||||||
onTranscriptRef,
|
handleDictationFailure,
|
||||||
reportError,
|
handleTranscriptionSuccess,
|
||||||
resetTranscriptionRpc,
|
resetTranscriptionRpc,
|
||||||
sendAgentAudio,
|
|
||||||
stopDurationTracking,
|
stopDurationTracking,
|
||||||
stopRecorder,
|
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(() => {
|
const reset = useCallback(() => {
|
||||||
pendingRequestIdRef.current = null;
|
pendingRequestIdRef.current = null;
|
||||||
setPendingRequestId(null);
|
setPendingRequestId(null);
|
||||||
@@ -324,6 +584,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
setVolume(0);
|
setVolume(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStatus("idle");
|
||||||
|
setRetryAttempt(0);
|
||||||
|
setRetryInfo(null);
|
||||||
|
setFailedRecording(null);
|
||||||
|
pendingAudioRef.current = null;
|
||||||
|
setLastOutcome(null);
|
||||||
resetTranscriptionRpc();
|
resetTranscriptionRpc();
|
||||||
}, [resetTranscriptionRpc, stopDurationTracking]);
|
}, [resetTranscriptionRpc, stopDurationTracking]);
|
||||||
|
|
||||||
@@ -352,6 +618,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
pendingRequestIdRef.current = null;
|
pendingRequestIdRef.current = null;
|
||||||
setPendingRequestId(null);
|
setPendingRequestId(null);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
pendingAudioRef.current = null;
|
||||||
|
setStatus("idle");
|
||||||
|
setRetryAttempt(0);
|
||||||
|
setRetryInfo(null);
|
||||||
|
setFailedRecording(null);
|
||||||
resetTranscriptionRpc();
|
resetTranscriptionRpc();
|
||||||
}, [agentId, resetTranscriptionRpc]);
|
}, [agentId, resetTranscriptionRpc]);
|
||||||
|
|
||||||
@@ -377,9 +648,17 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
|||||||
duration,
|
duration,
|
||||||
pendingRequestId,
|
pendingRequestId,
|
||||||
error,
|
error,
|
||||||
|
status,
|
||||||
|
retryAttempt,
|
||||||
|
maxRetryAttempts,
|
||||||
|
retryInfo,
|
||||||
|
failedRecording,
|
||||||
|
lastOutcome,
|
||||||
startDictation,
|
startDictation,
|
||||||
cancelDictation,
|
cancelDictation,
|
||||||
confirmDictation,
|
confirmDictation,
|
||||||
|
retryFailedDictation,
|
||||||
|
discardFailedDictation,
|
||||||
reset,
|
reset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "@server/serv
|
|||||||
import type { UseWebSocketReturn } from "./use-websocket";
|
import type { UseWebSocketReturn } from "./use-websocket";
|
||||||
import { generateMessageId } from "@/types/stream";
|
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 RequestType = SessionInboundMessage["type"];
|
||||||
type ResponseType = SessionOutboundMessage["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 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 = {
|
type WaitForResponseOptions = {
|
||||||
requestId: string;
|
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> = {
|
type UseSessionRpcReturn<TRequest extends RequestType, TData> = {
|
||||||
state: RpcState<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>;
|
waitForResponse: (options: WaitForResponseOptions) => Promise<TData>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
};
|
};
|
||||||
@@ -57,18 +158,39 @@ export function useSessionRpc<
|
|||||||
const [state, setState] = useState<RpcState<TData>>({ status: "idle", requestId: null });
|
const [state, setState] = useState<RpcState<TData>>({ status: "idle", requestId: null });
|
||||||
const activeRequestIdRef = useRef<string | null>(null);
|
const activeRequestIdRef = useRef<string | null>(null);
|
||||||
const resolveRef = useRef<((value: TData) => void) | 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 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(() => {
|
useEffect(() => {
|
||||||
dispatchRef.current = dispatch;
|
dispatchRef.current = dispatch;
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const clearTimeoutHandle = useCallback(() => {
|
||||||
|
if (timeoutHandleRef.current) {
|
||||||
|
clearTimeout(timeoutHandleRef.current);
|
||||||
|
timeoutHandleRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearActiveRequest = useCallback(() => {
|
const clearActiveRequest = useCallback(() => {
|
||||||
|
clearTimeoutHandle();
|
||||||
activeRequestIdRef.current = null;
|
activeRequestIdRef.current = null;
|
||||||
resolveRef.current = null;
|
resolveRef.current = null;
|
||||||
rejectRef.current = null;
|
rejectRef.current = null;
|
||||||
}, []);
|
retryOptionsRef.current = null;
|
||||||
|
failureHandlerRef.current = null;
|
||||||
|
currentAttemptRef.current = 0;
|
||||||
|
}, [clearTimeoutHandle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearActiveRequest();
|
||||||
|
};
|
||||||
|
}, [clearActiveRequest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = ws.on(responseType, (message) => {
|
const unsubscribe = ws.on(responseType, (message) => {
|
||||||
@@ -79,15 +201,15 @@ export function useSessionRpc<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearTimeoutHandle();
|
||||||
|
|
||||||
const payloadError =
|
const payloadError =
|
||||||
payload && typeof payload === "object" && "error" in payload && typeof (payload as any).error === "string"
|
payload && typeof payload === "object" && "error" in payload && typeof (payload as any).error === "string"
|
||||||
? ((payload as any).error as string)
|
? ((payload as any).error as string)
|
||||||
: null;
|
: null;
|
||||||
if (payloadError) {
|
if (payloadError) {
|
||||||
const error = new Error(payloadError);
|
const error = new Error(payloadError);
|
||||||
setState({ status: "error", requestId: payload.requestId ?? null, error });
|
failureHandlerRef.current?.("response", error);
|
||||||
rejectRef.current?.(error);
|
|
||||||
clearActiveRequest();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,59 +223,129 @@ export function useSessionRpc<
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [clearActiveRequest, responseType, select, ws]);
|
}, [clearActiveRequest, clearTimeoutHandle, responseType, select, ws]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ws.isConnected || !activeRequestIdRef.current) {
|
if (ws.subscribeConnectionStatus) {
|
||||||
return;
|
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");
|
if (!ws.isConnected && activeRequestIdRef.current && failureHandlerRef.current) {
|
||||||
setState({ status: "error", requestId: activeRequestIdRef.current, error });
|
failureHandlerRef.current("disconnected", new Error("WebSocket disconnected"));
|
||||||
rejectRef.current?.(error);
|
}
|
||||||
clearActiveRequest();
|
}, [ws.isConnected, ws.subscribeConnectionStatus]);
|
||||||
}, [clearActiveRequest, ws.isConnected]);
|
|
||||||
|
|
||||||
const waitForResponse = useCallback(
|
const waitForResponse = useCallback(
|
||||||
({ requestId, dispatch: dispatchOverride }: WaitForResponseOptions) => {
|
({ requestId, dispatch: dispatchOverride, retry, timeoutMs = null }: WaitForResponseOptions) => {
|
||||||
return new Promise<TData>((resolve, reject) => {
|
return new Promise<TData>((resolve, reject) => {
|
||||||
if (!ws.isConnected) {
|
const finalDispatch = dispatchOverride ?? null;
|
||||||
const error = new Error("WebSocket is disconnected");
|
const canRetry = typeof finalDispatch === "function";
|
||||||
setState({ status: "error", requestId: null, error });
|
const resolvedRetry = resolveRetryOptions(retry, canRetry);
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeRequestIdRef.current = requestId;
|
activeRequestIdRef.current = requestId;
|
||||||
resolveRef.current = resolve;
|
resolveRef.current = (value) => {
|
||||||
rejectRef.current = reject;
|
resolve(value);
|
||||||
setState({ status: "loading", requestId });
|
};
|
||||||
|
rejectRef.current = (error) => {
|
||||||
if (!dispatchOverride) {
|
reject(error);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDispatchError = (error: unknown) => {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
setState({ status: "error", requestId, error: err });
|
|
||||||
reject(err);
|
|
||||||
clearActiveRequest();
|
clearActiveRequest();
|
||||||
};
|
};
|
||||||
|
retryOptionsRef.current = resolvedRetry;
|
||||||
|
setState({ status: "loading", requestId });
|
||||||
|
|
||||||
try {
|
const finalizeError = (reason: RpcFailureReason, error: Error, attempt: number) => {
|
||||||
const maybePromise = dispatchOverride(requestId);
|
const rpcError = new RpcRequestError(error.message, {
|
||||||
if (maybePromise && typeof (maybePromise as Promise<unknown>).then === "function") {
|
reason,
|
||||||
(maybePromise as Promise<unknown>).catch(handleDispatchError);
|
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(
|
const send = useCallback(
|
||||||
(params: Omit<RequestOf<TRequest>, "type" | "requestId">) => {
|
(params: Omit<RequestOf<TRequest>, "type" | "requestId">, options?: SendOptions) => {
|
||||||
const dispatchRequest = dispatchRef.current;
|
const dispatchRequest = dispatchRef.current;
|
||||||
return waitForResponse({
|
return waitForResponse({
|
||||||
requestId: generateMessageId(),
|
requestId: generateMessageId(),
|
||||||
@@ -168,6 +360,8 @@ export function useSessionRpc<
|
|||||||
}
|
}
|
||||||
ws.send({ type: "session", message: request });
|
ws.send({ type: "session", message: request });
|
||||||
},
|
},
|
||||||
|
retry: options?.retry,
|
||||||
|
timeoutMs: options?.timeoutMs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[requestType, waitForResponse, ws]
|
[requestType, waitForResponse, ws]
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type {
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "@server/server/messages";
|
} from "@server/server/messages";
|
||||||
|
|
||||||
|
export interface ConnectionStatusSnapshot {
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseWebSocketReturn {
|
export interface UseWebSocketReturn {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
@@ -18,6 +23,8 @@ export interface UseWebSocketReturn {
|
|||||||
) => () => void;
|
) => () => void;
|
||||||
sendPing: () => void;
|
sendPing: () => void;
|
||||||
sendUserMessage: (message: string) => void;
|
sendUserMessage: (message: string) => void;
|
||||||
|
subscribeConnectionStatus?: (listener: (status: ConnectionStatusSnapshot) => void) => () => void;
|
||||||
|
getConnectionState?: () => ConnectionStatusSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECONNECT_BASE_DELAY_MS = 1500;
|
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 reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const reconnectAttemptRef = useRef(0);
|
const reconnectAttemptRef = useRef(0);
|
||||||
const shouldReconnectRef = useRef(true);
|
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(() => {
|
const connect = useCallback(() => {
|
||||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
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());
|
setLastError(reason.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsConnected(false);
|
updateConnectionState({ isConnected: false, isConnecting: false });
|
||||||
setIsConnecting(false);
|
|
||||||
|
|
||||||
const attempt = reconnectAttemptRef.current;
|
const attempt = reconnectAttemptRef.current;
|
||||||
const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS);
|
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) {
|
if (!shouldReconnectRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsConnecting(true);
|
updateConnectionState({ isConnected: false, isConnecting: true });
|
||||||
connect();
|
connect();
|
||||||
}, delay);
|
}, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add conversation ID to URL if provided
|
// Add conversation ID to URL if provided
|
||||||
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
|
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
setIsConnecting(true);
|
updateConnectionState({ isConnected: false, isConnecting: true });
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log("[WS] Connected to server");
|
console.log("[WS] Connected to server");
|
||||||
setIsConnected(true);
|
updateConnectionState({ isConnected: true, isConnecting: false });
|
||||||
setIsConnecting(false);
|
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
reconnectAttemptRef.current = 0;
|
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
|
typeof event?.reason === "string" && event.reason.trim().length > 0
|
||||||
? event.reason.trim()
|
? event.reason.trim()
|
||||||
: `Socket closed (code ${event?.code ?? "unknown"})`;
|
: `Socket closed (code ${event?.code ?? "unknown"})`;
|
||||||
setIsConnected(false);
|
updateConnectionState({ isConnected: false, isConnecting: false });
|
||||||
scheduleReconnect(reason);
|
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";
|
const reason = err instanceof Error ? err.message : "Failed to create WebSocket";
|
||||||
scheduleReconnect(reason);
|
scheduleReconnect(reason);
|
||||||
}
|
}
|
||||||
}, [url, conversationId]);
|
}, [updateConnectionState, url, conversationId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shouldReconnectRef.current = true;
|
shouldReconnectRef.current = true;
|
||||||
@@ -259,6 +283,25 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
|||||||
[send]
|
[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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -269,7 +312,20 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
|||||||
on,
|
on,
|
||||||
sendPing,
|
sendPing,
|
||||||
sendUserMessage,
|
sendUserMessage,
|
||||||
|
subscribeConnectionStatus,
|
||||||
|
getConnectionState,
|
||||||
}),
|
}),
|
||||||
[isConnected, isConnecting, currentConversationId, lastError, send, on, sendPing, sendUserMessage]
|
[
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
currentConversationId,
|
||||||
|
lastError,
|
||||||
|
send,
|
||||||
|
on,
|
||||||
|
sendPing,
|
||||||
|
sendUserMessage,
|
||||||
|
subscribeConnectionStatus,
|
||||||
|
getConnectionState,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user