// THE CHROME IS GONE, THE START BUTTON STAYS. deleted the docked surface - the meeting // window is now the only in-call surface. But this is where encounterId/patientId live, and starting // from here is what makes the call an Encounter (class = virtual + the care-view anchor on Finish). // Deleting this with the surface would quietly sever the Encounter integration. // // WHAT WENT: the `VideoTileGrid`, `CallControls`, the room-presence line and the // `registerDockedSurface()` effect - all four now live in (or died with) `MeetingWindow`. The // registration in particular has no successor and needs none: it existed to keep exactly ONE // `VideoTileGrid` mounted while two surfaces competed, and with one surface that is structural. // // WHAT STAYED BESIDES START: the care-team invite. It needs `patientId`, which exists // here and nowhere in the meeting window, and it is scoped to `mine` for a disclosure reason rather // than a tidiness one - see the comment on `live` below. // // Pure presentation, like everything else in this folder: no SDK, no transport, no fetch. Its mount in // `EncounterWorkspace.tsx` landed in Task 2 and is not touched again. import React, { useEffect, useState } from 'react'; import { Video } from 'lucide-react'; import { useVideoServiceContext } from '../../services/VideoService'; import { encounterContext } from '../../services/video/videoContract'; import { TELEHEALTH_VIDEO_ENABLED } from '../../services/video/videoFlags'; import { InviteCareTeamButton } from './InviteCareTeamButton'; import { encounterVideoEntry, nextPrevisitChange } from './encounterVideoEntry'; import { formatTimeOfDay } from '../../utils/dateUtils'; interface Props { encounterId: string; /** The encounter's own status, so the pane can stop offering a visit the server will refuse * to open. Optional for the same reason the resolver's argument is: absent means unknown. */ encounterStatus?: string; /** A video visit that has not started: its room may open before the encounter does. */ previsit?: boolean; /** The previsit's booked slot, so Start is offered only while the server's door is open. */ previsitSlot?: { start?: string; end?: string }; /** The visit's subject patient - needed only by the care-team invite, which must offer * THIS patient's teams and no others. Optional so the pane still renders for an encounter whose * patient has not resolved yet; the invite control simply does not appear until it has. */ patientId?: string; } export const EncounterVideoPane: React.FC = ({ encounterId, encounterStatus, patientId, previsit, previsitSlot }) => { const { call, startCall, joinAvailable } = useVideoServiceContext(); // Read again at the moment the previsit door opens or closes, so the offer changes then without a reload. const [now, setNow] = useState(() => Date.now()); const changesAt = previsit ? nextPrevisitChange(previsitSlot, now) : undefined; useEffect(() => { if (changesAt === undefined) return undefined; const timer = setTimeout(() => setNow(Date.now()), Math.min(Math.max(changesAt - Date.now(), 0) + 1, 2 ** 31 - 1)); return () => clearTimeout(timer); }, [changesAt]); // Scope, not decoration - and now a DISCLOSURE scope. A practitioner can have this pane open on // encounter B while a visit runs for encounter A. `inviteTeam` reads the session from the provider // (it takes no session id, deliberately), so an unscoped invite control on B's pane would fan // patient B's care team into patient A's live visit - a PHI disclosure to a roster nobody chose. const mine = call.context === encounterContext(encounterId); // Anchored on `idle` rather than listing the live phases, so a phase added later fails SAFE - it // keeps the in-call affordance instead of silently offering Start over a running visit. const inACall = call.phase !== 'idle'; const live = mine && inACall; // A call running for a DIFFERENT encounter. Starting one here would auto-leave it - // `ChimeCallSession.join()` tears the previous meeting down before building the next - so an // enabled button would silently drop a live visit somebody is sitting in. // THE SAME FENCE APPLIES TO REJOINING, which is why it gates the control rather than the verb: // a join tears the previous meeting down by the identical path. const elsewhere = !mine && inACall; // THE PRACTITIONER'S WAY BACK IN. Until 2026-08-15 this control was hardcoded to "Start video", // so a clinician who had left their own still-running visit pressed a button that calls the CREATE // route - which the server correctly refuses 409 `CONTEXT_TAKEN` - while `/join` answered 200 on the // same session, same token, same minute. The re-entry door was open and this surface never knocked // on it. See `encounterVideoEntry.ts` for the enumeration and why absent `returning` means "Join". const entry = encounterVideoEntry({ encounterId, phase: call.phase, callContext: call.context, available: call.available, encounterStatus, previsit, previsitSlot, now, }); const rejoining = entry.kind === 'join' || entry.kind === 'rejoin'; // `call.error` IS DELIBERATELY NOT RENDERED HERE. `CallOverlay` owns the report, and this is // not a style preference: `FAILED` clears `context`, so at the moment an error exists `mine` is // false and a correctly-scoped render here could never fire - while an UNSCOPED one would repeat // every video failure in every open encounter pane, including one from a call that was never // this encounter's. The failure also outlives this surface (navigate away and the pane unmounts, // taking the only report with it), which is exactly why the reporter is the app-level overlay. return (
{/* ๐Ÿ”ด SAY WHERE THE VIDEO WENT. Card 128 moved the chrome into the meeting window, and this box was left showing an "Invite care team" button and nothing else โ€” a practitioner whose window is MINIMISED sees a visit-less panel offering to invite people to something that appears not to exist. One honest line; the window itself is always reachable, because minimising parks it on a labelled button rather than hiding it. */} {live && (

This visit is running in the meeting window.

)} {/* Spec ยง1.2's last piece: a collaboration visit is the SAME path as a 1:1 โ€” one patient's Encounter with more participants โ€” so the team is pulled in here rather than started as a different kind of meeting. Inside the `live` branch because there is nothing to invite anyone INTO before the visit exists. */} {live && } {/* ๐Ÿšง THE SHIP GATE lives on the ENTRY POINT ONLY โ€” never on an in-call affordance. Gating those would strand a participant inside a meeting; what must not happen is STARTING one against a broker that isn't deployed. See `videoFlags.ts` for the flip condition. */} {/* ๐Ÿ”ด NO DOOR AT ALL, and it SAYS so rather than rendering an empty bordered box. The absence is not what carries the message - the sentence is; an empty panel in the corner of a finished visit reads as something that failed to load. */} {!live && entry.kind === 'none' && (

{entry.reason === 'visit-is-over' ? 'This video visit has ended.' : entry.reason === 'opens-later' ? 'You can start the video at ' + formatTimeOfDay(entry.opensAt) + ', 15 minutes before the visit.' : entry.reason === 'previsit-has-passed' ? 'The time for this video visit has passed.' : 'The video opens once this visit is started.'}

)} {!live && entry.kind !== 'none' && ( <> {/* Visible and disabled, not hidden (Spencer on card 073): an absent button reads as "this product has no telehealth", a disabled one reads as "not yet". The gate copy wins over the busy-elsewhere copy โ€” while it is closed, no call this pane could collide with can have been started from here anyway. */} {!TELEHEALTH_VIDEO_ENABLED ? (

Video visits are coming soon.

) : elsewhere ? (

Another video visit is in progress.

) : ( // SAY THAT THE ROOM SURVIVED. Without this the clinician cannot tell // "I am going back into the visit that is running" from "I am starting it // over" - and on a visit with a patient sitting in it those are very // different acts. It is also the only thing on screen that explains why the // button changed word. rejoining && (

This visit is still running.

) )} )}
); };