// THE ONE IN-CALL SURFACE ( sub-task 5a). It replaces BOTH surfaces this feature used to // arbitrate between: `FloatingCallSurface` (deleted with this change) and the video chrome that was // docked inside `EncounterWorkspace`. One Zoom-style window that follows the user around the app. // // WHY THE ARBITRATION RETIRED WITH THEM. `dockedSurfaceMounted` existed for exactly one reason: // EXACTLY ONE `VideoTileGrid` may be mounted at a time, or a tile id binds to two elements and the SDK's // id-keyed unbind tears down whichever it reaches first. With ONE surface that rule is STRUCTURAL rather // than arbitrated - there is no second place a grid can mount. Keeping the flag would have preserved the // "two surfaces answering one question" shape that produced this feature's two worst shipped defects: // 1. a PATIENT handed a "docked" verdict when no docked surface could exist for them, leaving a // Leave-only stub as their entire view of the visit; // 2. a live, BILLED, audible meeting with no Leave button anywhere in the app, once the practitioner // navigated off the encounter and the docked pane unmounted with the workspace. // Both are unrepresentable now: the surface is mounted app-wide by `CallOverlay` and depends on nothing // but the call itself. // // WHAT DID *NOT* GO WITH THE DOCKED CHROME is the Start-video button - see the header of // `EncounterVideoPane.tsx`. Starting from there is what makes the call an Encounter. // // SUB-TASK 5b - IT COLLAPSES LIKE ZOOM AS SPACE RUNS OUT, and the whole of that decision lives in TWO // pure functions with a hard line between them: `resolveMeetingPanels` says WHICH panels a viewer may // have (a disclosure boundary - the encounter note has a private mode "not shown to the patient", and a // patient sits in these visits), and `resolveMeetingLayout` says only HOW the panels they already have // are arranged as the window narrows. This component builds the entitled panel list ONCE and hands it // down; nothing downstream can add to it, so a layout change is structurally incapable of revealing a // panel. Never re-derive entitlement from width, and never render a panel outside that list. // // Pure presentation, like everything else in this folder: no SDK, no transport, no fetch. The media lives // in `ChimeCallSession`, which is why the call is untouched by anything this component does. import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'; import { useFollowNewest } from './useFollowNewest'; import type { Communication, Patient, Practitioner } from 'fhir/r4'; import clsx from 'clsx'; import { toast } from 'sonner'; import { describeServerRefusal } from '../../utils/serverRefusalMessage'; import { namedSend } from '../../utils/messaging/sendOperation'; import { MAY_HAVE_BEEN_SENT } from '../../hooks/useHeldSendOperations'; import { Lock, MessageSquare, NotebookPen, SendHorizontal, Stethoscope, StickyNote, Upload, Users, X, type LucideIcon } from 'lucide-react'; import { useAuthServiceContext } from '../../services/AuthService'; import { useVideoServiceContext } from '../../services/VideoService'; import type { CallPhase } from '../../services/video/callMachine'; import { encounterContext } from '../../services/video/videoContract'; import { roomPresence, roomPresenceCopy } from '../../services/video/roomPresence'; // The MODULE, not the `../../services` barrel - the same rule every other file in this folder follows // (see `InviteCareTeamButton.tsx`): the barrel drags the whole service graph in behind it. import { useCommunicationServiceContext } from '../../services/CommunicationService'; import { useActiveEncounterContext } from '../../pages/DashboardPage/ClinicianOverviewPage/ActiveEncounterContext'; import { isVideoPrevisit } from '../../utils/videoPrevisit'; // Task 5/Task 8: the two message-thread reads this window's `messages` panel renders. Neither pulls the // services barrel itself - both import `useCommunicationServiceContext` the SAME way this file does, from // the module, so the "no barrel in this folder" rule holds transitively too. // That was NOT true when first written: both hooks imported from `../services`, so this folder dragged // the whole service graph in through them while the comment claimed the opposite. Fixed at the source (the // hooks now import the module) rather than by softening the claim - a transitive barrel import is the same // cost as a direct one, and a rule that only holds one level deep is not a rule. import { useEncounterRoomThreadMessages } from '../../hooks/useEncounterRoomThreadMessages'; import { useRoomSelfReference } from '../../hooks/useRoomSelfReference'; import { useCollaborationTeamThreadMessages } from '../../hooks/useCollaborationTeamThreadMessages'; import { isEncounterRoomThreadId, extractEncounterIdFromRoomThreadId, buildEncounterRoomThreadId, } from '../../utils/encounterRoomThread'; import { isCollabTeamThreadId, extractTeamIdFromThreadId, buildCollabTeamThreadId, buildCollabTeamAsideThreadId, } from '../../utils/collaborationTeamThread'; import { ResourceListStatus } from '../ui/resource-list-status'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; // THE SAME COMPONENT THE WORKSPACE AND THE POP-OUT PANE RENDER, reused WHOLE - public/private toggle // included. A note-only reimplementation is exactly how the "Private - org only, not shown to the // patient" distinction gets quietly lost, and this surface is the one a PATIENT can be sitting in. import { ScribeRecordingScope } from '../../services/scribe/ScribeRecordingOwner'; import { useViewerEpoch } from '../../services/scribe/recordingDisposition'; import { ScribeRecordingBar } from '../../pages/DashboardPage/ClinicianOverviewPage/ScribeRecorderPanel'; import { EncounterDocumentation } from '../../pages/DashboardPage/ClinicianOverviewPage/EncounterDocumentation'; import { EncounterChartPanel } from '../../pages/DashboardPage/ClinicianOverviewPage/EncounterChartPanel'; import { formatFhirHumanName } from '../../utils/fhirHumanName'; // THE PATIENT IS NOT RE-DERIVED HERE, deliberately. This hook carries a RENDER-TIME identity gate that // exists because of a shipped defect: the fetched patient lags the active encounter by a commit, and // pairing a stale patient with a new encounter produced a wrong-SUBJECT clinical write - one patient's // note filed under another. Resolving it locally would re-arm that bug in a second place. import { useActiveEncounterPatient } from '../../pages/DashboardPage/ClinicianOverviewPage/useActiveEncounterPatient'; import { useMeetingVisitPatientId } from './useMeetingVisitPatientId'; import { InvitedPrivateNotes } from './InvitedPrivateNotes'; import { FloatingWindow, useFloatingWindowHeaderSlot, useFloatingWindowWidth } from '../ui/floating-window/FloatingWindow'; import { createPortal } from 'react-dom'; import { mayShowFromRecord, resolveMeetingPanels, type MeetingPanel, type MeetingPanelInputs, type MeetingViewerRole } from './meetingPanels'; import { ShownRecordItem } from './ShownRecordItem'; import { ShowFromRecordPicker } from './ShowFromRecordPicker'; import { useParticipantNames } from './useParticipantNames'; import { parseParticipantReference } from './participantIdentity'; // sub-task 5b. The ladder decides HOW the panels this viewer already has are shown as space // runs out; `resolveMeetingPanels` above decides WHICH ones they have at all. Two functions, and that // separation is the load-bearing design decision of this sub-task - see `MeetingWindowBody` below. import { resolveMeetingLayout, LADDER_WIDE_MIN, meetingWindowRect } from './meetingLadder'; import { MeetingBarPanelsContext, type MeetingBarPanels } from './meetingBarPanels'; import { threadUnreadCount, withReadReceipts } from '../../utils/messaging/readReceipts'; import { acknowledgeOnce, createReceiptLedger, unconfirmedOf } from '../../utils/messaging/receiptLedger'; // The composer's unsent text, held OUTSIDE the component the ladder is free to unmount. See the header // of that file: the medium/narrow rungs mount only the ACTIVE panel body, so a `useState` in the composer // was losing a mid-typed clinical message on every tab switch, resize across a rung, and minimise/restore. import { clearComposerDraft, composerDraftRevision, readComposerDraft, writeComposerDraft } from './meetingComposerDrafts'; import { useScopedSendRecovery, type ScopedSendRecovery } from '../../hooks/useScopedSendRecovery'; import EarlierSendCheck from '../messages/EarlierSendCheck'; import { VideoTileGrid } from './VideoTileGrid'; import { ShownRecordStage } from './ShownRecordStage'; import type { ShownItemSummary } from './shareableRecordItem'; import { useShownRecordItem } from './useShownRecordItem'; import { CallControls } from './CallControls'; import { MeetingParticipantsPanel } from './MeetingParticipantsPanel'; import './MeetingWindow.css'; /** STATIC, and it must stay static. `FloatingWindow` persists geometry under * `akashic_floating_window_`, so an id keyed by encounter or patient would write a record identifier * into a localStorage key that survives logout on a shared clinic workstation. ("Geometry is not PHI" is * true of the persisted VALUE - it is not a licence to make the KEY an identifier.) It is also a * mount-stable assumption inside `useFloatingGeometry`; see the JSDoc on `FloatingWindowProps.id`. */ const MEETING_WINDOW_ID = 'meeting-window'; // PHASE-derived and only phase-derived. "Waiting for the patient" is deliberately NOT in here: it is not // a phase (there is no ringing under the meeting model), and it comes from `roomPresence` instead. const STATUS: Partial> = { creating: 'Starting the meeting…', joining: 'Connecting…', ending: 'Leaving…', }; /** What the window is called - and, because `FloatingWindow` collapses to a single button carrying its * title, what a MINIMISED live call reads as. It has to name the meeting well enough to be clicked back * open. Same context split the join banner uses. * * Takes the ALREADY-DERIVED kind, never the raw context string. It used to re-parse the string with a * third `startsWith`, so the title could disagree with the panels about what kind of meeting this is. */ // The host's visit is named for the patient and the patient's for their clinician, as the deck titles // each side of the call; the subtitle carries the rest. /** The visit's clinician is the practitioner who started the meeting. Arrival order is never used: an * invited clinician joining first, or the host reconnecting, would otherwise be named instead. */ export const visitClinicianReference = (initiator: string | undefined): string | undefined => parseParticipantReference(initiator)?.type === 'Practitioner' ? initiator : undefined; export const windowTitle = ( contextKind: MeetingPanelInputs['contextKind'], patientName?: string, clinicianName?: string, ): string => { if (contextKind === 'encounter') return patientName ?? clinicianName ?? 'Video visit'; if (contextKind === 'collabteam') return 'Case conference'; return 'Meeting'; }; /** An invited clinician's visit is named for the patient like the host's, and says why they are there. */ export const windowSubtitle = ( { hostPatient, previsit, invitedPatient, clinicianName, organizationName }: { hostPatient: boolean; previsit?: boolean; invitedPatient: boolean; clinicianName?: string; organizationName?: string }, ): string | undefined => { if (hostPatient) return previsit ? 'waiting room' : 'encounter in progress'; if (invitedPatient) return 'invited practitioner'; return clinicianName ? organizationName : undefined; }; interface MeetingStripTab { key: string; label: string; icon: LucideIcon; testId: string; /** Amber marks the practitioner-only aside - the ONE tab whose selected state is a disclosure * statement rather than a preference, so it must never look like the others. */ tone?: 'primary' | 'amber'; } /** THE ONE TAB STRIP THIS WINDOW HAS, and both things that need one use it: the collaboration-team * aside toggle (sub-task 5a) and the ladder's medium/narrow rungs (5b). This markup WAS the aside * toggle's - extracted rather than copied, so a second, subtly-different tab implementation cannot come * to exist in this file and drift from it. * * `iconsOnly` is the narrow rung: the same control minus the words there is no room for. The label * survives as the accessible name and the tooltip either way, so an icon-only strip is not an unlabelled * one - a control a viewer cannot name is not a control. * * It renders only what it is HANDED. It has no access to the panel resolver, the viewer's role or the * call, so no tab can appear here that its caller did not already decide to pass. */ const MeetingTabStrip = ({ tabs, activeKey, onSelect, iconsOnly = false, testId }: { tabs: MeetingStripTab[]; activeKey: string | null; onSelect: (key: string) => void; iconsOnly?: boolean; testId?: string; }) => (
{tabs.map((tab) => { const selected = tab.key === activeKey; const Icon = tab.icon; return ( ); })}
); interface MeetingThreadPanelProps { messages: Communication[]; loading: boolean; error: string | null; selfReference: string | null; /** Resolves `true` only when the text reached the record. Forwarded verbatim to the composer, which * keeps its draft until that resolves. * Widening this back to `=> void` type-checks at neither end but breaks the composer at runtime in * the OPPOSITE direction to the defect it was written for: `await undefined` is falsy, so every * SUCCESSFUL send would look failed and the text would be restored into the composer - one Enter away * from being sent to the visit twice. Pinned by "does NOT restore a message that was already sent". */ onSend: (text: string, owns: () => boolean) => Promise; /** * A refusal to show INLINE, above the composer, where the person who just pressed Send is looking. * * NOT A TOAST, and that is measured rather than preferred. A practitioner alone in a live visit * pressed Send three times; the server refused each one with a sentence written for them to read, * and nothing appeared on screen at all. `serverRefusalMessage.ts` says why in its own header: a * toast is `position: fixed` inside a provider tree mounted into a practice's WordPress page, and * this window is dragged and transformed, so a transformed ancestor can put it off-screen. A toast * raised from the ordinary Messages page in the same session DID render - so it is this tree. */ refusal?: string | null; /** An earlier send here not yet confirmed: Check it before another is sent. */ recovery?: ScopedSendRecovery; placeholder: string; /** WHICH thread this panel composes into - the key the composer's unsent text is remembered under. * `null` for a context whose thread id could not be derived, which is also a context `onSend` drops: * no id, no send, no draft. Derived by the CALLER from the ids at the top of `MeetingWindow`, never * re-sliced from `call.context` here. */ threadId: string | null; /** Saves this reader’s receipts once the thread is on screen; the panel mounts only while it is shown. */ onRead?: (updated: Communication[]) => Promise; /** A receipt of this reader's was not confirmed: the messages are fine, only "read" may not be recorded. */ receiptsUnconfirmed?: boolean; /** Sends those unconfirmed receipts once more, at the reader's request. */ onRetryReceipts?: () => void; /** Not admitted to the room yet: nothing has been read, so nothing is claimed about it. */ waiting?: boolean; /** The server said this viewer is not in the conversation, as opposed to the read failing. */ refused?: boolean; /** The room's entitlement has not caught up with the meeting yet (`room:'pending'`, or a 503 * `ROOM_PUBLICATION_PENDING` read). The call stays usable; only the conversation is still arriving. */ pending?: boolean; /** One explicit read again after a failed or refused read; never repeated on its own. */ onRetry?: () => void; } /** The message list + composer for ONE thread (Task 8). Shared by the `encounter#` room thread and * both halves of a `collabteam#` conference - the only thing that differs between call sites is * WHICH thread's `messages`/`loading`/`error`/`onSend` it is handed; which thread that IS is decided * entirely by the caller, never by this component. * * `error` takes priority over the empty state via `ResourceListStatus` ( shared rule, * reused rather than re-invented here): a failed read must never render as "no messages", which * would be a false statement about the visit's record - the viewer cannot tell "could not load" from * "nobody has said anything" unless the two are spelled differently on screen. */ const MeetingThreadPanel = ({ messages, loading, error, waiting = false, refused = false, pending = false, onRetry, selfReference, onSend, refusal, recovery, placeholder, threadId, onRead, receiptsUnconfirmed = false, onRetryReceipts }: MeetingThreadPanelProps) => { useEffect(() => { if (!onRead || !selfReference) return; const updated = withReadReceipts(messages, selfReference, new Date().toISOString()); if (updated.length === 0) return; const timer = setTimeout(() => { void onRead(updated); }, 1000); return () => clearTimeout(timer); }, [messages, selfReference, onRead]); const { listRef, onScroll } = useFollowNewest(messages[messages.length - 1]?.id); return (
{waiting && (

Messages open once you have joined the visit.

)} {/* The call stays usable while the room entitlement catches up - this is not a failure, so it never goes through `ResourceListStatus`, which would report it as one. */} {!waiting && pending && (
The visit chat is still being set up. {onRetry && ( )}
)} {!waiting && !pending && } {error && onRetry && !loading && (
)} {receiptsUnconfirmed && onRetryReceipts && !waiting && !error && (
Your read receipt could not be confirmed, so the sender may not see that you read the latest messages.
)} {/* Never rendered alongside the failure banner above — `ResourceListStatus` already refuses to say both at once, and an unconditional list here would otherwise sit under the banner showing nothing, reading as a second, unlabelled claim of emptiness. */} {!error && !waiting && (
{messages.map((message) => { const mine = !!selfReference && message.sender?.reference === selfReference; const text = message.payload?.find((p) => p.contentString)?.contentString ?? ''; return (
{!mine && message.sender?.display && (

{message.sender.display}

)}

{text}

); })}
)} {/* Above the composer rather than below it: the draft is still in the box - deliberately, so the text is never lost - and a notice under it would sit below the thing the reader's eye returns to. */} {refusal && (

{refusal}

)} {recovery &&
}
); }; /** THE TYPED TEXT LIVES IN `meetingComposerDrafts`, NOT IN THIS COMPONENT - and that is the fix rather * than a refactor. `useState` here was safe only while the composer was permanently mounted, which it was * until the collapse ladder: at the `tabs` and `icons` rungs only the ACTIVE panel body mounts, so * clicking Notes and coming back - or crossing a rung boundary, or minimising and restoring - unmounted * this input and silently destroyed a half-written clinical message. Same defect class as, and * fixed the same way its two siblings in this feature were: the draft survives OUTSIDE the thing a layout * change may unmount. The `useState` below is now just the controlled-input mirror of that store. * * IN MEMORY ONLY - never localStorage/sessionStorage. Message content is PHI, and this window already * writes a geometry key that survives logout on a shared clinic workstation; nothing clinical goes near * browser storage. (Constraint carried over from the note-draft store verbatim.) * * MODULE-SCOPED, NOT DEFINED INSIDE `MeetingWindow`. A component defined inside another * component's render body is a NEW function identity every render, which React remounts rather than * updates - that would wipe whatever the user was mid-typing on every unrelated re-render this * window gets (a message arriving, presence changing, the phase ticking over). */ type MeetingSendResult = 'sent' | 'failed' | 'retired'; let meetingComposerOpenings = 0; const MeetingMessageComposer = ({ threadId, onSend, placeholder, blocked = false, onOpening }: { threadId: string | null; /** `sent` when the message reached the record; `failed` when the text must be given back - see `submit`; * `retired` when the send no longer belongs to what is on screen, which then changes nothing. */ /** `owns` is this composer opening; the parent publishes nothing for a send once it is false. */ onSend: (text: string, owns: () => boolean) => Promise; placeholder: string; /** An earlier send here is unconfirmed; nothing new goes until it is checked. */ blocked?: boolean; onOpening?: () => () => void; }) => { // This opening of the composer on this thread. Never reused: a switch away and back, an unmount or a reopen // starts a new one, so a late answer for an earlier opening touches neither the box nor the stored draft. const opening = useRef(0); useLayoutEffect(() => { meetingComposerOpenings += 1; opening.current = meetingComposerOpenings; const retireRecovery = onOpening?.(); return () => { opening.current = -1; retireRecovery?.(); }; }, [threadId, onOpening]); const [value, setValue] = useState(() => readComposerDraft(threadId)); // RE-SEEDED WHEN THE COMPOSER IS POINTED AT A DIFFERENT THREAD WITHOUT UNMOUNTING - the case // conference does exactly that when the practitioner-aside toggle flips, and carrying the team draft // across into the aside composer would put text written for the team one Enter away from a different // audience. Mirrors `EncounterDocumentation`'s own re-seed on a changed encounter id. // // `useLayoutEffect`, NOT `useEffect`, and the difference is user-visible. The aside toggle does not // remount this composer (the banner above it is a sibling, so the child index is stable), so with a // PASSIVE effect there is one committed frame showing the TEAM's text inside a composer already // labelled "Message practitioners only..." and wearing the amber private banner. That frame is a // wrong-audience display, however brief. A layout effect runs before the browser paints it. // Testing-library flushes passive effects inside `act()` before every assertion, so no unit test in // this repo can see the difference - the reason it is written this way lives here, not in a test. useLayoutEffect(() => { setValue(readComposerDraft(threadId)); }, [threadId]); const change = (next: string) => { setValue(next); writeComposerDraft(threadId, next); }; const submit = async () => { const text = value.trim(); if (!text || blocked) return; const sentFrom = { opening: opening.current, threadId, revision: composerDraftRevision(threadId) }; const owns = () => opening.current === sentFrom.opening; // OPTIMISTIC: the composer empties the instant Enter is pressed, so the message visibly leaves. // THE DRAFT IS DELIBERATELY *NOT* CLEARED HERE. Until the send resolves, the store holds the // only copy of that text - which is what makes the "Please try again" toast a retry the // practitioner can actually perform, and what lets the text survive the panel body being // unmounted (going to look at the note at the `tabs` rung) while the send is still in flight. // Note `setValue` is used rather than `change`, precisely so the store is left alone. setValue(''); const result = await onSend(text, owns); if (result === 'retired' || !owns()) return; if (result === 'sent') { // In the record now: nothing left to restore, and the map holds no PHI it does not need to - // unless it was written again since, in which case that newer text is the draft. if (composerDraftRevision(sentFrom.threadId) === sentFrom.revision) clearComposerDraft(sentFrom.threadId); return; } if (composerDraftRevision(sentFrom.threadId) !== sentFrom.revision) return; // The send failed. Give the text back - but NEVER over the top of something newer: the send is // asynchronous and the practitioner may have started typing the next message while it was in // flight. Whatever they typed most recently wins, and the store already agrees with it. setValue((current) => (current === '' ? text : current)); }; return (
change(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} placeholder={placeholder} aria-label="Message" />
); }; /** ONE granted, currently-open panel: what to show, and what to call it when there is only room for its * name - or only for its icon. * * BUILT IN `MeetingWindow` FROM THE RESOLVER'S LIST AND HANDED DOWN. The ladder is never given a panel * `resolveMeetingPanels` withheld, which is what makes a layout change *structurally incapable* of * revealing one: there is no width, no rung and no click that can add an entry to this array. */ interface MeetingPanelEntry { /** The resolver's own key. The wrapper testid derives from it, so the suite's disclosure assertions * read the same name at every rung. */ key: MeetingPanel; label: string; icon: LucideIcon; /** The panel's own skin, carried over verbatim from the fixed layout this replaced. Positioning * (`flex-1` and friends) comes from the rung instead - the same panel is a column at one rung and a * tab body at another. */ className?: string; body: ReactNode; /** Unread messages, when a count can be read. */ count?: number; } interface MeetingWindowBodyProps { /** `panels.includes('video')` - the resolver's verdict, passed in, never re-derived here. */ hasVideo: boolean; /** Already entitlement-filtered AND already open. See `MeetingPanelEntry`. */ openPanels: MeetingPanelEntry[]; /** * (E11) - how many people are in the call, self included, so the ladder can tell a 1:1 from * a collaboration visit. Computed once in `MeetingWindow` and handed down like `hasVideo`, rather * than re-derived here: two components counting the same roster is how they come to disagree. */ participantCount: number; /** The participant count (Task 9). Window-level: the ladder does not govern it, because 5a built it * as precisely the affordance that SURVIVES the collapse - "the ONLY signal once a later task * collapses the window to one tile on a narrow screen". */ header: ReactNode; /** On a phone Show sits top right, leaving the bar one row as the deck draws it. */ corner?: ReactNode; /** On a phone the participant count moves into the window title bar. */ count?: ReactNode; /** An item shown from the record takes the picture's place; the tiles drop to a strip beneath it. */ stagedItem?: ShownItemSummary; /** The participants roster, the status line, the presence line and `` - all * window-level, all OUTSIDE the panel region. A viewer who has no video panel, or who is at a rung * where every panel is collapsed to an icon, must still be able to LEAVE a live, billed meeting. */ footer: ReactNode; /** The panel the viewer last asked to see. Held by `MeetingWindow`, which stays mounted while minimised, * because minimising unmounts this body and a choice kept here came back as the default panel. */ requested: MeetingPanel | null; onRequest: (panel: MeetingPanel | null) => void; } /** * The window's content, and the ONE place the rung is decided ( sub-task 5b). * * A SEPARATE COMPONENT FOR A STRUCTURAL REASON, not for tidiness: `useFloatingWindowWidth` reads a * context `FloatingWindow` publishes to its CHILDREN. Called inside `MeetingWindow` - which *renders* the * window - it would read the provider from above and get `undefined` every time, and the ladder would * silently run on a fallback forever. It has to be called from inside the subtree. * * IT DECIDES NOTHING ABOUT ENTITLEMENT. It is handed `hasVideo` and an already-filtered `openPanels`, * and it has no access to the viewer's role, the call context or `resolveMeetingPanels`. The worst a bug * in here can do is show the wrong ARRANGEMENT of panels this viewer already has. */ // At the wide rung the stage is a column, so what is shown sits above the picture, not across the rail. const StageColumn = ({ wrap, header, children }: { wrap: boolean; header: ReactNode; children: ReactNode }) => ( wrap ?
{header}{children}
: <>{children} ); const MeetingWindowBody = ({ hasVideo, openPanels, participantCount, header, corner, count, stagedItem, footer, requested, onRequest, }: MeetingWindowBodyProps) => { const contentWidth = useFloatingWindowWidth(); const headerSlot = useFloatingWindowHeaderSlot(); // `requested` is a preference, not an entitlement. It is validated against `openPanels` below rather // than trusted, so a stale key cannot survive a panel closing. // THE FALLBACK BELOW IS LOUD RATHER THAN SILENT, and that is the whole point of this effect. The // failure it guards is invisible by construction: a caller placed on the WRONG SIDE of // `FloatingWindow`'s width provider gets `undefined` forever, silently renders the widest rung at every // real width, and every test in the suite stays green (the ladder's own inputs are still valid). // // IT LOGS AN ERROR (`console.error`), IT DOES NOT THROW - deliberately, and against this repo's usual `must be used within a //...Provider` convention for context hooks. Those guard app wiring at mount; this one would take down a // LIVE, BILLED, audible meeting whose Leave button is inside the tree it kills. "A live meeting with no // Leave button anywhere in the app" is one of the two defects this window exists to delete, and a // developer-ergonomics check must not be a second route to it. Degrading to the widest rung can only // change HOW the entries in `openPanels` are shown, never WHICH exist - the disclosure boundary is // untouched either way - so continuing is the honest degradation and the console is where it is said. // // In an effect rather than the render body so it fires once per transition, not once per render (a // live call re-renders on every presence tick, and a per-render log would be unreadable noise). useEffect(() => { if (contentWidth !== undefined) return; console.error( '[video] the meeting window\'s collapse ladder has no content width, so it is rendering the ' + 'WIDEST rung regardless of the real width. `useFloatingWindowWidth()` returned undefined, ' + 'which means this component was rendered OUTSIDE `FloatingWindow`\'s children — that context ' + 'is published to its subtree only. Render it as a child of .', ); }, [contentWidth]); const layout = resolveMeetingLayout({ // `undefined` means "not inside a FloatingWindow", which this component's own tree makes // unreachable - and which the effect above refuses to let happen quietly if a future caller // reintroduces it. contentWidth: contentWidth ?? LADDER_WIDE_MIN, openPanelCount: openPanels.length, participantCount, }); const stillOpen = requested !== null && openPanels.some((p) => p.key === requested); // At `tabs` something is always showing (a tab strip over an empty body reads as broken); at `icons` // nothing is, until the viewer taps - the narrow rung is the video, with the panels one tap away. const activeKey: MeetingPanel | null = layout.panels === 'tabs' ? (stillOpen ? requested : openPanels[0]?.key ?? null) : layout.panels === 'icons' ? (stillOpen ? requested : null) : null; const activeEntry = openPanels.find((p) => p.key === activeKey); // - "is a panel BODY on screen right now?", and it must stay in lockstep with the three // render branches below, which is why it is derived from the same two values they branch on rather // than from `openPanels.length` alone. At `side-by-side` every open panel is mounted; at `tabs` and // `icons` only `activeEntry` is - and at `icons` nothing is mounted until the viewer taps, which is // the narrow rung's whole design. It decides whether the video row is capped: with no body on screen // there is nothing to protect and a cap would leave dead space under a shrunken video. /** * - is the panel a COLUMN beside the video, rather than a row beneath it? * * Exactly `side-by-side`, i.e. the wide rung with at least one panel open. Named separately because it * flips three things that must agree: the container's axis, whether the video takes the 55% HEIGHT cap * (a column layout constrains the video by WIDTH instead, so the cap would shrink it twice), and * whether the panels get a fixed column width. */ const isSideColumn = layout.panels === 'side-by-side'; // At `icons` a second tap closes the panel again: on a phone that is the only way back to a // full-bleed video. const barPanels = useMemo(() => (layout.panels !== 'icons' ? null : { // Messages lead the bar, as the phone frame orders it. panels: [...openPanels].sort((a, b) => Number(b.key === 'messages') - Number(a.key === 'messages')).map((entry) => ({ key: entry.key, label: entry.label, icon: entry.icon, testId: `meeting-panel-icon-${entry.key}`, count: entry.count, })), activeKey, onSelect: (key) => onRequest(requested === key ? null : key as MeetingPanel), }), [layout.panels, openPanels, activeKey]); // The wide rail reads as the deck draws it: messages first, then the other panels as tabs. const railMessages = openPanels.find((p) => p.key === 'messages'); const railTabs = openPanels.filter((p) => p.key !== 'messages'); const railEntry = railTabs.find((p) => p.key === requested) ?? railTabs[0]; const hasPanelBody = layout.panels === 'side-by-side' ? openPanels.length > 0 : Boolean(activeEntry); const panelBody = (entry: MeetingPanelEntry, position: string) => (
{entry.body}
); return ( // `meeting-window` marks the window's CONTENT, not the portalled window root - `FloatingWindow` // takes no testid or className of its own and renders NO CHILDREN while minimised, so the absence // of this testid means "no window content on screen", NOT "no call". (`FloatingWindow` carries its // own `floating-window-` root testid for that distinction.)
{layout.panels === 'icons' && count && headerSlot && createPortal({count}, headerSlot)} {layout.panels === 'icons' && (corner || (count && !headerSlot)) && (
{!headerSlot && count}{corner}
)} {!(isSideColumn && hasVideo) && header} {/* 🚨 CARD 209 — THE SCROLL CONTAINER IS WHAT KEEPS `Leave` REACHABLE. Do not flatten it. This column is `h-full`: a FIXED height, not a growing one. Everything below used to be a direct child of it, and the vertical budget is over-subscribed the moment a real video tile exists — `aspect-video` alone resolves to width x 9/16, about 522px at the wide rung — while the messages panel carries a HARD `min-h-40` (160px) floor that cannot shrink. With nothing containing the overflow, the panel simply painted OUTSIDE its row and across the footer: in the first two-party browser run the empty-state Alert (448x45) sat over the Leave button (96x36), which was visible, enabled, and unclickable in a live billed visit. That is the defect card 128 exists to delete, in a new form. ⛔ NOT fixable with z-index (card 209's own fence): the overlap is a LAYOUT fault, and raising the button would leave an invisible 448x45 click-eater across the control strip. ⭐ Containing it here fixes the CLASS rather than this one Alert — any panel content that outgrows the space now SCROLLS instead of escaping over the controls — and it needs no change to the shared `ui/alert.tsx` (card 202, another session's lane). 🔴 `{footer}` MUST STAY OUTSIDE THIS DIV. That sibling relationship is the whole guarantee. ⚠️ jsdom reports zero for every bounding box, so NO unit test can prove the overlap is gone — see the structural pin in `MeetingWindow.test.tsx`. Browser-owed. */} {/* 🖥️ CARD 350 — AT THE WIDE RUNG THIS BECOMES A ROW, AND THAT IS THE WHOLE FIX. 🗣️ Spencer, from a live visit on a 32" monitor: *"Do we have anything filling up black space in the letter box like other attendees videos or the messages block… or is everything designed to still be under the one large video box at the top?"* 📏 Measured 2026-08-19 in a real case conference at 1829×902: the video tile was **610×343 — 33% of the width — with 1219px of dead bar**, and Team chat sat full-width BELOW it. 🔑 THE DIAGNOSIS THAT DECIDES THE FIX: the waste is **PILLARBOXING, not letterboxing**. The picture is correctly 16:9 at every rung; the CONTAINER is wider than 16:9, so the dead bars fall left and right — the widest part of a large display — while the thread is pushed below the video and often below the fold. ⇒ A panel that opens as a COLUMN consumes space that was already dead, and costs the video nothing it was using. ⛔ `side-by-side` used to mean "the open panels sit beside EACH OTHER", still underneath the video. It now means what its name says. ⚠️ Only at `wide`. `tabs` and `icons` stay stacked — at those widths there is no horizontal room to give away, which is the entire point of the ladder. */}
{hasVideo && ( /* The height wrapper is load-bearing, not decoration - carried across from `FloatingCallSurface` verbatim. `VideoTileGrid`'s tiles are `h-full`
{/* The recording, while its note panel is not on screen at this rung (or not opened, on a phone). */} entry.key === 'encounterNote') ? () => onRequest('encounterNote') : undefined} /> {/* 🔴 OUTSIDE the scroll container above, deliberately — card 209. `CallControls` (and with it the ONLY Leave button in the app during a call) is a sibling of the scrollable region, so no amount of panel content can be laid out across it. */} {footer}
); }; export const MeetingWindow = () => { const { call, remoteAttendees, remoteTiles, remoteParticipants, everRemote, removeParticipant, shown, showFromRecord, stopShowing, } = useVideoServiceContext(); const { currentUser, currentOrganization } = useAuthServiceContext(); const viewerEpoch = useViewerEpoch(); const { active, notesLocation, setNotesLocation } = useActiveEncounterContext(); // Task 9 - the participants LIST is opened from the count, not shown by default: the fetches it // triggers (one per unresolved `externalUserId`) are worth paying only once a viewer actually asks // "who is here". Local, not persisted - nothing about which meeting you were in belongs in storage. const [participantsOpen, setParticipantsOpen] = useState(false); // The open panel lives here, not in `MeetingWindowBody`: minimising unmounts the body, and this // component stays mounted until the call ends, so the choice survives a restore and dies with the call. const [requestedPanel, setRequestedPanel] = useState(null); // THE CALL'S CONTEXT, DERIVED EXACTLY ONCE - the ids first, the kind FROM the ids. Every other // reader below (`contextKind`, the panel resolver, the thread branches, the window title) reads these // three values; nothing re-parses `call.context`. // // IT USED TO BE PARSED TWICE, with two different rules: a bare `startsWith('encounter#')` here and // `isEncounterRoomThreadId` for the id. Those disagree - `encounter#a#b` passes the prefix test and // fails the predicate (a room thread id has EXACTLY ONE '#'; see `utils/encounterRoomThread.ts`, where // the extra condition exists because a patient whose FHIR id is literally `encounter` makes a DIRECT // thread id collide with a room one). The encounter branch would then render with `encounterId === null`: // a thread that is permanently empty AND a `handleSendRoomMessage` that silently drops every send. // Unreachable today only because FHIR ids cannot contain '#' - which is exactly the "two derivations of // one fact" shape this file's header exists to delete, so it is derived once instead of guarded. // // The ids are also a DISCLOSURE boundary, not a routing convenience: a drifted derivation routes a // read to the WRONG ROOM'S roster (cross-thread), so the shared predicate+extractor pair is // authoritative and this file never slices the string itself. const encounterId = isEncounterRoomThreadId(call.context) ? extractEncounterIdFromRoomThreadId(call.context) : null; const teamId = isCollabTeamThreadId(call.context) ? extractTeamIdFromThreadId(call.context) : null; // The THREAD IDS this window can read, send to and hold a draft for - built once from the ids above, // by the same builders the send handlers used to call inline. `null` propagates honestly: a context // whose id could not be extracted has no thread, so it has no send and no draft either. const roomThreadId = encounterId ? buildEncounterRoomThreadId(encounterId) : null; const teamPublicThreadId = teamId ? buildCollabTeamThreadId(teamId) : null; const teamAsideThreadId = teamId ? buildCollabTeamAsideThreadId(teamId) : null; // Falls closed through the ids: a context that yields neither is `unknown`, which `resolveMeetingPanels` // answers with video only. `collabteam#` with an EMPTY team id lands there too - previously it resolved // as a real conference whose thread could never load, the same silently-empty failure by another route. const contextKind = encounterId ? ('encounter' as const) : teamId ? ('collabteam' as const) : ('unknown' as const); const viewerRole: MeetingViewerRole = currentUser?.fhir?.resourceType === 'Practitioner' ? 'practitioner' : currentUser?.fhir?.resourceType === 'Patient' ? 'patient' : currentUser?.fhir?.resourceType === 'RelatedPerson' ? 'relatedPerson' : 'unknown'; // WHO IS READING THIS, not only what kind of meeting it is. In a two-party call every sentence // about "the other person" has two audiences, and getting it wrong is how a PATIENT was told "The // patient has left the visit." when their practitioner left (Spencer, 2026-08-02). // DERIVED FROM `viewerRole`, not re-read from `currentUser`. Two independent readings of one fact // is the drift shape this whole file exists to delete: the copy and the panel set would then be free // to disagree about who the viewer is, and only one of them is covered by a test. const viewerIsClinician = viewerRole === 'practitioner'; // THE HOST IS WHOEVER HOLDS THE ACTIVE-ENCOUNTER SLOT FOR *THIS* CALL - explicit slot state, never // derived from `status === 'in-progress'` (two encounters can legitimately be in-progress; that // ambiguity is the bug sub-task 0 deleted). Compared through `encounterContext()` rather than by // parsing the context string, so the two sides can never drift on the format. const isHost = !!active?.encounter?.id && call.context === encounterContext(active.encounter.id); const previsit = isHost && isVideoPrevisit(active?.encounter); // Who may take someone out of THIS meeting. A visit's host, as before, and in a case conference the // practitioner who started it - the server's rule (VideoSession/remove.ts) also admits the initiating // practice's other practitioners, but the client is never told that practice, so it offers Remove // only where it can be sure. Kept apart from `isHost`, which also decides what clinical panels show. const mayRemove = isHost || ( contextKind === 'collabteam' && viewerIsClinician && !!call.initiator && !!currentUser?.fhir?.id && call.initiator === `Practitioner/${currentUser.fhir.id}` ); // A DISCLOSURE BOUNDARY, NOT A LAYOUT PREFERENCE - see `meetingPanels.ts`. The panel list is // resolved BEFORE anything renders, so a panel a viewer may not have is never mounted at all. // `hasOrg` is "does this viewer have an org AT ALL", not "does it match the encounter's". const panels = resolveMeetingPanels({ contextKind, viewerRole, isHost, hasOrg: !!currentOrganization?.id, previsit, }); // The sharer is named from the roster, never from the item. const mayShow = mayShowFromRecord({ contextKind, viewerRole, isHost, hasOrg: !!currentOrganization?.id }); const [pickerOpen, setPickerOpen] = useState(false); const [stopping, setStopping] = useState(false); const [stopError, setStopError] = useState(null); const sharedBy = shown?.sharedBy; const sharerParticipants = useMemo( () => remoteParticipants.filter((p) => p.externalUserId === sharedBy), [remoteParticipants, sharedBy], ); const sharerNames = useParticipantNames(sharerParticipants); const clinicianRef = viewerRole === 'patient' || viewerRole === 'relatedPerson' ? visitClinicianReference(call.initiator) : undefined; const clinicianParticipants = useMemo( () => (clinicianRef ? [{ externalUserId: clinicianRef } as (typeof remoteParticipants)[number]] : []), [clinicianRef], ); const clinicianNames = useParticipantNames(clinicianParticipants); const clinicianName = clinicianRef ? clinicianNames[clinicianRef] || undefined : undefined; const selfRef = currentUser?.fhir?.id ? currentUser.fhir.resourceType + '/' + currentUser.fhir.id : undefined; const isSharer = !!sharedBy && sharedBy === selfRef; const sharerName = (sharedBy && sharerNames[sharedBy]) || 'Your clinician'; // One read serves the banner and the stage. const shownItem = useShownRecordItem(shown?.reference ?? null); const staged = !!shown && shownItem.read.state === 'ready' && panels.includes('video'); const handleStopShowing = () => { setStopping(true); setStopError(null); stopShowing() .catch((failure) => setStopError(describeServerRefusal(failure, 'Showing did not stop. Try again.'))) .finally(() => setStopping(false)); }; // Whether the ONE encounter-note editor lives in this window. Read off the resolved panel list rather // than re-testing `isHost`, so the disclosure decision has exactly one author. const ownsNote = panels.includes('encounterNote'); // Task 9 - REMOTE + 1 FOR SELF. `remoteParticipants` deliberately excludes the local attendee // (`ChimeCallSession.subscribeToPresence`), so without the `+ 1` a practitioner alone with one // patient would read "1" - indistinguishable from being alone. This is Zoom's mobile pattern // (Spencer's ruling) and the ONLY signal once a later task collapses the window to one tile on a // narrow screen. const hasParticipantsPanel = panels.includes('participants'); const participantCount = remoteParticipants.length + 1; // Board 23: the roster opens over the call from its count rather than pushing the video up. The // list is not mounted until opened, so its name fetches are paid only then. Focus moving into its own // Remove dialog must not close it, or the dialog would unmount with it. const participantButton = hasParticipantsPanel && ( e.preventDefault()} onInteractOutside={(e) => { if ((e.target as Element | null)?.closest?.('[data-slot^="dialog"]')) e.preventDefault(); }}> ); // The host's window is named for the patient whether or not the note is in it, so docking the note // back must not drop the name (C04, item 8). const { patient } = useActiveEncounterPatient(isHost || notesLocation === 'meeting'); // - a NON-HOST's patient, resolved from THIS VISIT's encounter rather than from the active // slot (which by definition holds a different patient for a non-host). Enabled on the ENTITLEMENT so // the id is ready before the tab is opened at the `icons`/`tabs` rungs - the panel entry has to be // built either way, and a resolve that starts on first tap would flash "loading" every time. const { patientId: visitPatientId, status: visitPatientStatus } = useMeetingVisitPatientId( encounterId, panels.includes('patientNotes'), ); const visitPatientRef = !isHost && visitPatientId ? 'Patient/' + visitPatientId : undefined; const visitPatientParticipants = useMemo( () => (visitPatientRef ? [{ externalUserId: visitPatientRef } as (typeof remoteParticipants)[number]] : []), [visitPatientRef], ); const visitPatientNames = useParticipantNames(visitPatientParticipants); const invitedPatientName = visitPatientRef ? visitPatientNames[visitPatientRef] || undefined : undefined; // THE `messages` PANEL'S OWN DISCLOSURE BOUNDARY (Task 8). `encounterId`/`teamId` come from the ONE // derivation at the top of this component. Both message hooks below are called UNCONDITIONALLY (Rules // of Hooks) but each is a no-op for whichever context this call is NOT: the other kind's id is `null`, // and both hooks already treat a `null` id as "do not fetch". // The room is read once THIS attendee has been admitted, which is the phase reaching `active` in this session: // the server records admission at join, and a read before it is refused. Kept for the session once reached. const [admittedIn, setAdmittedIn] = useState(null); useEffect(() => { if (call.phase === 'active' && call.sessionId) setAdmittedIn(call.sessionId); }, [call.phase, call.sessionId]); const roomThread = useEncounterRoomThreadMessages(encounterId, { admitted: Boolean(call.sessionId) && admittedIn === call.sessionId, sessionKey: call.sessionId, }); // BOTH THREADS ARE ALWAYS FETCHED - the public/aside toggle rendered below REVEALS one, it never // GATES the read. Mirrors `CollaborationTeamsPanel`, which keeps this exact pair mounted regardless // of which tab is showing. NEVER reached with real data for an `encounter#` context: `teamId` is // `null` there, so both calls take the hook's own no-op path and neither ever fetches - the aside // thread is structurally unreachable from a visit a patient is standing in, not merely hidden. const collabPublicThread = useCollaborationTeamThreadMessages(teamId, 'public'); const collabAsideThread = useCollaborationTeamThreadMessages(teamId, 'aside'); const [asideMode, setAsideMode] = useState(false); const { createCommunicationForThread, createCommunication, getCurrentUserReference, saveCommunicationBundle } = useCommunicationServiceContext(); // P9: the room (receipts, "mine") is room-only identity; ordinary messaging elsewhere keeps // `getCurrentUserReference()` untouched. const selfReference = useRoomSelfReference(getCurrentUserReference()); const roomThreadRefetch = roomThread.refetch; const beginRoomWork = roomThread.begin; // The same receipt is written once while it is out and once confirmed, whatever copies of the list arrive // meanwhile; one the save did not return waits for an explicit retry (utils/messaging/receiptLedger). const [roomReceipts] = useState(createReceiptLedger); // The ledger is not state; a settled save re-renders so an unconfirmed receipt is offered, or its offer goes. const [, receiptsSettled] = useReducer((n: number) => n + 1, 0); useEffect(() => { if (selfReference) roomReceipts.retry(selfReference); }, [call.sessionId, selfReference, roomReceipts]); const markRoomThreadRead = useCallback(async (updated: Communication[]) => { if (!selfReference) return; let work: ReturnType | undefined; const { sent, failed } = await acknowledgeOnce(roomReceipts, selfReference, updated, (receipts) => { work = beginRoomWork(); return saveCommunicationBundle(receipts); }); if (failed.length) console.error('Failed to save visit read receipts:', failed.map((message) => message.id)); if (sent.length) receiptsSettled(); if (sent.length === 0 || !work?.owns()) return; try { await roomThreadRefetch(); } catch (error) { console.error('Failed to read the visit thread after its receipts:', error); } }, [selfReference, roomReceipts, saveCommunicationBundle, roomThreadRefetch, beginRoomWork]); // Offered for the lines on screen only, so a receipt that landed after all, read back, is not offered again. const unconfirmedReceipts = selfReference ? unconfirmedOf(roomReceipts, selfReference, roomThread.messages) : []; const roomAdmitted = !roomThread.waiting && !roomThread.refused; const retryRoomReceipts = useCallback(() => { if (!selfReference || !roomAdmitted || unconfirmedReceipts.length === 0) return; roomReceipts.retry(selfReference, unconfirmedReceipts); void markRoomThreadRead(withReadReceipts(unconfirmedReceipts, selfReference, new Date().toISOString())); }, [selfReference, roomAdmitted, unconfirmedReceipts, roomReceipts, markRoomThreadRead]); // RETURNS WHETHER THE TEXT REACHED THE RECORD, and the composer keeps its draft until it does. A // `false` here is what makes "Please try again" a retry the practitioner can perform rather than an // instruction to retype from memory mid-visit. Every early return is a `false` for the same reason: // an unsendable message is not a sent one. /** * THE LAST REFUSAL, PER THREAD, so a message panel can say why the send did not land. * * KEYED BY THREAD rather than held as one string: this window shows the room thread and both halves * of a team conference, and one shared slot would put the room's refusal under the team composer the * moment somebody switched panels - a sentence about the wrong conversation, which is worse than no * sentence at all. * * Cleared on the next attempt, not on a timer. A notice that vanishes on its own is one a clinician * mid-visit can miss entirely, and the only thing that makes it stale is trying again. */ const [sendRefusal, setSendRefusal] = useState>({}); const roomRecovery = useScopedSendRecovery('meeting-room', roomThreadId ? `room:${roomThreadId}` : undefined); const teamSendThreadId = asideMode ? teamAsideThreadId : teamPublicThreadId; const teamRecovery = useScopedSendRecovery('meeting-team', teamSendThreadId ? `team:${teamSendThreadId}` : undefined); const noteRefusal = (threadId: string | null, message: string | null) => { if (!threadId) return; setSendRefusal((current) => ({ ...current, [threadId]: message })); }; // Derived once rather than inline in the JSX: the aside toggle swaps WHICH thread the one // composer is writing into, and a refusal about the team chat must not appear over the // practitioner-only box, or the other way round. const teamRefusalThreadId = asideMode ? teamAsideThreadId : teamPublicThreadId; const teamRefusal = teamRefusalThreadId ? sendRefusal[teamRefusalThreadId] : null; /** What the window says about a send that did not confirm; a refusal keeps it held for Check too. */ const noteHeldSend = (threadId: string, result: { state: 'held'; error: unknown; unsure: boolean } | { state: 'not-sent'; reason: string }) => { if (result.state === 'not-sent') { noteRefusal(threadId, result.reason); toast.error('The message was not sent', { position: 'top-right' }); return; } if (result.unsure) { noteRefusal(threadId, 'Message not confirmed. ' + MAY_HAVE_BEEN_SENT); toast.error('Message not confirmed', { position: 'top-right' }); return; } // INLINE FIRST, and the toast stays as well rather than instead. The inline notice is the // one that is guaranteed to be on screen in this transformed window; the toast is still // right for a practitioner whose eye is on the video rather than the panel. noteRefusal(threadId, describeServerRefusal(result.error, 'That message was not sent')); toast.error('Could not send the message. Check it before sending again.', { position: 'top-right' }); }; const handleSendRoomMessage = async (text: string, owns?: () => boolean): Promise => { if (!roomThreadId || roomRecovery.waits) return 'failed'; const sender = currentUser?.fhir as Practitioner | Patient | undefined; if (!sender?.id) return 'failed'; // The send belongs to the room opening it was pressed in; a later one is not told about it. const attempt = roomRecovery.begin(undefined, owns); const send = roomThread.begin(); const built = createCommunicationForThread(text, roomThreadId, sender, []); noteRefusal(roomThreadId, null); const result = await roomRecovery.run(attempt, `room:${roomThreadId}|${text}`, (operation, leaving) => { leaving(); return createCommunication(namedSend(built, operation), operation); }); if (result.state === 'retired' || !send.owns()) return 'retired'; if (result.state === 'sent') { roomThread.appendOptimistic(result.value); return 'sent'; } noteHeldSend(roomThreadId, result); return 'failed'; }; // Practitioner-only by construction - `collabteam#` contexts never carry a patient/related-person // viewer - so casting to `Practitioner` here mirrors `CollaborationTeamsPanel`'s own // `handleTeamSend` rather than inventing a broader cast this context can never need. const handleSendTeamMessage = async (text: string, owns?: () => boolean): Promise => { const threadId = teamSendThreadId; if (!threadId || teamRecovery.waits) return 'failed'; const sender = currentUser?.fhir as Practitioner | undefined; if (!sender?.id) return 'failed'; // Taken at the press: switching to the other half of the conference retires this send's opening. const attempt = teamRecovery.begin(undefined, owns); const target = asideMode ? collabAsideThread : collabPublicThread; const built = createCommunicationForThread(text, threadId, sender, []); noteRefusal(threadId, null); const result = await teamRecovery.run(attempt, `team:${threadId}|${text}`, (operation, leaving) => { leaving(); return createCommunication(namedSend(built, operation), operation); }); if (result.state === 'retired') return 'retired'; if (result.state === 'sent') { target.appendOptimistic(result.value); return 'sent'; } // The same treatment as the room, through the same shared panel. noteHeldSend(threadId, result); return 'failed'; }; // EXACTLY ONE NOTE EDITOR IS LIVE AT A TIME - three sites over one three-valued state, each // rendering iff it owns the location. This window TAKES the location while it holds the note and // GIVES IT BACK on unmount; without the release the workspace shows its "notes are elsewhere" // placeholder forever and the note has nowhere to live. `CallOverlay` unmounts this component when // the call ends, so the cleanup is the end-of-call path too. // // GATED ON `ownsNote`, AND THAT GATE IS LOAD-BEARING - do not "simplify" it to an unconditional // claim. A NON-HOST in this meeting may be hosting a DIFFERENT encounter whose note is docked in // their workspace: claiming the location here would blank that editor and point its placeholder at a // window that never shows the note. Only the site that renders the note may own it. useEffect(() => { if (!ownsNote) return; setNotesLocation('meeting'); return () => setNotesLocation('docked'); }, [ownsNote, setNotesLocation]); // THE COMPOSER DRAFTS DIE WITH THE CALL. `meetingComposerDrafts` is module-scoped so that a draft can // outlive the panel body the ladder unmounts - it is NOT meant to outlive the meeting. `CallOverlay` // unmounts this component when the phase returns to idle, so this cleanup is the end-of-call path, and // it keeps unsent PHI-bearing text from sitting in memory for the rest of the browser session. // // NOT a per-panel or per-rung cleanup, deliberately: those are exactly the unmounts the store exists // to survive. Only the whole window going away clears anything. useEffect(() => { const ids = [roomThreadId, teamPublicThreadId, teamAsideThreadId]; return () => { ids.forEach((id) => clearComposerDraft(id)); }; }, [roomThreadId, teamPublicThreadId, teamAsideThreadId]); // Anchored on `idle` rather than listing the live phases so that a phase added later fails SAFE: it // shows the call instead of silently vanishing it. Same reasoning as `CallOverlay`, which also gates // on this - belt and braces, because a surface that renders itself for a call that is over is a lie // and a surface that hides a live one is worse. if (call.phase === 'idle') return null; const status = STATUS[call.phase]; const presence = roomPresence({ phase: call.phase, remoteAttendees, remoteTiles, everRemote }); const presenceLine = presence ? roomPresenceCopy(presence, call.context, viewerIsClinician) : undefined; // The panels this viewer HAS and currently has OPEN // // THE DISCLOSURE BOUNDARY, ASSEMBLED. Every push below is gated on the resolver's list and nothing // else - never on the rung, never on the width. A panel a viewer may not have never enters this array, // so it cannot be mounted, hidden with CSS, or guarded from the inside at ANY rung: the ladder // downstream can only choose an arrangement of what is already here. The security decision lives in // `meetingPanels.ts`; this file renders its answer. // // Order is the ORDER THEY APPEAR IN - the tab strip and the icon row read it straight off, and the // first entry is what the medium rung opens on. const openPanels: MeetingPanelEntry[] = []; // THE THIRD (and last) RENDER SITE of the ONE encounter note - see `oneNoteEditorInvariant.test.ts`. // `notesLocation === 'meeting'` is what makes the three sites complementary rather than competing: on // the first commit after mount the workspace still owns the location, and only once the claim above // lands does the note move here. Rendering on `ownsNote` alone would put two live editors on one note // for that frame - the exact defect exists to delete. // // THE ENTRY IS THE RENDER CONDITION, so a rung that hides the note behind an icon is still exactly // one live editor: `EncounterDocumentation` unmounts with its tab, and its draft is held in the // page-level notes store (`useEncounterClinicalNote` `EncounterNotesContext`), not in the editor - // which is why collapsing it cannot lose what a practitioner was mid-typing. did the same for // which TAB they were on. if (ownsNote && notesLocation === 'meeting' && active) { openPanels.push({ key: 'encounterNote', label: 'Note', icon: NotebookPen, body: , }); } // - the non-host's patient notes, scoped to THIS VISIT's patient. // // THE PATIENT ID COMES FROM THE ENCOUNTER, NEVER FROM THE ACTIVE SLOT. A non-host is *defined* as // "the slot's encounter is not this call's", so the slot holds a DIFFERENT patient - rendering from it // would file this visit's notes into another patient's chart. `useMeetingVisitPatientId` resolves // `Encounter.subject` and carries a render-time identity gate so an answer can never be paired with a // different visit. Do not "simplify" this to `useActiveEncounterPatient`; that is the defect. // // THREE OUTCOMES, NOT TWO, and the third is the one that matters: a collaborator's access rides // CONSENT (active membership + the patient's authorization + their own BAA), which revokes instantly // from the membership row while their token still says `collaboration_member`. So a refusal is // REACHABLE for exactly this audience, and it must read as "no access" rather than as an empty chart - // an authz denial rendering as absent data is a false clinical assertion. if (panels.includes('patientNotes')) { openPanels.push({ key: 'patientNotes', label: 'Note', icon: StickyNote, ...(visitPatientId ? { body: } : { className: 'rounded-xl border border-dashed border-line p-3 text-center', body: (

{visitPatientStatus === 'unavailable' // Names a permission/consent outcome, and deliberately does NOT say // "no notes" - this viewer may have no access, which is a different fact. ? 'You don’t have access to this visit’s notes.' : 'Loading notes for this visit…'}

), }), }); } // The host's chart: the same conditions reference the encounter shows, for the active encounter's // patient, which for a host is this call's. The panel carries its own patient identity gate. if (panels.includes('chart')) { const patientName = patient ? formatFhirHumanName(patient.name) : ''; openPanels.push({ key: 'chart', label: 'Chart', icon: Stethoscope, body: ( {patientName} · patient record : 'Patient record'} /> ), }); } // THE THREAD FOR THIS ROOM (Task 8). if (panels.includes('messages')) { openPanels.push({ key: 'messages', label: 'Messages', count: contextKind === 'encounter' && !roomThread.error ? threadUnreadCount(roomThread.messages, selfReference) : undefined, icon: MessageSquare, className: 'flex min-h-40 flex-col overflow-hidden rounded-lg border border-line', body: ( <> {contextKind === 'encounter' && ( // EXACTLY ONE THREAD - the room thread, and nothing else. No toggle, no // aside, because this branch is the ONLY thing that can ever render for a // context a patient may be sitting in. { void roomThread.refetch(); }} selfReference={selfReference} onSend={handleSendRoomMessage} recovery={roomRecovery} refusal={roomThreadId ? sendRefusal[roomThreadId] : null} placeholder="Message everyone in this visit…" threadId={roomThreadId} onRead={markRoomThreadRead} receiptsUnconfirmed={unconfirmedReceipts.length > 0} onRetryReceipts={retryRoomReceipts} /> )} {/* ⛔ THE DISCLOSURE BOUNDARY, RENDERED: the aside toggle and its thread exist ONLY inside this branch, which is reachable ONLY when `contextKind === 'collabteam'`. A patient standing in an `encounter#` visit cannot reach this JSX at all — not mounted-then-hidden, not guarded from the inside, simply never in the tree the encounter branch above produces. */} {contextKind === 'collabteam' && ( <> {/* ⭐ THE SAME STRIP the ladder's medium/narrow rungs use — this markup is where it came from, extracted rather than copied so the two cannot drift into two different tab implementations in one file. */} setAsideMode(key === 'aside')} /> {asideMode && (
Private — practitioners only, not shown to the patient
)} )} ), }); } const visitTitle = windowTitle( contextKind, isHost && patient ? formatFhirHumanName(patient.name) || undefined : invitedPatientName, clinicianName, ); return ( // NO `onClose`, deliberately. `FloatingWindow` renders an X only when given one, and closing a // LIVE meeting would hide a running, billed, audible call with no route back - the defect the // docked surface shipped. Leave ends the call and the window goes with it; Minimise parks it on a // labelled button that stays on screen. // // `id` STAYS THE STATIC STRING. `FloatingWindow` persists geometry under // `akashic_floating_window_`, so an id keyed by encounter or patient would write a record // identifier into a localStorage key that survives logout on a shared clinic workstation. // // - A VISIT OPENS FULL SCREEN, FOR EVERY ROLE. Spencer: "this meeting needs everyone's // full attention so you shouldn't have to take an action to make it full screen, collapsing should // be the action if needed." // `openAs`, NOT a default: this window PERSISTS its mode, so a default is read once and never // again - after the first visit, whatever the user left last time greets them, and that includes // `minimised`, which is a missed visit rather than a small window. // The remembered SIZE survives. Collapsing mid-call still lands on the size they chose. Return to video visit {visitTitle !== 'Video visit' && {visitTitle}} ) : undefined} > {/* The recording outlives the panel bodies this window's rungs unmount; see ScribeRecordingOwner. */} {shown && ( )} )} count={participantButton} corner={( <> {mayShow && !shown && ( )} )} footer={( <> {/* 🚨 WINDOW-LEVEL, NEVER INSIDE A PANEL — the Leave button above all. "A live, BILLED, audible meeting with no Leave button anywhere in the app" is one of the two defects this window exists to delete, and nesting the controls under `panels.includes('video')` would make that defect one edit away: `resolveMeetingPanels` is touched by Tasks 7, 8 and 9, and the day `video` becomes conditional (a viewer with no camera permission, an audio-only mode) the controls vanish with it. No test would catch it either — every fixture in the suite has `video` in the list. The safety of the Leave control must not depend on a value another task is free to change. ⛔ 5b ADDS A SECOND WAY TO LOSE THEM and closes it the same way: they are passed as the window's FOOTER, outside the panel region entirely, so no rung — not even `panels: 'none'` or a narrow screen with every panel collapsed to an icon — can take Leave off the screen. Same reasoning for the two lines above it: "Connecting…" and "waiting for the patient" are facts about the MEETING, owed to a participant whichever panels they were granted. */} {status && (

{status}

)} {presenceLine && (

{presenceLine}

)} setPickerOpen(true), onStopShowing: handleStopShowing, showing: !!shown, } : {})} /> {mayShow && pickerOpen && ( setPickerOpen(false)} patientId={active?.item.patientId} patientName={patient ? formatFhirHumanName(patient.name) || 'the patient' : 'the patient'} onShow={showFromRecord} sessionKey={call.sessionId} /> )} )} />
); };