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:
2026-08-20 19:47:10 +03:30
parent 93c6513df6
commit c07f550e00
13 changed files with 672 additions and 146 deletions

View File

@@ -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>
);
}