import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { formatFhirHumanName } from '../../../utils/fhirHumanName'; import type { Encounter, Patient } from 'fhir/r4'; import { Check, Loader2, TriangleAlert, PencilLine, Save } from 'lucide-react'; import { useEncounterClinicalNote, type NoteSaveStatus, type NoteDraftStatus, } from './useEncounterClinicalNote'; import { ScribeDraftPanel } from './ScribeDraftPanel'; import { ScribeConsentControl } from './ScribeConsentControl'; import { ScribeRecorderPanel } from './ScribeRecorderPanel'; import { useScribeRecordingSession } from '../../../services/scribe/ScribeRecordingOwner'; import { useOptionalScribeService, type DraftResolution } from '../../../services/ScribeService'; import { useOptionalBillingContext } from '../../../services/BillingContext'; import { useOptionalAuthServiceContext } from '../../../services/AuthService'; import { useNoteRecordProposals, type AlreadyFiled } from './useNoteRecordProposals'; import { useFiledToEncounter } from './useFiledToEncounter'; import { RECORD_ACTIONS } from '../../../utils/recordActions'; import type { RecordProposal } from '../../../utils/recordProposals'; import { describeWhereAddedItemsGo } from '../../../utils/describeFilingTarget'; import { AddAnythingElse, RecordProposals } from './RecordProposals'; import { FileProposedItem } from './FileProposedItem'; import { AddSomethingElse } from './AddSomethingElse'; import { CheckTheNote } from './CheckTheNote'; import { useFrequentRecordItems } from './useFrequentRecordItems'; const SAVE_LABEL: Record = { saving: Saving note to the patient's record…, saved: Note saved to record, unsaved: Draft · not in the patient's record, error: The note was not saved to the patient's record. Your text is still here., // Neither is offered again: the saved note is reviewed first, and the text and its draft stay as they are. conflict: The note in the record changed elsewhere, so your text was not saved. Your text is still here., unconfirmed: Whether the note reached the record could not be confirmed. Your text is still here; review the saved note before saving again., // An untouched note is still a draft, and the deck says so before anything is typed. idle: Draft · not in the patient's record, }; /** * E3 - what the autosave has done, said while the note is NOT yet in the record. * * EVERY ONE OF THESE ALSO SAYS "not in the record yet", and that repetition is deliberate. The tab * above this editor shouts `VISIBLE IN PATIENT'S RECORD` - which describes the MODE, not this text - and * with the draft now durable and readable by a colleague, "saved" on its own would be read as "filed". * * `draft-failed` must never be quiet. E3's whole promise is that the clinician stops thinking about * saving, so an unreported failure is worse than no autosave at all: they have been taught to trust it, * and they will close the tab. */ const DRAFT_LABEL: Record = { 'draft-saving': Saving draft…, 'draft-saved': Draft saved · not in the patient's record, 'draft-failed': Draft NOT saved — this text only exists in this browser. Copy it or use Save to record before leaving., // Says "another window", never "someone else". Since the draft is keyed per author this can only be // the same practitioner in two places, and blaming a colleague would send them chasing a person who is // not there. 'draft-conflict': This note changed in another window. Autosave is off. Copy your text before leaving., // An untouched note is still a draft, and the deck says so before anything is typed. idle: Draft · not in the patient's record, }; interface Props { patient?: Patient; encounter?: Encounter; } /** A user-paced retry, bounded: past this the draft is left to its retention rather than asked about again. */ const DRAFT_CLEANUP_ATTEMPTS = 3; type OwedCleanup = { jobId: string; working: boolean; attempts: number; why: 'unconfirmed' | 'already-saved' }; export const ClinicalNoteEditor: React.FC = ({ patient, encounter }) => { // WHERE AN OPENED ITEM BELONGS. This editor is not remounted when the practice, patient or visit changes, so an // item is held with the place it was opened in and is open only while that place holds: a form opened for one is // never shown, or filed, as another's. const auth = useOptionalAuthServiceContext(); const practiceId = auth?.currentOrganization?.id; const openedIn = `${practiceId ?? ''}|${patient?.id ?? ''}|${encounter?.id ?? ''}`; // A draft cleanup belongs to the viewer too. Each opening, and leaving, moves the number, so a late answer from // an earlier opening is ignored even after a return to the same visit. const cleanupScope = `${auth?.currentUser?.fhir?.id ?? ''}|${openedIn}`; const openingRef = useRef(0); useLayoutEffect(() => { openingRef.current += 1; return () => { openingRef.current += 1; }; }, [cleanupScope]); const { text, onChange, status, saveToRecord, mergeDraft, appendDraft, committedHolds, loaded, loadFailed, retryLoad, draftStatus, conflictingServerText, reviewSaved, reviewing, savedText, } = useEncounterClinicalNote(patient, encounter, cleanupScope); // The OPTIONAL accessor, deliberately. This is the primary surface a clinician writes a note on; // the scribe is a paid add-on layered onto it, and an add-on must never be able to take down note // writing. Absent no panel, no resolve, and a one-time console error - see useOptionalScribeService. const scribe = useOptionalScribeService(); // The AI Notes add-on. Only it reads a just-saved note with a model; unknown is not entitled. const aiNotesEnabled = useOptionalBillingContext()?.entitlements?.aiNotesEnabled === true; const canSave = !loadFailed && (status === 'unsaved' || status === 'error'); // WHAT THE NOTE JUST SAVED OFFERS TO PUT ON THE RECORD. It fires on save rather than behind a button // - Spencer: "on-demand-only means the practitioners who most need it are the ones who never press // the button." Nothing is filed by any of it; the clinician decides each item, one at a time. const filedToEncounter = useFiledToEncounter(); const filedEncounterId = encounter?.id; const alreadyFiled = useCallback( (kinds) => (patient && filedEncounterId ? filedToEncounter(kinds, patient, filedEncounterId) : Promise.resolve(new Set())), [filedToEncounter, patient, filedEncounterId], ); const recordProposals = useNoteRecordProposals(alreadyFiled); // WHAT THIS CLINICIAN ADDS MOST, kept on the server so it follows them between machines - Spencer's // ruling of 2026-09-04. Read through a tolerant accessor like everything else layered onto this // editor: absent its provider there is simply no shortcut group. const frequent = useFrequentRecordItems(); // The one item being added, if any. Held here rather than inside the list because the overlay that // files it reaches nine resource services, and mounting it only while an item is open keeps those out // of the note editor's own dependencies. const [addingId, setAddingId] = useState<{ id: string; openedIn: string } | undefined>(); // An item picked from the search rather than offered by the note. It is a proposal like any other - // the same overlay, the same required rule, the same write - it simply has nothing prefilled. const [chosen, setChosen] = useState<{ proposal: RecordProposal; openedIn: string } | undefined>(); // A DEPARTURE RETIRES WHAT WAS OPEN. Hiding an opening while elsewhere is not enough: coming back to the same // practice, patient and visit would match it again and revive the old form. So leaving retires both selections, in // the render that sees the change, and returning asks for a new choice. const [openingsFor, setOpeningsFor] = useState(openedIn); if (openingsFor !== openedIn) { setOpeningsFor(openedIn); setChosen(undefined); setAddingId(undefined); } // Filed and offering suggestions: the note compacts to leave them room. const filed = status === 'saved' && recordProposals.attempted; const chosenHere = chosen?.openedIn === openedIn ? chosen.proposal : undefined; const addingIdHere = addingId?.openedIn === openedIn ? addingId.id : undefined; const adding = chosenHere ?? recordProposals.proposals.find((entry) => entry.proposal.id === addingIdHere)?.proposal; // S6. The scribe draft is scaffolding: it is deleted once its content has actually reached // the chart, and NOT before. `acceptedDraft` is the recording this note's text came (partly) from, by // its job: a practitioner who never touched a draft does not destroy one by saving a note they typed, // and cleanup names that recording, never a newer one now at the same visit. const acceptedDraft = useRef<{ jobId: string; body?: string; opening: number } | null>(null); // A draft not added because the saved note may already hold an edited copy. Its cleanup stays unconfirmed. const [heldDraft, setHeldDraft] = useState<{ jobId: string; text: string } | null>(null); const [resolvedJobId, setResolvedJobId] = useState(null); // The panel closes only when the recording it shows is the one resolved. const [panelJobId, setPanelJobId] = useState(null); // Kept apart from the note's save status: a saved note stays saved whatever its cleanup does. const [owedCleanup, setOwedCleanup] = useState(null); // The draft was already in the note - a reopened visit - rather than just added to it. const [draftRestored, setDraftRestored] = useState(false); // Owned here rather than fetched twice: the consent control does the lookup and the recorder reads its // answer, so the two can never disagree on screen. const [recordingPermitted, setRecordingPermitted] = useState(false); // Owned here because it crosses BOTH scribe panels: the recorder knows audio was sent, and only the // draft panel can tell the practitioner what became of it. Without this the draft panel fetched once on // mount, so a note that arrives 60-90s later never appeared - watched a clinician wait 151 // seconds at an empty panel while the finished note sat on the server. const [recordingSentAt, setRecordingSentAt] = useState(null); const patientId = patient?.id; const encounterId = encounter?.id; // Where a host holds the recording (the meeting window), the send can finish while this editor is not // mounted; the session keeps when, so a reopened note still waits for its draft. const recordingSession = useScribeRecordingSession(encounterId, patientId); // E2 - the draft arrives on its own, so the arrival and the merge are two separate moments. // The merge must wait for the committed note to load (see `loaded` on the hook), and a draft can easily // land first: it is fetched by a sibling panel on its own schedule. const [arrivedDraft, setArrivedDraft] = useState<{ jobId: string; text: string; createdAtMs?: number; opening: number } | null>(null); const textareaRef = useRef(null); const caretRef = useRef<{ start: number; end: number; scrollTop: number } | null>(null); useEffect(() => { acceptedDraft.current = null; setResolvedJobId(null); setPanelJobId(null); setOwedCleanup(null); setHeldDraft(null); setDraftRestored(false); setArrivedDraft(null); // Cleared with the rest: a pending recording belongs to the encounter it was made in, and carrying it // across would promise the NEXT patient's note a draft that is being written for the previous one. setRecordingSentAt(null); }, [cleanupScope]); const onDraftReady = useCallback((jobId: string, draftText: string, createdAtMs?: number) => { setArrivedDraft({ jobId, text: draftText, createdAtMs, opening: openingRef.current }); setPanelJobId(jobId); }, []); useEffect(() => { if (!arrivedDraft || !loaded) return; if (arrivedDraft.opening !== openingRef.current) { setArrivedDraft(null); return; } /* THE CARET IS SAVED BEFORE THE MERGE, NOT AFTER. The draft lands 60-90 seconds after Finish, so the clinician is quite likely typing when it does - and a controlled