import type { CapabilityUnavailableReason, DesktopRfbFactory, DesktopStreamCapability, } from "@opengeni/sdk"; import { LoaderCircleIcon, MonitorIcon, MousePointerClickIcon, WifiOffIcon } from "lucide-react"; import { type ClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useEffect, useRef, useState, } from "react"; import { cn } from "../lib/cn"; import { interactionHostPlatform, type InteractionHostPlatform } from "../lib/host-platform"; import { useDesktopStream } from "../hooks/use-desktop-stream"; import type { DesktopWebSocketFactory } from "../hooks/use-relay-frame-stream"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; export type DesktopViewerProps = { /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */ capability: DesktopStreamCapability | null; /** * Initial control mode. Default false (watch). When the user flips * "Take control" the viewer drives input — but only if `capability.mode` * permits it (server-gated; a read-only deployment disables the toggle). * Pass a value to control it externally; omit to let the viewer own the state. */ interactive?: boolean | undefined; /** Render the built-in Watching ⇄ Take control toggle (default true). */ showControlToggle?: boolean | undefined; scaleViewport?: boolean | undefined; /** Custom RFB factory; otherwise enableDesktopViewer() supplies lazy noVNC. */ rfbFactory?: DesktopRfbFactory | undefined; /** Authenticated controller protocols for proxied RFB transports. */ webSocketProtocols?: string[] | undefined; /** Custom socket factory for the self-hosted `relay-frames` transport (tests). * Defaults to `new WebSocket(url)`. Mirrors `rfbFactory`. */ webSocketFactory?: DesktopWebSocketFactory | undefined; /** * Optional host-owned paste path. ComputerSession hosts use their native, * target-scoped clipboard action instead of relying on an RFB server to sync * ClientCutText into the graphical seat. Return true once the host accepted * the text. Without a host authority the viewer leaves paste untouched rather * than claiming success for an unreliable display-server clipboard heuristic. */ onPasteText?: ((text: string) => boolean) | undefined; /** OS behind the RFB seat. Used only to translate the host's primary shortcut * (Cmd on macOS, Ctrl elsewhere) to the target's primary modifier. */ targetPlatform?: "linux" | "macos" | "windows" | null | undefined; /** * @deprecated Desktop acknowledgment is automatic. Retained as an ignored * compatibility prop so existing embedders keep compiling. */ renderConsentGate?: ((onAccept: () => void, shared: boolean) => ReactNode) | undefined; /** * Called once when the viewer mounts. Hosts use this to engage the desktop * holder immediately when the Desktop tab opens. */ onActivate?: (() => void) | undefined; /** * Called automatically when the server requires acknowledgment of the * un-redacted (and possibly shared) pixel plane. The viewer shows its ordinary * opening state while this completes; no click-through gate is rendered. */ onAcknowledge?: (() => void | Promise) | undefined; /** * Whether the host has the viewer attach engaged. Drives the cold-state * behaviour: when watching, a cold-but-warmable lease AUTO-WARMS (and re-warms * when the box drains) instead of dead-ending. Mounting the viewer counts as * watching when this is omitted. */ watching?: boolean | undefined; /** * Request a (re)warm of the sandbox WITHOUT re-acknowledging (the consent has * already been recorded — only the box drained). The host wires this to * "engage the viewer attach + re-negotiate". Called automatically when a * watched desktop is found cold-but-warmable, and behind the manual retry on * the warming notice. Distinct from the automatic acknowledgment request. */ onWarm?: (() => void) | undefined; /** Shown when transport is null (headless backend / degraded / disabled). */ renderUnavailable?: ((reason: CapabilityUnavailableReason | null) => ReactNode) | undefined; /** Shown while the box is cold/warming (no live address yet). */ renderWarming?: (() => ReactNode) | undefined; /** Shown when the per-session viewer cap (429) was hit. */ renderViewerCap?: (() => ReactNode) | undefined; /** Surface the 429 cap state from `useSessionCapabilities().viewerCapReached`. */ viewerCapReached?: boolean | undefined; /** * Connect watchdog (ms): if a live url is present but the RFB hasn't connected * within this window, surface a "Couldn't connect" + Reconnect instead of an * eternal idle scrim. Default 13000. 0 disables the watchdog. */ connectTimeoutMs?: number | undefined; className?: string | undefined; }; /** * The derived desktop surface state. A single source of truth so the overlay, * the scrim, the auto-warm effect, and the watchdog all agree. Priority order * (highest first): viewer-cap → unavailable → acknowledgment-error → warming → * connect-failed → error → opening/connecting → connected. */ type DesktopUiState = | "viewer_cap" // 429 — the per-session live-viewer limit is reached. | "unavailable" // genuinely unsupported (headless/policy/os/backend/not-provisioned). | "acknowledgment_error" // automatic un-redacted/shared acknowledgment failed. | "opening" // acknowledgment or viewer attach is in flight. | "warming" // cold-but-warmable: box not running yet (we auto-warm + spin). | "connecting" // url is live, RFB negotiating/handshaking (we spin, with a watchdog). | "connect_failed" // watchdog fired: url present but never connected. | "error" // RFB error / securityfailure after a connect. | "connected"; // live framebuffer painting. // Media-inspection exception: the framebuffer sits on true black so screenshots // and desktop pixels are not tinted by the product chrome palette. const DESKTOP_MEDIA_BACKGROUND = "#000"; /** Reasons that are genuinely-unavailable (never warmable from the viewer). A * `lease_cold` is deliberately EXCLUDED — that's the warmable cold state. */ function isHardUnavailable(reason: CapabilityUnavailableReason | null): boolean { switch (reason) { case "backend_unsupported": case "os_unsupported": case "not_provisioned": case "disabled_by_policy": case "tier_headless": return true; default: // null or "lease_cold" → not a hard reason (cold-but-warmable / live). return false; } } /** * The desktop surface: a noVNC client connecting to the Channel-B scoped tunnel * URL from the capability doc. Owns the mount `
`, drives * `useDesktopStream` (SSR-safe lazy RFB), and renders a real * cold → warming → connecting → connected → error state machine with live * feedback (spinners, transitions) — never a dead black box with stale text. * * The read-only vs interactive decision is enforced server-first * (`capability.mode`): when the deployment advertises mode "interactive" the * viewer can TAKE CONTROL and drive the mouse & keyboard into the box's :0; a * "read-only" deployment disables the take-control affordance (graceful, with a * reason). * * Warming: a cold-but-warmable lease (`reason: "lease_cold"`) is NOT a dead end. * When the viewer is mounted, it asks the host to engage the desktop and records * any required acknowledgment automatically. A cold-but-warmable box then * (re)warms through `onWarm`; if it later drains to cold it re-warms. * Genuinely-unavailable surfaces (headless/policy/os/backend) keep a clear, * static unavailable notice. */ export function DesktopViewer({ capability, interactive, showControlToggle = true, scaleViewport, rfbFactory, webSocketProtocols, webSocketFactory, onPasteText, targetPlatform, onActivate, onAcknowledge, watching, onWarm, renderUnavailable, renderWarming, renderViewerCap, viewerCapReached, connectTimeoutMs = 13_000, className, }: DesktopViewerProps) { const t = useOpenGeniText(); const containerRef = useRef(null); const onActivateRef = useRef(onActivate); const onAcknowledgeRef = useRef(onAcknowledge); onActivateRef.current = onActivate; onAcknowledgeRef.current = onAcknowledge; const acknowledgmentAttemptedRef = useRef(false); const [acknowledgmentError, setAcknowledgmentError] = useState(null); const [acknowledgmentRetryNonce, setAcknowledgmentRetryNonce] = useState(0); // Local control state when not externally controlled. The server gate // (`capability.mode`) is the hard ceiling — a read-only deployment can never // be flipped to interactive regardless of this toggle. const [takeControl, setTakeControl] = useState(interactive ?? false); const externallyControlled = interactive !== undefined; const serverAllowsControl = capability?.mode !== "read-only"; const wantControl = externallyControlled ? interactive : takeControl; const inControl = Boolean(wantControl) && serverAllowsControl; // Mounting this component means the Desktop surface is being viewed. A host // may still pass its holder state explicitly while the activation callback // flips that state on the first mount. const isWatching = watching ?? true; // ── Decide the rendered state (before touching the stream hook) ───────────── const transportNull = !capability || capability.transport === null; const reason = capability?.reason ?? null; const needsAcknowledgment = capability?.requiresAcknowledgment === true && capability.acknowledged !== true; // No live address yet on an otherwise-live transport (post-ack, mid-warm). const noLiveAddress = Boolean(capability) && !transportNull && !capability!.url; // Cold-but-warmable: the lease is cold (`lease_cold`) OR the transport is up // but no url has been minted yet — either way warming, not unavailable. const coldWarmable = (transportNull && reason === "lease_cold") || (!transportNull && noLiveAddress); // Genuinely-unavailable: transport null for a HARD reason (not lease_cold). const hardUnavailable = transportNull && isHardUnavailable(reason); // Selecting Desktop mounts the viewer, which is itself the user's intent to // open it. Engage the holder immediately — no separate "watch" click. useEffect(() => { onActivateRef.current?.(); }, []); // Preserve the server-side acknowledgment/audit contract without making the // user click through a disclosure card. One automatic attempt runs for each // unacknowledged episode; a genuine request failure gets an explicit retry. useEffect(() => { if (!needsAcknowledgment) { acknowledgmentAttemptedRef.current = false; setAcknowledgmentError(null); return; } if (acknowledgmentAttemptedRef.current) return; acknowledgmentAttemptedRef.current = true; setAcknowledgmentError(null); const acknowledge = onAcknowledgeRef.current; if (!acknowledge) { setAcknowledgmentError(new Error("Desktop acknowledgment is not configured.")); return; } let active = true; void Promise.resolve() .then(() => acknowledge()) .catch((cause) => { if (!active) return; setAcknowledgmentError(cause instanceof Error ? cause : new Error(String(cause))); }); return () => { active = false; }; }, [needsAcknowledgment, acknowledgmentRetryNonce]); const retryAcknowledgment = () => { acknowledgmentAttemptedRef.current = false; setAcknowledgmentError(null); setAcknowledgmentRetryNonce((nonce) => nonce + 1); }; // Release control WITHOUT ever swallowing a key the desktop needs. Esc, and // every other key, pass straight through to noVNC/:0 — vital for vim, menus, // and dialogs inside the box. Exactly ONE non-trapping keyboard exit: // • A single non-conflicting chord — Ctrl+Alt+Shift pressed on its own (no // other key) — which no app binds, so it never eats a real keystroke. // (Plus the always-visible "Return control" button in the in-control bar.) // // We deliberately DO NOT release on pointer-leave (or window blur): those fire // as a SIDE EFFECT of connecting — the noVNC canvas re-laying-out, the surface // grabbing focus, the connecting scrim swapping in — which bounced control // straight back to "watch" the instant the user took it. Control is given up // only on an explicit, intentional gesture (the button or the chord). useEffect(() => { if (!inControl || externallyControlled) return; const onKey = (event: KeyboardEvent) => { // Only the bare modifier chord releases; if any non-modifier key is also // down (event.key is a real key like "a"/"Escape"), let it pass through. if ( event.ctrlKey && event.altKey && event.shiftKey && (event.key === "Control" || event.key === "Alt" || event.key === "Shift") ) { event.preventDefault(); setTakeControl(false); } }; window.addEventListener("keydown", onKey); return () => { window.removeEventListener("keydown", onKey); }; }, [inControl, externallyControlled]); // The hook is always called (rules of hooks); it stays idle until `url` is set. // Do NOT open a socket while the viewer-cap (429) notice is showing — the slot // is already exhausted, so connecting would only burn a doomed attempt (and in // tests leak an unhandled ws error from the never-resolving tunnel URL). const connectCapability = !transportNull && !needsAcknowledgment && !viewerCapReached ? capability : null; const stream = useDesktopStream({ capability: connectCapability, containerRef, interactive: inControl, ...(scaleViewport !== undefined ? { scaleViewport } : {}), ...(rfbFactory ? { rfbFactory } : {}), ...(webSocketProtocols ? { webSocketProtocols } : {}), ...(webSocketFactory ? { webSocketFactory } : {}), }); const connected = stream.state === "connected"; const hasLiveUrl = Boolean(connectCapability?.url); // The latest stream state, read without making it an effect dependency — so the // re-attach below can consult it on a visibility change WITHOUT re-subscribing // (and re-firing) every time the state walks idle→negotiating→connected. const streamStateRef = useRef(stream.state); streamStateRef.current = stream.state; // ── AUTO-WARM ─────────────────────────────────────────────────────────────── // When the user is watching and the desktop is cold-but-warmable, ask the host // to (re)warm the box. This covers BOTH (a) the desktop was just opened and // (b) a previously-warm box that drained back to cold under a live viewer. We // fire once per distinct cold episode (keyed on the lease epoch + url) so we // don't spam the attach while a single warm is in flight. `onWarm` is the // no-re-ack path; acknowledgment remains a separate automatic request. const warmKeyRef = useRef(null); useEffect(() => { if (!isWatching || !coldWarmable || needsAcknowledgment || viewerCapReached) { // Reset the de-dupe once we leave the cold episode so a future drain warms. if (!coldWarmable) warmKeyRef.current = null; return; } if (!onWarm) return; // Key the de-dupe on the cell fields available to the viewer: while cold, // `reason`/`url`/`expiresAt` are stable, so we warm exactly once; once the box // warms the cell changes (url+expiresAt minted) and `coldWarmable` flips false, // resetting the ref so a later drain re-warms. const key = `${capability?.reason ?? ""}:${capability?.url ?? ""}:${capability?.expiresAt ?? ""}`; if (warmKeyRef.current === key) return; // already kicked this episode. warmKeyRef.current = key; onWarm(); // capability identity is the trigger; leaseEpoch/expiresAt key the episode. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isWatching, coldWarmable, needsAcknowledgment, viewerCapReached, capability, onWarm]); // ── TAB RE-ATTACH ─────────────────────────────────────────────────────────── // The "stuck Watching, never connects" bug: the RFB socket never (re)fires when // the Desktop tab is shown without a refresh. When a live url exists but we are // not connected/connecting, nudge the stream hook to (re)attach — on mount, on // the document becoming visible, and on a fresh capability. Re-attach is cheap // (disconnect-old/connect-new) and idempotent: the hook ignores it once live. useEffect(() => { if (!hasLiveUrl || typeof document === "undefined") return; const maybeReattach = () => { if (document.visibilityState !== "visible") return; // Only kick a socket that never OPENED (idle — e.g. the tab was hidden when // the url arrived, so the connect effect bailed on a missing container). // Deliberately NOT on "error": that surfaces an overlay with an explicit // Reconnect, and auto-retrying here would hammer (reconnect → error → // reconnect…). We read the live state via a ref so this effect does NOT // depend on `stream.state` — depending on it re-ran the effect on every // transition and re-fired the kick, which is the reconnect loop. if (streamStateRef.current === "idle") stream.reconnect(); }; // The connect effect already opens the socket on mount / a fresh url. The // ONLY gap it can't self-heal is a tab that was hidden when the url arrived // (container not in the DOM → connect effect bailed to idle): the socket then // never (re)fires on its own. So we revive it on the tab becoming visible — // NOT on mount (that double-opens the socket the connect effect just opened). document.addEventListener("visibilitychange", maybeReattach); return () => document.removeEventListener("visibilitychange", maybeReattach); // Re-run ONLY on a fresh live url, never on a state transition. // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasLiveUrl, connectCapability?.url]); // ── CONNECT WATCHDOG ───────────────────────────────────────────────────────── // If a live url is present but the RFB hasn't reached "connected" within the // window, stop spinning forever and surface "Couldn't connect" + Reconnect. // Cleared whenever we connect, the url rotates, or the user reconnects. const [connectTimedOut, setConnectTimedOut] = useState(false); // Bumped on every manual reconnect so the watchdog re-arms its window (the // stream hook's url/error don't change on a reconnect, so we need our own key). const [reconnectNonce, setReconnectNonce] = useState(0); useEffect(() => { // Re-arm on a fresh url / a reconnect and clear the moment we connect or hit a // real RFB error. We deliberately do NOT key on `stream.state`, so the benign // idle→connecting walk doesn't keep resetting the window — the full timeout // runs from the url becoming live. setConnectTimedOut(false); if (!hasLiveUrl || connected || stream.error || connectTimeoutMs <= 0) return; const timer = setTimeout(() => setConnectTimedOut(true), connectTimeoutMs); return () => clearTimeout(timer); }, [ hasLiveUrl, connected, connectTimeoutMs, connectCapability?.url, stream.error, reconnectNonce, ]); const reconnect = () => { setConnectTimedOut(false); setReconnectNonce((n) => n + 1); stream.reconnect(); }; const paste = (event: ClipboardEvent) => { if (!inControl) return; const text = event.clipboardData.getData("text/plain"); if (!text && !event.clipboardData.types.includes("text/plain")) return; if (!onPasteText?.(text)) return; event.preventDefault(); event.stopPropagation(); }; const primaryShortcut = (event: ReactKeyboardEvent) => { if (!inControl || !targetPlatform) return; const hostPlatform = interactionHostPlatform(); // noVNC maps a Mac Command key to remote Alt before the actual chord key // arrives. Suppress that standalone physical modifier; the complete target // chord below owns both its down and up events. if (isHostPrimaryModifier(event, hostPlatform)) { event.stopPropagation(); return; } const chord = desktopPrimaryShortcut(event, hostPlatform, targetPlatform); if (!chord) return; // Copy/paste must keep the browser's default clipboard dispatch alive, but // noVNC must not also send the host modifier into the remote seat. if (chord === "clipboard") { event.stopPropagation(); return; } const sent = chord.every(({ keysym, code, down }) => stream.sendKey?.(keysym, code, down)); if (!sent) return; event.preventDefault(); event.stopPropagation(); }; const primaryModifierUp = (event: ReactKeyboardEvent) => { if (inControl && isHostPrimaryModifier(event, interactionHostPlatform())) { event.stopPropagation(); } }; // ── Resolve the single UI state (priority order) ──────────────────────────── let uiState: DesktopUiState; if (viewerCapReached) uiState = "viewer_cap"; else if (hardUnavailable) uiState = "unavailable"; else if (acknowledgmentError) uiState = "acknowledgment_error"; else if (needsAcknowledgment) uiState = "opening"; else if (coldWarmable) uiState = "warming"; else if (connected) uiState = "connected"; else if (stream.error) uiState = "error"; else if (connectTimedOut) uiState = "connect_failed"; else uiState = "connecting"; // hasLiveUrl && not yet connected (idle/negotiating/connecting). // An overlay blocks the surface for every state except connected; the toggle // and the live scrim are suppressed under an overlay. const overlayShown = uiState !== "connected" && uiState !== "connecting" && uiState !== "opening"; const showToggle = showControlToggle && !overlayShown; let overlay: ReactNode = null; switch (uiState) { case "viewer_cap": overlay = renderViewerCap?.() ?? defaultNotice( t("desktopViewer.tooManyViewers"), t("desktopViewer.thisSessionHasReachedIts"), ); break; case "unavailable": overlay = renderUnavailable?.(reason) ?? defaultNotice(t("desktopViewer.desktopUnavailable"), unavailableCopy(t, reason)); break; case "acknowledgment_error": overlay = defaultNotice( t("desktopViewer.couldnTOpenDesktop"), acknowledgmentError?.message ?? t("desktopViewer.theDesktopAcknowledgmentFailed"), retryAcknowledgment, ); break; case "warming": overlay = renderWarming?.() ?? ; break; case "connect_failed": overlay = defaultNotice( t("desktopViewer.couldnTConnect"), t("desktopViewer.theDesktopIsWarmBut"), reconnect, ); break; case "error": overlay = defaultNotice( t("desktopViewer.desktopDisconnected"), stream.error?.message ?? t("desktopViewer.theStreamDropped"), reconnect, ); break; case "opening": case "connecting": case "connected": overlay = null; break; } return (
{/* noVNC mount target. It appends a `width:100%;height:100%` `_screen` div and AUTOSCALES the 1280x800 framebuffer to fit THIS box. We pin it to the bounded `relative` wrapper with `absolute inset-0` (not just `h-full w-full`) so its measured size is ALWAYS the panel — never the canvas content. A content-sized mount is exactly what makes noVNC measure a huge screen and paint the desktop "zoomed in". `overflow-hidden` keeps the centered (margin:auto) canvas from ever spilling the panel. */}
{/* Idle / connecting scrim: a quiet, ALIVE "connecting to the desktop" state behind the canvas so the surface never reads as a dead black rectangle before the first framebuffer paints. Only shown in the `connecting` UI-state (every other non-connected state has an explicit overlay). A spinner + transitional copy makes it feel live. */} {(uiState === "opening" || uiState === "connecting") && (
{uiState === "opening" ? t("desktopViewer.openingTheDesktop") : t("desktopViewer.connectingToTheDesktop")}
)} {/* Take-control affordance. Two distinct states: - WATCHING → a prominent, centered call-to-action button overlaid on the desktop (the primary CTA; tasteful so the screen stays visible). - IN CONTROL → a small top bar ("You're in control · click Return control (or Ctrl+Alt+Shift)") with a clearly-visible Return-control button; the desktop stays fully usable and EVERY key — Esc included — passes through to the box. Server-gated: when the deployment is read-only the CTA renders disabled with a reason so it degrades gracefully. */} {showToggle && !externallyControlled && inControl && ( setTakeControl(false)} /> )} {/* The big CTA only appears once the framebuffer is live + the viewer is watching: before connect the scrim already communicates state, so we don't double up. A read-only deployment (serverAllowsControl=false) still surfaces the CTA disabled-with-reason once connected, so it degrades gracefully and stays discoverable. */} {showToggle && !externallyControlled && !inControl && connected && ( setTakeControl(true)} /> )} {overlay && (
{overlay}
)}
); } type RfbKeyEvent = { keysym: number; code: string; down: boolean }; function isHostPrimaryModifier( event: Pick, "key">, hostPlatform: InteractionHostPlatform, ): boolean { return event.key === (hostPlatform === "mac" ? "Meta" : "Control"); } /** Translate one host-primary chord into deterministic RFB events for the * target OS. noVNC otherwise treats a Mac Command key as remote Alt, which * makes ordinary Cmd+A/Z shortcuts silently wrong on Linux sandboxes. */ export function desktopPrimaryShortcut( event: Pick< ReactKeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey" | "code" >, hostPlatform: InteractionHostPlatform, targetPlatform: "linux" | "macos" | "windows", ): RfbKeyEvent[] | "clipboard" | null { const primaryHeld = hostPlatform === "mac" ? event.metaKey : event.ctrlKey; if (!primaryHeld || [...event.key].length !== 1) return null; const key = event.key.toLowerCase(); if (!event.altKey && (key === "c" || key === "v")) return "clipboard"; const primary = targetPlatform === "macos" ? { keysym: 0xffeb, code: "MetaLeft" } : { keysym: 0xffe3, code: "ControlLeft" }; const modifiers = [ primary, ...(event.altKey ? [{ keysym: 0xffe9, code: "AltLeft" }] : []), ...(event.shiftKey ? [{ keysym: 0xffe1, code: "ShiftLeft" }] : []), ]; const keyEvent = { keysym: key.codePointAt(0)!, code: event.code || (/^[a-z]$/u.test(key) ? `Key${key.toUpperCase()}` : key), }; return [ ...modifiers.map((modifier) => ({ ...modifier, down: true })), { ...keyEvent, down: true }, { ...keyEvent, down: false }, ...modifiers.reverse().map((modifier) => ({ ...modifier, down: false })), ]; } /** * The primary WATCHING-state call-to-action: a large, centered pill button * overlaid on the desktop inviting the viewer to drive the mouse & keyboard. * Tasteful by default — it sits in the LOWER-center with a soft scrim only behind * the button (not the whole screen) and lifts on hover, so a watcher can still see * the agent work. Server-gated: when the deployment is read-only (or the desktop * hasn't connected yet) it renders disabled with a reason. Accessible: a real *
); } /** * The IN-CONTROL state: a small top bar so the desktop stays fully usable while * driving (the accent ring around the viewport carries the primary "you're * driving" signal). Every keystroke — including Esc — passes through to the box, * so release lives only on explicit, non-trapping affordances: a clearly-visible * "Return control" button and the Ctrl+Alt+Shift chord. The button is solid * (not a faint ghost) so it's always discoverable as the way out. */ function InControlBar({ shared, onRelease }: { shared: boolean; onRelease: () => void }) { const t = useOpenGeniText(); return (
{t("desktopViewer.youReInControl")} {t("desktopViewer.clickReturnControlOrCtrl")}
{shared && ( {t("desktopViewer.sharedSandboxOthersAreWatching")} )}
); } function unavailableCopy(t: OpenGeniText, reason: CapabilityUnavailableReason | null): string { switch (reason) { case "backend_unsupported": return t("desktopViewer.thisSandboxBackendCannotStream"); case "tier_headless": return t("desktopViewer.thisDeploymentIsHeadlessTerminal"); case "os_unsupported": return t("desktopViewer.theSandboxOsDoesNot"); case "not_provisioned": return t("desktopViewer.thisSandboxDoesnTHave"); case "disabled_by_policy": return t("desktopViewer.desktopStreamingIsDisabledOn"); case "lease_cold": // Should not reach the unavailable notice (lease_cold warms), but keep // friendly copy as a fallback rather than the old dead-end wording. return t("desktopViewer.waitingForTheSandboxTo"); default: return t("desktopViewer.theDesktopIsnTAvailable"); } } /** * The WARMING state: a cold-but-warmable box that is being spun up. A spinner + * clear, accurate copy — and (when the host wired a manual warm) an explicit * retry so a slow warm never feels stuck. This is the cure for the old * "Desktop unavailable — Start a turn or attach to warm it" dead-end: the box IS * being warmed automatically; we just tell the user it's happening. */ function WarmingNotice({ onRetry }: { onRetry?: (() => void) | undefined }) { const t = useOpenGeniText(); return (
{t("desktopViewer.warmingTheSandbox")}

{t("desktopViewer.spinningUpTheDesktopThis")}

{onRetry && ( )}
); } function defaultNotice(title: string, body: string, onRetry?: () => void): ReactNode { return (
{onRetry && } {title}

{body}

{onRetry && ( )}
); }