import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Mic, MicOff, TriangleAlert, Loader2, RotateCw } from 'lucide-react'; import { ScribeConsentUnverifiableError, useScribeServiceContext, type ScribeConsentStatus, } from '../../../services/ScribeService'; import { useOptionalBillingContext } from '../../../services/BillingContext'; /** * S6 - asking the patient, in the room, whether this visit may be recorded. * * THE STATE THIS COMPONENT EXISTS TO KEEP SEPARATE * * There are FOUR states here, not two, and collapsing any of them produces a wrong action: * * - **not asked** - nobody has put the question to this patient. The practitioner should ask. * - **declined** - they were asked and said no. The practitioner should NOT ask again unprompted. * - **permitted** - recording may start. * - **could not check** - our lookup failed. The patient's answer, whatever it was, is unchanged. * * "Could not check" rendered as "declined" is the failure this whole chain was built to avoid: it sends * a practitioner to re-ask a patient who already agreed, prompted entirely by our own outage. The server * separates them as a 503 rather than a 403 for exactly this reason, and throwing that away in the client * would waste the distinction. * * WHY STAFF-ATTESTED, AND WHAT THAT MEANS * * The caller here is a practitioner, so the server records this as a `staff-attested` capture: the PATIENT * decided, and the practitioner is the witness. This path exists because a consent model reachable only * through a portal dead-ends for a large and real share of any practice's panel *. * **PLACEMENT IS TEMPORARY.** This belongs beside the recorder, and moves there when S4 wires the * capture gate into the encounter surface. It sits with the note editor today because that is where the * rest of the scribe UI already lives and where a practitioner will actually see it. */ interface Props { patientId?: string; encounterId?: string; /** * Reports whether recording is currently permitted, so the recorder beside this control can offer * itself without a SECOND consent lookup - two fetches would be two answers that can disagree, and * the disagreement would show as a recorder offering to start next to a control saying the patient * declined. */ onPermittedChange?: (permitted: boolean) => void; } type Phase = | { kind: 'loading' } | { kind: 'known'; status: ScribeConsentStatus } | { kind: 'unverifiable' } /** The lookup itself failed in some other way. Distinct again — we know nothing, and say so. */ | { kind: 'error' }; export const ScribeConsentControl: React.FC = ({ patientId, encounterId, onPermittedChange }) => { // THE SERVICE IS READ THROUGH A REF, AND THE EFFECT DEPENDS ONLY ON THE IDS. Same pattern, for the // same reason, as `useEncounterClinicalNote`'s `notesRef` a few files over: the provider hands back a // NEW object every render, so depending on a function off it makes the effect re-run every render - // which sets state, which renders, which re-runs the effect. That is an infinite loop, and because // this component sits inside `ClinicalNoteEditor` it would spin the NOTE EDITOR, not just the scribe. // Do not "simplify" this into a dependency on `getConsent`. It crashed a test worker outright. const service = useScribeServiceContext(); const serviceRef = useRef(service); serviceRef.current = service; // - THE PROMPT IS GATED ON THE AI NOTES ADD-ON // // Spencer, 2026-08-18: *"Consent should NOT show if the add-on isn't enabled obviously"*. // Observed in a live encounter: Great River reads `aiNotesEnabled: false` and the panel still asked the // patient, in the room, for permission to record - for a paid capability the practice has not bought. // // NOT MERELY COSMETIC. It asks a patient to agree to something that cannot happen, it risks putting an // agreement-to-record on a record where no recording was possible, and it trains clinicians to click past // a consent control. // // `=== true`, AND THE OPTIONAL HOOK, ARE BOTH DELIBERATE: // - `entitlements` is `null` until the first fetch resolves, so **absent is NOT false - it is not known // yet**, and the ruling is that an unknown does not render the prompt. Testing `=== true` is what makes // "not loaded" and "not purchased" behave identically at the gate while staying different facts. // - `useOptionalBillingContext` rather than the throwing `useBillingContext`: this control is a REFINEMENT // inside the note editor, not a billing surface. The throwing hook without its provider above would take // the whole encounter note editor down to hide one prompt //. // // HIDING THE CONTROL IS NOT UNDOING AN ANSWER. A practice that cancels the add-on does not retroactively // un-ask a patient who agreed - nothing here writes, deletes or supersedes a stored Consent. The record // keeps exactly what it captured; only the question stops being put. // // DERIVED AT READ TIME, never copied into state. A cached // copy shows the prompt to a practice that just cancelled and hides it from one that just bought it. const billing = useOptionalBillingContext(); const aiNotesEnabled = billing?.entitlements?.aiNotesEnabled === true; const [phase, setPhase] = useState({ kind: 'loading' }); const [applyToFutureVisits, setApplyToFutureVisits] = useState(false); const [saving, setSaving] = useState(false); /** * Re-opening the row after a decision is on file. Separate from the decision itself so that "Change" * cannot be mistaken for a new answer - the radios come back pre-filled with what the patient actually * said, and nothing is written until somebody moves one. */ const [changing, setChanging] = useState(false); // Reported through a ref-held callback and only on a KNOWN state. `loading`, `error` and // `unverifiable` all report FALSE - the recorder must never be offered on the strength of a lookup // that has not answered, and "we do not know" is not permission. const notifyRef = useRef(onPermittedChange); notifyRef.current = onPermittedChange; useEffect(() => { // - the add-on gates PERMISSION, not just the prompt. Reporting `true` here while the // control is hidden would let the recorder beside it offer to record for a practice that has not // bought recording, which is the same defect one component over. notifyRef.current?.(aiNotesEnabled && phase.kind === 'known' && phase.status.permitted === true); }, [phase, aiNotesEnabled]); const load = useCallback(async (signal: { cancelled: boolean }) => { // - do not ask the server about a capability the practice has not bought. Keyed on // `aiNotesEnabled` so the lookup DOES run if the entitlement resolves late or the add-on is bought // mid-session: the gate stays derived rather than decided once at mount. if (!aiNotesEnabled) return; if (!patientId || !encounterId) return; setPhase({ kind: 'loading' }); try { const status = await serviceRef.current.getConsent(patientId, encounterId); // A response that is not a usable status is not a statement about the patient. Treated as // "we do not know" rather than allowed to throw inside a render of the note editor. if (signal.cancelled) return; setPhase(status && typeof status === 'object' ? { kind: 'known', status } : { kind: 'error' }); } catch (error) { if (signal.cancelled) return; setPhase(error instanceof ScribeConsentUnverifiableError ? { kind: 'unverifiable' } : { kind: 'error' }); } }, [patientId, encounterId, aiNotesEnabled]); useEffect(() => { // Encounter-scoped guard: a slow lookup must never land on a different encounter's control. const signal = { cancelled: false }; void load(signal); return () => { signal.cancelled = true; }; }, [load]); const decide = useCallback(async (decision: 'permit' | 'deny', standing = applyToFutureVisits) => { if (!patientId || !encounterId || saving) return; setSaving(true); try { await serviceRef.current.recordConsent({ patientId, // Omitting the encounter is what makes the decision STANDING - it then governs every visit // at this practice until superseded. ...(standing ? {} : { encounterId }), decision, }); // OPTIMISTIC, AND DELIBERATELY NOT A REFETCH. The consent lookup is a HealthLake SEARCH, and // HealthLake's search index lags a write by ~30s. A // refetch here would usually return the PREVIOUS state, the control would snap back to "not // asked" a moment after the practitioner recorded an answer, and they would click again - // writing a second Consent for a question they asked once. setPhase({ kind: 'known', status: { permitted: decision === 'permit', governedBy: standing ? 'standing' : 'encounter', captureMethod: 'staff-attested', decidedAt: new Date().toISOString(), }, }); // The answer is on file, so the row collapses to the one line that states it. setChanging(false); } catch { // The decision did not land. Say nothing about what the patient chose - we do not know whether // the write got through, and a control that claims a consent it may not have recorded is worse // than one that admits it failed. setPhase({ kind: 'error' }); } finally { setSaving(false); } }, [patientId, encounterId, applyToFutureVisits, saving]); /** * What the radios show. Derived from the RECORDED decision, never from a click - so re-opening the row * with "Change" pre-fills what the patient actually said rather than inviting a fresh guess, and a * failed write leaves nothing selected instead of showing an answer that was never stored. */ const selected: 'permit' | 'deny' | null = phase.kind === 'known' && phase.status.governedBy !== 'none' ? (phase.status.permitted ? 'permit' : 'deny') : null; // - FIRST, BEFORE EVERY OTHER BRANCH. The whole control disappears when the practice has // not bought AI Notes, including its `loading`, `error` and `unverifiable` rows: a spinner or a // "couldn't check" line for a capability that is off is the same wrong question in quieter clothing. // Not a disabled button and not an upsell - Spencer's ruling is that it does not show. if (!aiNotesEnabled) return null; if (!patientId || !encounterId) return null; if (phase.kind === 'loading') { return (
Checking recording consent…
); } const retry = ( ); if (phase.kind === 'unverifiable' || phase.kind === 'error') { return (
{/* 🚨 Careful wording. This says what WE could not do — it makes no claim about what the patient decided, because we do not know. "Recording is unavailable" would read as a refusal; "the patient has not consented" would be a statement about a real person we have no basis for. */} We couldn't check recording consent. The patient's previous decision is unchanged.
{retry}
); } const { status } = phase; const askedAlready = status.governedBy !== 'none'; /* E1 - ONE RADIO ROW, AND NOTHING IS WRITTEN UNTIL SOMEBODY ANSWERS. Radio, not a checkbox, and that is the whole safety property: consent has THREE states and a checkbox has two. Unchecked would conflate "nobody asked" with "the patient said no" - the exact collapse this component exists to prevent. NOTHING SELECTED NO CONSENT IS WRITTEN. We assume the decline for the purposes of recording (the button stays disabled and nothing can be captured) but we do NOT store one: writing a `deny` for a patient nobody asked puts a factually false assertion into an APPEND-ONLY clinical history - citable later, unremovable, and it would corrupt any "how many patients decline?" question for ever. `agrees declined` is also the mid-visit withdrawal path, so that case needs no special rule. */ if (!askedAlready || changing) { return (

May this visit be recorded to help draft the note?

{/* 📌 ONLY AFTER "AGREES", and only from this expanded state — you cannot grant standing consent to a patient who declined, and showing it up front puts a third control in a row that should hold two. It is a deliberate SECOND act rather than a checkbox brushed past on the way through: it covers every future visit at this practice. */} {selected === 'permit' && ( )} {saving && (

Recording their answer…

)}
); } /* COLLAPSED, AND SYMMETRICALLY - neither answered state keeps decision controls on screen. The bulk this redesign is removing came from showing the controls AFTER the decision was made. */ return (
{status.permitted ? : } {status.permitted ? (status.governedBy === 'standing' ? 'Recording permitted — at this practice' : 'Recording permitted for this visit') : (status.governedBy === 'standing' ? 'Patient declined recording at this practice' : 'Patient declined recording this visit')} {!status.permitted && ( Continue writing the note below. )}
); };