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
@@ -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],