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:
@@ -899,7 +899,11 @@
|
||||
"toothAria": "FDI tooth {fdi}",
|
||||
"toothSelectedSuffix": ", selected",
|
||||
"sentToAt": "Sent to {orgName} at {datetime}",
|
||||
"fallbackOrgName": "organization"
|
||||
"fallbackOrgName": "organization",
|
||||
"voiceStart": "Record treatment",
|
||||
"voiceStop": "Stop recording",
|
||||
"voiceCancel": "Cancel",
|
||||
"voiceProcessing": "Reading the recording…"
|
||||
},
|
||||
"organizations": {
|
||||
"loadingOrganization": "Loading organization...",
|
||||
|
||||
@@ -900,7 +900,11 @@
|
||||
"toothAria": "دندان FDI {fdi}",
|
||||
"toothSelectedSuffix": "، انتخاب شده",
|
||||
"sentToAt": "ارسال به {orgName} در {datetime}",
|
||||
"fallbackOrgName": "سازمان"
|
||||
"fallbackOrgName": "سازمان",
|
||||
"voiceStart": "ثبت گفتاری درمان",
|
||||
"voiceStop": "توقف ضبط",
|
||||
"voiceCancel": "لغو",
|
||||
"voiceProcessing": "در حال پردازش گفتار…"
|
||||
},
|
||||
"organizations": {
|
||||
"loadingOrganization": "در حال بارگذاری سازمان...",
|
||||
|
||||
@@ -899,7 +899,11 @@
|
||||
"toothAria": "FDI-tand {fdi}",
|
||||
"toothSelectedSuffix": ", geselecteerd",
|
||||
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
||||
"fallbackOrgName": "organisatie"
|
||||
"fallbackOrgName": "organisatie",
|
||||
"voiceStart": "Behandeling inspreken",
|
||||
"voiceStop": "Opname stoppen",
|
||||
"voiceCancel": "Annuleren",
|
||||
"voiceProcessing": "Opname wordt gelezen…"
|
||||
},
|
||||
"organizations": {
|
||||
"loadingOrganization": "Organisatie laden...",
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Mic, Square, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
|
||||
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
||||
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
|
||||
import { VoiceRecordingBar } from '@/components/ui/treatment/VoiceRecordingBar';
|
||||
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
||||
@@ -42,6 +44,12 @@ interface TreatmentDetailsEditorProps {
|
||||
stepper?: ReactNode;
|
||||
/** Shown below type + chart + notes (e.g. Continue to lab). */
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as
|
||||
* before this feature existed. Presence *is* the availability flag, so the two cannot
|
||||
* disagree.
|
||||
*/
|
||||
voice?: VoiceCaptureState;
|
||||
/** Dim the chart until a treatment type is chosen. */
|
||||
chartLocked?: boolean;
|
||||
chartLockMessage?: string;
|
||||
@@ -65,6 +73,7 @@ export function TreatmentDetailsEditor({
|
||||
onRemoveAttachment,
|
||||
showChrome = true,
|
||||
showFields = true,
|
||||
voice,
|
||||
chart,
|
||||
stepper,
|
||||
footer,
|
||||
@@ -109,18 +118,31 @@ export function TreatmentDetailsEditor({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
>
|
||||
{t('addDetail')}
|
||||
</Button>
|
||||
{voice ? (
|
||||
<AddDetailWithVoice
|
||||
addLabel={t('addDetail')}
|
||||
startLabel={t('voiceStart')}
|
||||
stopLabel={t('voiceStop')}
|
||||
disabled={!canEdit || disabled}
|
||||
onAddDetail={onAddDetail}
|
||||
voice={voice}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canEdit || disabled}
|
||||
onClick={onAddDetail}
|
||||
fullWidth
|
||||
className="sm:w-auto shrink-0"
|
||||
>
|
||||
{t('addDetail')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voice ? <VoiceRecordingBar voice={voice} /> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.map((d, idx) => {
|
||||
const detailLocked = isDetailLocked(d);
|
||||
@@ -312,3 +334,82 @@ function NotesField({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add detail", split into two segments with the microphone at the logical end.
|
||||
*
|
||||
* Built like the detail chip's trash affordance in this same file — an
|
||||
* `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw `<button>`s
|
||||
* divided by `border-s` — rather than two shared `Button`s, which each hardcode their own
|
||||
* rounding and would fight a segmented control.
|
||||
*
|
||||
* `border-s` puts the microphone 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 existing
|
||||
* behaviour; the microphone is an independent action that creates nothing until the
|
||||
* clinician confirms.
|
||||
*/
|
||||
function AddDetailWithVoice({
|
||||
addLabel,
|
||||
startLabel,
|
||||
stopLabel,
|
||||
disabled,
|
||||
onAddDetail,
|
||||
voice,
|
||||
}: {
|
||||
addLabel: string;
|
||||
startLabel: string;
|
||||
stopLabel: string;
|
||||
disabled: boolean;
|
||||
onAddDetail: () => void;
|
||||
voice: VoiceCaptureState;
|
||||
}) {
|
||||
const isRecording = voice.phase === 'recording';
|
||||
const isBusy = voice.phase !== 'idle';
|
||||
const micLabel = isRecording ? stopLabel : startLabel;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||
bg-primary text-white shrink-0 sm:w-auto
|
||||
${disabled ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddDetail}
|
||||
disabled={disabled || isBusy}
|
||||
className="
|
||||
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
|
||||
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
|
||||
focus-visible:ring-white/60
|
||||
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
|
||||
"
|
||||
>
|
||||
{addLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? voice.onStop : voice.onStart}
|
||||
disabled={disabled || voice.phase === 'processing'}
|
||||
title={micLabel}
|
||||
aria-label={micLabel}
|
||||
className={`
|
||||
inline-flex items-center justify-center border-s border-white/25 px-3
|
||||
transition-all duration-200 focus:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-white/60
|
||||
disabled:cursor-not-allowed disabled:opacity-60
|
||||
${isRecording ? 'bg-red-600 hover:bg-red-700' : 'hover:opacity-90'}
|
||||
`}
|
||||
>
|
||||
{isRecording ? (
|
||||
<Square className="h-4 w-4 fill-current" aria-hidden />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
90
frontend/src/components/ui/treatment/VoiceRecordingBar.tsx
Normal file
90
frontend/src/components/ui/treatment/VoiceRecordingBar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
||||
|
||||
const METER_BARS = 9;
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.floor(Math.max(0, ms) / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live recording / processing strip.
|
||||
*
|
||||
* Sits between the header row and the chip strip rather than inside the segmented
|
||||
* control: the header is `sm:justify-between`, so growing the button mid-recording would
|
||||
* shove the row on every start and every stop.
|
||||
*/
|
||||
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
if (voice.phase === 'idle') return null;
|
||||
|
||||
const isRecording = voice.phase === 'recording';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-red-500" aria-hidden />
|
||||
<span className="shrink-0 text-sm tabular-nums text-text-primary">
|
||||
{formatElapsed(voice.elapsedMs)}
|
||||
{voice.maxMs != null ? (
|
||||
<span className="text-text-muted"> / {formatElapsed(voice.maxMs)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<LevelMeter level={voice.level} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-text-secondary" aria-hidden />
|
||||
<span className="text-sm text-text-secondary">{t('voiceProcessing')}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={voice.onCancel}
|
||||
title={t('voiceCancel')}
|
||||
aria-label={t('voiceCancel')}
|
||||
className="ms-auto inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-red-500/15 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
{t('voiceCancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Proves the microphone is actually hearing something — silence looks identical otherwise. */
|
||||
function LevelMeter({ level }: { level: number }) {
|
||||
return (
|
||||
<span className="flex h-4 flex-1 items-end gap-0.5" aria-hidden>
|
||||
{Array.from({ length: METER_BARS }, (_, index) => {
|
||||
// Bars light up left to right as the level rises, with a floor so the meter never
|
||||
// looks dead while a quiet voice is still being captured.
|
||||
const threshold = (index + 1) / METER_BARS;
|
||||
const active = level >= threshold * 0.9;
|
||||
const height = active ? 30 + threshold * 70 : 20;
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={`w-1 rounded-sm transition-all duration-75 ${
|
||||
active ? 'bg-primary' : 'bg-border'
|
||||
}`}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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