fix: share dictation hook across surfaces

- add shared useDictation hook to prevent double-stop races
- refactor modal + agent chat to consume shared API
- improve web recorder errors with secure-context messaging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-01 12:25:58 +00:00
co-authored by Claude
parent ad804cb1fe
commit 27ec2b5c67
4 changed files with 661 additions and 278 deletions
@@ -133,14 +133,28 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
throw new Error("Already recording");
}
if (
const missingNavigator =
typeof navigator === "undefined" ||
!navigator.mediaDevices ||
typeof navigator.mediaDevices.getUserMedia !== "function"
) {
typeof navigator.mediaDevices.getUserMedia !== "function";
const secureContext =
typeof window !== "undefined" && typeof window.isSecureContext === "boolean"
? window.isSecureContext
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
}
if (!secureContext) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`
);
}
const options = configRef.current;
const constraints: MediaStreamConstraints = {
audio: {
+388
View File
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SessionContextValue } from "@/contexts/session-context";
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
import { generateMessageId } from "@/types/stream";
import type { SessionOutboundMessage } from "@server/server/messages";
export type UseDictationOptions = {
agentId: string;
sendAgentAudio: SessionContextValue["sendAgentAudio"];
ws: UseWebSocketReturn;
mode?: "transcribe_only" | "auto_run";
onTranscript: (text: string, meta: { requestId: string }) => void;
onError?: (error: Error) => void;
canStart?: () => boolean;
canConfirm?: () => boolean;
autoStopWhenHidden?: { isVisible: boolean };
enableDuration?: boolean;
};
export type UseDictationResult = {
isRecording: boolean;
isProcessing: boolean;
volume: number;
duration: number;
pendingRequestId: string | null;
error: string | null;
startDictation: () => Promise<void>;
cancelDictation: () => Promise<void>;
confirmDictation: () => Promise<void>;
reset: () => void;
};
const DURATION_TICK_MS = 1000;
const toError = (error: unknown): Error => {
if (error instanceof Error) {
return error;
}
if (typeof error === "string" && error.trim().length > 0) {
return new Error(error);
}
return new Error("An unexpected error occurred while handling dictation.");
};
export function useDictation(options: UseDictationOptions): UseDictationResult {
const {
agentId,
sendAgentAudio,
ws,
mode = "transcribe_only",
onTranscript,
onError,
canStart,
canConfirm,
autoStopWhenHidden,
enableDuration = false,
} = options;
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [volume, setVolume] = useState(0);
const [duration, setDuration] = useState(0);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAudioLevel = useCallback((level: number) => {
setVolume(level);
}, []);
const recorder = useAudioRecorder({ onAudioLevel: handleAudioLevel });
const recorderRef = useRef(recorder);
useEffect(() => {
recorderRef.current = recorder;
}, [recorder]);
const onTranscriptRef = useRef(onTranscript);
useEffect(() => {
onTranscriptRef.current = onTranscript;
}, [onTranscript]);
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const isRecordingRef = useRef(isRecording);
useEffect(() => {
isRecordingRef.current = isRecording;
}, [isRecording]);
const pendingRequestIdRef = useRef<string | null>(null);
const activeStopPromiseRef = useRef<Promise<Blob> | null>(null);
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopDurationTracking = useCallback(() => {
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
durationIntervalRef.current = null;
}
}, []);
const startDurationTracking = useCallback(() => {
if (!enableDuration) {
console.log("[useDictation] startDictation blocked by canStart()");
return;
}
if (durationIntervalRef.current) {
return;
}
durationIntervalRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, DURATION_TICK_MS);
}, [enableDuration]);
useEffect(() => {
if (!enableDuration) {
stopDurationTracking();
setDuration(0);
}
}, [enableDuration, stopDurationTracking]);
const reportError = useCallback(
(err: unknown, context?: string) => {
const normalized = toError(err);
if (context) {
console.error(`[useDictation] ${context}`, normalized);
} else {
console.error("[useDictation]", normalized);
}
setError(normalized.message);
onErrorRef.current?.(normalized);
},
[setError]
);
const stopRecorder = useCallback(async (): Promise<Blob> => {
if (activeStopPromiseRef.current) {
return activeStopPromiseRef.current;
}
const recorderInstance = recorderRef.current;
if (!recorderInstance) {
throw new Error("Recorder unavailable");
}
const stopPromise = (async () => {
try {
return await recorderInstance.stop();
} finally {
activeStopPromiseRef.current = null;
}
})();
activeStopPromiseRef.current = stopPromise;
return stopPromise;
}, []);
const startDictation = useCallback(async () => {
console.log("[useDictation] startDictation requested", {
isRecording: isRecordingRef.current,
isProcessing,
});
if (isRecordingRef.current || isProcessing) {
console.log("[useDictation] startDictation aborted: already recording/processing", {
isRecording: isRecordingRef.current,
isProcessing,
});
return;
}
const startAllowed = canStart ? canStart() : true;
if (!startAllowed) {
console.log("[useDictation] startDictation blocked by canStart()");
return;
}
setError(null);
setVolume(0);
setDuration(0);
setIsProcessing(false);
pendingRequestIdRef.current = null;
setPendingRequestId(null);
try {
const recorderInstance = recorderRef.current;
if (!recorderInstance) {
throw new Error("Recorder unavailable");
}
await recorderInstance.start();
console.log("[useDictation] recorder.start succeeded");
isRecordingRef.current = true;
setIsRecording(true);
if (enableDuration) {
startDurationTracking();
}
} catch (err) {
stopDurationTracking();
isRecordingRef.current = false;
setIsRecording(false);
reportError(err, "Failed to start dictation");
}
}, [
canStart,
enableDuration,
isProcessing,
reportError,
startDurationTracking,
stopDurationTracking,
]);
const cancelDictation = useCallback(async () => {
console.log("[useDictation] cancelDictation requested", {
isRecording: isRecordingRef.current,
hasActiveStop: Boolean(activeStopPromiseRef.current),
});
if (!isRecordingRef.current && !activeStopPromiseRef.current) {
console.log("[useDictation] cancelDictation ignored: nothing to cancel");
return;
}
stopDurationTracking();
setDuration(0);
setError(null);
try {
await stopRecorder();
} catch (err) {
reportError(err, "Failed to cancel dictation");
} finally {
isRecordingRef.current = false;
setIsRecording(false);
setIsProcessing(false);
setVolume(0);
}
}, [reportError, stopDurationTracking, stopRecorder]);
const confirmDictation = useCallback(async () => {
console.log("[useDictation] confirmDictation requested", {
isRecording: isRecordingRef.current,
isProcessing,
});
if (!isRecordingRef.current || isProcessing) {
console.log("[useDictation] confirmDictation ignored: recording flag", {
isRecording: isRecordingRef.current,
isProcessing,
});
return;
}
const confirmAllowed = canConfirm ? canConfirm() : true;
if (!confirmAllowed) {
console.log("[useDictation] confirmDictation blocked by canConfirm()");
return;
}
setError(null);
stopDurationTracking();
setDuration(0);
setIsProcessing(true);
try {
const audioData = await stopRecorder();
isRecordingRef.current = false;
setIsRecording(false);
setVolume(0);
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
setPendingRequestId(requestId);
await sendAgentAudio(agentId, audioData, requestId, { mode });
console.log("[useDictation] sendAgentAudio resolved", { requestId });
} catch (err) {
pendingRequestIdRef.current = null;
setPendingRequestId(null);
setIsProcessing(false);
isRecordingRef.current = false;
setIsRecording(false);
setVolume(0);
reportError(err, "Failed to complete dictation");
}
}, [
agentId,
canConfirm,
isProcessing,
mode,
reportError,
sendAgentAudio,
stopDurationTracking,
stopRecorder,
]);
const reset = useCallback(() => {
pendingRequestIdRef.current = null;
setPendingRequestId(null);
setIsRecording(false);
isRecordingRef.current = false;
setIsProcessing(false);
stopDurationTracking();
setDuration(0);
setVolume(0);
setError(null);
}, [stopDurationTracking]);
const cancelRef = useRef<(() => void) | null>(null);
useEffect(() => {
cancelRef.current = () => {
void cancelDictation();
};
}, [cancelDictation]);
const visibilityRef = useRef<boolean | null>(
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null
);
useEffect(() => {
const nextVisible =
typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null;
const prevVisible = visibilityRef.current;
visibilityRef.current = nextVisible;
if (prevVisible === true && nextVisible === false && isRecordingRef.current) {
cancelRef.current?.();
}
}, [autoStopWhenHidden?.isVisible]);
useEffect(() => {
pendingRequestIdRef.current = null;
setPendingRequestId(null);
setIsProcessing(false);
}, [agentId]);
useEffect(() => {
const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => {
if (message.type !== "transcription_result") {
return;
}
const expectedId = pendingRequestIdRef.current;
if (!expectedId || message.payload.requestId !== expectedId) {
return;
}
pendingRequestIdRef.current = null;
setPendingRequestId(null);
setIsProcessing(false);
const transcriptText = message.payload.text?.trim();
if (!transcriptText) {
return;
}
console.log("[useDictation] transcription_result received", {
requestId: expectedId,
textLength: transcriptText.length,
});
onTranscriptRef.current?.(transcriptText, {
requestId: message.payload.requestId ?? expectedId,
});
});
return () => {
unsubscribe();
};
}, [ws]);
useEffect(() => {
return () => {
stopDurationTracking();
const activeStop = activeStopPromiseRef.current;
if (activeStop) {
void activeStop.catch(() => undefined);
return;
}
const recorderInstance = recorderRef.current;
if (recorderInstance?.isRecording?.()) {
void recorderInstance.stop().catch(() => undefined);
}
};
}, [stopDurationTracking]);
return {
isRecording,
isProcessing,
volume,
duration,
pendingRequestId,
error,
startDictation,
cancelDictation,
confirmDictation,
reset,
};
}