import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Check, TriangleAlert, Plus, X, Loader2 } from 'lucide-react'; import { useScribeServiceContext, type ScribeDraft, type ScribeNoteOutcome } from '../../../services/ScribeService'; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { appendWithheldSentence, renderDraftNote, type RenderedDraft, type WithheldSentence, } from '../../../services/scribe/renderDraftNote'; import { SCRIBE_DRAFT_RETENTION_DAYS, daysUntilDraftExpiry } from '../../../services/scribe/draftRetentionPolicy'; /** * Why the draft has no note, said from what became of the request: one with no answer is not a failure, * and one never made is neither. Drafts from before outcomes were recorded keep the failure wording. */ const missingNoteCopy = (outcome: ScribeNoteOutcome | undefined, hasTranscript: boolean): string => { if (outcome === 'unconfirmed') { return hasTranscript ? "We couldn't confirm the generated note. You can review the transcript." : "We couldn't confirm the generated note, and no transcript is available. Write your note below."; } if (outcome === 'not-sent') { return hasTranscript ? 'The note was not drafted from this recording. The transcript is below; write your note from it.' : 'The note was not drafted from this recording, and no transcript is available. Write your note below.'; } if (!hasTranscript) return 'The note could not be drafted from this recording, and no transcript is available. Write your note below.'; return 'The recording was transcribed, but the note could not be drafted from it. The transcript is below; write your note from it.'; }; /** * S6 - the review surface for a generated draft. * * WHAT THIS COMPONENT IS FOR * * Everything upstream in exists to produce this moment: a human reads a machine's account of a * consultation and decides what, if anything, becomes the record. So the panel is built around the two * things the practitioner has to be able to tell apart: * * 1. **What the recording supports** - offered as note text, one click to use. * 2. **What it does NOT** - held back, listed separately, each with its own explicit insert. * * IT NEVER WRITES TO THE RECORD ITSELF. The grounded text lands in the note editor and nothing more; * the practitioner's own Save to record is still the only thing that reaches the chart. One path for text * entering a patient's record, whoever composed it **E4**. * * It never CLEARS what the practitioner has already typed - it appends. Their words are the record; * the machine's are a suggestion arriving 60-90 seconds late, possibly mid-sentence. * * E2 REVERSED THIS COMPONENT'S CENTRAL RULE, AND THE REASON IS WORTH KEEPING * * This file used to say, in bold: *"IT NEVER AUTO-FILLS. A draft that silently appeared in the box would * be accepted by default by a busy clinician, which is the entire failure mode this feature has to * avoid."* **That was wrong**, and Spencer's ruling says why: the safety lives in the **COMMIT**, not in * the insert. With the draft already in an editable field the clinician still reads it, still edits it, * and still has to press Save to record. The extra click only ever confirmed that some text belonged in a * box they could already see and change - it bought no review that the Save does not buy. * * And it REMOVED a hazard rather than adding one: while the draft sat unused beside the note, saving * the note destroyed it (the audio is long gone), so the old flow had a silent way to lose the generated * text. With the draft in the note there is nothing left beside it to lose. * * WHAT DID NOT CHANGE, and must not: the **withheld** sentences below are the model's, not the * recording's, and each still takes a separate deliberate act to enter the note. Acceptance-by-default is * still exactly how a plausible fabrication becomes a clinical record - it was never the auto-fill that * made that dangerous, it was the ungrounded text. */ interface Props { patientId?: string; encounterId?: string; /** Current editor text, so a withheld-sentence insert appends rather than replaces. */ noteText: string; /** The recording the inserted text came from travels with it, so the editor knows which draft it used. */ onInsert: (nextText: string, jobId: string) => void; /** * E2 - a draft with grounded text is available for this encounter. * * This panel does NOT merge it. It reports the arrival and the editor decides, because the editor * owns the note text and the "has this draft already been merged" answer - and that answer has to * outlive this component, which unmounts every time the practitioner juggles encounters. */ onDraftReady: (jobId: string, draftText: string, createdAtMs?: number) => void; /** * When audio was last sent for this encounter, or null. Arms the wait below. * * A DRAFT ARRIVES 60-90 SECONDS AFTER THE RECORDER FINISHES, AND THIS PANEL FETCHED ONCE ON MOUNT. * watched a clinician wait **151 seconds** at an empty panel while the finished note already * sat on the server; a single reload showed it in full. Two harms, and the second is the bad one: * silence after a visit reads as failure, and the recovery a clinician reaches for - record it again - * **replaces the draft that was about to appear.** */ pendingSince?: number | null; /** The draft was already in the note when the visit was reopened; `held` when it was deliberately not added. */ restored?: boolean | 'held'; } /** * How often to look for a draft that is expected, and for how long. * * 8s is chosen against what it costs: this only runs after a recording was sent, only until the draft * lands, and the request is metadata-sized. 6 minutes is well past the ~90s the pipeline takes - long * enough to cover a slow Transcribe queue, short enough that a job which genuinely died stops pretending. * Deliberately NOT an unconditional poll: a panel that polled on every encounter forever would spend a * clinician's whole day asking about drafts that were never recorded. */ const DRAFT_POLL_MS = 8_000; const DRAFT_POLL_WINDOW_MS = 6 * 60 * 1000; type Segment = ScribeDraft['segments'][number]; const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`; /** * The transcript the draft keeps, read-only, in the order it was spoken. The speaker label is the * transcriber's (spk_0, spk_1) and is shown as given: it separates voices, it does not say who spoke. */ const DraftTranscript = ({ segments }: { segments: Segment[] }) => (
Transcript
    {segments.map((segment, index) => (
  1. {segment.startSeconds !== null && {clock(segment.startSeconds)}} {segment.speaker} {segment.text}
  2. ))}
); const REFUSED_TEXT: Record = { 'session-over-cap': 'This recording was not transcribed: it ran past the one-hour limit for a session. Record the rest of the visit as a new session.', 'recording-unreadable': 'This recording was not transcribed: its audio could not be read. Record the visit again if you still need a draft.', 'ceiling-reached': 'This recording was not transcribed: this clinician has reached the monthly recorded-minute limit for AI Notes.', }; export const ScribeDraftPanel: React.FC = ({ patientId, encounterId, noteText, onInsert, onDraftReady, pendingSince, restored = false, }) => { // Read through a ref, and the effect depends only on the ids - see the note on ScribeConsentControl. // The provider returns a new object every render, so depending on `getDraft` re-runs this effect on // every render, which sets state, which renders. Inside `ClinicalNoteEditor` that spins the NOTE // EDITOR, not just the scribe. const service = useScribeServiceContext(); const serviceRef = useRef(service); serviceRef.current = service; // Same reason as the service above: read through a ref so the arrival effect depends on the DRAFT and // nothing else. A callback identity change must never be able to re-fire a merge. const onDraftReadyRef = useRef(onDraftReady); onDraftReadyRef.current = onDraftReady; const [draft, setDraft] = useState(null); const [rendered, setRendered] = useState({ text: '', withheld: [] }); const [loading, setLoading] = useState(false); const [dismissed, setDismissed] = useState(false); const [inserted, setInserted] = useState>(new Set()); const [checkingAgain, setCheckingAgain] = useState(false); const [stillWaiting, setStillWaiting] = useState(false); // Which visit this review is for, so an answer that arrives after it has moved on is dropped. const visitRef = useRef({ patientId, encounterId }); visitRef.current = { patientId, encounterId }; const forThisVisit = (result: ScribeDraft | null, asked: { patientId?: string; encounterId?: string }) => Boolean(result) && asked.patientId === visitRef.current.patientId && asked.encounterId === visitRef.current.encounterId && (!result?.patientId || result.patientId === asked.patientId) && (!result?.encounterId || result.encounterId === asked.encounterId); // Which review is on screen. It moves when the visit changes and when the panel unmounts, so a Check again // pressed in one review can never write into another, even one for the same visit reopened (A, B, A). const reviewRef = useRef(0); const checkRef = useRef(0); useEffect(() => { // Encounter-scoped guard: a slow fetch must never land on a different encounter's panel. let current = true; reviewRef.current += 1; setCheckingAgain(false); setStillWaiting(false); setDraft(null); setRendered({ text: '', withheld: [] }); setDismissed(false); setInserted(new Set()); if (!patientId || !encounterId) return () => { current = false; }; setLoading(true); const asked = { patientId, encounterId }; void serviceRef.current.getDraft(patientId, encounterId) .then((result) => { if (!current) return; const ours = forThisVisit(result, asked) ? result : null; setDraft(ours); setRendered(renderDraftNote(ours?.note)); }) .catch(() => { // A draft that cannot be fetched is not an error the practitioner can act on, and this // panel is additive to a note they can write by hand regardless. Stay silent rather than // putting a failure banner above a working editor. if (current) setDraft(null); }) .finally(() => { if (current) setLoading(false); }); return () => { current = false; reviewRef.current += 1; }; }, [patientId, encounterId]); /** * A SEPARATE EFFECT FROM THE MOUNT FETCH ABOVE, ON PURPOSE. That one RESETS `dismissed` and * `inserted` every time it runs, which is right when the encounter changes and destructive on a poll: * folding polling into it would un-dismiss the panel and forget which withheld sentences the * practitioner had already added, every 8 seconds. */ const [gaveUp, setGaveUp] = useState(false); // One read on request, once polling has stopped. Nothing here asks for a reload, which would lose // a note whose draft autosave had failed. // Its answer, failure and end all count only while the review it was pressed in is still on screen and it // is still the latest press there. const checkAgain = async () => { if (!patientId || !encounterId) return; const review = reviewRef.current; const press = ++checkRef.current; const ours = () => review === reviewRef.current && press === checkRef.current; setCheckingAgain(true); setStillWaiting(false); const asked = { patientId, encounterId }; try { const result = await serviceRef.current.getDraft(patientId, encounterId); if (!ours()) return; if (result && forThisVisit(result, asked)) { setDraft(result); setRendered(renderDraftNote(result.note)); } else { setStillWaiting(true); } } catch { if (ours()) setStillWaiting(true); } finally { if (ours()) setCheckingAgain(false); } }; useEffect(() => { setGaveUp(false); if (!pendingSince || !patientId || !encounterId || draft) return undefined; let current = true; const timer = setInterval(() => { if (Date.now() - pendingSince > DRAFT_POLL_WINDOW_MS) { clearInterval(timer); // Said out loud rather than reverting to an empty panel. An empty panel is the state // that means "nothing was recorded", and this is not that - a job ran and we stopped // waiting for it. if (current) setGaveUp(true); return; } const asked = { patientId, encounterId }; void serviceRef.current.getDraft(patientId, encounterId) .then((result) => { if (!current || !result || !forThisVisit(result, asked)) return; setDraft(result); setRendered(renderDraftNote(result.note)); }) // Silent, like the mount fetch: this panel is additive to a note the practitioner can // write by hand, and a failure banner over a working editor helps nobody. .catch(() => {}); }, DRAFT_POLL_MS); return () => { current = false; clearInterval(timer); }; }, [pendingSince, patientId, encounterId, draft]); /** * E2 - hand the grounded text to the editor as soon as it exists. * * Fires again on every poll tick that produces a new draft object, and that is fine BY DESIGN * rather than by luck: the editor's merge is keyed on the draft's `jobId`, so a repeat is a no-op. * Idempotency belongs there, where the answer survives this component unmounting. */ /** * The same arrival, told to anything COUNTING drafts rather than displaying one. * * This component is where the fact becomes true - it is the thing that watched the draft land. The * "Waiting on you" card on the same screen refetches on a ten-minute interval and had no way to know, * so it sat four minutes behind the panel beside it, about a note the clinician was looking straight * at. `useOwedWork` re-asks on this signal. * * ONCE PER JOB, and that is not belt-and-braces. The effect above deliberately re-fires on every * poll tick that yields a new draft object - harmless there, because the editor's merge is idempotent * by `jobId`. A notification is NOT idempotent: it costs a `/scribe/drafts` request each time. * Kept in its own effect so the merge above stays exactly as it was. */ const notifiedJobRef = useRef(null); useEffect(() => { if (!draft || !rendered.text.trim()) return; onDraftReadyRef.current(draft.jobId, rendered.text, draft.createdAtMs); }, [draft, rendered.text]); useEffect(() => { if (!draft || notifiedJobRef.current === draft.jobId) return; notifiedJobRef.current = draft.jobId; serviceRef.current.notifyDraftsChanged(); }, [draft]); const insertWithheld = useCallback((sentence: WithheldSentence, key: string) => { if (!draft) return; onInsert(appendWithheldSentence(noteText, sentence), draft.jobId); setInserted((previous) => new Set(previous).add(key)); }, [draft, noteText, onInsert]); if (loading) { return (
Checking for a recorded draft…
); } /* THE WAITING STATE, AND IT IS THE ACTUAL FIX - polling alone would only have made the draft appear eventually to someone still watching an empty space. A clinician who finishes a visit and sees NOTHING concludes it failed, and the recovery they reach for destroys the draft that is on its way. Saying "this takes a minute or two" is what stops that. */ if (!draft && !dismissed && pendingSince && !gaveUp) { return (
Transcribing and drafting the note… This usually takes a minute or two. Keep typing; the draft will appear here.
); } if (!draft && pendingSince && gaveUp && !dismissed) { return (
{/* ⚠️ Does NOT say the recording was lost — we do not know that, and a reload is genuinely the thing that has surfaced a finished draft before. It also does not suggest recording again, which would replace a draft that may yet arrive. */} The draft hasn't arrived yet. It may still be processing. Check again before recording the visit again. {stillWaiting ? " Still not ready." : ""}
); } if (!draft || dismissed) return null; if (draft.refusedReason) { return (
setDismissed(true)}> {REFUSED_TEXT[draft.refusedReason] ?? REFUSED_TEXT['recording-unreadable']}
); } // WAS `Math.ceil`, and this is the surface the clinician is standing in while deciding whether to // finish the note now or tomorrow - so it was the one place a day too many actually costs the note. // Measured: this panel said "27 days left" while the overview said "Expires in 26 days" about the same // draft in the same minute; 26.15 remained. One rule, in `draftRetentionPolicy`, rounded DOWN. const daysLeft = Math.max(0, daysUntilDraftExpiry(draft, Date.now())); const hasText = rendered.text.trim().length > 0; const segments = draft.segments ?? []; const hasTranscript = segments.length > 0; return ( // A named region, not a bare div: this is machine-generated content sitting next to a clinician's // own writing, which is exactly the kind of boundary a screen-reader user needs announced. It also // gives E4's guard something stable to scope to - "no control in HERE commits".
{/* Success only when supported text reached the note; otherwise the heading stays neutral. */} {hasText && } {!hasText ? (hasTranscript ? 'Recording transcribed' : 'Recording processed') : restored === 'held' ? 'Recording draft kept, not added to your note' : restored ? 'Your saved draft is restored' : 'Recording draft added to your note'}
{/* 🚨 Said plainly, never implied by an empty box. The transcript survived and the note did not; a practitioner shown nothing would read it as "the consultation contained nothing". */} {draft.noteGenerationFailed && (
{missingNoteCopy(draft.noteOutcome, hasTranscript)}
)} {/* Said from the response: an empty note from a real transcript is not the same as no transcript. */} {!hasText && !draft.noteGenerationFailed && (

{hasTranscript ? 'No supported clinical note text was generated. Review the transcript or write your note below.' : 'No transcript is available to review. Write your note below.'}

)} {hasTranscript && } {/* 🚨 A STATEMENT, NOT A COPY OF THE TEXT. The draft is in the note below, so repeating it here would show the clinician the same paragraphs twice and leave them unsure which one they are editing. What they need from this panel is the one fact the note itself cannot tell them: where that text came from, and that it is not in the chart yet. ⭐ Past tense on purpose — it stays true after they edit or delete the text. */} {hasText && (

Review and edit below. It is not in the patient's record yet.

)} {hasText && (
Recording draft details {rendered.withheld.length === 0 && (

No statements were flagged as unsupported by the recording.

)}

Your editable note is saved separately from the recording draft.

)} {/* 🚨 THE PART THAT MATTERS MOST. These sentences are the model's, not the recording's. They are SHOWN — hiding them would repeat, one layer down, the failure the server deliberately avoided by flagging rather than dropping — but each takes a separate, deliberate act to enter the note. */} {rendered.withheld.length > 0 && (
Not supported by the recording — check before using
{rendered.withheld.map((sentence, index) => { const key = `${sentence.heading}:${sentence.text}:${index}`; const alreadyInserted = inserted.has(key); return (
{sentence.heading && ( {sentence.heading} )} {sentence.text}
); })}
)} {/* The retention window, stated while the practitioner can still act on it. Retention without this warning is data loss wearing a compliance justification — the audio is already deleted, so an expired draft is unrecoverable. */}
{daysLeft > 0 ? `This draft is kept for ${SCRIBE_DRAFT_RETENTION_DAYS} days and is deleted once you save the note — ${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} left.` : 'This draft expires today. The recording it came from has already been deleted.'}
); };