feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like the detail chip's trash affordance in the same file — an overflow-hidden rounded wrapper holding two raw <button>s divided by border-s — rather than two shared Buttons, which each hardcode their own rounding and would fight a segmented control. border-s puts the mic at the logical end: visually right in en/nl, visually left in fa, on the same side as the chip's trash in both directions. The two halves share a wrapper and nothing else. Add keeps its exact behaviour. The control never changes size while recording; the timer and level meter live in a bar between the header row and the chip strip, because the header is sm:justify-between and growing the button would shove the row on every start and stop. The meter exists to prove the microphone is actually hearing something — silence and a dead mic look identical otherwise. Voice reaches the editor as one optional `voice` prop, so its absence *is* the unavailable state and the two cannot disagree. Fixes from review of this commit: - mountedRef was set false on unmount and never re-armed, so under StrictMode the hook was permanently "unmounted" in dev and recording silently never started. - onStart guarded only on `phase`, which does not change until getUserMedia resolves; a second click during the permission prompt orphaned the first MediaStream, leaving the mic indicator lit. - Week start is now per locale. "Next Thursday" is week-relative, and hardcoding Saturday put an en/nl clinician's deadline a week out. - A missing `which` on a weekday intent is read as "this" rather than failing — a bare weekday carries no qualifier, and rejecting it discarded a real deadline. - durationMs is client-reported and so is a claim, not enforcement; the cap is now also checked against the vendor's own usage.seconds. - Blob type falls back to the recorder's actual mimeType before webm, so old Safari's mp4/aac clips are not mislabelled. Two review findings were rejected as incorrect, both re-verified against live sources: google/gemini-3.7-flash does exist on OpenRouter (1M context, $0.375/$1.875 per M), and base64 JSON input_audio is the documented primary path for /audio/transcriptions, with multipart as the OpenAI-compatible alternative. The spec's stale "unverified" note is corrected, and the provider now has unit tests covering the request shape, usage parsing, and that a vendor error body never reaches the thrown message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,12 @@ export function useVoiceCapture({
|
||||
const cancelledRef = useRef(false);
|
||||
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
|
||||
const mountedRef = useRef(true);
|
||||
/**
|
||||
* Set synchronously on click. `phase` does not become 'recording' until getUserMedia
|
||||
* resolves, so without this a second click during the permission prompt would start a
|
||||
* second stream and orphan the first — mic indicator lit, interval leaked.
|
||||
*/
|
||||
const startingRef = useRef(false);
|
||||
|
||||
const teardown = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
@@ -81,16 +87,21 @@ export function useVoiceCapture({
|
||||
|
||||
// Releasing the microphone on unmount matters: the browser shows a recording indicator
|
||||
// for as long as the track is live, and an orphaned one looks like the app is listening.
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
cancelledRef.current = true;
|
||||
abortRef.current?.abort();
|
||||
try {
|
||||
recorderRef.current?.stop();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
teardown();
|
||||
useEffect(() => {
|
||||
// Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and
|
||||
// a ref that is only ever set false would leave the hook permanently "unmounted".
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cancelledRef.current = true;
|
||||
abortRef.current?.abort();
|
||||
try {
|
||||
recorderRef.current?.stop();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
teardown();
|
||||
};
|
||||
}, [teardown]);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -133,71 +144,80 @@ export function useVoiceCapture({
|
||||
}, [teardown]);
|
||||
|
||||
const onStart = useCallback(() => {
|
||||
if (phase !== 'idle') return;
|
||||
if (phase !== 'idle' || startingRef.current) return;
|
||||
if (!isMediaRecorderSupported()) {
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
cancelledRef.current = false;
|
||||
startingRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
// Permission refused, or no input device. Never a server round-trip.
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mountedRef.current) {
|
||||
// Permission resolved after the component went away — release it immediately
|
||||
// rather than leaving the browser's recording indicator lit.
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = pickRecordingMimeType();
|
||||
if (mimeType === null) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startedAtRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType });
|
||||
teardown();
|
||||
if (cancelledRef.current || blob.size === 0) {
|
||||
setPhase('idle');
|
||||
setElapsedMs(0);
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
// Permission refused, or no input device. Never a server round-trip.
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
void send(blob, recorder.mimeType || mimeType || 'audio/webm', durationMs);
|
||||
};
|
||||
|
||||
attachLevelMeter(stream, audioContextRef, setLevel);
|
||||
if (!mountedRef.current) {
|
||||
// Permission resolved after the component went away — release it immediately
|
||||
// rather than leaving the browser's recording indicator lit.
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
startedAtRef.current = Date.now();
|
||||
recorder.start();
|
||||
setPhase('recording');
|
||||
setElapsedMs(0);
|
||||
const mimeType = pickRecordingMimeType();
|
||||
if (mimeType === null) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startedAtRef.current;
|
||||
setElapsedMs(elapsed);
|
||||
// Auto-stop proceeds to processing with what was captured; discarding two minutes
|
||||
// of dictation because a timer expired would be the worst possible failure.
|
||||
if (maxMs != null && elapsed >= maxMs) stop();
|
||||
}, LEVEL_POLL_MS);
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startedAtRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType });
|
||||
teardown();
|
||||
if (cancelledRef.current || blob.size === 0) {
|
||||
setPhase('idle');
|
||||
setElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
// Prefer what the recorder actually produced, then the blob's own type. Old
|
||||
// Safari accepts no mimeType hint, and defaulting to webm would mislabel its
|
||||
// mp4/aac clips as something they are not.
|
||||
void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs);
|
||||
};
|
||||
|
||||
attachLevelMeter(stream, audioContextRef, setLevel);
|
||||
|
||||
startedAtRef.current = Date.now();
|
||||
recorder.start();
|
||||
setPhase('recording');
|
||||
setElapsedMs(0);
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - startedAtRef.current;
|
||||
setElapsedMs(elapsed);
|
||||
// Auto-stop proceeds to processing with what was captured; discarding two
|
||||
// minutes of dictation because a timer expired would be the worst failure.
|
||||
if (maxMs != null && elapsed >= maxMs) stop();
|
||||
}, LEVEL_POLL_MS);
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [maxMs, onError, phase, send, stop, teardown]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user